diff --git a/packages/release/encrypt-datastore.service b/packages/release/encrypt-datastore.service index f30872625..2e3488ccc 100644 --- a/packages/release/encrypt-datastore.service +++ b/packages/release/encrypt-datastore.service @@ -19,7 +19,7 @@ ExecCondition=/usr/bin/rottweiler check directory ${DATASTORE_DIR} unencrypted ExecStart=/usr/sbin/tune2fs -O encrypt /dev/disk/by-partlabel/BOTTLEROCKET-PRIVATE # Generate key and encrypt directory. -ExecStart=/usr/bin/rottweiler generate-key datastore +ExecStart=/usr/bin/rottweiler generate key datastore ExecStart=/usr/bin/rottweiler encrypt directory ${DATASTORE_DIR} datastore UMask=0077 diff --git a/packages/release/encrypt-local-fs.service b/packages/release/encrypt-local-fs.service index 2e3503116..d8f31b129 100644 --- a/packages/release/encrypt-local-fs.service +++ b/packages/release/encrypt-local-fs.service @@ -17,7 +17,7 @@ Environment=BOTTLEROCKET_DATA=/dev/disk/by-partlabel/BOTTLEROCKET-DATA ExecCondition=/usr/bin/rottweiler check block-device ${BOTTLEROCKET_DATA} unencrypted # Generate key and encrypt block device. -ExecStart=/usr/bin/rottweiler generate-key bottlerocket-data +ExecStart=/usr/bin/rottweiler generate key bottlerocket-data ExecStart=/usr/bin/rottweiler encrypt block-device ${BOTTLEROCKET_DATA} bottlerocket-data UMask=0077 diff --git a/sources/Cargo.lock b/sources/Cargo.lock index fe2fce5cf..398fb30e5 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -497,6 +497,12 @@ dependencies = [ "serde", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arrayvec" version = "0.7.6" @@ -1300,6 +1306,30 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "binrw" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d4bca59c20d6f40c2cc0802afbe1e788b89096f61bdf7aeea6bf00f10c2909b" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ba42866ce5bced2645bfa15e97eef2c62d2bdb530510538de8dd3d04efff3c" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bit_field" version = "0.10.3" @@ -1719,6 +1749,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" @@ -4170,6 +4206,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "p256" version = "0.11.1" @@ -4843,15 +4885,21 @@ name = "rottweiler" version = "0.1.0" dependencies = [ "argh", + "base64", + "binrw", "bottlerocket-image-features", "envy", "generate-readme", "hex", + "hex-literal", "hkdf", "nix", "serde", + "serde_json", + "serde_plain", "sha2", "snafu", + "test-case", "walkdir", "zeroize", ] diff --git a/sources/Cargo.toml b/sources/Cargo.toml index 202e49ac1..94a627fa4 100644 --- a/sources/Cargo.toml +++ b/sources/Cargo.toml @@ -132,6 +132,7 @@ aws-smithy-runtime-api = "1" aws-smithy-types = "1" aws-smithy-async = "1" aws-types = "1" +binrw = "0.14" bit_field = "0.10" bon = "2" bytes = "1" diff --git a/sources/api/apiserver/src/server/ephemeral_storage.rs b/sources/api/apiserver/src/server/ephemeral_storage.rs index f3169165e..4e424bee3 100644 --- a/sources/api/apiserver/src/server/ephemeral_storage.rs +++ b/sources/api/apiserver/src/server/ephemeral_storage.rs @@ -557,7 +557,7 @@ fn encrypt_ephemeral_device(device: &str) -> Result { if !is_encrypted { run_rottweiler_checked( - &["generate-key", EPHEMERAL_STORAGE_KEY_ID], + &["generate", "key", EPHEMERAL_STORAGE_KEY_ID], EPHEMERAL_DATA_LINK, )?; run_rottweiler_checked( diff --git a/sources/deny.toml b/sources/deny.toml index fbe4487cd..08c546edd 100644 --- a/sources/deny.toml +++ b/sources/deny.toml @@ -21,6 +21,7 @@ allow = [ ] exceptions = [ + { name = "rottweiler", allow = ["LGPL-2.1"], version = "*" }, ] # https://github.com/hsivonen/encoding_rs The non-test code that isn't generated from the WHATWG data in this crate is diff --git a/sources/rottweiler/Cargo.toml b/sources/rottweiler/Cargo.toml index ac78d48d3..03ed01d6f 100644 --- a/sources/rottweiler/Cargo.toml +++ b/sources/rottweiler/Cargo.toml @@ -2,21 +2,29 @@ name = "rottweiler" version = "0.1.0" edition = "2024" -license = "Apache-2.0 OR MIT" +license = "(Apache-2.0 OR MIT) AND LGPL-2.1-or-later" publish = false [dependencies] argh.workspace = true +base64.workspace = true +binrw.workspace = true bottlerocket-image-features.workspace = true envy.workspace = true hex.workspace = true +hex-literal.workspace = true hkdf = { workspace = true, features = ["std"] } nix = { workspace = true, features = ["fs", "ioctl", "mount"] } serde = { workspace = true, features = ["derive"] } +serde_plain.workspace = true +serde_json.workspace = true sha2.workspace = true snafu.workspace = true walkdir.workspace = true zeroize = { workspace = true, features = ["alloc", "derive"] } +[dev-dependencies] +test-case.workspace = true + [build-dependencies] generate-readme.workspace = true diff --git a/sources/rottweiler/README.md b/sources/rottweiler/README.md index 596a1898b..f3b7684bf 100644 --- a/sources/rottweiler/README.md +++ b/sources/rottweiler/README.md @@ -14,7 +14,8 @@ interface for encrypting and managing encrypted storage resources including: ### Commands #### Key Management -- `generate-key ` - Generate an encryption key +- `generate key ` - Generate an encryption key +- `dump key ` - Dump encrypted key structure #### Block Device Operations - `encrypt block-device ` - Encrypt a block device using LUKS @@ -28,6 +29,7 @@ interface for encrypting and managing encrypted storage resources including: - `lock directory ` - Lock an encrypted directory (remove key) - `unlock directory ` - Unlock an encrypted directory (add key) - `check directory encrypted|unencrypted` - Check directory encryption state +- `check directory locked|unlocked` - Check directory lock state #### TPM Measurement Operations - `measure settings` - Measure OS settings into PCR 8 diff --git a/sources/rottweiler/src/cred.rs b/sources/rottweiler/src/cred.rs new file mode 100644 index 000000000..c63d8a01d --- /dev/null +++ b/sources/rottweiler/src/cred.rs @@ -0,0 +1,704 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * Copyright (C) 2021-2026 Systemd Authors + * + * SPDX-License-Identifier: LGPL-2.1-or-later + * Originally derived from: + * https://github.com/systemd/systemd/blob/7e37e01768e2f223750ead2c9e08b4490243b8d1/src/shared/creds-util.c + * https://github.com/systemd/systemd/blob/7e37e01768e2f223750ead2c9e08b4490243b8d1/src/shared/creds-util.h + * + */ + +//! Systemd credential format structures and parsing +//! +//! This module implements parsing and serialization for systemd's encrypted credential format. +//! Credentials are encrypted using AES-256-GCM and can be sealed to: +//! - Host key (stored in /var/lib/systemd/credential.secret) +//! - TPM2 HMAC (sealed to PCR values) +//! - Both host and TPM2 keys combined +//! - Null key (no confidentiality, integrity only) +//! +//! The binary format consists of: +//! 1. Main header (encryption type, key/block/IV/tag sizes, IV data) +//! 2. Optional TPM2 header (PCR mask, sealed blob, policy hash) +//! 3. Optional TPM2 public key header (for signed PCR policies) +//! 4. Optional scoped header (for user-scoped credentials) +//! 5. Encrypted data (metadata + payload + GCM authentication tag) +//! +//! All sections are aligned to 8-byte boundaries. + +use base64::{Engine, engine::general_purpose}; +use binrw::{BinRead, BinResult, BinWrite}; +use hex_literal::hex; +use serde::{Deserialize, Serialize, Serializer, de::Error}; +use snafu::prelude::*; +use std::io::Cursor; +use std::str; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +type Result = std::result::Result; +type SerdeResult = std::result::Result; + +/// Parsed systemd encrypted credential +/// +/// Represents the complete structure of a systemd encrypted credential file, +/// including the main header, optional TPM2 headers, and encrypted payload. +/// +/// The encrypted_data field contains: metadata header + credential name + payload + GCM tag +#[derive(BinRead, BinWrite, Debug, Serialize, Deserialize, ZeroizeOnDrop)] +#[brw(little)] +pub(crate) struct ParsedCredential { + /// Encryption type/method used for this credential + encryption_type: EncryptionType, + /// Size of the encryption key in bytes (typically 32 for AES-256) + key_size: u32, + /// Block size for the cipher in bytes + block_size: u32, + /// Size of the initialization vector in bytes + iv_size: u32, + /// Size of the GCM authentication tag in bytes + tag_size: u32, + + /// Initialization vector for AES-GCM encryption + #[br(count = iv_size)] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + iv: Vec, + + /// TPM2 header (present if encryption type requires TPM2) + #[brw(align_before = 8)] + #[brw(if(encryption_type.requires_tpm2()))] + tpm2_header: Option, + + /// TPM2 public key header (present if using signed PCR policy) + #[brw(align_before = 8)] + #[brw(if(encryption_type.requires_tpm2_pk()))] + tpm2_pubkey_header: Option, + + /// Scoped header (present if credential is user-scoped) + #[brw(align_before = 8)] + #[brw(if(encryption_type.is_scoped()))] + scoped_header: Option, + + /// Encrypted data: metadata header (16 bytes) + credential name + payload + GCM tag + /// The metadata header contains timestamp, not_after, and name_size fields + #[br(parse_with = binrw::helpers::until_eof)] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + encrypted_data: Vec, +} + +impl ParsedCredential { + /// Parse a credential from bytes, attempting base64 decode first + pub(crate) fn from_bytes(data: &[u8]) -> Result { + // Try to decode as base64 first (systemd-creds stores credentials base64-encoded) + let decoded = Self::try_base64_decode(data).unwrap_or_else(|_| data.to_vec()); + + let mut cursor = Cursor::new(&decoded); + Self::read_le(&mut cursor).whatever_context("failed to parse credential structure") + } + + /// Attempt to decode base64, handling both standard and URL-safe variants + fn try_base64_decode(data: &[u8]) -> Result> { + // Convert to string, removing whitespace + let s = str::from_utf8(data).whatever_context("invalid UTF-8 in credential data")?; + let s = s.chars().filter(|c| !c.is_whitespace()).collect::(); + + // Try standard base64 first + if let Ok(decoded) = general_purpose::STANDARD.decode(&s) { + return Ok(decoded); + } + + // Try URL-safe base64 + if let Ok(decoded) = general_purpose::URL_SAFE.decode(&s) { + return Ok(decoded); + } + + whatever!("failed to decode base64 data") + } +} + +/// Validate that a credential is TPM2 HMAC encrypted with the expected PCR mask +pub(crate) fn validate_tpm2_hmac(data: &[u8], expected_pcrs: &[u32]) -> Result<()> { + let parsed = ParsedCredential::from_bytes(data)?; + + ensure_whatever!( + parsed.encryption_type == EncryptionType::Tpm2Hmac, + "expected Tpm2Hmac encryption, found {:?}", + parsed.encryption_type + ); + + let tpm2_header = parsed + .tpm2_header + .as_ref() + .whatever_context("missing TPM2 header")?; + + let expected_mask = pcr_list_to_mask(expected_pcrs); + let actual_pcrs = pcr_mask_to_list(tpm2_header.pcr_mask); + ensure_whatever!( + tpm2_header.pcr_mask == expected_mask, + "PCR mask mismatch: expected {:?}, found {:?}", + expected_pcrs, + actual_pcrs + ); + + Ok(()) +} + +/// Encryption type identifier for systemd credentials +/// +/// Each type uses a unique 128-bit UUID to identify the encryption method. +/// The encryption key is derived from one or more sources: +/// - Host: Key stored in /var/lib/systemd/credential.secret +/// - TPM2: HMAC key sealed to TPM2 PCR values +/// - Null: Empty key (provides integrity but no confidentiality) +/// +/// Scoped variants derive a per-user key by HMAC'ing the base key with +/// the user's UID, username, and machine ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Zeroize)] +enum EncryptionType { + /// Encrypted with host key only + Host, + /// Encrypted with host key, scoped to a specific user + HostScoped, + /// Encrypted with TPM2 HMAC key sealed to fixed PCR values + Tpm2Hmac, + /// Encrypted with TPM2 HMAC key using signed PCR policy + Tpm2HmacWithPk, + /// Encrypted with both host and TPM2 HMAC keys + HostAndTpm2Hmac, + /// Encrypted with host and TPM2 keys, scoped to a specific user + HostAndTpm2HmacScoped, + /// Encrypted with host and TPM2 keys using signed PCR policy + HostAndTpm2HmacWithPk, + /// Encrypted with host and TPM2 keys using signed PCR policy, user-scoped + HostAndTpm2HmacWithPkScoped, + /// Encrypted with null key (integrity only, no confidentiality) + Null, + /// Unknown encryption type with raw UUID + Unknown([u8; 16]), +} + +/// UUID constants for systemd credential encryption types +/// +/// These correspond to the CRED_AES256_GCM_BY_* constants in systemd's creds-util.h +const UUID_HOST: [u8; 16] = hex!("5a1c6a86df9d4096b1d5a65e0862f19a"); +const UUID_HOST_SCOPED: [u8; 16] = hex!("55b9ed1d38594d43a8319d2ebb332ac6"); +const UUID_TPM2_HMAC: [u8; 16] = hex!("0c7cc07b117645919c4b0bea08bc20fe"); +const UUID_TPM2_HMAC_WITH_PK: [u8; 16] = hex!("faf7eb9341e3412ca1a436f95a29362f"); +const UUID_HOST_AND_TPM2_HMAC: [u8; 16] = hex!("93a894094874449090caf2fc93cab553"); +const UUID_HOST_AND_TPM2_HMAC_SCOPED: [u8; 16] = hex!("ef4ac13679a9480ea7db68897f9f165d"); +const UUID_HOST_AND_TPM2_HMAC_WITH_PK: [u8; 16] = hex!("af4950a849134eb1a73846304ff30c05"); +const UUID_HOST_AND_TPM2_HMAC_WITH_PK_SCOPED: [u8; 16] = hex!("adbc4ca3efb64201ba881b6f2e4095ea"); +const UUID_NULL: [u8; 16] = hex!("058469daf6f54324800549da0f8ea2fb"); + +impl EncryptionType { + /// Convert a 128-bit UUID to an encryption type + fn from_id(id: [u8; 16]) -> Self { + match id { + UUID_HOST => Self::Host, + UUID_HOST_SCOPED => Self::HostScoped, + UUID_TPM2_HMAC => Self::Tpm2Hmac, + UUID_TPM2_HMAC_WITH_PK => Self::Tpm2HmacWithPk, + UUID_HOST_AND_TPM2_HMAC => Self::HostAndTpm2Hmac, + UUID_HOST_AND_TPM2_HMAC_SCOPED => Self::HostAndTpm2HmacScoped, + UUID_HOST_AND_TPM2_HMAC_WITH_PK => Self::HostAndTpm2HmacWithPk, + UUID_HOST_AND_TPM2_HMAC_WITH_PK_SCOPED => Self::HostAndTpm2HmacWithPkScoped, + UUID_NULL => Self::Null, + _ => Self::Unknown(id), + } + } + + /// Returns true if this encryption type requires TPM2 hardware + fn requires_tpm2(&self) -> bool { + matches!( + self, + Self::Tpm2Hmac + | Self::Tpm2HmacWithPk + | Self::HostAndTpm2Hmac + | Self::HostAndTpm2HmacScoped + | Self::HostAndTpm2HmacWithPk + | Self::HostAndTpm2HmacWithPkScoped + ) + } + + /// Returns true if this encryption type requires a TPM2 public key for signed PCR policy + fn requires_tpm2_pk(&self) -> bool { + matches!( + self, + Self::Tpm2HmacWithPk | Self::HostAndTpm2HmacWithPk | Self::HostAndTpm2HmacWithPkScoped + ) + } + + /// Returns true if this encryption type is user-scoped + fn is_scoped(&self) -> bool { + matches!( + self, + Self::HostScoped | Self::HostAndTpm2HmacScoped | Self::HostAndTpm2HmacWithPkScoped + ) + } +} + +impl BinRead for EncryptionType { + type Args<'a> = (); + + fn read_options( + reader: &mut R, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> BinResult { + let id = <[u8; 16]>::read_options(reader, endian, args)?; + Ok(Self::from_id(id)) + } +} + +impl BinWrite for EncryptionType { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> BinResult<()> { + let id = match self { + Self::Host => UUID_HOST, + Self::HostScoped => UUID_HOST_SCOPED, + Self::Tpm2Hmac => UUID_TPM2_HMAC, + Self::Tpm2HmacWithPk => UUID_TPM2_HMAC_WITH_PK, + Self::HostAndTpm2Hmac => UUID_HOST_AND_TPM2_HMAC, + Self::HostAndTpm2HmacScoped => UUID_HOST_AND_TPM2_HMAC_SCOPED, + Self::HostAndTpm2HmacWithPk => UUID_HOST_AND_TPM2_HMAC_WITH_PK, + Self::HostAndTpm2HmacWithPkScoped => UUID_HOST_AND_TPM2_HMAC_WITH_PK_SCOPED, + Self::Null => UUID_NULL, + Self::Unknown(id) => *id, + }; + id.write_options(writer, endian, args) + } +} + +serde_plain::derive_fromstr_from_deserialize!(EncryptionType); +serde_plain::derive_display_from_serialize!(EncryptionType); + +/// TPM2-specific header for credentials sealed to TPM2 PCR values +/// +/// Contains the TPM2 sealed blob and policy hash. The blob is created by sealing +/// a random key to specific PCR values. At decryption, TPM2 only unseals if current +/// PCR values match the policy. +/// +/// Corresponds to the TPM2 metadata in systemd's encrypted credential format. +#[derive(BinRead, BinWrite, Debug, Serialize, Deserialize, ZeroizeOnDrop)] +#[brw(little)] +struct Tpm2Header { + /// Bitmask of PCRs used for sealing (e.g., 0b0000_0111 = PCRs 0,1,2) + #[serde( + serialize_with = "serialize_pcr_mask", + deserialize_with = "deserialize_pcr_mask" + )] + pcr_mask: u64, + /// Hash algorithm used for PCR bank + pcr_bank: PcrBank, + /// TPM2 primary key algorithm + primary_alg: PrimaryAlg, + /// Size of the TPM2 sealed blob in bytes + blob_size: u32, + /// Size of the TPM2 policy hash in bytes + policy_hash_size: u32, + /// TPM2 sealed blob containing the encryption key + #[br(count = blob_size)] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + blob: Vec, + /// TPM2 policy hash for authorization + #[br(count = policy_hash_size)] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + policy_hash: Vec, +} + +/// TPM2 public key header for signed PCR policies +/// +/// Used when credentials are sealed with a signed PCR policy, allowing +/// PCR values to be updated without re-encrypting the credential. +#[derive(BinRead, BinWrite, Debug, Serialize, Deserialize, ZeroizeOnDrop)] +#[brw(little)] +struct Tpm2PublicKeyHeader { + /// Bitmask of PCRs covered by the signed policy + #[serde( + serialize_with = "serialize_pcr_mask", + deserialize_with = "deserialize_pcr_mask" + )] + pcr_mask: u64, + /// Size of the public key data in bytes + size: u32, + /// Public key data for verifying PCR policy signatures + #[br(count = size)] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + data: Vec, +} + +/// Scoped credential header for user-specific credentials +/// +/// Contains flags indicating the scope (e.g., per-user) of the credential. +#[derive(BinRead, BinWrite, Debug, Serialize, Deserialize, ZeroizeOnDrop)] +#[brw(little)] +struct ScopedHeader { + /// Flags indicating credential scope + flags: u64, +} + +/// TPM2 primary key algorithm used for sealing +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Zeroize)] +enum PrimaryAlg { + /// RSA algorithm (TPM_ALG_RSA = 0x0001) + #[serde(rename = "RSA")] + Rsa, + /// ECC algorithm (TPM_ALG_ECC = 0x0023) + #[serde(rename = "ECC")] + Ecc, + /// Unknown or unsupported algorithm + #[serde(rename = "Unknown")] + Unknown(u16), +} + +impl PrimaryAlg { + fn from_u16(value: u16) -> Self { + match value { + 0x01 => Self::Rsa, + 0x23 => Self::Ecc, + _ => Self::Unknown(value), + } + } + + fn to_u16(self) -> u16 { + match self { + Self::Rsa => 0x01, + Self::Ecc => 0x23, + Self::Unknown(v) => v, + } + } +} + +impl BinRead for PrimaryAlg { + type Args<'a> = (); + + fn read_options( + reader: &mut R, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> BinResult { + let value = u16::read_options(reader, endian, args)?; + Ok(Self::from_u16(value)) + } +} + +impl BinWrite for PrimaryAlg { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> BinResult<()> { + self.to_u16().write_options(writer, endian, args) + } +} + +serde_plain::derive_fromstr_from_deserialize!(PrimaryAlg); +serde_plain::derive_display_from_serialize!(PrimaryAlg); + +/// TPM2 PCR bank (hash algorithm) used for sealing credentials +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Zeroize)] +enum PcrBank { + /// SHA-1 hash algorithm (TPM_ALG_SHA1 = 0x0004) + #[serde(rename = "SHA1")] + Sha1, + /// SHA-256 hash algorithm (TPM_ALG_SHA256 = 0x000B) + #[serde(rename = "SHA256")] + Sha256, + /// SHA-384 hash algorithm (TPM_ALG_SHA384 = 0x000C) + #[serde(rename = "SHA384")] + Sha384, + /// SHA-512 hash algorithm (TPM_ALG_SHA512 = 0x000D) + #[serde(rename = "SHA512")] + Sha512, + /// Unknown or unsupported hash algorithm + #[serde(rename = "Unknown")] + Unknown(u16), +} + +impl PcrBank { + fn from_u16(value: u16) -> Self { + match value { + 0x04 => Self::Sha1, + 0x0b => Self::Sha256, + 0x0c => Self::Sha384, + 0x0d => Self::Sha512, + _ => Self::Unknown(value), + } + } + + fn to_u16(self) -> u16 { + match self { + Self::Sha1 => 0x04, + Self::Sha256 => 0x0b, + Self::Sha384 => 0x0c, + Self::Sha512 => 0x0d, + Self::Unknown(v) => v, + } + } +} + +impl BinRead for PcrBank { + type Args<'a> = (); + + fn read_options( + reader: &mut R, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> BinResult { + let value = u16::read_options(reader, endian, args)?; + Ok(Self::from_u16(value)) + } +} + +impl BinWrite for PcrBank { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> BinResult<()> { + self.to_u16().write_options(writer, endian, args) + } +} + +serde_plain::derive_fromstr_from_deserialize!(PcrBank); +serde_plain::derive_display_from_serialize!(PcrBank); + +/// Serialize a byte array as a hex string with 0x prefix +fn serialize_hex(bytes: &[u8], serializer: S) -> SerdeResult +where + S: Serializer, +{ + serializer.serialize_str(&format!("0x{}", hex::encode(bytes))) +} + +/// Deserialize a hex string (with or without 0x prefix) to bytes +fn deserialize_hex<'de, D>(deserializer: D) -> SerdeResult, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + let s = s.strip_prefix("0x").unwrap_or(&s); + hex::decode(s).map_err(D::Error::custom) +} + +/// Serialize PCR mask as a list of PCR numbers for JSON output +fn serialize_pcr_mask(mask: &u64, serializer: S) -> SerdeResult +where + S: Serializer, +{ + pcr_mask_to_list(*mask).serialize(serializer) +} + +/// Deserialize a list of PCR numbers back to a bitmask +fn deserialize_pcr_mask<'de, D>(deserializer: D) -> SerdeResult +where + D: serde::Deserializer<'de>, +{ + let pcrs: Vec = Deserialize::deserialize(deserializer)?; + Ok(pcr_list_to_mask(&pcrs)) +} + +/// Convert a PCR bitmask to a list of PCR numbers +fn pcr_mask_to_list(mask: u64) -> Vec { + (0..64).filter(|i| mask & (1 << i) != 0).collect() +} + +/// Convert a list of PCR numbers to a bitmask +fn pcr_list_to_mask(pcrs: &[u32]) -> u64 { + pcrs.iter().fold(0u64, |acc, &pcr| acc | (1 << pcr)) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{Engine, engine::general_purpose}; + + use binrw::BinRead; + use binrw::BinWrite; + + use std::io::Cursor; + use test_case::test_case; + + const TEST_CRED_NULL: &str = "BYRp2vb1QySABUnaD46i+yAAAAABAAAADAAAABAAAAATPU8srWAq3mtWrGkAAAAAb3P+yO/nQ2tRS+zpGHvQ8Jffr3a9SizoK5fzgIdgPxngfrszhbnh06X70Z+O1MObn+Jug1bwyvf1PBLNPdJevk8="; + const TEST_CRED_HOST: &str = "Whxqht+dQJax1aZeCGLxmiAAAAABAAAADAAAABAAAABuACQdV2GLhe0fc8IAAAAAhqpZ5GFTeZCZh4YSKvZ4TG8+SnHrIMduVkhbim5KDU7weMALI2GYks0GtAc1+HZraySbYV5klqXQwqlGvFFor6sl"; + const TEST_CRED_HOST_SCOPED: &str = "VbntHThZTUOoMZ0uuzMqxiAAAAABAAAADAAAABAAAACsr1L/GQT7Ec6jyREAAAAABwAAAAAAAAALOLwic485DC2MU64Nxw+u2Vc3f1smLnndgkpXsc2SpTxQa0vpGBKR/VKrgR5/So44bzfZ42R+uyKUDwtUygezSvQmNEqiBCkYQb40oPgmKSY="; + const TEST_CRED_TPM2: &str = "DHzAexF2RZGcSwvqCLwg/iAAAAABAAAADAAAABAAAACwL4g6mLs1KRACizoAAAAAgEgAAAAAAAALACMA8AAAACAAAAAAngAgCy4nQemrR8CRWFSvIa27VXsNfribuCyxTulHJEIJAEQAEHhKu845D580QKiryffnpiFy2okaY7/3/1teZpcYz7uSzhZygCf/7jVsVGohFseJQD2bZDXHoLlbamkVer5uHYqOhA6k45wp8jNSDnpyNegg1wPDW2l/7nG9SlcwP6ydYeGJqDgr5XvkfL6aKPb7PzaKyDHS6pwKRaRZAE4ACAQLAAAEEgAgPCLw7Y+QTu3anSLQAz/AdihEDbxRyFRBjJlurO3yJGYAEAAgnITcQgel3etyLepe53Hvzt4yFTyLtHi+IeBBWMWpr9M8IvDtj5BO7dqdItADP8B2KEQNvFHIVEGMmW6s7fIkZgAAAAAQxnOVemcSI6hw8np7pKCysTwF9+x91U1R3WbZhSHiRJjBl7dlNJe6XqyHRVcmpuMwP94mcd2POgpIzc+k1IPkeXsC46yc5HUH03DH0lN5xLpXe/0q33SRPTa8uCVaAyX2V+c4JlL+oR9AxEHeYk66"; + const TEST_CRED_TPM2_PK: &str = "+vfrk0HjQSyhpDb5Wik2LyAAAAABAAAADAAAABAAAADr/l6SPu4hgcyb8hsAAAAAgAAAAAAAAAALACMA8AAAACAAAAAAngAgXEtd5ZKymJc4LAUa24ihiDpMwenVj+2cx54ndzbLt2EAEPZ2qz3k1NKXdVubpcUoPiUHcQmmyADucLIPQdSMbtOBiZDJnewqiV4huRajKq/qyAP8TaCF5Cy/sSlrmgk3gbNyiUp/pWHjkysPpxLDGgOfgSQmTnVJQ/YvUGrfcz10ANZ5bDM0fiRLUuT7N3J7jHhidSeg5gxboWzfAE4ACAALAAAEEgAgzMd0m0Ud4W/pko5DzNAYtpqKVP/aELu40y6ytxXVtFoAEAAg1XfuDJrHZGKNvFZ9CmOxpmbXQOTbFIptMLG9kXHGqwXMx3SbRR3hb+mSjkPM0Bi2mopU/9oQu7jTLrK3FdW0WgAAAAAACAAAAAAAAMMBAAAtLS0tLUJFR0lOIFBVQkxJQyBLRVktLS0tLQpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXpOK0NDNGJ4aCtVQ3lQQVNzT2MrCkx2Zm5EdEU4bXBRalZMcFJVS1o5aTRLZlE4UGhqUlY5Kyt5SWY4S2t5NWJQMnFlMkt0Mk9OMlpPZWkyNUg4cEwKekRQUzV4SUtPTksvNTBTVDg3NjRDVjBnanVTSExkMXFJSVZsUUE1bkUzcEluU1h4R2pDeHRCQW5OOWNaRkNpeApTdWdER29ROThwVk9PUWJJSHBYZHR0VWZRSmVMRGhkNlB4ZmRtanMvdG9QbmdWTmVRVG5ZaXo2T3NCZ0VmNm5oClVMYkZTOHlhNGd6WmRKR0xZcHdQUmt6YUtRQ1JXQkhXOEp4VXVPUEpPdVJ6TUhjWW11ci9RWEdXTGEyeEZlcjEKQ05Od0tEczhvMDNucFFOczFWcVlwQndhM2J5Qk9NU1FkSk1oUVV5U0VpY1F1a1dBN3QwY0JwVnpRTlM5VFFDUwp0d0lEQVFBQgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0KACeqyiAxYPRchoIUhLacmLm8i2s0EozluIWleMivmvCbL2lbjg0uWagbzFnUPktEWNMz1EdPPOP+sDfdHSlPDaTw7V0grg=="; + const TEST_CRED_HOST_TPM2: &str = "k6iUCUh0RJCQyvL8k8q1UyAAAAABAAAADAAAABAAAAAc6j5b3UjlfGGuwgoAAAAAgAAAAAAAAAALACMA8AAAACAAAAAAngAgRULYCfz4rcbWuYQ9n9Mpj8sH44iEzs8JxnUBJI5Y45QAEGN4WkhMiMPu0sEmjdRgzyQ+206EKqgCaQwqYWUkao0d4z4aNfXqOYQP4OTli/mSS+960q7TnuEzY3rR8WhBDP6+0Cg7+i+WqD5+/x5LfiAMUHM4JAJF4pyHvauPEaBK0FIvlhuYvO7uafRO+maaw7o3fKXgrFYIy86/AE4ACAALAAAEEgAg8iJDoUtcVQ9l/zoZVoeBqDQw2CJeyuE6Z98IqPlTJb0AEAAgV/J/fU+Zfu2wtVC1f5MZiuf193nROgCS6o1P6Xg98w/yIkOhS1xVD2X/OhlWh4GoNDDYIl7K4Tpn3wio+VMlvQAAAACV56EMGxp9JjGTW+JekslX30ylk9re29gQgt+GmpdKAVkz0m9L0Q+X5OVusSFfP9Wl8+hFYzgs08BPwupraCHiAYU="; + const TEST_CRED_HOST_TPM2_SCOPED: &str = "70rBNnmpSA6n22iJf58WXSAAAAABAAAADAAAABAAAACDq8reWt0mjtVQvAsAAAAAgAAAAAAAAAALACMA8AAAACAAAAAAngAg9sCnvuaDetNfPQFADWFbCNHJ20by95yBrVKorCXjy+YAEMxvT/qs/i6e2618Cp6ArqWKfVrHhhs0wZ8qnRnEtPimM2ujRrX++b523Hum/YEVlUot3G6eCn40Mge5u28FvQbMIKIHd0Ta6dHmTdI18kwSQEuv5wVvPZiL8mbbA3zjEHw741t3Qc0Nl0I9UBXGkHU0LPCGStQsxc+HAE4ACAALAAAEEgAg8iJDoUtcVQ9l/zoZVoeBqDQw2CJeyuE6Z98IqPlTJb0AEAAgsMFQiG2Oao/Z9wPrsa6fmg3F5LUOcT/Tx9egzh9YBnfyIkOhS1xVD2X/OhlWh4GoNDDYIl7K4Tpn3wio+VMlvQAAAAAHAAAAAAAAANx7GNuQDUPpYw/3PoFGgXx9N8yXDACcmBW3S1caSurSmr+0VZRW0qf5owKxyExBxQFIhzBFa8PY15mGxyABJCaR/JrJDeXC/3Oe5eX4hTuMGg=="; + const TEST_CRED_HOST_TPM2_PK: &str = "r0lQqEkTTrGnOEYwT/MMBSAAAAABAAAADAAAABAAAAAOPM2bh8TYfQZabS4AAAAAgAAAAAAAAAALACMA8AAAACAAAAAAngAg+4OBD0KI8a/F2QVD5T4bkYJ1+91xmDc9lU+d4GLbATIAEPmc/sqtwShsliB/tvNj3SVIqMEWkgt1eYr4KKYMX0kRvhQ91aENQrDFMtHaoNe4+Fcj6KV4CDAgeAIFe+Fhrdr/6OErMvOCzuaFtF22vmIhD/iUeMGugX1i3L7VCwFl9c97RpczIE8OSr5JRgHv1dz02OX84mzW65MjAE4ACAALAAAEEgAgzMd0m0Ud4W/pko5DzNAYtpqKVP/aELu40y6ytxXVtFoAEAAgZflj1qxOgOX6yNtBp6VZyHW7e1e/1P0t64Y7ioj6WvXMx3SbRR3hb+mSjkPM0Bi2mopU/9oQu7jTLrK3FdW0WgAAAAAACAAAAAAAAMMBAAAtLS0tLUJFR0lOIFBVQkxJQyBLRVktLS0tLQpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXpOK0NDNGJ4aCtVQ3lQQVNzT2MrCkx2Zm5EdEU4bXBRalZMcFJVS1o5aTRLZlE4UGhqUlY5Kyt5SWY4S2t5NWJQMnFlMkt0Mk9OMlpPZWkyNUg4cEwKekRQUzV4SUtPTksvNTBTVDg3NjRDVjBnanVTSExkMXFJSVZsUUE1bkUzcEluU1h4R2pDeHRCQW5OOWNaRkNpeApTdWdER29ROThwVk9PUWJJSHBYZHR0VWZRSmVMRGhkNlB4ZmRtanMvdG9QbmdWTmVRVG5ZaXo2T3NCZ0VmNm5oClVMYkZTOHlhNGd6WmRKR0xZcHdQUmt6YUtRQ1JXQkhXOEp4VXVPUEpPdVJ6TUhjWW11ci9RWEdXTGEyeEZlcjEKQ05Od0tEczhvMDNucFFOczFWcVlwQndhM2J5Qk9NU1FkSk1oUVV5U0VpY1F1a1dBN3QwY0JwVnpRTlM5VFFDUwp0d0lEQVFBQgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0KAHU4LyqVyGZfdeNitbe7p9Ycol2sGR2jHFhNojU20RFySitY4tkVArDXzp08UaIRqulBg6AlFxROWTDSYueyaadWQYXgkg=="; + const TEST_CRED_HOST_TPM2_PK_SCOPED: &str = "rbxMo++2QgG6iBtvLkCV6iAAAAABAAAADAAAABAAAABnM7Vsx0eBsO4oC+AAAAAAgAAAAAAAAAALACMA8AAAACAAAAAAngAgVJglupflu/1r6BPo/Kx3FBaZxvkzNTnvYNbuuOQ+K+4AELDrnjG7ZNHTZqcgFePAdlrLLXpHrV91WTR4EsPXSa+w16l6RCabdfzbX/xkq0Z+bTiCNaxYtq3YzPYcor3ldpMB85er8MISzJWka7q+1j1NaSXtuI20zchmvQFUYW8ZScGQdm0/26UZX1A+7EO5Y5M+TCF+rFyMeKjPAE4ACAALAAAEEgAgzMd0m0Ud4W/pko5DzNAYtpqKVP/aELu40y6ytxXVtFoAEAAgh4mzAtHOBi8U0FiIyth5c6f+G0I56yHFKBvgIf1BwlDMx3SbRR3hb+mSjkPM0Bi2mopU/9oQu7jTLrK3FdW0WgAAAAAACAAAAAAAAMMBAAAtLS0tLUJFR0lOIFBVQkxJQyBLRVktLS0tLQpNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXpOK0NDNGJ4aCtVQ3lQQVNzT2MrCkx2Zm5EdEU4bXBRalZMcFJVS1o5aTRLZlE4UGhqUlY5Kyt5SWY4S2t5NWJQMnFlMkt0Mk9OMlpPZWkyNUg4cEwKekRQUzV4SUtPTksvNTBTVDg3NjRDVjBnanVTSExkMXFJSVZsUUE1bkUzcEluU1h4R2pDeHRCQW5OOWNaRkNpeApTdWdER29ROThwVk9PUWJJSHBYZHR0VWZRSmVMRGhkNlB4ZmRtanMvdG9QbmdWTmVRVG5ZaXo2T3NCZ0VmNm5oClVMYkZTOHlhNGd6WmRKR0xZcHdQUmt6YUtRQ1JXQkhXOEp4VXVPUEpPdVJ6TUhjWW11ci9RWEdXTGEyeEZlcjEKQ05Od0tEczhvMDNucFFOczFWcVlwQndhM2J5Qk9NU1FkSk1oUVV5U0VpY1F1a1dBN3QwY0JwVnpRTlM5VFFDUwp0d0lEQVFBQgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0KAAcAAAAAAAAAu88CqoLt4hAGrueYixcusMt0mKH0OZngkM/3rm5hriZRr+pBgp9wfsBZ14KmctN5/zFmW+s37Sw3w6DWCYmwM9Zma+LgA8hOKOQuUqLMOOKptSmo"; + + #[test_case(TEST_CRED_NULL, EncryptionType::Null, false ; "null encryption")] + #[test_case(TEST_CRED_HOST, EncryptionType::Host, false ; "host encryption")] + #[test_case(TEST_CRED_HOST_SCOPED, EncryptionType::HostScoped, false ; "host scoped")] + #[test_case(TEST_CRED_TPM2, EncryptionType::Tpm2Hmac, true ; "tpm2 hmac")] + #[test_case(TEST_CRED_TPM2_PK, EncryptionType::Tpm2HmacWithPk, true ; "tpm2 with public key")] + #[test_case(TEST_CRED_HOST_TPM2, EncryptionType::HostAndTpm2Hmac, true ; "host and tpm2")] + #[test_case(TEST_CRED_HOST_TPM2_SCOPED, EncryptionType::HostAndTpm2HmacScoped, true ; "host and tpm2 scoped")] + #[test_case(TEST_CRED_HOST_TPM2_PK, EncryptionType::HostAndTpm2HmacWithPk, true ; "host and tpm2 with public key")] + #[test_case(TEST_CRED_HOST_TPM2_PK_SCOPED, EncryptionType::HostAndTpm2HmacWithPkScoped, true ; "host and tpm2 with public key scoped")] + fn test_from_bytes(base64: &str, expected_type: EncryptionType, has_tpm2: bool) { + let parsed = ParsedCredential::from_bytes(base64.as_bytes()).unwrap(); + assert_eq!(parsed.encryption_type, expected_type); + assert_eq!(parsed.tpm2_header.is_some(), has_tpm2); + } + + #[test_case(TEST_CRED_NULL ; "null")] + #[test_case(TEST_CRED_HOST ; "host")] + #[test_case(TEST_CRED_HOST_SCOPED ; "host scoped")] + #[test_case(TEST_CRED_TPM2 ; "tpm2")] + #[test_case(TEST_CRED_TPM2_PK ; "tpm2 pk")] + #[test_case(TEST_CRED_HOST_TPM2 ; "host tpm2")] + #[test_case(TEST_CRED_HOST_TPM2_SCOPED ; "host tpm2 scoped")] + #[test_case(TEST_CRED_HOST_TPM2_PK ; "host tpm2 pk")] + #[test_case(TEST_CRED_HOST_TPM2_PK_SCOPED ; "host tpm2 pk scoped")] + fn test_serialize_deserialize_roundtrip(base64: &str) { + let decoded = general_purpose::STANDARD.decode(base64).unwrap(); + let mut cursor = Cursor::new(&decoded); + let parsed = ParsedCredential::read_le(&mut cursor).unwrap(); + + let json = serde_json::to_string(&parsed).unwrap(); + let deserialized: ParsedCredential = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.encryption_type, deserialized.encryption_type); + assert_eq!(parsed.key_size, deserialized.key_size); + assert_eq!(parsed.iv, deserialized.iv); + assert_eq!(parsed.encrypted_data, deserialized.encrypted_data); + } + + #[test_case(TEST_CRED_NULL ; "null")] + #[test_case(TEST_CRED_HOST ; "host")] + #[test_case(TEST_CRED_HOST_SCOPED ; "host scoped")] + #[test_case(TEST_CRED_TPM2 ; "tpm2")] + #[test_case(TEST_CRED_TPM2_PK ; "tpm2 pk")] + #[test_case(TEST_CRED_HOST_TPM2 ; "host tpm2")] + #[test_case(TEST_CRED_HOST_TPM2_SCOPED ; "host tpm2 scoped")] + #[test_case(TEST_CRED_HOST_TPM2_PK ; "host tpm2 pk")] + #[test_case(TEST_CRED_HOST_TPM2_PK_SCOPED ; "host tpm2 pk scoped")] + fn test_binary_encoding_roundtrip(base64: &str) { + let original_decoded = general_purpose::STANDARD.decode(base64).unwrap(); + let mut cursor = Cursor::new(&original_decoded); + let parsed = ParsedCredential::read_le(&mut cursor).unwrap(); + + let mut output = Cursor::new(Vec::new()); + parsed.write_le(&mut output).unwrap(); + let reencoded = output.into_inner(); + + assert_eq!(original_decoded, reencoded); + } + + #[test_case(0b0000_0001, vec![0] ; "single pcr 0")] + #[test_case(0b0000_0010, vec![1] ; "single pcr 1")] + #[test_case(0b0000_0101, vec![0, 2] ; "pcr 0 and 2")] + #[test_case(0b1111_1111, vec![0, 1, 2, 3, 4, 5, 6, 7] ; "first 8 pcrs")] + fn test_pcr_mask_to_list(mask: u64, expected: Vec) { + assert_eq!(pcr_mask_to_list(mask), expected); + } + + #[test_case(&[0], 0b0000_0001 ; "single pcr 0")] + #[test_case(&[1], 0b0000_0010 ; "single pcr 1")] + #[test_case(&[0, 2], 0b0000_0101 ; "pcr 0 and 2")] + #[test_case(&[0, 1, 2, 3, 4, 5, 6, 7], 0b1111_1111 ; "first 8 pcrs")] + fn test_pcr_list_to_mask(pcrs: &[u32], expected: u64) { + assert_eq!(pcr_list_to_mask(pcrs), expected); + } + + #[test_case(PcrBank::Sha1, "SHA1" ; "sha1")] + #[test_case(PcrBank::Sha256, "SHA256" ; "sha256")] + #[test_case(PcrBank::Sha384, "SHA384" ; "sha384")] + #[test_case(PcrBank::Sha512, "SHA512" ; "sha512")] + fn test_pcr_bank_display(bank: PcrBank, expected: &str) { + assert_eq!(bank.to_string(), expected); + } + + #[test_case(PrimaryAlg::Rsa, "RSA" ; "rsa")] + #[test_case(PrimaryAlg::Ecc, "ECC" ; "ecc")] + fn test_primary_alg_display(alg: PrimaryAlg, expected: &str) { + assert_eq!(alg.to_string(), expected); + } + + #[test] + fn test_encryption_type_requires_tpm2() { + assert!(!EncryptionType::Null.requires_tpm2()); + assert!(!EncryptionType::Host.requires_tpm2()); + assert!(EncryptionType::Tpm2Hmac.requires_tpm2()); + assert!(EncryptionType::HostAndTpm2Hmac.requires_tpm2()); + } + + #[test] + fn test_encryption_type_requires_tpm2_pk() { + assert!(!EncryptionType::Tpm2Hmac.requires_tpm2_pk()); + assert!(EncryptionType::Tpm2HmacWithPk.requires_tpm2_pk()); + assert!(EncryptionType::HostAndTpm2HmacWithPk.requires_tpm2_pk()); + } + + #[test] + fn test_encryption_type_is_scoped() { + assert!(!EncryptionType::Host.is_scoped()); + assert!(EncryptionType::HostScoped.is_scoped()); + assert!(EncryptionType::HostAndTpm2HmacScoped.is_scoped()); + } + + #[test] + fn test_pcr_mask_empty() { + assert_eq!(pcr_mask_to_list(0), Vec::::new()); + assert_eq!(pcr_list_to_mask(&[]), 0); + } + + #[test] + fn test_pcr_mask_high_bits() { + assert_eq!(pcr_mask_to_list(1u64 << 63), vec![63]); + assert_eq!(pcr_list_to_mask(&[63]), 1u64 << 63); + } + + #[test] + fn test_pcr_bank_roundtrip() { + assert_eq!(PcrBank::from_u16(0x04).to_u16(), 0x04); + assert_eq!(PcrBank::from_u16(0x0b).to_u16(), 0x0b); + assert_eq!(PcrBank::from_u16(0x99).to_u16(), 0x99); + } + + #[test] + fn test_primary_alg_roundtrip() { + assert_eq!(PrimaryAlg::from_u16(0x01).to_u16(), 0x01); + assert_eq!(PrimaryAlg::from_u16(0x23).to_u16(), 0x23); + assert_eq!(PrimaryAlg::from_u16(0xff).to_u16(), 0xff); + } + + #[test] + fn test_base64_decode_variants() { + let data = b"test"; + let standard = general_purpose::STANDARD.encode(data); + let url_safe = general_purpose::URL_SAFE.encode(data); + + assert!(ParsedCredential::try_base64_decode(standard.as_bytes()).is_ok()); + assert!(ParsedCredential::try_base64_decode(url_safe.as_bytes()).is_ok()); + } + + #[test] + fn test_invalid_base64() { + assert!(ParsedCredential::try_base64_decode(b"not valid base64!!!").is_err()); + } + + #[test] + fn test_hex_serde_roundtrip() { + let data = vec![0xde, 0xad, 0xbe, 0xef]; + let json = serde_json::json!({"data": format!("0x{}", hex::encode(&data))}); + let hex_str: String = serde_json::from_value(json["data"].clone()).unwrap(); + let decoded = hex::decode(hex_str.strip_prefix("0x").unwrap()).unwrap(); + assert_eq!(data, decoded); + } +} diff --git a/sources/rottweiler/src/directory.rs b/sources/rottweiler/src/directory.rs index 2f650c57e..f191da827 100644 --- a/sources/rottweiler/src/directory.rs +++ b/sources/rottweiler/src/directory.rs @@ -60,3 +60,10 @@ pub fn is_encrypted(path: PathBuf) -> Result { Err(_) => Ok(false), } } + +/// Check if an encrypted directory is unlocked (key is in kernel keyring) +pub fn is_unlocked(path: PathBuf) -> Result { + let key = FscryptPublicKey::from_directory(&path) + .with_whatever_context(|_| format!("failed to read key id from '{}'", path.display()))?; + key.is_key_present(&path) +} diff --git a/sources/rottweiler/src/fscrypt.rs b/sources/rottweiler/src/fscrypt.rs index 9ecce3ead..5e93d22ab 100644 --- a/sources/rottweiler/src/fscrypt.rs +++ b/sources/rottweiler/src/fscrypt.rs @@ -25,6 +25,27 @@ impl FscryptPublicKey { }) } + /// Check if the encryption key is present in the kernel keyring + pub fn is_key_present(&self, path: &Path) -> Result { + let mount_point = MountPoint::from_path(path).with_whatever_context(|_| { + format!("Failed to find mount point for '{}'", path.display()) + })?; + let mount_fd = mount_point.open()?; + let mut status_arg = FscryptGetKeyStatusArg::new(self.identifier); + // SAFETY: The ioctl requires a valid file descriptor and a pointer to fscrypt_get_key_status_arg. + // - `mount_fd` is a valid open file descriptor for the mount point + // - `status_arg` is properly initialized with repr(C) layout matching the kernel struct + // - The kernel copies data with copy_from_user/copy_to_user, so no lifetime issues + unsafe { get_encryption_key_status(mount_fd.as_raw_fd(), &mut status_arg) } + .with_whatever_context(|_| { + format!( + "Failed to get encryption key status for '{}'", + path.display() + ) + })?; + Ok(status_arg.status == FSCRYPT_KEY_STATUS_PRESENT) + } + /// Remove the encryption key from the kernel keyring, locking the directory pub fn lock_directory(&self, path: &Path) -> Result<()> { let mount_point = MountPoint::from_path(path).with_whatever_context(|_| { @@ -112,6 +133,7 @@ const FSCRYPT_MODE_AES_256_CTS: u8 = 4; const FSCRYPT_POLICY_FLAGS_PAD_32: u8 = 3; const FSCRYPT_POLICY_V2: u8 = 2; const FSCRYPT_MAX_KEY_SIZE: usize = 64; +const FSCRYPT_KEY_STATUS_PRESENT: u32 = 2; /// Ioctl struct for FS_IOC_SET_ENCRYPTION_POLICY (ioctl 19). /// Corresponds to kernel's fscrypt_policy_v1. Used for ioctl definition @@ -310,6 +332,36 @@ impl FscryptAddKey { } } +/// Get key status argument struct. Corresponds to kernel's fscrypt_get_key_status_arg. +#[repr(C)] +struct FscryptGetKeyStatusArg { + key_spec: FscryptKeySpecifier, + __reserved: [u32; 6], + status: u32, + status_flags: u32, + user_count: u32, + __out_reserved: [u32; 13], +} + +impl FscryptGetKeyStatusArg { + fn new(key_identifier: [u8; 16]) -> Self { + Self { + key_spec: FscryptKeySpecifier { + type_: FSCRYPT_KEY_SPEC_TYPE_IDENTIFIER, + __reserved: 0, + u: FscryptKeySpecifierU { + identifier: key_identifier, + }, + }, + __reserved: [0; 6], + status: 0, + status_flags: 0, + user_count: 0, + __out_reserved: [0; 13], + } + } +} + ioctl_readwrite!( get_encryption_policy_ex, b'f', @@ -319,6 +371,7 @@ ioctl_readwrite!( ioctl_readwrite!(remove_encryption_key_all_users, b'f', 25, FscryptRemoveKey); ioctl_readwrite!(add_encryption_key, b'f', 23, FscryptAddKeyIoctl); ioctl_read!(set_encryption_policy, b'f', 19, FscryptPolicyV1Ioctl); +ioctl_readwrite!(get_encryption_key_status, b'f', 26, FscryptGetKeyStatusArg); /// Calculate the fscrypt key identifier from raw key bytes using HKDF-SHA512 fn calculate_key_identifier(key: &[u8]) -> Result<[u8; 16]> { @@ -382,4 +435,14 @@ const _: () = { // __u8[9] for ioctl definition: 8 + 1 = 9 bytes (packed) const _: () = assert!(size_of::() == 9); const _: () = assert!(align_of::() == 1); + + // struct fscrypt_get_key_status_arg: 40 + 24 + 4 + 4 + 4 + 52 = 128 bytes + const _: () = assert!(size_of::() == 128); + const _: () = assert!(align_of::() == 4); + const _: () = assert!(offset_of!(FscryptGetKeyStatusArg, key_spec) == 0); + const _: () = assert!(offset_of!(FscryptGetKeyStatusArg, __reserved) == 40); + const _: () = assert!(offset_of!(FscryptGetKeyStatusArg, status) == 64); + const _: () = assert!(offset_of!(FscryptGetKeyStatusArg, status_flags) == 68); + const _: () = assert!(offset_of!(FscryptGetKeyStatusArg, user_count) == 72); + const _: () = assert!(offset_of!(FscryptGetKeyStatusArg, __out_reserved) == 76); }; diff --git a/sources/rottweiler/src/key.rs b/sources/rottweiler/src/key.rs index a0897f275..18b1b51ba 100644 --- a/sources/rottweiler/src/key.rs +++ b/sources/rottweiler/src/key.rs @@ -4,6 +4,7 @@ use std::io::Read; use std::path::PathBuf; use zeroize::Zeroizing; +use crate::cred::{self, ParsedCredential}; use crate::system; type Result = std::result::Result; @@ -55,5 +56,27 @@ pub fn load(key_id: String) -> Result>> { let encrypted = fs::read(&key_path) .with_whatever_context(|_| format!("failed to read key from '{}'", key_path.display()))?; + // Validate credential type and PCR binding before decrypting + let expected_pcrs = system::get_tpm2_pcrs()?; + cred::validate_tpm2_hmac(&encrypted, &expected_pcrs)?; + system::systemd_creds_decrypt(&key_id, &encrypted) } + +/// Dump the structure of an encrypted key file +pub fn dump(key_id: String) -> Result<()> { + let key_path = PathBuf::from(KEYSTORE).join(&key_id); + + let encrypted = fs::read(&key_path) + .with_whatever_context(|_| format!("failed to read key from '{}'", key_path.display()))?; + + let parsed = ParsedCredential::from_bytes(&encrypted)?; + + // Output as JSON + let json = serde_json::to_string_pretty(&parsed) + .with_whatever_context(|_| "failed to serialize to JSON")?; + + println!("{}", json); + + Ok(()) +} diff --git a/sources/rottweiler/src/main.rs b/sources/rottweiler/src/main.rs index 4cd893dcc..de23aee80 100644 --- a/sources/rottweiler/src/main.rs +++ b/sources/rottweiler/src/main.rs @@ -11,7 +11,8 @@ interface for encrypting and managing encrypted storage resources including: ## Commands ### Key Management -- `generate-key ` - Generate an encryption key +- `generate key ` - Generate an encryption key +- `dump key ` - Dump encrypted key structure ### Block Device Operations - `encrypt block-device ` - Encrypt a block device using LUKS @@ -25,6 +26,7 @@ interface for encrypting and managing encrypted storage resources including: - `lock directory ` - Lock an encrypted directory (remove key) - `unlock directory ` - Unlock an encrypted directory (add key) - `check directory encrypted|unencrypted` - Check directory encryption state +- `check directory locked|unlocked` - Check directory lock state ### TPM Measurement Operations - `measure settings` - Measure OS settings into PCR 8 @@ -40,10 +42,12 @@ For convenience, the following aliases are supported: */ use argh::FromArgs; +use serde::{Deserialize, Serialize}; use snafu::Whatever; use std::path::{Path, PathBuf}; mod block_device; +mod cred; mod directory; mod fscrypt; mod key; @@ -91,7 +95,12 @@ fn main() -> Result<()> { }; match args.command { - Command::GenerateKey(cmd) => key::generate(cmd.key_id), + Command::Generate(cmd) => match cmd.resource { + GenerateResource::Key(cmd) => key::generate(cmd.key_id), + }, + Command::Dump(cmd) => match cmd.resource { + DumpResource::Key(cmd) => key::dump(cmd.key_id), + }, Command::Encrypt(cmd) => match cmd.resource { EncryptResource::BlockDevice(cmd) => block_device::encrypt(cmd.path, cmd.key_id), EncryptResource::Directory(cmd) => directory::encrypt(cmd.path, cmd.key_id), @@ -113,43 +122,45 @@ fn main() -> Result<()> { }, Command::Check(cmd) => match cmd.resource { CheckResource::BlockDevice(cmd) => { - let path = cmd.path; - match cmd.state { - CheckBlockDeviceState::Encrypted(_) => handle_check( - block_device::is_encrypted(path.clone())?, - "block device", - &path, - true, - "encrypted", - ), - CheckBlockDeviceState::Unencrypted(_) => handle_check( - block_device::is_encrypted(path.clone())?, - "block device", - &path, - false, - "encrypted", - ), - } - } - CheckResource::Directory(cmd) => { - let path = cmd.path; - match cmd.state { - CheckDirectoryState::Encrypted(_) => handle_check( - directory::is_encrypted(path.clone())?, - "directory", - &path, - true, - "encrypted", - ), - CheckDirectoryState::Unencrypted(_) => handle_check( - directory::is_encrypted(path.clone())?, - "directory", - &path, - false, - "encrypted", - ), - } + let expected = cmd.state == CheckState::Encrypted; + handle_check( + block_device::is_encrypted(cmd.path.clone())?, + "block device", + &cmd.path, + expected, + "encrypted", + ) } + CheckResource::Directory(cmd) => match cmd.state { + CheckDirectoryState::Encrypted => handle_check( + directory::is_encrypted(cmd.path.clone())?, + "directory", + &cmd.path, + true, + "encrypted", + ), + CheckDirectoryState::Unencrypted => handle_check( + directory::is_encrypted(cmd.path.clone())?, + "directory", + &cmd.path, + false, + "encrypted", + ), + CheckDirectoryState::Unlocked => handle_check( + directory::is_unlocked(cmd.path.clone())?, + "directory", + &cmd.path, + true, + "unlocked", + ), + CheckDirectoryState::Locked => handle_check( + directory::is_unlocked(cmd.path.clone())?, + "directory", + &cmd.path, + false, + "unlocked", + ), + }, }, Command::Measure(cmd) => match cmd.resource { MeasureResource::Settings(_) => measure::os_settings(), @@ -192,7 +203,8 @@ struct Args { #[derive(FromArgs)] #[argh(subcommand)] enum Command { - GenerateKey(GenerateKeyCmd), + Generate(GenerateCmd), + Dump(DumpCmd), Encrypt(EncryptCmd), Attach(AttachCmd), Detach(DetachCmd), @@ -204,13 +216,49 @@ enum Command { } #[derive(FromArgs)] -#[argh(subcommand, name = "generate-key")] +#[argh(subcommand, name = "generate")] +/// Generate a resource +struct GenerateCmd { + #[argh(subcommand)] + resource: GenerateResource, +} + +#[derive(FromArgs)] +#[argh(subcommand)] +enum GenerateResource { + Key(GenerateKeyCmd), +} + +#[derive(FromArgs)] +#[argh(subcommand, name = "key")] /// Generate an encryption key struct GenerateKeyCmd { #[argh(positional)] key_id: String, } +#[derive(FromArgs)] +#[argh(subcommand, name = "dump")] +/// Dump resource structure +struct DumpCmd { + #[argh(subcommand)] + resource: DumpResource, +} + +#[derive(FromArgs)] +#[argh(subcommand)] +enum DumpResource { + Key(DumpKeyCmd), +} + +#[derive(FromArgs)] +#[argh(subcommand, name = "key")] +/// Dump encrypted key structure +struct DumpKeyCmd { + #[argh(positional)] + key_id: String, +} + #[derive(FromArgs)] #[argh(subcommand, name = "encrypt")] /// Encrypt a resource @@ -389,27 +437,10 @@ struct CheckBlockDeviceCmd { #[argh(positional)] path: PathBuf, - #[argh(subcommand)] - state: CheckBlockDeviceState, -} - -#[derive(FromArgs)] -#[argh(subcommand)] -enum CheckBlockDeviceState { - Encrypted(CheckBlockDeviceEncryptedCmd), - Unencrypted(CheckBlockDeviceUnencryptedCmd), + #[argh(positional)] + state: CheckState, } -#[derive(FromArgs)] -#[argh(subcommand, name = "encrypted")] -/// Check if encrypted -struct CheckBlockDeviceEncryptedCmd {} - -#[derive(FromArgs)] -#[argh(subcommand, name = "unencrypted")] -/// Check if unencrypted -struct CheckBlockDeviceUnencryptedCmd {} - #[derive(FromArgs)] #[argh(subcommand, name = "directory")] /// Check directory state @@ -417,26 +448,29 @@ struct CheckDirectoryCmd { #[argh(positional)] path: PathBuf, - #[argh(subcommand)] + #[argh(positional)] state: CheckDirectoryState, } -#[derive(FromArgs)] -#[argh(subcommand)] -enum CheckDirectoryState { - Encrypted(CheckDirectoryEncryptedCmd), - Unencrypted(CheckDirectoryUnencryptedCmd), +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum CheckState { + Encrypted, + Unencrypted, } -#[derive(FromArgs)] -#[argh(subcommand, name = "encrypted")] -/// Check if encrypted -struct CheckDirectoryEncryptedCmd {} +serde_plain::derive_fromstr_from_deserialize!(CheckState); -#[derive(FromArgs)] -#[argh(subcommand, name = "unencrypted")] -/// Check if unencrypted -struct CheckDirectoryUnencryptedCmd {} +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +enum CheckDirectoryState { + Encrypted, + Unencrypted, + Locked, + Unlocked, +} + +serde_plain::derive_fromstr_from_deserialize!(CheckDirectoryState); #[derive(FromArgs)] #[argh(subcommand, name = "measure")] @@ -472,7 +506,8 @@ struct MeasurePcrphaseCmd { phase: Phase, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] enum Phase { Sysinit, Preconfigured, @@ -482,34 +517,5 @@ enum Phase { Final, } -impl std::str::FromStr for Phase { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - match s { - "sysinit" => Ok(Phase::Sysinit), - "preconfigured" => Ok(Phase::Preconfigured), - "configured" => Ok(Phase::Configured), - "ready" => Ok(Phase::Ready), - "shutdown" => Ok(Phase::Shutdown), - "final" => Ok(Phase::Final), - _ => Err(format!( - "invalid phase '{}', must be one of: sysinit, preconfigured, configured, ready, shutdown, final", - s - )), - } - } -} - -impl std::fmt::Display for Phase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Phase::Sysinit => write!(f, "sysinit"), - Phase::Preconfigured => write!(f, "preconfigured"), - Phase::Configured => write!(f, "configured"), - Phase::Ready => write!(f, "ready"), - Phase::Shutdown => write!(f, "shutdown"), - Phase::Final => write!(f, "final"), - } - } -} +serde_plain::derive_fromstr_from_deserialize!(Phase); +serde_plain::derive_display_from_serialize!(Phase); diff --git a/sources/rottweiler/src/system.rs b/sources/rottweiler/src/system.rs index 21d6260ef..1869b6031 100644 --- a/sources/rottweiler/src/system.rs +++ b/sources/rottweiler/src/system.rs @@ -13,7 +13,13 @@ const TPM2_PCREXTEND: &str = "/usr/bin/tpm2_pcrextend"; /// Encrypt data using systemd-creds with TPM2 PCRs pub fn systemd_creds_encrypt(name: &str, plaintext: &[u8]) -> Result>> { - let pcrs = format!("--tpm2-pcrs={}", get_tpm2_pcrs()?); + let pcrs_vec = get_tpm2_pcrs()?; + let pcrs_str = pcrs_vec + .iter() + .map(|p| p.to_string()) + .collect::>() + .join("+"); + let pcrs = format!("--tpm2-pcrs={}", pcrs_str); execute( SYSTEMD_CREDS, &[ @@ -152,12 +158,12 @@ fn execute(cmd: &str, args: &[&str], input: Option<&[u8]>) -> Result Result { +pub fn get_tpm2_pcrs() -> Result> { let features = bottlerocket_image_features::parse_image_features()?; Ok(if features.in_place_updates { - "7+11+14".to_string() + vec![7, 11, 14] } else { - "4+7+9+11+14".to_string() + vec![4, 7, 9, 11, 14] }) }