diff --git a/Cargo.lock b/Cargo.lock index 516d0e6..067e0f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -526,7 +526,7 @@ dependencies = [ [[package]] name = "tropic01-driver" -version = "0.1.0" +version = "0.0.1" dependencies = [ "aes-gcm", "ecdsa", diff --git a/Cargo.toml b/Cargo.toml index c51be40..0d566b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ members = ["crates/tropic01-driver"] [workspace.package] edition = "2024" -version = "0.1.0" +version = "0.0.1" +rust-version = "1.88" authors = ["PatinaKey "] repository = "https://github.com/PatinaKey/firmware" license = "GPL-3.0-or-later" @@ -28,7 +29,7 @@ hmac = { version = "0.13", default-features = false } # release candidates. the last STABLE curve releases (p384 0.13, ecdsa 0.16) speak the older # digest 0.10 generation and would pull a SECOND copy of sha2/digest into the # binary. Pinning the rc keeps a single crypto generation, which is the smaller -# audit and flash surface. +# audit and flash surface. # TODO: drop the -rc pins once p384/p521 0.14 and ecdsa 0.17 ship stable. ecdsa = { version = "0.17.0-rc.19", default-features = false } p384 = { version = "0.14.0-rc.11", default-features = false, features = ["ecdsa"] } diff --git a/README.md b/README.md index f7e1d0e..b1e00fb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/PatinaKey/firmware/actions/workflows/ci.yml/badge.svg)](https://github.com/PatinaKey/firmware/actions/workflows/ci.yml) [![License: GPL-3.0-or-later](https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg)](LICENSE) -[![Rust 1.95+](https://img.shields.io/badge/Rust-1.95%2B%20edition%202024-orange.svg)](https://doc.rust-lang.org/edition-guide/) +[![Rust 1.88+](https://img.shields.io/badge/Rust-1.88%2B%20edition%202024-orange.svg)](https://doc.rust-lang.org/edition-guide/) [![no_std](https://img.shields.io/badge/no__std-bare--metal-green.svg)](https://docs.rust-embedded.org/book/intro/no-std.html) Open-source Rust firmware for **PatinaKey**, a USB hardware security key implementing FIDO2, OpenPGP card, and PKCS#11. @@ -51,7 +51,7 @@ The project is under active development. Only the secure-element driver (`crates ## Building -Developed using rustc 1.95. No guarantee is provided that the code will work with an earlier version. +Minimum supported Rust version (MSRV): 1.88 (edition 2024), verified to build. Developed on a newer stable. No guarantee is provided below 1.88. ```sh # Host check and tests diff --git a/crates/tropic01-driver/Cargo.toml b/crates/tropic01-driver/Cargo.toml index ef93f27..d8d37fb 100644 --- a/crates/tropic01-driver/Cargo.toml +++ b/crates/tropic01-driver/Cargo.toml @@ -2,13 +2,13 @@ name = "tropic01-driver" edition.workspace = true version.workspace = true +rust-version.workspace = true authors.workspace = true repository.workspace = true license.workspace = true description = "Unofficial no_std, panic-free Rust driver for the TROPIC01 secure element." readme = "README.md" -documentation = "Not published to docs.rs yet." -keywords = ["tropic01", "secure-element", "no-std", "no_std", "embedded", "crypto"] +keywords = ["tropic01", "secure-element", "no-std", "embedded", "crypto"] categories = ["embedded", "no-std", "cryptography", "hardware-support"] # Dev/validation tooling kept out of the published package. The crate is the # library. The fuzz harness has its own detached workspace and the scripts need @@ -16,6 +16,13 @@ categories = ["embedded", "no-std", "cryptography", "hardware-support"] exclude = ["/fuzz", "/scripts"] [features] +# X.509 attestation chain verification (verify_cert_chain, parse_verified_stpub, +# RootAnchor, read_verified_chip_stpub). ON by default. It pulls the ECDSA curve +# crates (ecdsa / p384 / p521). Disable via default-features = false to drop +# those deps when only STPUB extraction (parse_stpub / read_chip_stpub) is +# needed. The handshake still binds STPUB, so a session stays authenticated. +default = ["attestation"] +attestation = ["dep:ecdsa", "dep:p384", "dep:p521"] # Exposes the `fuzz` module (attacker-facing parsers) to libFuzzer harnesses. # Not for production. Enabled only by the fuzz crate. _fuzz = [] @@ -33,9 +40,14 @@ x25519-dalek = { workspace = true } aes-gcm = { workspace = true } sha2 = { workspace = true } hmac = { workspace = true } -ecdsa = { workspace = true } -p384 = { workspace = true } -p521 = { workspace = true } +ecdsa = { workspace = true, optional = true } +p384 = { workspace = true, optional = true } +p521 = { workspace = true, optional = true } + +# Render the attestation API on docs.rs. Explicit (not all-features) so the +# dev-only _fuzz / model-itest modules stay hidden from the published docs. +[package.metadata.docs.rs] +features = ["attestation"] [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/crates/tropic01-driver/README.md b/crates/tropic01-driver/README.md index b62faab..373c3c3 100644 --- a/crates/tropic01-driver/README.md +++ b/crates/tropic01-driver/README.md @@ -1,10 +1,69 @@ # tropic01-driver - TROPIC01 secure-element driver (no_std) +[![crates.io](https://img.shields.io/crates/v/tropic01-driver.svg)](https://crates.io/crates/tropic01-driver) +[![docs.rs](https://docs.rs/tropic01-driver/badge.svg)](https://docs.rs/tropic01-driver) +[![MSRV 1.88+](https://img.shields.io/badge/MSRV-1.88-blue.svg)](https://www.rust-lang.org) +[![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) + + +> **BETA - unofficial, NOT silicon-validated.** This is an early `0.0.x` +> release. The driver has been validated host-side against the official +> `tropic01_model` emulator, but it has NOT run on real TROPIC01 silicon. Do not +> use it for production trust decisions yet. + A `no_std`, heap-free, `unsafe-free` Rust driver for the **TROPIC01** secure element (Tropic Square, part `TR01-C2P-T301`), spoken over SPI through an authenticated, encrypted **Noise KK1** session. -⚠️ Disclaimer: This is an unofficial, community-driven project. It is not affiliated with, endorsed by, or officially supported by Tropic Square. For the official SDK, please refer to Tropic Square's libtropic. +## Quick start + +The integrator supplies the SPI bus (`embedded_hal::spi::SpiDevice`) and a +ready/timeout provider (the crate's `SeWait` trait). All key material is +caller-provided via `SessionConfig`. Open a channel, run a command, close it: + +```rust,no_run +use tropic01_driver::{SeCommands, SessionConfig, StartupId, Tropic01}; +use zeroize::Zeroizing; + +fn run(spi: impl embedded_hal::spi::SpiDevice, wait: impl tropic01_driver::SeWait) + -> Result<(), tropic01_driver::SeError> +{ + let mut dev = Tropic01::new(spi, wait); + dev.reboot(StartupId::Reboot)?; // load the Application firmware + + // Placeholder keys: real ephemerals come from a TRNG, the pairing keys from + // provisioning, and `stpub` from the chip certificate. For a genuine-chip + // trust decision, get `stpub` via `read_verified_chip_stpub` (not the + // unverified `read_chip_stpub`) against an out-of-band-pinned `RootAnchor`. + let ehpriv = Zeroizing::new([0u8; 32]); + let shipriv = Zeroizing::new([0u8; 32]); + let shipub = [0u8; 32]; + let stpub = [0u8; 32]; + let cfg = SessionConfig + { + ehpriv: &ehpriv, + shipriv: &shipriv, + shipub: &shipub, + stpub: &stpub, + pkey_index: 0, + }; + // open_session reports its error as a tuple (handle, error). + let mut session = dev.open_session(cfg).map_err(|(_dev, e)| e)?; + + let mut random = [0u8; 32]; + session.random_into(&mut random)?; + let _dev = session.close_session(); + Ok(()) +} +``` + +The `attestation` feature (ON by default) enables X.509 chain verification +(`verify_cert_chain` / `read_verified_chip_stpub`) and pulls the ECDSA curve +crates (`ecdsa` / `p384` / `p521`), which are PRE-RELEASE (`-rc`) today (pinned to +keep one `digest` generation in the tree). Build with `default-features = false` +to drop those dependencies when only STPUB extraction is needed. + +**Disclaimer:** This is an unofficial, community-driven project. It is not affiliated with, endorsed by, or officially supported by Tropic Square. For the official SDK, please refer to Tropic Square's libtropic. Written as a clean-room rewrite with the official C SDK [`libtropic`](https://github.com/tropicsquare/libtropic) used as a differential @@ -106,11 +165,12 @@ This runs in the normal hermetic test suite. The TROPIC01 command surface is fully wired: every L2 request and L3 command, attestation, and the firmware-update bootloader are implemented. -Non-command work toward a publishable crate: validate against silicon (the -`tropic01_model` emulator is already wired, see -[Validation](#validation-against-real-libtropic)), examples on docs.rs, -and an optional `embedded-hal`-based port so external users can plug -their own HAL (currently the ports are the crate's own `SpiDevice` / `SeWait` traits). +Work toward a production `1.0`: validate against real silicon (the +`tropic01_model` emulator is wired, see +[Validation](#validation-against-real-libtropic), but no silicon run yet). Move the +`ecdsa` / `p384` / `p521` curve crates off their release candidates once stable +versions on the same `digest` generation ship. A type-state `open_session` that +consumes a chain-verified STPUB and an `embedded-hal-async` path. ## Design principles diff --git a/crates/tropic01-driver/fuzz/Cargo.lock b/crates/tropic01-driver/fuzz/Cargo.lock index 996fa06..88c5f81 100644 --- a/crates/tropic01-driver/fuzz/Cargo.lock +++ b/crates/tropic01-driver/fuzz/Cargo.lock @@ -594,7 +594,7 @@ dependencies = [ [[package]] name = "tropic01-driver" -version = "0.1.0" +version = "0.0.1" dependencies = [ "aes-gcm", "ecdsa", diff --git a/crates/tropic01-driver/fuzz/Cargo.toml b/crates/tropic01-driver/fuzz/Cargo.toml index e13c42e..8075ce2 100644 --- a/crates/tropic01-driver/fuzz/Cargo.toml +++ b/crates/tropic01-driver/fuzz/Cargo.toml @@ -16,7 +16,8 @@ libfuzzer-sys = "0.4" [dependencies.tropic01-driver] path = ".." -features = ["_fuzz"] +# attestation is needed so the verify_cert_chain fuzz target still builds. +features = ["_fuzz", "attestation"] [[bin]] name = "parse_l2_response" diff --git a/crates/tropic01-driver/src/cert.rs b/crates/tropic01-driver/src/cert.rs index 483cf28..bc66e4b 100644 --- a/crates/tropic01-driver/src/cert.rs +++ b/crates/tropic01-driver/src/cert.rs @@ -24,8 +24,10 @@ //! root. It covers the cryptographic path only. Validity dates and revocation are //! left to the integrator. +#[cfg(feature = "attestation")] use crate::crypto; use crate::error::CertError; +#[cfg(feature = "attestation")] use crate::error::ChainError; use crate::error::SeError; use crate::parse::take; @@ -59,28 +61,38 @@ const TAG_OID: u8 = 0x06; const TAG_BIT_STRING: u8 = 0x03; /// ecdsa-with-SHA384 OID content bytes (1.2.840.10045.4.3.3). +#[cfg(feature = "attestation")] const OID_ECDSA_SHA384: [u8; 8] = [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x03]; /// ecdsa-with-SHA512 OID content bytes (1.2.840.10045.4.3.4). +#[cfg(feature = "attestation")] const OID_ECDSA_SHA512: [u8; 8] = [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x04]; /// id-ecPublicKey OID content bytes (1.2.840.10045.2.1). +#[cfg(feature = "attestation")] const OID_EC_PUBLIC_KEY: [u8; 7] = [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]; /// secp384r1 (P-384) named-curve OID content bytes (1.3.132.0.34). +#[cfg(feature = "attestation")] const OID_SECP384R1: [u8; 5] = [0x2b, 0x81, 0x04, 0x00, 0x22]; /// secp521r1 (P-521) named-curve OID content bytes (1.3.132.0.35). +#[cfg(feature = "attestation")] const OID_SECP521R1: [u8; 5] = [0x2b, 0x81, 0x04, 0x00, 0x23]; /// SEC1 uncompressed point length for P-384: 0x04 || X(48) || Y(48). +#[cfg(feature = "attestation")] const P384_POINT_LEN: usize = 97; /// SEC1 uncompressed point length for P-521: 0x04 || X(66) || Y(66). +#[cfg(feature = "attestation")] const P521_POINT_LEN: usize = 133; /// Number of certificates the chain expects in the store. +#[cfg(feature = "attestation")] const CHAIN_CERT_COUNT: usize = 4; /// Extracts STPUB from a raw `Get_Info` X.509 certificate store. /// -/// WARNING: this does NOT verify the certificate chain. For any trust decision -/// use `parse_verified_stpub`. +/// WARNING: this does NOT attest TROPIC01 authenticity. It does NOT verify the +/// certificate chain up to the Tropic root, so a counterfeit chip can serve any +/// STPUB here. For the genuine-chip guarantee, use `parse_verified_stpub` with a +/// `RootAnchor` pinned out-of-band. /// /// Parses the 10-byte store header (version 0x01, num_certs 0x04, four big-endian /// u16 per-cert lengths), takes the DEVICE certificate body, then walks its DER @@ -261,6 +273,7 @@ fn parse_der_len(input: &[u8]) -> Result<(&[u8], usize), CertError> /// bytes or indefinite) becomes `ChainError::Unsupported`, every other fault /// becomes `ChainError::Malformed`. This keeps the chain path's error taxonomy /// aligned with the STPUB path while reusing the one length parser. +#[cfg(feature = "attestation")] fn der_len(input: &[u8]) -> Result<(&[u8], usize), ChainError> { parse_der_len(input).map_err(|e| match e @@ -306,12 +319,14 @@ fn crop_x25519_key(content: &[u8]) -> Result<[u8; 32], CertError> /// The wrapped bytes are the SEC1 uncompressed point (0x04 || X || Y, 133 bytes). /// The TEST root here differs from PROD: the integrator compiles in the correct /// production root point. +#[cfg(feature = "attestation")] #[derive(Clone, Copy)] pub struct RootAnchor { point: [u8; P521_POINT_LEN], } +#[cfg(feature = "attestation")] impl RootAnchor { /// Builds an anchor from a P-521 SEC1 uncompressed point. @@ -348,6 +363,7 @@ impl RootAnchor } /// A certificate's signatureAlgorithm: the ECDSA curve+digest it was signed with. +#[cfg(feature = "attestation")] #[derive(Clone, Copy, PartialEq, Eq)] enum SigAlg { @@ -358,6 +374,7 @@ enum SigAlg } /// A key's elliptic curve, taken from its SubjectPublicKeyInfo named-curve OID. +#[cfg(feature = "attestation")] #[derive(Clone, Copy, PartialEq, Eq)] enum Curve { @@ -371,6 +388,7 @@ enum Curve /// /// All three are sub-slices of the cert body, never copies. `tbs` is the exact /// signed byte range (the first inner SEQUENCE INCLUDING its tag+length header). +#[cfg(feature = "attestation")] struct CertParts<'a> { /// The tbsCertificate bytes that were signed (SEQUENCE header included). @@ -408,6 +426,7 @@ struct CertParts<'a> /// `SeError::Chain` when a signature link fails to verify under the expected /// key, or when the store header, a certificate, or a key is malformed, /// unsupported, or carries an unexpected signature algorithm. +#[cfg(feature = "attestation")] pub fn verify_cert_chain(cert_store: &[u8], anchor: &RootAnchor) -> Result<(), SeError> { let mut bodies: [&[u8]; CHAIN_CERT_COUNT] = [&[]; CHAIN_CERT_COUNT]; @@ -435,6 +454,7 @@ pub fn verify_cert_chain(cert_store: &[u8], anchor: &RootAnchor) -> Result<(), S /// /// `SeError::Chain` when the chain does not verify under `anchor`, or /// `SeError::Cert` when the DEVICE certificate does not parse. +#[cfg(feature = "attestation")] pub fn parse_verified_stpub ( cert_store: &[u8], @@ -451,6 +471,7 @@ pub fn parse_verified_stpub /// Reads the subject's tbsCertificate, signatureAlgorithm, and signatureValue, /// reads the issuer's SubjectPublicKeyInfo point, then dispatches the verify by /// the subject's algorithm. The issuer key's curve must match the algorithm. +#[cfg(feature = "attestation")] fn verify_link(subject: &[u8], issuer: &[u8]) -> Result<(), ChainError> { let parts = parse_cert_parts(subject)?; @@ -462,6 +483,7 @@ fn verify_link(subject: &[u8], issuer: &[u8]) -> Result<(), ChainError> /// /// SECURITY: the load-bearing trust step. The anchor is the caller's pinned key, /// not store bytes. The subject's signatureAlgorithm must be ecdsa-with-SHA512. +#[cfg(feature = "attestation")] fn verify_under_anchor(subject: &[u8], anchor: &RootAnchor) -> Result<(), ChainError> { let parts = parse_cert_parts(subject)?; @@ -479,6 +501,7 @@ fn verify_under_anchor(subject: &[u8], anchor: &RootAnchor) -> Result<(), ChainE /// be the matching one (SHA-384 with P-384, SHA-512 with P-521). Any other pairing /// is rejected as `BadPublicKey` before any crypto runs. This names the /// curve<->digest constraint instead of comparing two signatureAlgorithm values. +#[cfg(feature = "attestation")] fn verify_with_curve ( sig_alg: SigAlg, @@ -511,6 +534,7 @@ fn verify_with_curve /// VERSION == 0x01 and NUM_CERTS == 4. Each cert body is `LEN[i]` bytes, taken in /// order, `take` rejects any length that overruns the store. Trailing padding is /// ignored. +#[cfg(feature = "attestation")] fn split_cert_bodies<'a> ( store: &'a [u8], @@ -558,6 +582,7 @@ fn split_cert_bodies<'a> /// SEQUENCE, its first OID selects the algorithm. The signatureValue is the /// trailing BIT STRING. Its content is one 0x00 unused-bits byte then the /// ECDSA-Sig-Value DER. +#[cfg(feature = "attestation")] fn parse_cert_parts(cert: &[u8]) -> Result, ChainError> { // Outer cert SEQUENCE must span the whole body. @@ -597,6 +622,7 @@ fn parse_cert_parts(cert: &[u8]) -> Result, ChainError> /// /// The first object is the algorithm OID. It must equal ecdsa-with-SHA384 or /// ecdsa-with-SHA512. Trailing params (if any) are ignored. +#[cfg(feature = "attestation")] fn parse_sig_alg(alg_seq: &[u8]) -> Result { let (oid, _rest) = der_oid(alg_seq)?; @@ -618,6 +644,7 @@ fn parse_sig_alg(alg_seq: &[u8]) -> Result /// /// The BIT STRING content is one 0x00 unused-bits byte then SEQUENCE { r, s }. /// Returns `(sig_der, trailing)` where `sig_der` is the bytes after the 0x00. +#[cfg(feature = "attestation")] fn parse_signature_value(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> { let (after_tag, tag) = take_u8(input).map_err(|_| ChainError::Malformed)?; @@ -643,6 +670,7 @@ fn parse_signature_value(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> /// returned point is the 0x04 || X || Y SEC1 form (the BIT STRING content minus /// its leading 0x00 unused-bits byte). The curve is derived from the curve OID /// (secp384r1 -> P-384, secp521r1 -> P-521). +#[cfg(feature = "attestation")] fn parse_spki_point(cert: &[u8]) -> Result<(Curve, &[u8]), ChainError> { let (inner, _trailing) = der_sequence_content(cert)?; @@ -667,6 +695,7 @@ fn parse_spki_point(cert: &[u8]) -> Result<(Curve, &[u8]), ChainError> /// Returns `Ok(Some(..))` when `elem` is `SEQUENCE { SEQUENCE { OID /// id-ecPublicKey, OID curve }, BIT STRING }`, `Ok(None)` when it is not an SPKI /// (so scanning continues), and `Err` only on a structurally broken SPKI. +#[cfg(feature = "attestation")] fn try_spki(elem: &[u8]) -> Result, ChainError> { let (after_tag, tag) = take_u8(elem).map_err(|_| ChainError::Malformed)?; @@ -719,6 +748,7 @@ fn try_spki(elem: &[u8]) -> Result, ChainError> } /// Maps a named-curve OID to the matching curve. +#[cfg(feature = "attestation")] fn curve_from_oid(curve_oid: &[u8]) -> Result { if curve_oid == OID_SECP384R1 @@ -740,6 +770,7 @@ fn curve_from_oid(curve_oid: &[u8]) -> Result /// The BIT STRING content is one 0x00 unused-bits byte then 0x04 || X || Y. The /// returned slice is the 0x04 || X || Y part and must be exactly `expected_len` /// bytes with a leading 0x04, else the key is rejected. +#[cfg(feature = "attestation")] fn parse_ec_point(input: &[u8], expected_len: usize) -> Result<&[u8], ChainError> { let (after_tag, tag) = take_u8(input).map_err(|_| ChainError::BadPublicKey)?; @@ -772,6 +803,7 @@ fn parse_ec_point(input: &[u8], expected_len: usize) -> Result<&[u8], ChainError /// /// The leading tag must be 0x30. The content is bounded to the declared length, /// `trailing` is whatever follows the SEQUENCE in `input`. +#[cfg(feature = "attestation")] fn der_sequence_content(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> { let (after_tag, tag) = take_u8(input).map_err(|_| ChainError::Malformed)?; @@ -788,6 +820,7 @@ fn der_sequence_content(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> /// Returns `(element, rest)` where `element` is the contiguous tag || length || /// content byte range and `rest` is what follows. This is how the exact signed /// tbsCertificate byte range is captured. +#[cfg(feature = "attestation")] fn der_element_with_header(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> { let (after_tag, _tag) = take_u8(input).map_err(|_| ChainError::Malformed)?; @@ -802,6 +835,7 @@ fn der_element_with_header(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> } /// Reads a DER OBJECT IDENTIFIER, returning its `(content, rest)`. +#[cfg(feature = "attestation")] fn der_oid(input: &[u8]) -> Result<(&[u8], &[u8]), ChainError> { let (after_tag, tag) = take_u8(input).map_err(|_| ChainError::Malformed)?; @@ -1187,6 +1221,7 @@ mod tests // and cross-checked with openssl (the full TEST chain verifies, the root is // self-signed, cert[0] STPUB == model s_t_pub). See golden_chain module. + #[cfg(feature = "attestation")] fn test_anchor() -> RootAnchor { RootAnchor::from_sec1_p521(&golden_chain::MODEL_TEST_ROOT_PUBKEY) @@ -1194,6 +1229,7 @@ mod tests } /// Embeds the un-padded store into a 3840-byte buffer like the chip serves. + #[cfg(feature = "attestation")] fn store_padded_3840() -> [u8; 3840] { let mut padded = [0u8; 3840]; @@ -1202,18 +1238,21 @@ mod tests padded } + #[cfg(feature = "attestation")] #[test] fn verify_cert_chain_accepts_the_model_chain() { assert_eq!(verify_cert_chain(&golden_chain::MODEL_CERT_STORE, &test_anchor()), Ok(())); } + #[cfg(feature = "attestation")] #[test] fn verify_cert_chain_accepts_block_padded_store() { assert_eq!(verify_cert_chain(&store_padded_3840(), &test_anchor()), Ok(())); } + #[cfg(feature = "attestation")] #[test] fn parse_verified_stpub_returns_model_stpub() { @@ -1223,6 +1262,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn parse_verified_stpub_matches_unverified_on_good_chain() { @@ -1236,12 +1276,14 @@ mod tests /// /// Computed from the pointer offset, so the test byte to flip is DERIVED from /// the parsed structure rather than a hard-coded magic number. + #[cfg(feature = "attestation")] fn abs_offset(outer: &[u8], inner: &[u8]) -> usize { (inner.as_ptr() as usize) - (outer.as_ptr() as usize) } /// Returns the absolute store index of a byte inside cert[i]'s sig_der. + #[cfg(feature = "attestation")] fn sig_byte_index(store: &[u8], cert_index: usize) -> usize { let mut bodies: [&[u8]; CHAIN_CERT_COUNT] = [&[]; CHAIN_CERT_COUNT]; @@ -1251,6 +1293,7 @@ mod tests abs_offset(store, parts.sig_der) + parts.sig_der.len() / 2 } + #[cfg(feature = "attestation")] #[test] fn flipping_a_signature_byte_fails_bad_signature() { @@ -1265,6 +1308,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn flipping_a_tbs_byte_fails() { @@ -1286,6 +1330,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn flipping_cert1_signature_fails_bad_signature() { @@ -1300,6 +1345,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn flipping_cert2_signature_fails_bad_signature() { @@ -1315,6 +1361,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn wrong_anchor_fails_bad_signature() { @@ -1335,6 +1382,7 @@ mod tests /// Derived from a fixed non-trivial scalar so it is a real on-curve point and /// is accepted by the eagerly-validating anchor constructor, yet differs from /// the model TEST root, so signatures fail to verify under it. + #[cfg(feature = "attestation")] fn other_valid_p521_point() -> [u8; P521_POINT_LEN] { use p521::ecdsa::SigningKey; @@ -1347,6 +1395,7 @@ mod tests out } + #[cfg(feature = "attestation")] #[test] fn anchor_without_uncompressed_tag_rejected() { @@ -1358,6 +1407,7 @@ mod tests )); } + #[cfg(feature = "attestation")] #[test] fn wrong_num_certs_in_header_rejected() { @@ -1369,6 +1419,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn bad_version_in_header_rejected() { @@ -1380,6 +1431,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn empty_store_rejected() { @@ -1387,6 +1439,7 @@ mod tests assert!(matches!(r, Err(SeError::Chain(_)))); } + #[cfg(feature = "attestation")] #[test] fn verify_cert_chain_never_panics_on_any_truncation() { @@ -1400,6 +1453,7 @@ mod tests } } + #[cfg(feature = "attestation")] #[test] fn verify_cert_chain_never_panics_on_single_byte_flips_across_store() { @@ -1415,6 +1469,7 @@ mod tests } } + #[cfg(feature = "attestation")] #[test] fn parse_spki_point_reads_the_xxxx_ca_p384_key() { @@ -1428,6 +1483,7 @@ mod tests assert_eq!(point[0], 0x04); } + #[cfg(feature = "attestation")] #[test] fn parse_spki_point_reads_the_root_p521_key() { @@ -1441,6 +1497,7 @@ mod tests assert_eq!(point, &golden_chain::MODEL_TEST_ROOT_PUBKEY[..]); } + #[cfg(feature = "attestation")] #[test] fn each_leaf_sig_alg_is_dispatched_from_its_own_oid() { @@ -1454,6 +1511,7 @@ mod tests assert!(matches!(parse_cert_parts(bodies[3]).unwrap().sig_alg, SigAlg::EcdsaSha512)); } + #[cfg(feature = "attestation")] #[test] fn algorithm_confusion_in_leaf_is_rejected_bad_public_key() { @@ -1488,6 +1546,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn chain_der_length_long_form_over_two_bytes_unsupported() { @@ -1514,6 +1573,7 @@ mod tests ); } + #[cfg(feature = "attestation")] #[test] fn parse_spki_point_without_ec_public_key_is_bad_public_key() { @@ -1532,7 +1592,7 @@ mod tests } } -#[cfg(test)] +#[cfg(all(test, feature = "attestation"))] mod golden_chain { // HERMETIC GOLDEN for chain verification diff --git a/crates/tropic01-driver/src/crypto.rs b/crates/tropic01-driver/src/crypto.rs index eff884c..19644ea 100644 --- a/crates/tropic01-driver/src/crypto.rs +++ b/crates/tropic01-driver/src/crypto.rs @@ -18,10 +18,15 @@ use x25519_dalek::PublicKey; use x25519_dalek::StaticSecret; use zeroize::Zeroizing; +#[cfg(feature = "attestation")] use p384::ecdsa::signature::Verifier; +#[cfg(feature = "attestation")] use p384::ecdsa::Signature as P384Signature; +#[cfg(feature = "attestation")] use p384::ecdsa::VerifyingKey as P384VerifyingKey; +#[cfg(feature = "attestation")] use p521::ecdsa::Signature as P521Signature; +#[cfg(feature = "attestation")] use p521::ecdsa::VerifyingKey as P521VerifyingKey; /// AES-GCM authentication tag length, in bytes. @@ -154,6 +159,7 @@ pub(crate) fn aes256gcm_open /// verification failure maps to `CryptoError`. /// /// This operates on PUBLIC certificate data, so constant time is not required. +#[cfg(feature = "attestation")] pub(crate) fn ecdsa_p384_sha384_verify ( pubkey_sec1: &[u8], @@ -177,6 +183,7 @@ pub(crate) fn ecdsa_p384_sha384_verify /// verification failure maps to `CryptoError`. /// /// This operates on PUBLIC certificate data, so constant time is not required. +#[cfg(feature = "attestation")] pub(crate) fn ecdsa_p521_sha512_verify ( pubkey_sec1: &[u8], @@ -198,6 +205,7 @@ pub(crate) fn ecdsa_p521_sha512_verify /// a malformed pinned anchor fails at construction rather than at first verify. /// /// This operates on PUBLIC key data, so constant time is not required. +#[cfg(feature = "attestation")] pub(crate) fn p521_validate_point(point: &[u8]) -> Result<(), CryptoError> { P521VerifyingKey::from_sec1_bytes(point).map_err(|_| CryptoError)?; diff --git a/crates/tropic01-driver/src/device/bootloader.rs b/crates/tropic01-driver/src/device/bootloader.rs index 309385c..ed9ef98 100644 --- a/crates/tropic01-driver/src/device/bootloader.rs +++ b/crates/tropic01-driver/src/device/bootloader.rs @@ -199,6 +199,38 @@ where /// the returned `Bootloader` handle gates the 0xB0/0xB1 update primitives. /// Mirrors the maintenance-reboot step of libtropic `lt_do_mutable_fw_update`. /// + /// # Example + /// + /// The full update flow: enter the bootloader, drive both bank pairs, then + /// reboot back to the Application firmware. (For a single call that does all + /// of this, see `NoSession::update_firmware`.) + /// + /// ```no_run + /// # use embedded_hal::spi::{ErrorType, Operation, SpiDevice}; + /// # use core::convert::Infallible; + /// # struct Spi; + /// # impl ErrorType for Spi { type Error = Infallible; } + /// # impl SpiDevice for Spi { + /// # fn transaction(&mut self, _ops: &mut [Operation<'_, u8>]) -> Result<(), Infallible> { Ok(()) } + /// # } + /// # struct Wait; + /// # impl tropic01_driver::SeWait for Wait { + /// # type Error = Infallible; + /// # fn wait_ready(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } + /// # fn delay_ms(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } + /// # } + /// use tropic01_driver::Tropic01; + /// + /// fn update(cpu_image: &[u8], spect_image: &[u8]) -> Result<(), tropic01_driver::SeError> + /// { + /// let dev = Tropic01::new(Spi, Wait); + /// let mut bl = dev.enter_bootloader().map_err(|(_dev, e)| e)?; + /// bl.update_firmware(cpu_image, spect_image)?; + /// let _dev = bl.exit_to_application().map_err(|(_bl, e)| e)?; + /// Ok(()) + /// } + /// ``` + /// /// # Errors /// /// On failure returns the `NoSession` handle plus the error (a bus fault or diff --git a/crates/tropic01-driver/src/device/nosession.rs b/crates/tropic01-driver/src/device/nosession.rs index 758a318..f22d9b8 100644 --- a/crates/tropic01-driver/src/device/nosession.rs +++ b/crates/tropic01-driver/src/device/nosession.rs @@ -156,10 +156,15 @@ where /// static handle does not grow a 3840-byte stack frame. Requires Application /// FW mode. /// - /// SECURITY: this extracts STPUB only. It does NOT verify the certificate - /// chain up to the Tropic root (mirrors libtropic `lt_get_st_pub`). The - /// handshake auth tag already binds STPUB. To also attest the chip identity, - /// use `read_verified_chip_stpub`. See `cert::parse_stpub`. + /// SECURITY: this DOES NOT ATTEST that the chip is a genuine TROPIC01. It + /// extracts STPUB only and does NOT verify the certificate chain up to the + /// Tropic root (mirrors libtropic `lt_get_st_pub`). A counterfeit or swapped + /// chip can serve any STPUB here, and this call will return it. The handshake + /// auth tag binds STPUB into the session, so a wrong STPUB cannot silently + /// open a channel, but that is integrity, NOT authenticity. For the + /// true-TROPIC01 guarantee, use `read_verified_chip_stpub` with a + /// `RootAnchor` pinned OUT-OF-BAND (compiled in, never read from the chip). + /// See `cert::parse_stpub`. /// /// # Errors /// @@ -208,6 +213,7 @@ where /// chain does not verify under `anchor`, or `SeError::Cert(_)` when a /// certificate does not parse. Otherwise `SeError` on a bus fault or a /// malformed reply. + #[cfg(feature = "attestation")] pub fn read_verified_chip_stpub ( &mut self, @@ -424,6 +430,50 @@ where /// Consumes the handle. On success returns an `ActiveSession` handle ready /// for L3 commands. /// + /// # Example + /// + /// ```no_run + /// # use embedded_hal::spi::{ErrorType, Operation, SpiDevice}; + /// # use core::convert::Infallible; + /// # struct Spi; + /// # impl ErrorType for Spi { type Error = Infallible; } + /// # impl SpiDevice for Spi { + /// # fn transaction(&mut self, _ops: &mut [Operation<'_, u8>]) -> Result<(), Infallible> { Ok(()) } + /// # } + /// # struct Wait; + /// # impl tropic01_driver::SeWait for Wait { + /// # type Error = Infallible; + /// # fn wait_ready(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } + /// # fn delay_ms(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } + /// # } + /// use tropic01_driver::{SessionConfig, StartupId, Tropic01}; + /// use zeroize::Zeroizing; + /// + /// fn open() -> Result<(), tropic01_driver::SeError> + /// { + /// let mut dev = Tropic01::new(Spi, Wait); + /// // The secure channel lives in Application FW: reboot into it first. + /// dev.reboot(StartupId::Reboot)?; + /// // Placeholder keys: real ones come from a TRNG / provisioning, and + /// // `stpub` from the chip certificate (verified via read_verified_chip_stpub). + /// let ehpriv = Zeroizing::new([0u8; 32]); + /// let shipriv = Zeroizing::new([0u8; 32]); + /// let shipub = [0u8; 32]; + /// let stpub = [0u8; 32]; + /// let cfg = SessionConfig + /// { + /// ehpriv: &ehpriv, + /// shipriv: &shipriv, + /// shipub: &shipub, + /// stpub: &stpub, + /// pkey_index: 0, + /// }; + /// // The error path returns the handle too. Keep only the SeError here. + /// let _session = dev.open_session(cfg).map_err(|(_dev, e)| e)?; + /// Ok(()) + /// } + /// ``` + /// /// # Errors /// /// On failure returns the `NoSession` handle plus the error (a handshake, diff --git a/crates/tropic01-driver/src/error.rs b/crates/tropic01-driver/src/error.rs index 7ef9bf7..e24341d 100644 --- a/crates/tropic01-driver/src/error.rs +++ b/crates/tropic01-driver/src/error.rs @@ -102,6 +102,7 @@ pub enum CertError /// from the chip), so every variant is a fail-closed rejection. The load-bearing /// trust step is verifying the product CA under the caller-PINNED root key, never /// under a key taken from the store. +#[cfg(feature = "attestation")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChainError { @@ -170,6 +171,7 @@ pub enum SeError /// Parsing the X.509 certificate store (STPUB extraction) failed. Cert(CertError), /// Verifying the X.509 certificate chain up to the pinned root failed. + #[cfg(feature = "attestation")] Chain(ChainError), /// The session was torn down. Re-handshake before any further L3 command. SessionLost, @@ -272,6 +274,7 @@ impl From for SeError } } +#[cfg(feature = "attestation")] impl From for SeError { fn from(e: ChainError) -> Self @@ -341,6 +344,7 @@ mod tests assert_eq!(e, SeError::Cert(CertError::KeyNotFound)); } + #[cfg(feature = "attestation")] #[test] fn chain_folds_into_se_error() { diff --git a/crates/tropic01-driver/src/lib.rs b/crates/tropic01-driver/src/lib.rs index 4362502..98f83ee 100644 --- a/crates/tropic01-driver/src/lib.rs +++ b/crates/tropic01-driver/src/lib.rs @@ -14,10 +14,74 @@ //! //! # Crate features //! -//! There is no user-facing feature. Both Cargo features are development-only and -//! a consumer leaves them off. `_fuzz` exposes the attacker-facing parsers to +//! The user-facing feature is `attestation` (ON by default): it enables X.509 +//! chain verification and pulls the ECDSA curve crates (`ecdsa`, `p384`, +//! `p521`). Those are PRE-RELEASE (`-rc`) crates today, pinned to keep one +//! `digest` generation in the tree, so a default build drags in release-candidate +//! crypto. Disable the feature via `default-features = false` to drop them when +//! only STPUB extraction is needed. The other two features are development-only +//! and a consumer leaves them off. `_fuzz` exposes the attacker-facing parsers to //! the libFuzzer harnesses. `model-itest` compiles the live integration tests //! that run against the official TROPIC01 emulator. +//! +//! # Example +//! +//! Open a secure channel and run one L3 command. The chip wiring (the SPI bus +//! and the ready/timeout provider) is supplied by the integrator, here it is +//! stubbed. All keys come from the caller via [`SessionConfig`]. +//! +//! ```no_run +//! # use embedded_hal::spi::{ErrorType, Operation, SpiDevice}; +//! # use core::convert::Infallible; +//! # struct Spi; +//! # impl ErrorType for Spi { type Error = Infallible; } +//! # impl SpiDevice for Spi { +//! # fn transaction(&mut self, _ops: &mut [Operation<'_, u8>]) -> Result<(), Infallible> { Ok(()) } +//! # } +//! # struct Wait; +//! # impl tropic01_driver::SeWait for Wait { +//! # type Error = Infallible; +//! # fn wait_ready(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } +//! # fn delay_ms(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } +//! # } +//! use tropic01_driver::{SeCommands, SessionConfig, StartupId, Tropic01}; +//! use zeroize::Zeroizing; +//! +//! fn run() -> Result<(), tropic01_driver::SeError> +//! { +//! let mut dev = Tropic01::new(Spi, Wait); +//! // Load the Application firmware: the secure channel lives there. +//! dev.reboot(StartupId::Reboot)?; +//! +//! // All key material is caller-provided. The driver hardcodes no secrets. +//! // These zeros are PLACEHOLDERS: real ephemerals come from a TRNG, the +//! // pairing keys from provisioning, and `stpub` from the chip certificate. +//! // For a genuine-chip trust decision, obtain `stpub` via +//! // `read_verified_chip_stpub` against a `RootAnchor` pinned out-of-band, +//! // not the unverified `read_chip_stpub`. +//! let ehpriv = Zeroizing::new([0u8; 32]); +//! let shipriv = Zeroizing::new([0u8; 32]); +//! let shipub = [0u8; 32]; +//! let stpub = [0u8; 32]; +//! let cfg = SessionConfig +//! { +//! ehpriv: &ehpriv, +//! shipriv: &shipriv, +//! shipub: &shipub, +//! stpub: &stpub, +//! pkey_index: 0, +//! }; +//! // open_session consumes the handle and reports the error as a tuple, so +//! // recover the SeError with map_err before using `?`. +//! let mut session = dev.open_session(cfg).map_err(|(_dev, e)| e)?; +//! +//! // Run one encrypted L3 command, then tear the channel down. +//! let mut random = [0u8; 32]; +//! session.random_into(&mut random)?; +//! let _dev = session.close_session(); +//! Ok(()) +//! } +//! ``` #![cfg_attr(not(test), no_std)] @@ -43,8 +107,11 @@ mod test_support; // Curated public surface. Nothing else is exported. pub use crate::cert::parse_stpub; +#[cfg(feature = "attestation")] pub use crate::cert::parse_verified_stpub; +#[cfg(feature = "attestation")] pub use crate::cert::verify_cert_chain; +#[cfg(feature = "attestation")] pub use crate::cert::RootAnchor; pub use crate::device::ActiveSession; pub use crate::device::Bootloader; @@ -56,6 +123,7 @@ pub use crate::device::SessionConfig; pub use crate::device::StartupId; pub use crate::device::Tropic01; pub use crate::error::CertError; +#[cfg(feature = "attestation")] pub use crate::error::ChainError; pub use crate::error::FwImageError; pub use crate::error::HandshakeError; @@ -147,6 +215,7 @@ pub mod fuzz /// pinned anchor. Must never panic. The anchor's exact value is irrelevant: /// fuzzing targets the bounded DER parsing in front of the crypto, which /// fails closed on essentially every mutated input. + #[cfg(feature = "attestation")] pub fn verify_cert_chain(data: &[u8]) { // A fixed, REAL P-521 SEC1 point (0x04 || X(66) || Y(66)). The anchor now diff --git a/crates/tropic01-driver/src/port.rs b/crates/tropic01-driver/src/port.rs index 0a54129..f6f1a47 100644 --- a/crates/tropic01-driver/src/port.rs +++ b/crates/tropic01-driver/src/port.rs @@ -560,6 +560,51 @@ pub trait SeCommands /// Returns the number of bytes written, which equals `out.len()`. An empty /// `out` returns `Ok(0)` with no chip traffic. /// + /// # Example + /// + /// ```no_run + /// # use embedded_hal::spi::{ErrorType, Operation, SpiDevice}; + /// # use core::convert::Infallible; + /// # struct Spi; + /// # impl ErrorType for Spi { type Error = Infallible; } + /// # impl SpiDevice for Spi { + /// # fn transaction(&mut self, _ops: &mut [Operation<'_, u8>]) -> Result<(), Infallible> { Ok(()) } + /// # } + /// # struct Wait; + /// # impl tropic01_driver::SeWait for Wait { + /// # type Error = Infallible; + /// # fn wait_ready(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } + /// # fn delay_ms(&mut self, _ms: u32) -> Result<(), Infallible> { Ok(()) } + /// # } + /// # use tropic01_driver::{SessionConfig, StartupId, Tropic01}; + /// # use zeroize::Zeroizing; + /// use tropic01_driver::SeCommands; + /// + /// # fn demo() -> Result<(), tropic01_driver::SeError> + /// # { + /// # let mut dev = Tropic01::new(Spi, Wait); + /// # dev.reboot(StartupId::Reboot)?; + /// # let ehpriv = Zeroizing::new([0u8; 32]); + /// # let shipriv = Zeroizing::new([0u8; 32]); + /// # let shipub = [0u8; 32]; + /// # let stpub = [0u8; 32]; + /// # let cfg = SessionConfig + /// # { + /// # ehpriv: &ehpriv, + /// # shipriv: &shipriv, + /// # shipub: &shipub, + /// # stpub: &stpub, + /// # pkey_index: 0, + /// # }; + /// # let mut session = dev.open_session(cfg).map_err(|(_d, e)| e)?; + /// // `session` is a Tropic01<_, _, ActiveSession>, which implements SeCommands. + /// let mut buf = [0u8; 16]; + /// let n = session.random_into(&mut buf)?; + /// assert_eq!(n, buf.len()); + /// # Ok(()) + /// # } + /// ``` + /// /// # Errors /// /// `SeError::InvalidArgument` when `out.len() > 255` (chunking is a caller diff --git a/crates/tropic01-driver/tests/model_itest.rs b/crates/tropic01-driver/tests/model_itest.rs index 3a7a735..d9f4c3a 100644 --- a/crates/tropic01-driver/tests/model_itest.rs +++ b/crates/tropic01-driver/tests/model_itest.rs @@ -78,6 +78,7 @@ const EHPRIV: [u8; 32] = hex32("0102030405060708090a0b0c0d0e0f101112131415161718 // This is the model's TEST root, captured from model_cfg.yml and cross-checked // with openssl. PROD differs: the integrator compiles in the real root. The // chain verifier anchors trust HERE, never in the store's self-signed root. +#[cfg(feature = "attestation")] const MODEL_TEST_ROOT_PUBKEY: [u8; 133] = hex_arr::<133>( "040135c7a24d16b374b207ade8fe50f503ad34e0e596c83fc98adb4c4388ca0ad9b24e\ 77e984b8978253a8e0d6fd68eaa8d9c9a9a6c8835a138cccff51130da109868000cdf7f\ @@ -712,6 +713,7 @@ fn read_chip_stpub_returns_pinned_stpub() assert_eq!(stpub, STPUB, "read_chip_stpub must match the model's pinned key"); } +#[cfg(feature = "attestation")] #[test] fn verify_cert_chain_accepts_the_model_chain() { @@ -728,6 +730,7 @@ fn verify_cert_chain_accepts_the_model_chain() tropic01_driver::verify_cert_chain(&store, &anchor).expect("model chain verifies under pinned root"); } +#[cfg(feature = "attestation")] #[test] fn verify_cert_chain_rejects_a_wrong_anchor() { @@ -745,6 +748,7 @@ fn verify_cert_chain_rejects_a_wrong_anchor() ); } +#[cfg(feature = "attestation")] #[test] fn read_verified_chip_stpub_returns_pinned_stpub() { @@ -869,6 +873,7 @@ fn sleep_is_reachable() /// Derived from a fixed non-trivial scalar so it is a real on-curve point that /// the eagerly-validating anchor constructor accepts, yet differs from the model /// TEST root, so the chain fails to verify under it. +#[cfg(feature = "attestation")] fn other_valid_p521_point() -> [u8; 133] { use p521::ecdsa::SigningKey; @@ -898,6 +903,7 @@ const fn hex32(s: &str) -> [u8; 32] /// Decodes a hex string (whitespace allowed) to `N` bytes at compile time. /// /// Skips ASCII whitespace, so the literal may be wrapped across lines. +#[cfg(feature = "attestation")] const fn hex_arr(s: &str) -> [u8; N] { let b = s.as_bytes();