From 3ab260a645aec8d639481a5148265048b6f36242 Mon Sep 17 00:00:00 2001 From: Michael Field Date: Mon, 21 Sep 2026 16:56:44 -0700 Subject: [PATCH 1/7] tlvtool: initial CLI structure and generic TLV inspect --- tlvtool/BUILD.bazel | 62 ++++++++ tlvtool/src/cli.rs | 49 +++++++ tlvtool/src/inspect.rs | 293 ++++++++++++++++++++++++++++++++++++++ tlvtool/src/lib.rs | 23 +++ tlvtool/src/main.rs | 71 +++++++++ tlvtool/tests/BUILD.bazel | 23 +++ tlvtool/tests/cli_test.rs | 78 ++++++++++ 7 files changed, 599 insertions(+) create mode 100644 tlvtool/BUILD.bazel create mode 100644 tlvtool/src/cli.rs create mode 100644 tlvtool/src/inspect.rs create mode 100644 tlvtool/src/lib.rs create mode 100644 tlvtool/src/main.rs create mode 100644 tlvtool/tests/BUILD.bazel create mode 100644 tlvtool/tests/cli_test.rs diff --git a/tlvtool/BUILD.bazel b/tlvtool/BUILD.bazel new file mode 100644 index 00000000..996cf5fa --- /dev/null +++ b/tlvtool/BUILD.bazel @@ -0,0 +1,62 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_library", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +rust_library( + name = "tlvtool_lib", + srcs = [ + "src/cli.rs", + "src/inspect.rs", + "src/lib.rs", + ], + crate_name = "tlvtool", + edition = "2024", + visibility = ["//visibility:public"], + deps = [ + "//contrib/tlv", + "//third_party:anyhow", + "//third_party:argh", + "//third_party:hex", + "//third_party:zerocopy", + ], +) + +rust_binary( + name = "tlvtool", + srcs = ["src/main.rs"], + edition = "2024", + rustc_flags = [ + "-C", + "link-arg=-lgcc", + ], + visibility = ["//visibility:public"], + deps = [ + ":tlvtool_lib", + "//third_party:anyhow", + "//third_party:argh", + ], +) + +rust_test( + name = "tlvtool_test", + crate = ":tlvtool_lib", + edition = "2024", + rustc_flags = [ + "-C", + "link-arg=-lgcc", + ], +) diff --git a/tlvtool/src/cli.rs b/tlvtool/src/cli.rs new file mode 100644 index 00000000..7da9fa90 --- /dev/null +++ b/tlvtool/src/cli.rs @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Command line interface definitions and handlers. + +use crate::inspect::inspect_file; +use anyhow::Context; +use anyhow::Result; +use argh::FromArgs; + +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +#[argh(subcommand, name = "inspect")] +/// Inspect and print a human-readable representation of a binary record. +pub struct InspectArgs { + /// path to the binary TLV file. + #[argh(positional)] + pub file: String, +} + +pub fn handle_inspect(args: InspectArgs) -> Result { + let record_bytes = + std::fs::read(&args.file).with_context(|| format!("Failed to read {}", args.file))?; + inspect_file(&record_bytes).with_context(|| format!("Failed to inspect {}", args.file)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cli_argh_subcommand_dispatch() { + use argh::FromArgs; + + let inspect = InspectArgs::from_args(&["inspect"], &["record.bin"]) + .expect("Failed to parse 'inspect'"); + assert_eq!(inspect.file, "record.bin"); + } +} diff --git a/tlvtool/src/inspect.rs b/tlvtool/src/inspect.rs new file mode 100644 index 00000000..4adc7773 --- /dev/null +++ b/tlvtool/src/inspect.rs @@ -0,0 +1,293 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! TLV inspection: a schema-free wire walker for raw structure inspection. + +use anyhow::Result; +use anyhow::ensure; +use core::fmt; +use core::fmt::Write as _; +use mutask_tlv::TlvAny; +use mutask_tlv::TlvData; +use mutask_tlv::TlvHeader; +use mutask_tlv::word_size_of; +use zerocopy::IntoBytes; + +/// Maximum number of payload bytes rendered as hex in a leaf node. +const PREVIEW_BYTES: usize = 32; + +/// A node in the raw TLV container hierarchy. +#[derive(Debug, Clone, PartialEq, Eq)] +struct RawNode { + header: TlvHeader, + byte_offset: usize, + data_preview: Option, + children: Vec, +} + +/// Formats a 32-bit tag as printable ASCII if possible, otherwise hex. +fn format_tag_fourcc(tag: u32) -> String { + let b = tag.to_le_bytes(); + if b.iter().all(|&c| (0x20..=0x7e).contains(&c)) { + let s = core::str::from_utf8(&b).expect("printable ASCII is valid UTF-8"); + format!("'{s}'") + } else { + format!("0x{:08X}", tag) + } +} + +/// Returns true if a header is well-formed on the wire: a non-zero tag with all +/// reserved bits clear. +fn is_plausible(header: &TlvHeader) -> bool { + header.tag != 0 && header.reserved == 0 && header.flags.into_bits() >> 2 == 0 +} + +/// Renders up to [`PREVIEW_BYTES`] of unpadded payload as hex, or `None` if empty. +fn preview(header: &TlvHeader, payload: &[u32]) -> Option { + let bytes = payload.as_bytes(); + let bytes = bytes.get(..header.byte_len()).unwrap_or(bytes); + if bytes.is_empty() { + return None; + } + let mut hex = hex::encode(&bytes[..bytes.len().min(PREVIEW_BYTES)]); + if bytes.len() > PREVIEW_BYTES { + hex.push_str("..."); + } + Some(hex) +} + +/// Walks `words` as a sequence of TLV containers, stopping at the first one that +/// is truncated or implausible. +/// +/// Returns the nodes parsed and the number of words they consumed; a payload is +/// only treated as nested TLV when its containers consume it exactly. +fn walk(words: &[u32], base_offset: usize) -> (Vec, usize) { + let mut nodes = Vec::new(); + let mut consumed = 0; + for item in TlvData::overlay(words).iter::() { + let header = item.container.header; + if !is_plausible(&header) { + break; + } + let byte_offset = base_offset + consumed * 4; + let payload_offset = byte_offset + word_size_of::() * 4; + let (children, nested_words) = walk(item.extra, payload_offset); + let has_nested = !children.is_empty() && nested_words == item.extra.len(); + nodes.push(RawNode { + byte_offset, + data_preview: (!has_nested) + .then(|| preview(&header, item.extra)) + .flatten(), + children: if has_nested { children } else { Vec::new() }, + header, + }); + consumed += item.container.as_words().len(); + } + (nodes, consumed) +} + +fn format_tree(nodes: &[RawNode], prefix: &str, out: &mut String) -> fmt::Result { + for (i, node) in nodes.iter().enumerate() { + let is_last = i + 1 == nodes.len(); + writeln!( + out, + "{}{}[{}] Tag: {} | Offset: 0x{:08x} | Words: {} ({} bytes) | Padding: {}", + prefix, + if is_last { "└── " } else { "├── " }, + i, + format_tag_fourcc(node.header.tag), + node.byte_offset, + node.header.word_len, + node.header.byte_len(), + node.header.flags.padding_bytes() + )?; + let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " }); + if let Some(preview) = &node.data_preview { + writeln!(out, "{}Data: {}", child_prefix, preview)?; + } + if !node.children.is_empty() { + writeln!(out, "{}Children:", child_prefix)?; + format_tree(&node.children, &child_prefix, out)?; + } + } + Ok(()) +} + +const RULE: &str = "================================================================="; + +fn format_report( + words: &[u32], + nodes: &[RawNode], + consumed: usize, + out: &mut String, +) -> fmt::Result { + writeln!(out, "{RULE}")?; + writeln!( + out, + " OpenPRoT TLV File Inspector " + )?; + writeln!(out, "{RULE}")?; + writeln!( + out, + " Total Size : {} bytes ({} words)", + words.len() * 4, + words.len() + )?; + writeln!(out, " Root Containers: {}", nodes.len())?; + writeln!( + out, + "-----------------------------------------------------------------" + )?; + writeln!(out, " TLV Structure Tree:")?; + format_tree(nodes, " ", out)?; + if consumed < words.len() { + writeln!( + out, + " Unparsed trailing: {} words at 0x{:08x}", + words.len() - consumed, + consumed * 4 + )?; + } + writeln!(out, "{RULE}") +} + +/// Inspects a slice of 32-bit TLV words using the generic wire walker. +pub fn inspect_words(words: &[u32]) -> Result { + let (nodes, consumed) = walk(words, 0); + ensure!( + !nodes.is_empty(), + "Malformed TLV record: no valid container stream" + ); + + let mut out = String::new(); + format_report(words, &nodes, consumed, &mut out).expect("writing to a String cannot fail"); + Ok(out) +} + +/// Inspects a TLV byte slice using the generic wire walker. +pub fn inspect_file(bytes: &[u8]) -> Result { + ensure!( + bytes.len().is_multiple_of(4), + "Record bytes must be 4-byte aligned" + ); + ensure!(!bytes.is_empty(), "Malformed TLV record: empty file"); + let words: Vec = bytes + .chunks_exact(4) + .map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap())) + .collect(); + inspect_words(&words) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inspect_arbitrary_tlv_raw() { + let words = [ + u32::from_le_bytes(*b"TEST"), + 2, // 2 words of payload, reserved=0, flags=0 + 0x12345678, + 0x9abcdef0, + ]; + let tlv_bytes = zerocopy::IntoBytes::as_bytes(&words[..]).to_vec(); + + // Raw inspection succeeds without any schema knowledge: + let report = inspect_file(&tlv_bytes).unwrap(); + assert!(report.contains("Tag: 'TEST'")); + assert!(report.contains("TLV Structure Tree:")); + assert!(report.contains("Words: 2 (8 bytes)")); + } + + #[test] + fn test_inspect_nested_tlv_raw() { + let words = [ + u32::from_le_bytes(*b"ROOT"), + 3, // 3 words of payload (1 child container) + u32::from_le_bytes(*b"CHLD"), + 1, // 1 word of payload + 0xdeadbeef, + ]; + let tlv_bytes = zerocopy::IntoBytes::as_bytes(&words[..]).to_vec(); + + let report = inspect_file(&tlv_bytes).unwrap(); + assert!(report.contains("Tag: 'ROOT'")); + assert!(report.contains("Children:")); + assert!(report.contains("Tag: 'CHLD'")); + } + + #[test] + fn test_partly_parsing_payload_is_shown_as_data() { + // 'ROOT' holds 4 words. The first 3 are a well-formed 'CHLD' container, + // but the 4th is left over. A payload is only a container stream when + // its children consume it exactly, so this one is a leaf: the walker + // must render it as a hex preview rather than invent a child tree. + let words = [ + u32::from_le_bytes(*b"ROOT"), + 4, + u32::from_le_bytes(*b"CHLD"), + 1, + 0xdeadbeef, + 0x11111111, + ]; + let tlv_bytes = zerocopy::IntoBytes::as_bytes(&words[..]).to_vec(); + + let report = inspect_file(&tlv_bytes).unwrap(); + assert!(report.contains("Tag: 'ROOT'"), "{report}"); + assert!( + !report.contains("Children:"), + "unexpected child tree:\n{report}" + ); + assert!( + !report.contains("Tag: 'CHLD'"), + "unexpected child node:\n{report}" + ); + assert!( + report.contains("Data: "), + "expected a hex preview:\n{report}" + ); + } + + #[test] + fn test_unparsed_trailing_words_are_reported() { + // A valid 'GOOD' container followed by a 'TRNC' header claiming more + // payload than remains. The walker stops at 'TRNC', and the report + // must say so rather than look like a clean single-container file. + let words = [ + u32::from_le_bytes(*b"GOOD"), + 1, + 0xdeadbeef, + u32::from_le_bytes(*b"TRNC"), + 8, + 0x11111111, + ]; + let tlv_bytes = zerocopy::IntoBytes::as_bytes(&words[..]).to_vec(); + + let report = inspect_file(&tlv_bytes).unwrap(); + assert!(report.contains("Root Containers: 1"), "{report}"); + assert!( + report.contains("Unparsed trailing: 3 words at 0x0000000c"), + "{report}" + ); + } + + #[test] + fn test_invalid_alignment_and_empty() { + let err = inspect_file(&[1, 2, 3]).unwrap_err().to_string(); + assert!(err.contains("4-byte aligned"), "{err}"); + + let err = inspect_file(&[]).unwrap_err().to_string(); + assert!(err.contains("empty file"), "{err}"); + } +} diff --git a/tlvtool/src/lib.rs b/tlvtool/src/lib.rs new file mode 100644 index 00000000..5add1c18 --- /dev/null +++ b/tlvtool/src/lib.rs @@ -0,0 +1,23 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! OpenPRoT TLV Key Rotation Tool library for open RoT architectures. + +extern crate alloc; + +pub mod cli; +pub mod inspect; + +pub use inspect::inspect_file; +pub use inspect::inspect_words; diff --git a/tlvtool/src/main.rs b/tlvtool/src/main.rs new file mode 100644 index 00000000..f04510f4 --- /dev/null +++ b/tlvtool/src/main.rs @@ -0,0 +1,71 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! CLI entry point for `tlvtool`. + +use argh::FromArgs; +use std::process::ExitCode; +use tlvtool::cli::InspectArgs; +use tlvtool::cli::handle_inspect; + +// The top-level CLI structure, `Commands` enum, and dispatch logic are defined here +// rather than in `cli.rs` so that `tlvtool_lib` remains a modular library defining +// subcommand arguments and handlers, while `main.rs` owns binary CLI composition +// and dispatch to let downstream binaries compose extra subcommands without duplication. +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +/// A tool to create, inspect, and manage OpenPRoT TLV binary files and records. +pub struct Cli { + #[argh(subcommand)] + pub command: Commands, +} + +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +#[argh(subcommand)] +pub enum Commands { + Inspect(InspectArgs), +} + +pub fn run_command(command: Commands) -> anyhow::Result { + match command { + Commands::Inspect(args) => handle_inspect(args), + } +} + +fn main() -> ExitCode { + let cli: Cli = argh::from_env(); + match run_command(cli.command) { + Ok(msg) => { + if !msg.is_empty() { + println!("{}", msg); + } + ExitCode::SUCCESS + } + Err(err) => { + eprintln!("Error: {:#}", err); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_main_cli_dispatch() { + let cli_inspect = Cli::from_args(&["tlvtool"], &["inspect", "file.bin"]) + .expect("Failed to parse inspect"); + assert!(matches!(cli_inspect.command, Commands::Inspect(_))); + } +} diff --git a/tlvtool/tests/BUILD.bazel b/tlvtool/tests/BUILD.bazel new file mode 100644 index 00000000..9a1f2c30 --- /dev/null +++ b/tlvtool/tests/BUILD.bazel @@ -0,0 +1,23 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@rules_rust//rust:defs.bzl", "rust_test") + +rust_test( + name = "cli_test", + srcs = ["cli_test.rs"], + data = ["//tlvtool"], + edition = "2024", + deps = ["@rules_rust//tools/runfiles"], +) diff --git a/tlvtool/tests/cli_test.rs b/tlvtool/tests/cli_test.rs new file mode 100644 index 00000000..697a1cfa --- /dev/null +++ b/tlvtool/tests/cli_test.rs @@ -0,0 +1,78 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Integration tests for the `tlvtool` command line interface. + +use std::path::PathBuf; +use std::process::Command; + +/// Returns a [`Command`] for the built `tlvtool` binary. +fn tlvtool() -> Command { + let r = runfiles::Runfiles::create().expect("failed to locate runfiles"); + Command::new( + runfiles::rlocation!(r, "mutask/tlvtool/tlvtool").expect("tlvtool is not in the runfiles"), + ) +} + +/// Runs `tlvtool` with `args` and returns stdout, or panics and shows stderr. +fn run(args: &[&str]) -> String { + let out = tlvtool() + .args(args) + .output() + .expect("failed to run tlvtool"); + assert!( + out.status.success(), + "`tlvtool {}` exited with {}:\n{}", + args.join(" "), + out.status, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).expect("tlvtool wrote non-UTF-8 to stdout") +} + +/// Writes `words` to a scratch file as little-endian TLV and returns its path. +fn write_words(name: &str, words: &[u32]) -> PathBuf { + let mut bytes = Vec::with_capacity(words.len() * 4); + for word in words { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let path = + PathBuf::from(std::env::var("TEST_TMPDIR").expect("TEST_TMPDIR is not set")).join(name); + std::fs::write(&path, &bytes).expect("failed to write the record file"); + path +} + +#[test] +fn test_inspect_reads_a_record_file() { + let path = write_words( + "inspect_input.bin", + &[u32::from_le_bytes(*b"TEST"), 2, 0x1234_5678, 0x9abc_def0], + ); + + let report = run(&["inspect", path.to_str().unwrap()]); + assert!(report.contains("Tag: 'TEST'"), "{report}"); + assert!(report.contains("TLV Structure Tree:"), "{report}"); +} + +#[test] +fn test_inspect_rejects_a_missing_file() { + let out = tlvtool() + .args(["inspect", "/nonexistent/record.bin"]) + .output() + .expect("failed to run tlvtool"); + + assert!(!out.status.success(), "a missing file must not exit zero"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Failed to read"), "{stderr}"); +} From 619e7022044248f9376a6aa9cfe19ecc43a29076 Mon Sep 17 00:00:00 2001 From: Michael Field Date: Mon, 21 Sep 2026 16:58:47 -0700 Subject: [PATCH 2/7] tlvtool: add krr build subcommand and RecordBuilder --- tlvtool/BUILD.bazel | 13 + tlvtool/src/builder.rs | 614 ++++++++++++++++++++++++++++++++++++++ tlvtool/src/cli.rs | 63 +++- tlvtool/src/crypto.rs | 204 +++++++++++++ tlvtool/src/lib.rs | 6 +- tlvtool/src/main.rs | 11 + tlvtool/tests/cli_test.rs | 70 ++++- 7 files changed, 978 insertions(+), 3 deletions(-) create mode 100644 tlvtool/src/builder.rs create mode 100644 tlvtool/src/crypto.rs diff --git a/tlvtool/BUILD.bazel b/tlvtool/BUILD.bazel index 996cf5fa..88d8acb5 100644 --- a/tlvtool/BUILD.bazel +++ b/tlvtool/BUILD.bazel @@ -19,7 +19,9 @@ package(default_visibility = ["//visibility:public"]) rust_library( name = "tlvtool_lib", srcs = [ + "src/builder.rs", "src/cli.rs", + "src/crypto.rs", "src/inspect.rs", "src/lib.rs", ], @@ -27,11 +29,22 @@ rust_library( edition = "2024", visibility = ["//visibility:public"], deps = [ + "//contrib/crypto:sha256_sw", + "//contrib/openprot/proto", + "//contrib/openprot/proto:crypto_rust_proto", + "//contrib/openprot/proto:identity_rust_proto", + "//contrib/openprot/proto:key_rotation_rust_proto", "//contrib/tlv", + "//contrib/tlv:proto_conv", + "//contrib/tlv:proto_meta", "//third_party:anyhow", "//third_party:argh", + "//third_party:base64", "//third_party:hex", + "//third_party:serde", + "//third_party:serde_json", "//third_party:zerocopy", + "@protobuf//rust:protobuf", ], ) diff --git a/tlvtool/src/builder.rs b/tlvtool/src/builder.rs new file mode 100644 index 00000000..cf268a86 --- /dev/null +++ b/tlvtool/src/builder.rs @@ -0,0 +1,614 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Record compilation, partition size limit verification, and binary emission. + +use crate::crypto::p256_fingerprint_sha256; +use crate::crypto::parse_mldsa44_public_key; +use crate::crypto::parse_p256_public_key; +use crate::crypto::sha256; +use alloc::format; +use alloc::string::String; +use alloc::string::ToString; +use alloc::vec::Vec; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use anyhow::ensure; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use zerocopy::IntoBytes; + +pub const MAX_IDENTITY_STRING_LEN: usize = 8; +pub const MAX_UNSIGNED_RECORD_SIZE_BYTES: usize = 2016; +pub const MAX_MAILBOX_RECORD_SIZE_BYTES: usize = 8192; + +/// Rejects an unsigned record image that would not fit its flash slot. +fn check_unsigned_size(size: usize) -> Result<()> { + ensure!( + size <= MAX_UNSIGNED_RECORD_SIZE_BYTES, + "Unsigned key rotation record size ({size} bytes) exceeds flash slot payload limit \ + ({MAX_UNSIGNED_RECORD_SIZE_BYTES} bytes)" + ); + Ok(()) +} + +/// Signature algorithm selecting which fingerprint variant a key populates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Algorithm { + P256, + Mldsa, +} + +/// Key domain parsing helper. +pub fn parse_key_domain(s: &str) -> Result { + Ok(match s { + "prod" => crypto_rust_proto::KeyDomain::PayloadProd, + "dev" => crypto_rust_proto::KeyDomain::PayloadDev, + "test" => crypto_rust_proto::KeyDomain::PayloadTest, + "bios_prod" => crypto_rust_proto::KeyDomain::BiosProd, + "bios_dev" => crypto_rust_proto::KeyDomain::BiosDev, + "rotr" => crypto_rust_proto::KeyDomain::Rotr, + _ => bail!( + "Unknown key domain '{s}'. Valid domains: prod, dev, test, bios_prod, bios_dev, rotr" + ), + }) +} + +/// Decodes a hex string that must denote exactly one SHA2-256 digest. +fn parse_digest32(label: &str, hex_str: &str) -> Result<[u8; 32]> { + let raw = crate::crypto::from_hex(hex_str).with_context(|| format!("Invalid {label} hex"))?; + raw.try_into() + .map_err(|_| anyhow!("{label} '{hex_str}' must be 32 bytes (64 hex chars)")) +} + +/// Validates an identity string and NUL-pads it to [`MAX_IDENTITY_STRING_LEN`]. +fn identity_name(field: &str, s: &str) -> Result { + ensure!(s.is_ascii(), "{field} string must be ASCII (got '{s}')"); + ensure!( + s.len() <= MAX_IDENTITY_STRING_LEN, + "{} string exceeds maximum limit of {} bytes (got {} bytes)", + field, + MAX_IDENTITY_STRING_LEN, + s.len() + ); + let mut padded = String::from(s); + padded.push_str(&"\0".repeat(MAX_IDENTITY_STRING_LEN - s.len())); + Ok(padded) +} + +fn default_key_domain() -> String { + "prod".to_string() +} + +/// JSON configuration for building a Key Rotation Record. +/// +/// Every field is optional; omitted fields take their [`Default`] value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct KrrBuildConfig { + pub version: u32, + pub image_vendor: String, + pub image_family: String, + pub legacy_image_family: Option, + pub fingerprints: Vec, + pub digests: Vec, +} + +impl Default for KrrBuildConfig { + fn default() -> Self { + Self { + version: 1, + image_vendor: "GOOGLE".to_string(), + image_family: "EARLGREY".to_string(), + legacy_image_family: None, + fingerprints: Vec::new(), + digests: Vec::new(), + } + } +} + +/// Where the fingerprint bytes for a key come from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FingerprintSource { + /// Path to a public key file; the fingerprint is derived from its contents. + KeyFile(String), + /// A precomputed SHA2-256 fingerprint, hex encoded. + Fingerprint(String), +} + +/// Configuration for an individual key fingerprint in JSON. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KeyFingerprintConfig { + #[serde(default = "default_key_domain")] + pub key_domain: String, + + pub algorithm: Algorithm, + + #[serde(flatten)] + pub source: FingerprintSource, +} + +/// Fluent builder for constructing canonical OpenPRoT key rotation records (`KROT`). +#[derive(Debug)] +pub struct RecordBuilder { + version: u32, + image_vendor: String, + image_family: String, + legacy_image_family: Option, + fingerprints: Vec, + img_descriptor_digests: Vec, +} + +impl Default for RecordBuilder { + fn default() -> Self { + // Defaults live in exactly one place: `KrrBuildConfig::default()`. + let config = KrrBuildConfig::default(); + Self { + version: config.version, + image_vendor: config.image_vendor, + image_family: config.image_family, + legacy_image_family: config.legacy_image_family, + fingerprints: Vec::new(), + img_descriptor_digests: Vec::new(), + } + } +} + +impl RecordBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn from_config(config: &KrrBuildConfig) -> Result { + let mut builder = Self::new() + .version(config.version) + .image_vendor(&config.image_vendor) + .image_family(&config.image_family); + + if let Some(legacy) = config.legacy_image_family { + builder = builder.legacy_image_family(legacy); + } + + for fp_cfg in &config.fingerprints { + let domain = parse_key_domain(&fp_cfg.key_domain)?; + builder = match &fp_cfg.source { + FingerprintSource::KeyFile(path) => { + builder.add_key_file(domain, fp_cfg.algorithm, path)? + } + FingerprintSource::Fingerprint(hex_str) => builder.add_fingerprint( + domain, + fp_cfg.algorithm, + parse_digest32("Fingerprint", hex_str)?, + ), + }; + } + + for hex_str in &config.digests { + builder = builder.add_image_digest(parse_digest32("Image digest", hex_str)?); + } + + Ok(builder) + } + + pub fn from_json(json_str: &str) -> Result { + let config: KrrBuildConfig = + serde_json::from_str(json_str).context("Failed to parse JSON config")?; + Self::from_config(&config) + } + + pub fn from_json_file(path: impl AsRef) -> Result { + let p = path.as_ref(); + let content = std::fs::read_to_string(p) + .with_context(|| format!("Failed to read {}", p.display()))?; + Self::from_json(&content) + } + + pub fn version(mut self, version: u32) -> Self { + self.version = version; + self + } + + pub fn image_vendor(mut self, vendor: impl Into) -> Self { + self.image_vendor = vendor.into(); + self + } + + pub fn image_family(mut self, family: impl Into) -> Self { + self.image_family = family.into(); + self + } + + pub fn legacy_image_family(mut self, family: u32) -> Self { + self.legacy_image_family = Some(family); + self + } + + /// Appends a precomputed SHA2-256 fingerprint for `algorithm` under `domain`. + pub fn add_fingerprint( + mut self, + domain: crypto_rust_proto::KeyDomain, + algorithm: Algorithm, + digest: [u8; 32], + ) -> Self { + let fp = match algorithm { + Algorithm::P256 => protobuf::proto!(crypto_rust_proto::KeyFingerprint { + key_domain: domain, + p256: crypto_rust_proto::P256Fingerprint { + sha2_256: crypto_rust_proto::Sha2_256Digest { + data: digest.to_vec() + } + } + }), + Algorithm::Mldsa => protobuf::proto!(crypto_rust_proto::KeyFingerprint { + key_domain: domain, + mldsa: crypto_rust_proto::MlDsa44Fingerprint { + sha2_256: crypto_rust_proto::Sha2_256Digest { + data: digest.to_vec() + } + } + }), + }; + self.fingerprints.push(fp); + self + } + + pub fn add_p256_fingerprint( + self, + domain: crypto_rust_proto::KeyDomain, + digest: [u8; 32], + ) -> Self { + self.add_fingerprint(domain, Algorithm::P256, digest) + } + + pub fn add_mldsa44_fingerprint( + self, + domain: crypto_rust_proto::KeyDomain, + digest: [u8; 32], + ) -> Self { + self.add_fingerprint(domain, Algorithm::Mldsa, digest) + } + + /// Fingerprints a public key held in memory and appends it. + pub fn add_key_bytes( + self, + domain: crypto_rust_proto::KeyDomain, + algorithm: Algorithm, + key: &[u8], + ) -> Result { + let digest = match algorithm { + Algorithm::P256 => { + let (x, y) = parse_p256_public_key(key).context("Invalid P256 key")?; + p256_fingerprint_sha256(&x, &y) + } + Algorithm::Mldsa => { + let mldsa = parse_mldsa44_public_key(key).context("Invalid Mldsa key")?; + sha256(&mldsa) + } + }; + Ok(self.add_fingerprint(domain, algorithm, digest)) + } + + /// Reads a public key file and appends its SHA2-256 fingerprint. + pub fn add_key_file( + self, + domain: crypto_rust_proto::KeyDomain, + algorithm: Algorithm, + path: impl AsRef, + ) -> Result { + let p = path.as_ref(); + let content = + std::fs::read(p).with_context(|| format!("Failed to read key file {}", p.display()))?; + self.add_key_bytes(domain, algorithm, &content) + .with_context(|| format!("Failed to fingerprint key file {}", p.display())) + } + + pub fn add_image_digest(mut self, digest: [u8; 32]) -> Self { + let d = protobuf::proto!(key_rotation_rust_proto::ImgDescriptorDigest { + sha2_256: crypto_rust_proto::Sha2_256Digest { + data: digest.to_vec() + } + }); + self.img_descriptor_digests.push(d); + self + } + + pub fn build_unsigned(self) -> Result { + ensure!( + self.version != 0, + "Record version must be > 0 (v0 rollback records are not supported)" + ); + ensure!( + !self.fingerprints.is_empty() || !self.img_descriptor_digests.is_empty(), + "Key rotation record must contain at least one key fingerprint or image descriptor digest" + ); + + let vendor_name = identity_name("Image Vendor", &self.image_vendor)?; + let family_name = identity_name("Image Family", &self.image_family)?; + let mut desc = protobuf::proto!(key_rotation_rust_proto::Descriptor { + version: self.version, + image_vendor: identity_rust_proto::ImageVendor { name: vendor_name }, + image_family: identity_rust_proto::ImageFamily { name: family_name }, + fingerprint: self.fingerprints.into_iter(), + img_descriptor_digest: self.img_descriptor_digests.into_iter(), + }); + if let Some(family) = self.legacy_image_family { + desc.set_legacy_image_family(protobuf::proto!( + key_rotation_rust_proto::LegacyImageFamily { family } + )); + } + + let tlv_words = mutask_tlv_proto_conv::message_to_tlv( + &key_rotation_rust_proto::__unstable::CONTRIB_OPENPROT_PROTO_KEY_ROTATION_DESCRIPTOR_INFO, + &desc, + ) + .context("Failed to convert protobuf record to TLV wire format")?; + + let tlv_bytes = tlv_words.as_bytes().to_vec(); + check_unsigned_size(tlv_bytes.len())?; + + Ok(UnsignedRecord(tlv_bytes)) + } +} + +/// A validated unsigned key rotation record wire image (`KROT`). +#[derive(Debug, Clone)] +pub struct UnsignedRecord(Vec); + +impl UnsignedRecord { + pub fn from_bytes(bytes: &[u8]) -> Result { + check_unsigned_size(bytes.len())?; + let tlv_data = mutask_tlv::TlvData::overlay_bytes(bytes) + .map_err(|_| anyhow!("Unsigned record length must be 4-byte aligned"))?; + let has_desc = tlv_data + .iter::() + .next() + .is_some(); + ensure!(has_desc, "No 'KROT' descriptor found in binary record"); + Ok(Self(bytes.to_vec())) + } + + pub fn from_file(path: impl AsRef) -> Result { + let p = path.as_ref(); + let bytes = std::fs::read(p).with_context(|| format!("Failed to read {}", p.display()))?; + Self::from_bytes(&bytes) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + pub fn to_bytes(self) -> Vec { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inspect::inspect_file; + + /// Parses `bytes` as TLV and returns the contained `KROT` descriptor version. + fn descriptor_version(bytes: &[u8]) -> u32 { + mutask_tlv::TlvData::overlay_bytes(bytes) + .expect("Invalid alignment") + .iter::() + .next() + .expect("No descriptor") + .data + .version + } + + #[test] + fn test_record_builder_fluent_unsigned() { + let unsigned = RecordBuilder::new() + .version(2) + .image_vendor("VNDR") + .image_family("FAM") + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x42u8; 32]) + .add_image_digest([0x99u8; 32]) + .build_unsigned() + .expect("Failed to build unsigned record with RecordBuilder"); + + assert!(unsigned.as_bytes().len() <= MAX_UNSIGNED_RECORD_SIZE_BYTES); + assert_eq!(descriptor_version(unsigned.as_bytes()), 2); + + let report = inspect_file(unsigned.as_bytes()).unwrap(); + assert!(report.contains("Tag: 'KROT'")); + assert!(report.contains("TLV Structure Tree:")); + } + + #[test] + fn test_record_too_large_rejected() { + let mut builder = RecordBuilder::new() + .image_vendor("VNDR") + .image_family("FAM"); + for i in 0..70u8 { + let mut d = [0u8; 32]; + d[0] = i; + builder = builder.add_image_digest(d); + } + let err = builder.build_unsigned().unwrap_err().to_string(); + assert!(err.contains("exceeds flash slot payload limit")); + } + + #[test] + fn test_build_unsigned_validation() { + let base = || { + RecordBuilder::new() + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x42u8; 32]) + }; + + // v0 rollback records are not representable. + assert!(base().version(0).build_unsigned().is_err()); + + // A record with no fingerprints and no digests carries no information. + assert!(RecordBuilder::new().build_unsigned().is_err()); + + // Identity strings are bounded and must be ASCII, so that the padded + // proto string is exactly MAX_IDENTITY_STRING_LEN bytes on the wire. + assert!( + base() + .image_vendor("TOOLONGVENDOR") + .build_unsigned() + .is_err() + ); + assert!(base().image_family("é").build_unsigned().is_err()); + } + + #[test] + fn test_from_bytes_rejects_bad_length_and_missing_descriptor() { + // Not 4-byte aligned. + assert!(UnsignedRecord::from_bytes(&[0u8; 15]).is_err()); + + // Well-formed length, but contains no 'KROT' descriptor. + assert!(UnsignedRecord::from_bytes(&[0u8; 16]).is_err()); + + // Round-trips a real record. + let unsigned = RecordBuilder::new() + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x42u8; 32]) + .build_unsigned() + .unwrap(); + let reparsed = UnsignedRecord::from_bytes(unsigned.as_bytes()).unwrap(); + assert_eq!(reparsed.as_bytes(), unsigned.as_bytes()); + } + + #[test] + fn test_add_key_bytes_accepts_raw_binary() { + let mut p256_key = vec![1u8; 32]; + p256_key.extend_from_slice(&[2u8; 32]); + + let unsigned = RecordBuilder::new() + .image_vendor("VNDR") + .image_family("FAM") + .add_key_bytes( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + &p256_key, + ) + .unwrap() + .add_key_bytes( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::Mldsa, + &vec![3u8; 1312], + ) + .unwrap() + .build_unsigned() + .unwrap(); + + assert!( + inspect_file(unsigned.as_bytes()) + .unwrap() + .contains("Tag: 'KROT'") + ); + } + + #[test] + fn test_add_key_bytes_rejects_a_bad_key() { + let err = RecordBuilder::new() + .add_key_bytes( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + &[0u8; 8], + ) + .unwrap_err(); + assert!(err.to_string().contains("Invalid P256 key"), "{err:#}"); + } + + #[test] + fn test_record_builder_from_json() { + let json = r#"{ + "version": 2, + "image_vendor": "CUSTOM", + "image_family": "CHIP", + "legacy_image_family": 42, + "fingerprints": [ + { + "key_domain": "prod", + "algorithm": "p256", + "fingerprint": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + }, + { + "key_domain": "dev", + "algorithm": "mldsa", + "fingerprint": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40" + } + ], + "digests": [ + "6162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80" + ] + }"#; + let unsigned = RecordBuilder::from_json(json) + .expect("Failed to parse JSON") + .build_unsigned() + .expect("Failed to build unsigned"); + + assert_eq!(descriptor_version(unsigned.as_bytes()), 2); + assert!( + inspect_file(unsigned.as_bytes()) + .unwrap() + .contains("Tag: 'KROT'") + ); + } + + #[test] + fn test_record_builder_from_json_defaults() { + // Every field is optional; a bare fingerprint list is a valid config. + let json = r#"{ + "fingerprints": [ + { "algorithm": "p256", "fingerprint": "0000000000000000000000000000000000000000000000000000000000000001" } + ] + }"#; + let unsigned = RecordBuilder::from_json(json) + .unwrap() + .build_unsigned() + .unwrap(); + assert_eq!( + descriptor_version(unsigned.as_bytes()), + KrrBuildConfig::default().version + ); + } + + #[test] + fn test_record_builder_from_json_errors() { + let cases = [ + // Invalid JSON syntax. + "{ invalid json }", + // Unknown key domain. + r#"{"fingerprints": [{ "key_domain": "nonexistent", "algorithm": "p256", + "fingerprint": "0000000000000000000000000000000000000000000000000000000000000000" }]}"#, + // Unknown algorithm. + r#"{"fingerprints": [{ "algorithm": "rsa", + "fingerprint": "0000000000000000000000000000000000000000000000000000000000000000" }]}"#, + // Neither key_file nor fingerprint. + r#"{"fingerprints": [{ "algorithm": "p256" }]}"#, + // Invalid hex. + r#"{"fingerprints": [{ "algorithm": "p256", "fingerprint": "not_hex" }]}"#, + // Correct hex, wrong length. + r#"{"fingerprints": [{ "algorithm": "p256", "fingerprint": "0102" }]}"#, + // Invalid image digest. + r#"{"digests": ["0102"]}"#, + ]; + for json in cases { + assert!( + RecordBuilder::from_json(json).is_err(), + "expected rejection of: {json}" + ); + } + } +} diff --git a/tlvtool/src/cli.rs b/tlvtool/src/cli.rs index 7da9fa90..0aa51f9a 100644 --- a/tlvtool/src/cli.rs +++ b/tlvtool/src/cli.rs @@ -14,20 +14,72 @@ //! Command line interface definitions and handlers. +use crate::builder::RecordBuilder; use crate::inspect::inspect_file; use anyhow::Context; use anyhow::Result; use argh::FromArgs; +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +#[argh(subcommand, name = "krr")] +/// Key Rotation Record commands. +pub struct KrrArgs { + #[argh(subcommand)] + pub command: KrrCommands, +} + +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +#[argh(subcommand)] +pub enum KrrCommands { + Build(BuildArgs), +} + +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +#[argh(subcommand, name = "build")] +/// Build an unsigned Key Rotation record from a JSON configuration file. +pub struct BuildArgs { + /// path to the JSON configuration file. + #[argh(positional)] + pub config: String, + + /// output file path (.unsigned). If omitted, prints human-readable record inspection to stdout. + #[argh(option, short = 'o')] + pub out: Option, +} + #[derive(FromArgs, Debug, Clone, PartialEq, Eq)] #[argh(subcommand, name = "inspect")] /// Inspect and print a human-readable representation of a binary record. pub struct InspectArgs { - /// path to the binary TLV file. + /// path to the binary record file. #[argh(positional)] pub file: String, } +pub fn handle_krr(args: KrrArgs) -> Result { + match args.command { + KrrCommands::Build(build_args) => handle_build(build_args), + } +} + +pub fn handle_build(args: BuildArgs) -> Result { + let unsigned = RecordBuilder::from_json_file(&args.config) + .context("Failed to load build config")? + .build_unsigned() + .context("Failed to build record")?; + + match args.out.as_deref() { + Some(path) => { + std::fs::write(path, unsigned.as_bytes()) + .with_context(|| format!("Failed to write {path}"))?; + Ok(format!( + "Key rotation record successfully written to {path}" + )) + } + None => inspect_file(unsigned.as_bytes()).context("Failed to inspect built record"), + } +} + pub fn handle_inspect(args: InspectArgs) -> Result { let record_bytes = std::fs::read(&args.file).with_context(|| format!("Failed to read {}", args.file))?; @@ -42,6 +94,15 @@ mod tests { fn test_cli_argh_subcommand_dispatch() { use argh::FromArgs; + let krr_build = KrrArgs::from_args(&["krr"], &["build", "config.json", "-o", "out.bin"]) + .expect("Failed to parse 'krr build'"); + match krr_build.command { + KrrCommands::Build(b_args) => { + assert_eq!(b_args.config, "config.json"); + assert_eq!(b_args.out.as_deref(), Some("out.bin")); + } + } + let inspect = InspectArgs::from_args(&["inspect"], &["record.bin"]) .expect("Failed to parse 'inspect'"); assert_eq!(inspect.file, "record.bin"); diff --git a/tlvtool/src/crypto.rs b/tlvtool/src/crypto.rs new file mode 100644 index 00000000..eb4a95e5 --- /dev/null +++ b/tlvtool/src/crypto.rs @@ -0,0 +1,204 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Cryptographic primitives and public key coordinate parsing. + +use alloc::string::String; +use alloc::vec::Vec; +use anyhow::Result; +use anyhow::anyhow; +use base64::Engine as _; + +// ----------------------------------------------------------------------------- +// FIPS 180-4 SHA2-256 (via mutask) +// ----------------------------------------------------------------------------- + +pub use mutask_sha256_sw::Sha256; +pub use mutask_sha256_sw::sha256; + +/// Length of an ML-DSA-44 public key, in bytes. +pub const MLDSA44_PUBLIC_KEY_LEN: usize = 1312; + +// ----------------------------------------------------------------------------- +// Encoding Utilities +// ----------------------------------------------------------------------------- + +pub fn to_hex(bytes: &[u8]) -> String { + hex::encode(bytes) +} + +pub fn from_hex(s: &str) -> Result> { + let clean = s.trim().trim_start_matches("0x").trim_start_matches("0X"); + Ok(hex::decode(clean)?) +} + +/// Decodes `s` as either hex or standard base64. +pub fn decode_hex_or_base64(s: &str) -> Option> { + let clean = s.trim(); + from_hex(clean) + .ok() + .or_else(|| base64::engine::general_purpose::STANDARD.decode(clean).ok()) +} + +/// Strips PEM armour from `data` and decodes the body as hex or base64. +/// +/// Returns `None` if `data` is not UTF-8 text or the body does not decode. +fn decode_pem(data: &[u8]) -> Option> { + let text = core::str::from_utf8(data).ok()?; + let body: String = text.lines().filter(|l| !l.starts_with("-----")).collect(); + decode_hex_or_base64(&body) +} + +// ----------------------------------------------------------------------------- +// P-256 Curve & Utilities +// ----------------------------------------------------------------------------- + +pub fn p256_public_key_to_words(x_bytes: &[u8; 32], y_bytes: &[u8; 32]) -> ([u32; 8], [u32; 8]) { + let mut x_words = [0u32; 8]; + let mut y_words = [0u32; 8]; + for i in 0..8 { + x_words[i] = u32::from_le_bytes(x_bytes[i * 4..(i + 1) * 4].try_into().unwrap()); + y_words[i] = u32::from_le_bytes(y_bytes[i * 4..(i + 1) * 4].try_into().unwrap()); + } + (x_words, y_words) +} + +pub fn p256_fingerprint_sha256(x_le: &[u32; 8], y_le: &[u32; 8]) -> [u8; 32] { + let mut raw_le = [0u8; 64]; + for (i, word) in x_le.iter().enumerate() { + raw_le[i * 4..(i + 1) * 4].copy_from_slice(&word.to_le_bytes()); + } + for (i, word) in y_le.iter().enumerate() { + raw_le[32 + i * 4..32 + (i + 1) * 4].copy_from_slice(&word.to_le_bytes()); + } + sha256(&raw_le) +} + +/// Locates an uncompressed SEC1 point (`0x04 || X || Y`) inside `data`, which may +/// be the bare point or a DER/SPKI structure wrapping it. +fn uncompressed_point(data: &[u8]) -> Option<([u32; 8], [u32; 8])> { + let pos = data.windows(65).position(|w| w[0] == 0x04)?; + let point = &data[pos..pos + 65]; + let x: [u8; 32] = point[1..33].try_into().ok()?; + let y: [u8; 32] = point[33..65].try_into().ok()?; + Some(p256_public_key_to_words(&x, &y)) +} + +pub fn parse_p256_public_key(data: &[u8]) -> Result<([u32; 8], [u32; 8])> { + // Bare `X || Y`, with no SEC1 point prefix. + if let Ok(xy) = <[u8; 64]>::try_from(data) { + let x: [u8; 32] = xy[0..32].try_into().unwrap(); + let y: [u8; 32] = xy[32..64].try_into().unwrap(); + return Ok(p256_public_key_to_words(&x, &y)); + } + // A bare SEC1 point, or binary DER/SPKI wrapping one. + if let Some(point) = uncompressed_point(data) { + return Ok(point); + } + // PEM-armoured hex or base64 SPKI. + if let Some(point) = decode_pem(data).as_deref().and_then(uncompressed_point) { + return Ok(point); + } + Err(anyhow!("Unrecognized P-256 public key encoding")) +} + +// ----------------------------------------------------------------------------- +// ML-DSA-44 Public Key Parsing +// ----------------------------------------------------------------------------- + +/// Extracts an ML-DSA-44 public key from the bare key, or from a DER/SPKI blob +/// whose trailing `MLDSA44_PUBLIC_KEY_LEN` bytes are preceded by the BIT STRING +/// unused-bits octet. +fn mldsa44_key_bytes(data: &[u8]) -> Option<[u8; MLDSA44_PUBLIC_KEY_LEN]> { + if let Ok(pk) = <[u8; MLDSA44_PUBLIC_KEY_LEN]>::try_from(data) { + return Some(pk); + } + let start = data.len().checked_sub(MLDSA44_PUBLIC_KEY_LEN)?; + if start > 0 && data[start - 1] == 0x00 { + return data[start..].try_into().ok(); + } + None +} + +pub fn parse_mldsa44_public_key(data: &[u8]) -> Result<[u8; MLDSA44_PUBLIC_KEY_LEN]> { + if let Some(pk) = mldsa44_key_bytes(data) { + return Ok(pk); + } + if let Some(pk) = decode_pem(data).as_deref().and_then(mldsa44_key_bytes) { + return Ok(pk); + } + Err(anyhow!( + "Expected 1312-byte raw or SPKI PEM ML-DSA-44 public key" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hex_conversion() { + let data = [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]; + let hex_str = to_hex(&data); + assert_eq!(hex_str, "0123456789abcdef"); + let decoded = from_hex(&hex_str).unwrap(); + assert_eq!(decoded, data); + + let prefixed = from_hex("0x0123456789abcdef").unwrap(); + assert_eq!(prefixed, data); + } + + #[test] + fn test_decode_hex_or_base64() { + assert_eq!( + decode_hex_or_base64("AQIDBAU=").unwrap(), + vec![1, 2, 3, 4, 5] + ); + assert_eq!( + decode_hex_or_base64("0102030405").unwrap(), + vec![1, 2, 3, 4, 5] + ); + assert!(decode_hex_or_base64("not valid!!").is_none()); + } + + #[test] + fn test_p256_key_parsing() { + let mut raw_64 = vec![0x11u8; 32]; + raw_64.extend_from_slice(&[0x22u8; 32]); + let (x, y) = parse_p256_public_key(&raw_64).unwrap(); + assert_eq!(x[0], 0x11111111); + assert_eq!(y[0], 0x22222222); + + let mut raw_65 = vec![0x04u8]; + raw_65.extend_from_slice(&raw_64); + let (x2, y2) = parse_p256_public_key(&raw_65).unwrap(); + assert_eq!(x, x2); + assert_eq!(y, y2); + + assert!(parse_p256_public_key(&[0u8; 8]).is_err()); + } + + #[test] + fn test_mldsa44_key_parsing() { + let raw_pk = vec![0x55u8; MLDSA44_PUBLIC_KEY_LEN]; + assert_eq!(parse_mldsa44_public_key(&raw_pk).unwrap()[0], 0x55); + + // SPKI-shaped: header, BIT STRING unused-bits octet, then the key. + let mut spki = vec![0x30, 0x82, 0x05, 0x26, 0x03, 0x82, 0x05, 0x21, 0x00]; + spki.extend_from_slice(&raw_pk); + assert_eq!(parse_mldsa44_public_key(&spki).unwrap(), raw_pk[..]); + + assert!(parse_mldsa44_public_key(&[0u8; 16]).is_err()); + } +} diff --git a/tlvtool/src/lib.rs b/tlvtool/src/lib.rs index 5add1c18..e764dd14 100644 --- a/tlvtool/src/lib.rs +++ b/tlvtool/src/lib.rs @@ -12,12 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! OpenPRoT TLV Key Rotation Tool library for open RoT architectures. +//! A library and tool to create, inspect, and manage OpenPRoT TLV records. extern crate alloc; +pub mod builder; pub mod cli; +pub mod crypto; pub mod inspect; +pub use builder::RecordBuilder; +pub use builder::UnsignedRecord; pub use inspect::inspect_file; pub use inspect::inspect_words; diff --git a/tlvtool/src/main.rs b/tlvtool/src/main.rs index f04510f4..0a6a42c4 100644 --- a/tlvtool/src/main.rs +++ b/tlvtool/src/main.rs @@ -17,7 +17,9 @@ use argh::FromArgs; use std::process::ExitCode; use tlvtool::cli::InspectArgs; +use tlvtool::cli::KrrArgs; use tlvtool::cli::handle_inspect; +use tlvtool::cli::handle_krr; // The top-level CLI structure, `Commands` enum, and dispatch logic are defined here // rather than in `cli.rs` so that `tlvtool_lib` remains a modular library defining @@ -33,11 +35,13 @@ pub struct Cli { #[derive(FromArgs, Debug, Clone, PartialEq, Eq)] #[argh(subcommand)] pub enum Commands { + Krr(KrrArgs), Inspect(InspectArgs), } pub fn run_command(command: Commands) -> anyhow::Result { match command { + Commands::Krr(krr) => handle_krr(krr), Commands::Inspect(args) => handle_inspect(args), } } @@ -64,6 +68,13 @@ mod tests { #[test] fn test_main_cli_dispatch() { + let cli_krr = Cli::from_args( + &["tlvtool"], + &["krr", "build", "config.json", "-o", "out.bin"], + ) + .expect("Failed to parse krr"); + assert!(matches!(cli_krr.command, Commands::Krr(_))); + let cli_inspect = Cli::from_args(&["tlvtool"], &["inspect", "file.bin"]) .expect("Failed to parse inspect"); assert!(matches!(cli_inspect.command, Commands::Inspect(_))); diff --git a/tlvtool/tests/cli_test.rs b/tlvtool/tests/cli_test.rs index 697a1cfa..33db84c6 100644 --- a/tlvtool/tests/cli_test.rs +++ b/tlvtool/tests/cli_test.rs @@ -41,6 +41,8 @@ fn run(args: &[&str]) -> String { String::from_utf8(out.stdout).expect("tlvtool wrote non-UTF-8 to stdout") } +const TEST_TEMPDIR: &str = "TEST_TMPDIR"; + /// Writes `words` to a scratch file as little-endian TLV and returns its path. fn write_words(name: &str, words: &[u32]) -> PathBuf { let mut bytes = Vec::with_capacity(words.len() * 4); @@ -48,7 +50,7 @@ fn write_words(name: &str, words: &[u32]) -> PathBuf { bytes.extend_from_slice(&word.to_le_bytes()); } let path = - PathBuf::from(std::env::var("TEST_TMPDIR").expect("TEST_TMPDIR is not set")).join(name); + PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")).join(name); std::fs::write(&path, &bytes).expect("failed to write the record file"); path } @@ -76,3 +78,69 @@ fn test_inspect_rejects_a_missing_file() { let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("Failed to read"), "{stderr}"); } + +const CONFIG_JSON: &str = r#"{ + "version": 1, + "image_vendor": "VNDR", + "image_family": "FAM", + "fingerprints": [ + { + "key_domain": "prod", + "algorithm": "p256", + "fingerprint": "0102030400000000000000000000000000000000000000000000000000000000" + } + ] +}"#; + +/// Writes [`CONFIG_JSON`] to a scratch file and returns its path. +fn write_config(name: &str) -> PathBuf { + let path = + PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")).join(name); + std::fs::write(&path, CONFIG_JSON).expect("failed to write the build config"); + path +} + +#[test] +fn test_build_writes_the_output_file() { + let config = write_config("build_emit.json"); + let out = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")) + .join("build_emit.bin"); + + let msg = run(&[ + "krr", + "build", + config.to_str().unwrap(), + "-o", + out.to_str().unwrap(), + ]); + assert!(msg.contains("successfully written"), "{msg}"); + assert!( + !std::fs::read(&out).unwrap().is_empty(), + "wrote an empty record" + ); +} + +#[test] +fn test_build_prints_the_record_without_an_output_path() { + let config = write_config("build_dump.json"); + + let report = run(&["krr", "build", config.to_str().unwrap()]); + assert!(report.contains("Tag: 'KROT'"), "{report}"); + assert!(report.contains("TLV Structure Tree:"), "{report}"); +} + +#[test] +fn test_build_rejects_a_bad_config() { + let config = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")) + .join("build_bad.json"); + std::fs::write(&config, "{ not json }").unwrap(); + + let out = tlvtool() + .args(["krr", "build", config.to_str().unwrap()]) + .output() + .expect("failed to run tlvtool"); + + assert!(!out.status.success(), "a bad config must not exit zero"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("Failed to parse JSON config"), "{stderr}"); +} From 287b134196147f6452439c7973f85868d85538d3 Mon Sep 17 00:00:00 2001 From: Michael Field Date: Mon, 21 Sep 2026 17:01:16 -0700 Subject: [PATCH 3/7] tlvtool: add krr sign subcommand and SignedRecordBuilder --- tlvtool/BUILD.bazel | 1 + tlvtool/src/cli.rs | 105 +++++++++ tlvtool/src/crypto.rs | 198 ++++++++++++++--- tlvtool/src/lib.rs | 5 + tlvtool/src/signature.rs | 438 ++++++++++++++++++++++++++++++++++++++ tlvtool/tests/cli_test.rs | 115 ++++++++++ 6 files changed, 831 insertions(+), 31 deletions(-) create mode 100644 tlvtool/src/signature.rs diff --git a/tlvtool/BUILD.bazel b/tlvtool/BUILD.bazel index 88d8acb5..94bf25ec 100644 --- a/tlvtool/BUILD.bazel +++ b/tlvtool/BUILD.bazel @@ -24,6 +24,7 @@ rust_library( "src/crypto.rs", "src/inspect.rs", "src/lib.rs", + "src/signature.rs", ], crate_name = "tlvtool", edition = "2024", diff --git a/tlvtool/src/cli.rs b/tlvtool/src/cli.rs index 0aa51f9a..ccebd8bc 100644 --- a/tlvtool/src/cli.rs +++ b/tlvtool/src/cli.rs @@ -16,9 +16,12 @@ use crate::builder::RecordBuilder; use crate::inspect::inspect_file; +use crate::signature::HybridSignatureFiles; +use crate::signature::SignedRecordBuilder; use anyhow::Context; use anyhow::Result; use argh::FromArgs; +use std::path::Path; #[derive(FromArgs, Debug, Clone, PartialEq, Eq)] #[argh(subcommand, name = "krr")] @@ -32,6 +35,7 @@ pub struct KrrArgs { #[argh(subcommand)] pub enum KrrCommands { Build(BuildArgs), + Sign(SignArgs), } #[derive(FromArgs, Debug, Clone, PartialEq, Eq)] @@ -56,9 +60,39 @@ pub struct InspectArgs { pub file: String, } +#[derive(FromArgs, Debug, Clone, PartialEq, Eq)] +#[argh(subcommand, name = "sign")] +/// Attach offline signatures to a frozen unsigned Key Rotation record. +pub struct SignArgs { + /// path to the unsigned record file (.unsigned). + #[argh(positional)] + pub record: String, + + /// output file path (.signed). If omitted, prints human-readable record inspection to stdout. + #[argh(option, short = 'o')] + pub out: Option, + + /// path to EC (P-256) public key file (PEM/DER/raw). + #[argh(option)] + pub ec_public_key: String, + + /// path to ML-DSA-44 public key file (raw). + #[argh(option)] + pub mldsa_public_key: String, + + /// path to EC (P-256) signature file (DER/raw). + #[argh(option)] + pub ec_signature: String, + + /// path to ML-DSA-44 signature file (raw). + #[argh(option)] + pub mldsa_signature: String, +} + pub fn handle_krr(args: KrrArgs) -> Result { match args.command { KrrCommands::Build(build_args) => handle_build(build_args), + KrrCommands::Sign(sig_args) => handle_sign(sig_args), } } @@ -86,6 +120,46 @@ pub fn handle_inspect(args: InspectArgs) -> Result { inspect_file(&record_bytes).with_context(|| format!("Failed to inspect {}", args.file)) } +pub fn handle_sign(args: SignArgs) -> Result { + let signed = SignedRecordBuilder::from_unsigned_file(&args.record) + .context("Failed to load unsigned record")? + // Key rotation records are only ever countersigned by the RoT rotation + // authority, so the signing key domain is fixed rather than selectable. + .add_hybrid_signature_files( + crypto_rust_proto::KeyDomain::Rotr, + HybridSignatureFiles { + ec_public_key: Path::new(&args.ec_public_key), + mldsa_public_key: Path::new(&args.mldsa_public_key), + ec_signature: Path::new(&args.ec_signature), + mldsa_signature: Path::new(&args.mldsa_signature), + }, + ) + .context("Failed to attach hybrid signature")? + .build() + .context("Failed to sign record")?; + + let report = match args.out.as_deref() { + Some(path) => { + std::fs::write(path, signed.as_bytes()) + .with_context(|| format!("Failed to write {path}"))?; + format!("Signed key rotation record successfully written to {path}") + } + None => inspect_file(signed.as_bytes()).context("Failed to inspect built record")?, + }; + let fingerprint = signed + .otp_fingerprint() + .map(|fp| format!("\n Target RoT OTP FP : {}", fp)) + .unwrap_or_default(); + + Ok(format!( + "{}\n Unsigned Body Size : {} bytes\n Total Signed Size : {} bytes{}", + report, + signed.unsigned_size(), + signed.signed_size(), + fingerprint, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -101,6 +175,37 @@ mod tests { assert_eq!(b_args.config, "config.json"); assert_eq!(b_args.out.as_deref(), Some("out.bin")); } + _ => panic!("Expected Krr Build subcommand"), + } + + let krr_sign = KrrArgs::from_args( + &["krr"], + &[ + "sign", + "in.unsigned", + "-o", + "out.signed", + "--ec-public-key", + "ec.pub", + "--mldsa-public-key", + "mldsa.pub", + "--ec-signature", + "ec.sig", + "--mldsa-signature", + "mldsa.sig", + ], + ) + .expect("Failed to parse 'krr sign'"); + match krr_sign.command { + KrrCommands::Sign(sig_args) => { + assert_eq!(sig_args.record, "in.unsigned"); + assert_eq!(sig_args.out.as_deref(), Some("out.signed")); + assert_eq!(sig_args.ec_public_key, "ec.pub"); + assert_eq!(sig_args.mldsa_public_key, "mldsa.pub"); + assert_eq!(sig_args.ec_signature, "ec.sig"); + assert_eq!(sig_args.mldsa_signature, "mldsa.sig"); + } + _ => panic!("Expected Krr Sign subcommand"), } let inspect = InspectArgs::from_args(&["inspect"], &["record.bin"]) diff --git a/tlvtool/src/crypto.rs b/tlvtool/src/crypto.rs index eb4a95e5..cb0926d7 100644 --- a/tlvtool/src/crypto.rs +++ b/tlvtool/src/crypto.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Cryptographic primitives and public key coordinate parsing. +//! Cryptographic primitives, key coordinate parsing, and signature verification. use alloc::string::String; use alloc::vec::Vec; @@ -30,6 +30,15 @@ pub use mutask_sha256_sw::sha256; /// Length of an ML-DSA-44 public key, in bytes. pub const MLDSA44_PUBLIC_KEY_LEN: usize = 1312; +/// Length of an ML-DSA-44 signature, in bytes. +pub const MLDSA44_SIGNATURE_LEN: usize = 2420; + +/// Length of a raw P-256 public key (`x || y`), in bytes. +pub const P256_PUBLIC_KEY_LEN: usize = 64; + +/// Length of a raw P-256 signature (`r || s`), in bytes. +pub const P256_SIGNATURE_LEN: usize = 64; + // ----------------------------------------------------------------------------- // Encoding Utilities // ----------------------------------------------------------------------------- @@ -61,46 +70,57 @@ fn decode_pem(data: &[u8]) -> Option> { } // ----------------------------------------------------------------------------- -// P-256 Curve & Utilities +// Fingerprints // ----------------------------------------------------------------------------- -pub fn p256_public_key_to_words(x_bytes: &[u8; 32], y_bytes: &[u8; 32]) -> ([u32; 8], [u32; 8]) { - let mut x_words = [0u32; 8]; - let mut y_words = [0u32; 8]; - for i in 0..8 { - x_words[i] = u32::from_le_bytes(x_bytes[i * 4..(i + 1) * 4].try_into().unwrap()); - y_words[i] = u32::from_le_bytes(y_bytes[i * 4..(i + 1) * 4].try_into().unwrap()); +/// SHA2-256 over the concatenation of `parts`. +fn sha256_parts(parts: &[&[u8]]) -> [u8; 32] { + let mut hasher = Sha256::new(); + for part in parts { + hasher.update(part); } - (x_words, y_words) + hasher.finalize() } -pub fn p256_fingerprint_sha256(x_le: &[u32; 8], y_le: &[u32; 8]) -> [u8; 32] { - let mut raw_le = [0u8; 64]; - for (i, word) in x_le.iter().enumerate() { - raw_le[i * 4..(i + 1) * 4].copy_from_slice(&word.to_le_bytes()); - } - for (i, word) in y_le.iter().enumerate() { - raw_le[32 + i * 4..32 + (i + 1) * 4].copy_from_slice(&word.to_le_bytes()); - } - sha256(&raw_le) +/// Fingerprints a P-256 public key as `SHA2-256(x || y)` over the coordinates +/// exactly as they appear in the parsed key. +pub fn p256_fingerprint_sha256(x: &[u8; 32], y: &[u8; 32]) -> [u8; 32] { + sha256_parts(&[x, y]) +} + +/// Fingerprints a hybrid signing authority as `SHA2-256(x || y || mldsa)`. +/// +/// This is the value provisioned into RoT OTP to pin the key rotation authority. +pub fn compute_hybrid_otp_fingerprint( + x: &[u8; 32], + y: &[u8; 32], + mldsa: &[u8; MLDSA44_PUBLIC_KEY_LEN], +) -> [u8; 32] { + sha256_parts(&[x, y, mldsa]) +} + +// ----------------------------------------------------------------------------- +// P-256 Public Key & Signature Parsing +// ----------------------------------------------------------------------------- + +/// Splits a 64-byte `a || b` buffer into its two 32-byte halves. +fn split_halves(data: &[u8; 64]) -> ([u8; 32], [u8; 32]) { + let (a, b) = data.split_at(32); + (a.try_into().unwrap(), b.try_into().unwrap()) } /// Locates an uncompressed SEC1 point (`0x04 || X || Y`) inside `data`, which may /// be the bare point or a DER/SPKI structure wrapping it. -fn uncompressed_point(data: &[u8]) -> Option<([u32; 8], [u32; 8])> { +fn uncompressed_point(data: &[u8]) -> Option<([u8; 32], [u8; 32])> { let pos = data.windows(65).position(|w| w[0] == 0x04)?; - let point = &data[pos..pos + 65]; - let x: [u8; 32] = point[1..33].try_into().ok()?; - let y: [u8; 32] = point[33..65].try_into().ok()?; - Some(p256_public_key_to_words(&x, &y)) + let point: &[u8; 64] = data[pos + 1..pos + 65].try_into().ok()?; + Some(split_halves(point)) } -pub fn parse_p256_public_key(data: &[u8]) -> Result<([u32; 8], [u32; 8])> { +pub fn parse_p256_public_key(data: &[u8]) -> Result<([u8; 32], [u8; 32])> { // Bare `X || Y`, with no SEC1 point prefix. - if let Ok(xy) = <[u8; 64]>::try_from(data) { - let x: [u8; 32] = xy[0..32].try_into().unwrap(); - let y: [u8; 32] = xy[32..64].try_into().unwrap(); - return Ok(p256_public_key_to_words(&x, &y)); + if let Ok(xy) = <&[u8; P256_PUBLIC_KEY_LEN]>::try_from(data) { + return Ok(split_halves(xy)); } // A bare SEC1 point, or binary DER/SPKI wrapping one. if let Some(point) = uncompressed_point(data) { @@ -113,8 +133,56 @@ pub fn parse_p256_public_key(data: &[u8]) -> Result<([u32; 8], [u32; 8])> { Err(anyhow!("Unrecognized P-256 public key encoding")) } +pub fn parse_p256_signature(data: &[u8]) -> Result<([u8; 32], [u8; 32])> { + if let Ok(rs) = <&[u8; P256_SIGNATURE_LEN]>::try_from(data) { + return Ok(split_halves(rs)); + } + if let Some(rs) = parse_der_signature(data) { + return Ok(rs); + } + Err(anyhow!("Expected 64-byte raw or DER P-256 signature")) +} + +/// Reads the DER `INTEGER` at `*idx`, advancing `idx` past it. +/// +/// Every access is bounds checked, so a truncated or malformed encoding yields +/// `None` rather than panicking on untrusted input. +fn der_integer(data: &[u8], idx: &mut usize) -> Option<[u8; 32]> { + if *data.get(*idx)? != 0x02 { + return None; + } + let len = *data.get(*idx + 1)? as usize; + let value = data.get(*idx + 2..*idx + 2 + len)?; + *idx += 2 + len; + pad_or_trim_32(value) +} + +/// Parses a DER `SEQUENCE { INTEGER r, INTEGER s }` ECDSA signature. +fn parse_der_signature(data: &[u8]) -> Option<([u8; 32], [u8; 32])> { + if *data.first()? != 0x30 { + return None; + } + // Skip the outer SEQUENCE tag and length. + let mut idx = 2; + Some((der_integer(data, &mut idx)?, der_integer(data, &mut idx)?)) +} + +fn pad_or_trim_32(bytes: &[u8]) -> Option<[u8; 32]> { + let trimmed = if bytes.len() > 32 && bytes[0] == 0x00 { + &bytes[1..] + } else { + bytes + }; + if trimmed.len() > 32 { + return None; + } + let mut out = [0u8; 32]; + out[32 - trimmed.len()..].copy_from_slice(trimmed); + Some(out) +} + // ----------------------------------------------------------------------------- -// ML-DSA-44 Public Key Parsing +// ML-DSA-44 Public Key & Signature Parsing // ----------------------------------------------------------------------------- /// Extracts an ML-DSA-44 public key from the bare key, or from a DER/SPKI blob @@ -143,6 +211,21 @@ pub fn parse_mldsa44_public_key(data: &[u8]) -> Result<[u8; MLDSA44_PUBLIC_KEY_L )) } +pub fn parse_mldsa44_signature(data: &[u8]) -> Result<[u8; MLDSA44_SIGNATURE_LEN]> { + if let Ok(sig) = <[u8; MLDSA44_SIGNATURE_LEN]>::try_from(data) { + return Ok(sig); + } + if let Some(sig) = decode_pem(data) + .as_deref() + .and_then(|b| <[u8; MLDSA44_SIGNATURE_LEN]>::try_from(b).ok()) + { + return Ok(sig); + } + Err(anyhow!( + "Expected 2420-byte raw, hex, or base64 ML-DSA-44 signature" + )) +} + #[cfg(test)] mod tests { use super::*; @@ -177,8 +260,8 @@ mod tests { let mut raw_64 = vec![0x11u8; 32]; raw_64.extend_from_slice(&[0x22u8; 32]); let (x, y) = parse_p256_public_key(&raw_64).unwrap(); - assert_eq!(x[0], 0x11111111); - assert_eq!(y[0], 0x22222222); + assert_eq!(x, [0x11u8; 32]); + assert_eq!(y, [0x22u8; 32]); let mut raw_65 = vec![0x04u8]; raw_65.extend_from_slice(&raw_64); @@ -201,4 +284,57 @@ mod tests { assert!(parse_mldsa44_public_key(&[0u8; 16]).is_err()); } + + #[test] + fn test_p256_signature_parsing() { + let mut raw_sig = vec![0x33u8; 32]; + raw_sig.extend_from_slice(&[0x44u8; 32]); + let (r, s) = parse_p256_signature(&raw_sig).unwrap(); + assert_eq!(r, [0x33u8; 32]); + assert_eq!(s, [0x44u8; 32]); + } + + #[test] + fn test_der_signature_parsing() { + // SEQUENCE { INTEGER 0x01, INTEGER 0x02 }, left-padded to 32 bytes each. + let der = [0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]; + let (r, s) = parse_p256_signature(&der).unwrap(); + assert_eq!(r[31], 0x01); + assert_eq!(s[31], 0x02); + assert_eq!(r[..31], [0u8; 31]); + } + + #[test] + fn test_malformed_der_signature_is_rejected_not_panic() { + // An INTEGER whose declared length runs off the end of the buffer. + assert!(parse_p256_signature(&[0x30, 0x06, 0x02, 0x7f, 0, 0, 0, 0]).is_err()); + // A second INTEGER whose length octet is missing entirely. + assert!(parse_p256_signature(&[0x30, 0x04, 0x02, 0x01, 0x01, 0x02]).is_err()); + // Not a SEQUENCE at all. + assert!(parse_p256_signature(&[0x02, 0x01, 0x01]).is_err()); + } + + #[test] + fn test_mldsa44_signature_parsing() { + let raw_sig = vec![0x66u8; MLDSA44_SIGNATURE_LEN]; + assert_eq!(parse_mldsa44_signature(&raw_sig).unwrap()[0], 0x66); + + assert!(parse_mldsa44_signature(&[0u8; 16]).is_err()); + } + + #[test] + fn test_hybrid_otp_fingerprint() { + let x = [0x12u8; 32]; + let y = [0x34u8; 32]; + let mldsa = [0x77u8; MLDSA44_PUBLIC_KEY_LEN]; + let fp = compute_hybrid_otp_fingerprint(&x, &y, &mldsa); + assert_ne!(fp, [0u8; 32]); + + // The hybrid fingerprint is the streaming hash of the same concatenation. + let mut material = Vec::new(); + material.extend_from_slice(&x); + material.extend_from_slice(&y); + material.extend_from_slice(&mldsa); + assert_eq!(fp, sha256(&material)); + } } diff --git a/tlvtool/src/lib.rs b/tlvtool/src/lib.rs index e764dd14..9e3163e9 100644 --- a/tlvtool/src/lib.rs +++ b/tlvtool/src/lib.rs @@ -20,8 +20,13 @@ pub mod builder; pub mod cli; pub mod crypto; pub mod inspect; +pub mod signature; pub use builder::RecordBuilder; pub use builder::UnsignedRecord; pub use inspect::inspect_file; pub use inspect::inspect_words; +pub use signature::HybridMaterial; +pub use signature::HybridSignatureFiles; +pub use signature::SignedRecord; +pub use signature::SignedRecordBuilder; diff --git a/tlvtool/src/signature.rs b/tlvtool/src/signature.rs new file mode 100644 index 00000000..e8f47f77 --- /dev/null +++ b/tlvtool/src/signature.rs @@ -0,0 +1,438 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Offline signature attachment and byte-identical container integration for `krr sign`. + +use crate::builder::MAX_MAILBOX_RECORD_SIZE_BYTES; +use crate::builder::UnsignedRecord; +use crate::crypto::compute_hybrid_otp_fingerprint; +use crate::crypto::parse_mldsa44_public_key; +use crate::crypto::parse_mldsa44_signature; +use crate::crypto::parse_p256_public_key; +use crate::crypto::parse_p256_signature; +use crate::crypto::to_hex; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::ensure; +use mutask_tlv::TlvBuilder; +use mutask_tlv::TlvData; +use mutask_tlv::TlvHeader; +use mutask_tlv::TlvItem; +use mutask_tlv::TlvObject; +use std::path::Path; + +/// The four offline artefacts that make up one hybrid P-256 + ML-DSA-44 signature. +/// +/// Generic over the representation so that the same field names describe the +/// inputs as paths on the way in and as bytes once loaded. Naming the fields +/// makes the otherwise interchangeable blobs impossible to transpose at a call site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HybridMaterial { + pub ec_public_key: T, + pub mldsa_public_key: T, + pub ec_signature: T, + pub mldsa_signature: T, +} + +/// [`HybridMaterial`] as paths on disk. +pub type HybridSignatureFiles<'a> = HybridMaterial<&'a Path>; + +impl HybridSignatureFiles<'_> { + /// Loads the contents of all four files. + pub fn read(&self) -> Result>> { + let read = |label: &str, path: &Path| { + std::fs::read(path) + .with_context(|| format!("Failed to read {label} from {}", path.display())) + }; + Ok(HybridMaterial { + ec_public_key: read("EC public key", self.ec_public_key)?, + mldsa_public_key: read("ML-DSA public key", self.mldsa_public_key)?, + ec_signature: read("EC signature", self.ec_signature)?, + mldsa_signature: read("ML-DSA signature", self.mldsa_signature)?, + }) + } +} + +/// Builds a `SIGC` container for a hybrid signature, alongside the hex OTP +/// fingerprint of the signing authority it names. +fn build_hybrid_sigc>( + key_domain: crypto_rust_proto::KeyDomain, + material: &HybridMaterial, +) -> Result<(crypto_rust_proto::SignatureContainer, String)> { + let (x, y) = parse_p256_public_key(material.ec_public_key.as_ref()) + .context("Failed to parse EC public key")?; + let mldsa_pk = parse_mldsa44_public_key(material.mldsa_public_key.as_ref()) + .context("Failed to parse ML-DSA public key")?; + let (r, s) = parse_p256_signature(material.ec_signature.as_ref()) + .context("Failed to parse EC signature")?; + let mldsa_sig = parse_mldsa44_signature(material.mldsa_signature.as_ref()) + .context("Failed to parse ML-DSA signature")?; + + let otp_fp_hex = to_hex(&compute_hybrid_otp_fingerprint(&x, &y, &mldsa_pk)); + + let sigc = protobuf::proto!(crypto_rust_proto::SignatureContainer { + public_key: crypto_rust_proto::PublicKey { + key_domain: key_domain, + hybrid_p256_mldsa: crypto_rust_proto::HybridP256MlDsa44PublicMaterial { + x: x.to_vec(), + y: y.to_vec(), + mldsa: mldsa_pk.to_vec(), + } + }, + signature: crypto_rust_proto::Signature { + hybrid_p256_mldsa: crypto_rust_proto::HybridP256MlDsa44Signature { + r: r.to_vec(), + s: s.to_vec(), + mldsa: mldsa_sig.to_vec(), + } + } + }); + + Ok((sigc, otp_fp_hex)) +} + +/// Views `data` as the single `T` container it is expected to hold. +fn root_container(data: &TlvData) -> Result> { + data.iter::().next().with_context(|| { + format!( + "TLV image is missing its '{}' root container", + String::from_utf8_lossy(&T::TAG.to_le_bytes()) + ) + }) +} + +/// Fluent builder for integrating offline signatures into `SignedDescriptor` (`KRSD`). +#[derive(Debug, Clone)] +pub struct SignedRecordBuilder { + unsigned: UnsignedRecord, + signatures: Vec, + otp_fingerprint: Option, +} + +impl SignedRecordBuilder { + pub fn new(unsigned: UnsignedRecord) -> Self { + Self { + unsigned, + signatures: Vec::new(), + otp_fingerprint: None, + } + } + + pub fn from_unsigned_bytes(bytes: &[u8]) -> Result { + Ok(Self::new(UnsignedRecord::from_bytes(bytes)?)) + } + + pub fn from_unsigned_file(path: impl AsRef) -> Result { + Ok(Self::new(UnsignedRecord::from_file(path)?)) + } + + pub fn add_signature_container(mut self, sigc: crypto_rust_proto::SignatureContainer) -> Self { + self.signatures.push(sigc); + self + } + + pub fn add_hybrid_signature>( + mut self, + key_domain: crypto_rust_proto::KeyDomain, + material: &HybridMaterial, + ) -> Result { + let (sigc, otp_fp_hex) = build_hybrid_sigc(key_domain, material)?; + // The first signing authority is the one reported back to the operator. + self.otp_fingerprint.get_or_insert(otp_fp_hex); + self.signatures.push(sigc); + Ok(self) + } + + pub fn add_hybrid_signature_files( + self, + key_domain: crypto_rust_proto::KeyDomain, + files: HybridSignatureFiles<'_>, + ) -> Result { + let material = files.read()?; + self.add_hybrid_signature(key_domain, &material) + } + + /// Wraps the frozen `KROT` record and every encoded `SIGC` in a `KRSD` container. + /// + /// Children are copied verbatim by [`TlvBuilder::add_copy`], so unknown sub-TLVs + /// in the unsigned record survive signing byte-for-byte. + pub fn build(self) -> Result { + ensure!( + !self.signatures.is_empty(), + "At least one signature container must be provided" + ); + + // Encode every `SIGC` up front: the TLV builder copies out of these + // buffers, so they have to outlive it. + let sigc_words = self + .signatures + .iter() + .map(|sigc| { + mutask_tlv_proto_conv::message_to_tlv( + &crypto_rust_proto::__unstable::CONTRIB_OPENPROT_PROTO_CRYPTO_DESCRIPTOR_INFO, + sigc, + ) + .context("Failed to convert signature container to TLV wire format") + }) + .collect::>>()?; + + let unsigned_bytes = self.unsigned.as_bytes(); + let unsigned_size = unsigned_bytes.len(); + let unsigned_data = TlvData::overlay_bytes(unsigned_bytes) + .map_err(|_| anyhow!("Unsigned record has invalid alignment or length"))?; + let krot_item = root_container::(unsigned_data)?; + + // `add_copy` only reports *that* the destination ran out of room, so work + // out the size the operator needs to see before handing the buffer over. + let sigc_size: usize = sigc_words.iter().map(|w| w.len() * size_of::()).sum(); + let total_size = size_of::() + unsigned_size + sigc_size; + let too_large = || { + anyhow!( + "Signed record size ({total_size} bytes) exceeds mailbox capacity \ + ({MAX_MAILBOX_RECORD_SIZE_BYTES} bytes)" + ) + }; + + let mut buf = vec![0u32; MAX_MAILBOX_RECORD_SIZE_BYTES / 4]; + let (_, mut krsd) = TlvBuilder::new::(&mut buf) + .map_err(|_| too_large())?; + + krsd.add_copy(&krot_item).map_err(|_| too_large())?; + + for words in &sigc_words { + let sigc = + root_container::(TlvData::overlay(words))?; + krsd.add_copy(&sigc).map_err(|_| too_large())?; + } + + let tlv_bytes = krsd + .finish() + .into_bytes(&buf) + .map_err(|_| too_large())? + .to_vec(); + + // Signing is only meaningful if what we shipped is what was signed. + let embedded = + tlv_bytes.get(size_of::()..size_of::() + unsigned_size); + ensure!( + embedded == Some(self.unsigned.as_bytes()), + "Embedded unsigned record is not byte-identical to the signed input; \ + the attached signatures would not verify" + ); + + Ok(SignedRecord { + tlv_bytes, + unsigned_size, + otp_fingerprint: self.otp_fingerprint, + }) + } +} + +/// A validated signed key rotation record wire image (`KRSD`). +#[derive(Debug, Clone)] +pub struct SignedRecord { + tlv_bytes: Vec, + unsigned_size: usize, + otp_fingerprint: Option, +} + +impl SignedRecord { + pub fn as_bytes(&self) -> &[u8] { + &self.tlv_bytes + } + + pub fn to_bytes(self) -> Vec { + self.tlv_bytes + } + + /// Size of the embedded unsigned `KROT` record, in bytes. + pub fn unsigned_size(&self) -> usize { + self.unsigned_size + } + + /// Size of the complete `KRSD` wire image, in bytes. + pub fn signed_size(&self) -> usize { + self.tlv_bytes.len() + } + + /// Hex OTP fingerprint of the first hybrid signing authority, if one was added. + pub fn otp_fingerprint(&self) -> Option<&str> { + self.otp_fingerprint.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builder::RecordBuilder; + use crate::crypto::MLDSA44_PUBLIC_KEY_LEN; + use crate::crypto::MLDSA44_SIGNATURE_LEN; + use crate::crypto::P256_SIGNATURE_LEN; + use zerocopy::IntoBytes; + + /// Filler P-256 public key: `x = [1u8; 32]`, `y = [2u8; 32]`. + const EC_PUBLIC_KEY: [u8; 64] = { + let mut key = [1u8; 64]; + let mut i = 32; + while i < 64 { + key[i] = 2; + i += 1; + } + key + }; + + /// A complete set of hybrid signing inputs, using filler key material. + const MATERIAL: HybridMaterial<&[u8]> = HybridMaterial { + ec_public_key: &EC_PUBLIC_KEY, + mldsa_public_key: &[3u8; MLDSA44_PUBLIC_KEY_LEN], + ec_signature: &[1u8; P256_SIGNATURE_LEN], + mldsa_signature: &[2u8; MLDSA44_SIGNATURE_LEN], + }; + + /// `SHA2-256([1u8; 32] || [2u8; 32] || [3u8; 1312])`, for [`MATERIAL`]. + /// + /// Pinned rather than computed: this is the value provisioned into RoT OTP + /// to name the rotation authority, so a test must not derive it with the + /// same function it is checking. Verified against an independent SHA2-256: + /// + /// ```text + /// python3 -c "import hashlib; print(hashlib.sha256( + /// bytes([1]*32 + [2]*32 + [3]*1312)).hexdigest())" + /// ``` + const EXPECTED_OTP_FP: &str = + "450b9724056546773e54021936e3d34962b8d6628c0fd3801e1e6fde79342682"; + + /// Asserts that `bytes` inspect as a `KRSD` wrapping a `KROT` and a `SIGC`. + fn assert_signed_shape(bytes: &[u8]) { + let report = crate::inspect::inspect_file(bytes).unwrap(); + for tag in ["KRSD", "KROT", "SIGC"] { + assert!( + report.contains(&format!("Tag: '{}'", tag)), + "expected a '{}' container in:\n{}", + tag, + report + ); + } + } + + #[test] + fn test_hybrid_signature_integration() { + let unsigned = RecordBuilder::new() + .version(3) + .image_vendor("VNDR") + .image_family("FAM") + .add_mldsa44_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x11u8; 32]) + .build_unsigned() + .unwrap(); + + let signed = SignedRecordBuilder::new(unsigned) + .add_hybrid_signature(crypto_rust_proto::KeyDomain::Rotr, &MATERIAL) + .unwrap() + .build() + .expect("Hybrid signature integration failed"); + + assert_eq!(signed.otp_fingerprint(), Some(EXPECTED_OTP_FP)); + assert_eq!(signed.signed_size(), signed.as_bytes().len()); + assert!(signed.signed_size() > 3800 && signed.signed_size() <= 8192); + assert_signed_shape(signed.as_bytes()); + } + + #[test] + fn test_empty_signatures_rejected() { + let unsigned = RecordBuilder::new() + .version(1) + .image_vendor("VNDR") + .image_family("FAM") + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x01u8; 32]) + .build_unsigned() + .unwrap(); + + assert!(SignedRecordBuilder::new(unsigned).build().is_err()); + } + + #[test] + fn test_prebuilt_signature_containers_are_all_embedded() { + let domain = crypto_rust_proto::KeyDomain::Rotr; + + let unsigned = RecordBuilder::new() + .image_vendor("VNDR") + .image_family("FAM") + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x01u8; 32]) + .build_unsigned() + .unwrap(); + + let signed = SignedRecordBuilder::new(unsigned) + .add_signature_container(build_hybrid_sigc(domain, &MATERIAL).unwrap().0) + .add_signature_container(build_hybrid_sigc(domain, &MATERIAL).unwrap().0) + .build() + .expect("Signing with pre-built containers failed"); + + assert_signed_shape(signed.as_bytes()); + // Both containers survive into the wire image. + let report = crate::inspect::inspect_file(signed.as_bytes()).unwrap(); + assert_eq!(report.matches("Tag: 'SIGC'").count(), 2); + // This path never parses a public key, so no fingerprint is reported. + assert_eq!(signed.otp_fingerprint(), None); + } + + #[test] + fn test_sign_preserves_unknown_sub_tlvs_byte_for_byte() { + let unsigned = RecordBuilder::new() + .version(2) + .image_vendor("GOOGLE") + .image_family("FAMLY") + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0xAAu8; 32]) + .build_unsigned() + .unwrap(); + + // Inject a custom/unknown proprietary sub-TLV ("CUST", 1 word payload = 0xDEADBEEF) + // directly into the frozen `KROT` binary image. + let mut modified_krot_bytes = unsigned.to_bytes(); + let custom_sub_tlv: [u32; 3] = [ + u32::from_le_bytes(*b"CUST"), + 1, // word_len = 1 word + 0xDEAD_BEEF, + ]; + modified_krot_bytes.extend_from_slice(custom_sub_tlv.as_bytes()); + // Update KROT header word_len (at byte offset 4..6) to include the 3 extra words. + let old_word_len = u16::from_le_bytes([modified_krot_bytes[4], modified_krot_bytes[5]]); + let new_word_len = old_word_len + 3; + modified_krot_bytes[4..6].copy_from_slice(&new_word_len.to_le_bytes()); + + let frozen_unsigned = UnsignedRecord::from_bytes(&modified_krot_bytes) + .expect("Frozen unsigned record with custom sub-TLV should be valid"); + + let signed = SignedRecordBuilder::new(frozen_unsigned) + .add_hybrid_signature(crypto_rust_proto::KeyDomain::Rotr, &MATERIAL) + .unwrap() + .build() + .expect("Signing frozen record failed"); + + // Verify the KRSD header is followed by the EXACT byte-for-byte modified_krot_bytes! + let signed_bytes = signed.as_bytes(); + let header_len = size_of::(); + assert_eq!( + &signed_bytes[header_len..header_len + modified_krot_bytes.len()], + &modified_krot_bytes[..], + "Embedded KROT bytes in KRSD must be 100% byte-identical to input unsigned record!" + ); + + // Verify that `tlvtool inspect` surfaces the outer containers. + assert_signed_shape(signed_bytes); + } +} diff --git a/tlvtool/tests/cli_test.rs b/tlvtool/tests/cli_test.rs index 33db84c6..a28ebb39 100644 --- a/tlvtool/tests/cli_test.rs +++ b/tlvtool/tests/cli_test.rs @@ -14,6 +14,7 @@ //! Integration tests for the `tlvtool` command line interface. +use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -144,3 +145,117 @@ fn test_build_rejects_a_bad_config() { let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("Failed to parse JSON config"), "{stderr}"); } + +/// Filler hybrid signing inputs, written to scratch as `krr sign` expects them. +/// +/// Returns the four paths in the order the command line takes them: EC public +/// key, ML-DSA public key, EC signature, ML-DSA signature. +fn write_key_material(prefix: &str) -> [PathBuf; 4] { + let mut ec_public_key = vec![1u8; 32]; + ec_public_key.extend_from_slice(&[2u8; 32]); + + [ + ("ec.pub", ec_public_key), + ("mldsa.pub", vec![3u8; 1312]), + ("ec.sig", vec![1u8; 64]), + ("mldsa.sig", vec![2u8; 2420]), + ] + .map(|(name, bytes)| { + let path = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")) + .join(format!("{}_{}", prefix, name)); + std::fs::write(&path, bytes).expect("failed to write key material"); + path + }) +} + +/// Builds an unsigned record with `krr build` and returns its path. +fn build_unsigned(prefix: &str) -> PathBuf { + let config = write_config(&format!("{}_config.json", prefix)); + let record = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")) + .join(format!("{}.unsigned", prefix)); + run(&[ + "krr", + "build", + config.to_str().unwrap(), + "-o", + record.to_str().unwrap(), + ]); + record +} + +/// Returns `krr sign` arguments for `record`, writing to `out` if given. +fn sign_args(record: &Path, keys: &[PathBuf; 4], out: Option<&Path>) -> Vec { + let mut args = vec![ + "krr".to_string(), + "sign".to_string(), + record.display().to_string(), + "--ec-public-key".to_string(), + keys[0].display().to_string(), + "--mldsa-public-key".to_string(), + keys[1].display().to_string(), + "--ec-signature".to_string(), + keys[2].display().to_string(), + "--mldsa-signature".to_string(), + keys[3].display().to_string(), + ]; + if let Some(out) = out { + args.push("-o".to_string()); + args.push(out.display().to_string()); + } + args +} + +#[test] +fn test_sign_writes_the_output_file() { + let record = build_unsigned("sign_emit"); + let keys = write_key_material("sign_emit"); + let signed = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")) + .join("sign_emit.signed"); + + let args = sign_args(&record, &keys, Some(&signed)); + let msg = run(&args.iter().map(String::as_str).collect::>()); + + assert!(msg.contains("successfully written"), "{msg}"); + assert!(msg.contains("Total Signed Size"), "{msg}"); + assert!(!std::fs::read(&signed).unwrap().is_empty()); + + // The signed record must still inspect as a KRSD wrapping the original. + let report = run(&["inspect", signed.to_str().unwrap()]); + for tag in ["KRSD", "KROT", "SIGC"] { + assert!(report.contains(&format!("Tag: '{tag}'")), "{report}"); + } +} + +#[test] +fn test_sign_prints_the_record_without_an_output_path() { + let record = build_unsigned("sign_dump"); + let keys = write_key_material("sign_dump"); + + let args = sign_args(&record, &keys, None); + let report = run(&args.iter().map(String::as_str).collect::>()); + + assert!(report.contains("Tag: 'KRSD'"), "{report}"); + assert!(report.contains("Target RoT OTP FP"), "{report}"); +} + +#[test] +fn test_sign_rejects_a_bad_key_file() { + let record = build_unsigned("sign_bad"); + let mut keys = write_key_material("sign_bad"); + keys[0] = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TMPDIR is not set")) + .join("sign_bad_short.pub"); + std::fs::write(&keys[0], [0u8; 8]).unwrap(); + + let args = sign_args(&record, &keys, None); + let out = tlvtool() + .args(&args) + .output() + .expect("failed to run tlvtool"); + + assert!(!out.status.success(), "a bad key must not exit zero"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Failed to attach hybrid signature"), + "{stderr}" + ); +} From 9e8fd527475e26687532ae33775716859a98011e Mon Sep 17 00:00:00 2001 From: Michael Field Date: Mon, 21 Sep 2026 17:01:44 -0700 Subject: [PATCH 4/7] tlvtool: add golden record inspection tests Build each record through the public builder API and compare the whole inspect report to a committed golden file. This covers byte offsets, word counts, padding and the shape of the tree, none of which a search for tag names can check. Regenerate with: UPDATE_GOLDEN=1 bazel run //tlvtool/tests:inspect_test --- tlvtool/tests/BUILD.bazel | 18 +++ tlvtool/tests/inspect_test.rs | 104 ++++++++++++++++++ .../inspect_test/krr_v1_unsigned.golden | 10 ++ .../inspect_test/krr_v2_signed_hybrid.golden | 20 ++++ .../inspect_test/krr_v2_unsigned.golden | 10 ++ 5 files changed, 162 insertions(+) create mode 100644 tlvtool/tests/inspect_test.rs create mode 100644 tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden create mode 100644 tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden create mode 100644 tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden diff --git a/tlvtool/tests/BUILD.bazel b/tlvtool/tests/BUILD.bazel index 9a1f2c30..e0e19e59 100644 --- a/tlvtool/tests/BUILD.bazel +++ b/tlvtool/tests/BUILD.bazel @@ -21,3 +21,21 @@ rust_test( edition = "2024", deps = ["@rules_rust//tools/runfiles"], ) + +# Golden tests for the `inspect` report. Update them with: +# UPDATE_GOLDEN=1 bazel run //tlvtool/tests:inspect_test +rust_test( + name = "inspect_test", + srcs = ["inspect_test.rs"], + compile_data = glob(["testdata/inspect_test/*.golden"]), + edition = "2024", + rustc_flags = [ + "-C", + "link-arg=-lgcc", + ], + deps = [ + "//contrib/golden", + "//contrib/openprot/proto:crypto_rust_proto", + "//tlvtool:tlvtool_lib", + ], +) diff --git a/tlvtool/tests/inspect_test.rs b/tlvtool/tests/inspect_test.rs new file mode 100644 index 00000000..441f8f61 --- /dev/null +++ b/tlvtool/tests/inspect_test.rs @@ -0,0 +1,104 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Golden tests for the `inspect` report. +//! +//! Each test builds a record through the public builder API, renders it, and +//! compares the whole report to a committed `.golden` file. Comparing the full +//! text (rather than searching it for tag names) also covers byte offsets, word +//! counts, padding and the shape of the tree. +//! +//! To update the golden files after an intended change: +//! +//! ```text +//! UPDATE_GOLDEN=1 bazel run //tlvtool/tests:inspect_test +//! ``` + +use mutask_golden::assert_eq_golden; +use tlvtool::HybridMaterial; +use tlvtool::RecordBuilder; +use tlvtool::SignedRecordBuilder; +use tlvtool::inspect_file; + +/// Filler P-256 public key: `x = [0x30..0x4f]`, `y = [0x50..0x6f]`. +fn ec_public_key() -> Vec { + (0x30u8..0x70).collect() +} + +/// An unsigned v1 record with both a P-256 and an ML-DSA-44 fingerprint. +fn v1_unsigned() -> Vec { + let mut p256_fp = [0u8; 32]; + let mut mldsa_fp = [0u8; 32]; + for i in 0..32 { + p256_fp[i] = i as u8 + 1; + mldsa_fp[i] = i as u8 + 0x21; + } + + RecordBuilder::new() + .version(1) + .image_vendor("GOOGLE") + .image_family("FAMILYV1") + .legacy_image_family(1) + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, p256_fp) + .add_mldsa44_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, mldsa_fp) + .add_image_digest([0xaa; 32]) + .build_unsigned() + .expect("failed to build the v1 record") + .to_bytes() +} + +/// An unsigned v2 record carrying a single fingerprint and an image digest. +fn v2_unsigned() -> tlvtool::UnsignedRecord { + RecordBuilder::new() + .version(2) + .image_vendor("GOOGLE") + .image_family("FAMILYV1") + .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x50; 32]) + .add_image_digest([0x77; 32]) + .build_unsigned() + .expect("failed to build the v2 record") +} + +#[test] +fn test_inspect_krr_v1_unsigned() { + let report = inspect_file(&v1_unsigned()).unwrap(); + assert_eq_golden!(&report, "testdata/inspect_test/krr_v1_unsigned.golden"); +} + +#[test] +fn test_inspect_krr_v2_unsigned() { + let report = inspect_file(v2_unsigned().as_bytes()).unwrap(); + assert_eq_golden!(&report, "testdata/inspect_test/krr_v2_unsigned.golden"); +} + +#[test] +fn test_inspect_krr_v2_signed_hybrid() { + let ec = ec_public_key(); + let signed = SignedRecordBuilder::new(v2_unsigned()) + .add_hybrid_signature( + crypto_rust_proto::KeyDomain::Rotr, + &HybridMaterial { + ec_public_key: ec.as_slice(), + mldsa_public_key: &[0xbb; 1312], + ec_signature: &[0x88; 64], + mldsa_signature: &[0xaa; 2420], + }, + ) + .expect("failed to attach the hybrid signature") + .build() + .expect("failed to build the signed record"); + + let report = inspect_file(signed.as_bytes()).unwrap(); + assert_eq_golden!(&report, "testdata/inspect_test/krr_v2_signed_hybrid.golden"); +} diff --git a/tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden b/tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden new file mode 100644 index 00000000..d4729d71 --- /dev/null +++ b/tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden @@ -0,0 +1,10 @@ +================================================================= + OpenPRoT TLV File Inspector +================================================================= + Total Size : 208 bytes (52 words) + Root Containers: 1 +----------------------------------------------------------------- + TLV Structure Tree: + └── [0] Tag: 'KROT' | Offset: 0x00000000 | Words: 50 (200 bytes) | Padding: 0 + Data: 01000000474f4f474c45000046414d494c5956314c4749460100000001000000... +================================================================= diff --git a/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden b/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden new file mode 100644 index 00000000..13ad3019 --- /dev/null +++ b/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden @@ -0,0 +1,20 @@ +================================================================= + OpenPRoT TLV File Inspector +================================================================= + Total Size : 4048 bytes (1012 words) + Root Containers: 1 +----------------------------------------------------------------- + TLV Structure Tree: + └── [0] Tag: 'KRSD' | Offset: 0x00000000 | Words: 1010 (4040 bytes) | Padding: 0 + Children: + ├── [0] Tag: 'KROT' | Offset: 0x00000008 | Words: 32 (128 bytes) | Padding: 0 + │ Data: 02000000474f4f474c45000046414d494c5956314b4649500d00000050524f44... + └── [1] Tag: 'SIGC' | Offset: 0x00000090 | Words: 974 (3896 bytes) | Padding: 0 + Children: + ├── [0] Tag: 'PKEY' | Offset: 0x00000098 | Words: 347 (1388 bytes) | Padding: 0 + │ Data: 524f54524d34503258010000303132333435363738393a3b3c3d3e3f40414243... + └── [1] Tag: 'SIGN' | Offset: 0x0000060c | Words: 623 (2492 bytes) | Padding: 0 + Children: + └── [0] Tag: 'M4P2' | Offset: 0x00000614 | Words: 621 (2484 bytes) | Padding: 0 + Data: 8888888888888888888888888888888888888888888888888888888888888888... +================================================================= diff --git a/tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden b/tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden new file mode 100644 index 00000000..d02e62b6 --- /dev/null +++ b/tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden @@ -0,0 +1,10 @@ +================================================================= + OpenPRoT TLV File Inspector +================================================================= + Total Size : 136 bytes (34 words) + Root Containers: 1 +----------------------------------------------------------------- + TLV Structure Tree: + └── [0] Tag: 'KROT' | Offset: 0x00000000 | Words: 32 (128 bytes) | Padding: 0 + Data: 02000000474f4f474c45000046414d494c5956314b4649500d00000050524f44... +================================================================= From a920a1ef3cb3463431bedb097cafb2910f1f0b0a Mon Sep 17 00:00:00 2001 From: Michael Field Date: Tue, 22 Sep 2026 15:25:38 -0700 Subject: [PATCH 5/7] tlvtool: Add all payload fingerprint types --- tlvtool/BUILD.bazel | 2 + tlvtool/src/builder.rs | 223 ++++--- tlvtool/src/crypto.rs | 600 ++++++++++++++---- tlvtool/src/lib.rs | 2 + tlvtool/src/signature.rs | 96 ++- tlvtool/tests/cli_test.rs | 77 +++ tlvtool/tests/inspect_test.rs | 19 +- .../inspect_test/krr_v2_signed_hybrid.golden | 2 +- 8 files changed, 798 insertions(+), 223 deletions(-) diff --git a/tlvtool/BUILD.bazel b/tlvtool/BUILD.bazel index 94bf25ec..f5d637fd 100644 --- a/tlvtool/BUILD.bazel +++ b/tlvtool/BUILD.bazel @@ -31,8 +31,10 @@ rust_library( visibility = ["//visibility:public"], deps = [ "//contrib/crypto:sha256_sw", + "//contrib/fingerprints", "//contrib/openprot/proto", "//contrib/openprot/proto:crypto_rust_proto", + "//contrib/openprot/proto:crypto_tlv", "//contrib/openprot/proto:identity_rust_proto", "//contrib/openprot/proto:key_rotation_rust_proto", "//contrib/tlv", diff --git a/tlvtool/src/builder.rs b/tlvtool/src/builder.rs index cf268a86..d3fd29fe 100644 --- a/tlvtool/src/builder.rs +++ b/tlvtool/src/builder.rs @@ -14,10 +14,9 @@ //! Record compilation, partition size limit verification, and binary emission. -use crate::crypto::p256_fingerprint_sha256; -use crate::crypto::parse_mldsa44_public_key; -use crate::crypto::parse_p256_public_key; -use crate::crypto::sha256; +use crate::crypto::Algorithm; +use crate::crypto::KeyBytes; +use crate::crypto::KeyMaterial; use alloc::format; use alloc::string::String; use alloc::string::ToString; @@ -46,12 +45,8 @@ fn check_unsigned_size(size: usize) -> Result<()> { Ok(()) } -/// Signature algorithm selecting which fingerprint variant a key populates. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Algorithm { - P256, - Mldsa, +fn read_key(path: &Path) -> Result> { + std::fs::read(path).with_context(|| format!("Failed to read key file {}", path.display())) } /// Key domain parsing helper. @@ -128,10 +123,19 @@ impl Default for KrrBuildConfig { pub enum FingerprintSource { /// Path to a public key file; the fingerprint is derived from its contents. KeyFile(String), + /// Paths to the two halves of a hybrid public key. + KeyFiles(HybridKeyFiles), /// A precomputed SHA2-256 fingerprint, hex encoded. Fingerprint(String), } +/// The EC and post-quantum public key files making up one hybrid key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HybridKeyFiles { + pub ec: String, + pub pq: String, +} + /// Configuration for an individual key fingerprint in JSON. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct KeyFingerprintConfig { @@ -191,6 +195,9 @@ impl RecordBuilder { FingerprintSource::KeyFile(path) => { builder.add_key_file(domain, fp_cfg.algorithm, path)? } + FingerprintSource::KeyFiles(files) => { + builder.add_hybrid_key_files(domain, fp_cfg.algorithm, &files.ec, &files.pq)? + } FingerprintSource::Fingerprint(hex_str) => builder.add_fingerprint( domain, fp_cfg.algorithm, @@ -246,62 +253,34 @@ impl RecordBuilder { algorithm: Algorithm, digest: [u8; 32], ) -> Self { - let fp = match algorithm { - Algorithm::P256 => protobuf::proto!(crypto_rust_proto::KeyFingerprint { - key_domain: domain, - p256: crypto_rust_proto::P256Fingerprint { - sha2_256: crypto_rust_proto::Sha2_256Digest { - data: digest.to_vec() - } - } - }), - Algorithm::Mldsa => protobuf::proto!(crypto_rust_proto::KeyFingerprint { - key_domain: domain, - mldsa: crypto_rust_proto::MlDsa44Fingerprint { - sha2_256: crypto_rust_proto::Sha2_256Digest { - data: digest.to_vec() - } - } - }), - }; + let mut fp = protobuf::proto!(crypto_rust_proto::KeyFingerprint { key_domain: domain }); + let digest = protobuf::proto!(crypto_rust_proto::Sha2_256Digest { + data: digest.to_vec() + }); + match algorithm { + Algorithm::P256 => fp.p256_mut().set_sha2_256(digest), + Algorithm::Slhdsa => fp.slhdsa_mut().set_sha2_256(digest), + Algorithm::Rsa2048 => fp.rsa2048_mut().set_sha2_256(digest), + Algorithm::Rsa3072 => fp.rsa3072_mut().set_sha2_256(digest), + Algorithm::Rsa4096 => fp.rsa4096_mut().set_sha2_256(digest), + Algorithm::HybridP256Slhdsa => fp.hybrid_p256_slhdsa_mut().set_sha2_256(digest), + Algorithm::Mldsa => fp.mldsa_mut().set_sha2_256(digest), + Algorithm::HybridP256Mldsa => fp.hybrid_p256_mldsa_mut().set_sha2_256(digest), + } self.fingerprints.push(fp); self } - pub fn add_p256_fingerprint( - self, - domain: crypto_rust_proto::KeyDomain, - digest: [u8; 32], - ) -> Self { - self.add_fingerprint(domain, Algorithm::P256, digest) - } - - pub fn add_mldsa44_fingerprint( - self, - domain: crypto_rust_proto::KeyDomain, - digest: [u8; 32], - ) -> Self { - self.add_fingerprint(domain, Algorithm::Mldsa, digest) - } - /// Fingerprints a public key held in memory and appends it. pub fn add_key_bytes( self, domain: crypto_rust_proto::KeyDomain, algorithm: Algorithm, - key: &[u8], + key: KeyBytes<'_>, ) -> Result { - let digest = match algorithm { - Algorithm::P256 => { - let (x, y) = parse_p256_public_key(key).context("Invalid P256 key")?; - p256_fingerprint_sha256(&x, &y) - } - Algorithm::Mldsa => { - let mldsa = parse_mldsa44_public_key(key).context("Invalid Mldsa key")?; - sha256(&mldsa) - } - }; - Ok(self.add_fingerprint(domain, algorithm, digest)) + let material = KeyMaterial::parse(algorithm, key) + .with_context(|| format!("Invalid {algorithm:?} key"))?; + Ok(self.add_fingerprint(domain, algorithm, material.fingerprint_sha256())) } /// Reads a public key file and appends its SHA2-256 fingerprint. @@ -311,11 +290,31 @@ impl RecordBuilder { algorithm: Algorithm, path: impl AsRef, ) -> Result { - let p = path.as_ref(); - let content = - std::fs::read(p).with_context(|| format!("Failed to read key file {}", p.display()))?; - self.add_key_bytes(domain, algorithm, &content) - .with_context(|| format!("Failed to fingerprint key file {}", p.display())) + let content = read_key(path.as_ref())?; + self.add_key_bytes(domain, algorithm, KeyBytes::Single(&content)) + .with_context(|| format!("Failed to fingerprint key file {}", path.as_ref().display())) + } + + /// Reads the EC and PQ halves of a hybrid public key and appends its fingerprint. + pub fn add_hybrid_key_files( + self, + domain: crypto_rust_proto::KeyDomain, + algorithm: Algorithm, + ec: impl AsRef, + pq: impl AsRef, + ) -> Result { + let (ec_key, pq_key) = (read_key(ec.as_ref())?, read_key(pq.as_ref())?); + let key = KeyBytes::Hybrid { + ec: &ec_key, + pq: &pq_key, + }; + self.add_key_bytes(domain, algorithm, key).with_context(|| { + format!( + "Failed to fingerprint key files {} + {}", + ec.as_ref().display(), + pq.as_ref().display() + ) + }) } pub fn add_image_digest(mut self, digest: [u8; 32]) -> Self { @@ -420,7 +419,11 @@ mod tests { .version(2) .image_vendor("VNDR") .image_family("FAM") - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x42u8; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x42u8; 32], + ) .add_image_digest([0x99u8; 32]) .build_unsigned() .expect("Failed to build unsigned record with RecordBuilder"); @@ -450,8 +453,11 @@ mod tests { #[test] fn test_build_unsigned_validation() { let base = || { - RecordBuilder::new() - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x42u8; 32]) + RecordBuilder::new().add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x42u8; 32], + ) }; // v0 rollback records are not representable. @@ -481,41 +487,78 @@ mod tests { // Round-trips a real record. let unsigned = RecordBuilder::new() - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x42u8; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x42u8; 32], + ) .build_unsigned() .unwrap(); let reparsed = UnsignedRecord::from_bytes(unsigned.as_bytes()).unwrap(); assert_eq!(reparsed.as_bytes(), unsigned.as_bytes()); } - #[test] - fn test_add_key_bytes_accepts_raw_binary() { - let mut p256_key = vec![1u8; 32]; - p256_key.extend_from_slice(&[2u8; 32]); + /// Every `KeyFingerprint` arm, with filler key bytes its parser accepts. + fn all_algorithms() -> Vec<(Algorithm, Vec, Vec)> { + let ec: Vec = (0u8..64).collect(); + let slh = vec![0x11u8; 32]; + let mldsa = vec![0x22u8; 1312]; + let rsa = |n: usize| match n { + 256 => crate::crypto::rsa_pkcs1_der::<256>(&[1, 0, 1]), + 384 => crate::crypto::rsa_pkcs1_der::<384>(&[1, 0, 1]), + _ => crate::crypto::rsa_pkcs1_der::<512>(&[1, 0, 1]), + }; + vec![ + (Algorithm::P256, ec.clone(), vec![]), + (Algorithm::Slhdsa, slh.clone(), vec![]), + (Algorithm::Rsa2048, rsa(256), vec![]), + (Algorithm::Rsa3072, rsa(384), vec![]), + (Algorithm::Rsa4096, rsa(512), vec![]), + (Algorithm::HybridP256Slhdsa, ec.clone(), slh), + (Algorithm::Mldsa, mldsa.clone(), vec![]), + (Algorithm::HybridP256Mldsa, ec, mldsa), + ] + } - let unsigned = RecordBuilder::new() - .image_vendor("VNDR") - .image_family("FAM") - .add_key_bytes( - crypto_rust_proto::KeyDomain::PayloadProd, - Algorithm::P256, - &p256_key, - ) + /// Reads the single `KeyFingerprint` out of `bytes` the way the firmware + /// does and reports which `key_type` arm it populates. + fn fingerprint_arm(bytes: &[u8]) -> Algorithm { + use proto::crypto::KeyFingerprintExt; + use proto::crypto::KeyFingerprintKeyType as K; + use proto::key_rotation::DescriptorExt; + let desc = mutask_tlv::TlvData::overlay_bytes(bytes) .unwrap() - .add_key_bytes( - crypto_rust_proto::KeyDomain::PayloadProd, - Algorithm::Mldsa, - &vec![3u8; 1312], - ) - .unwrap() - .build_unsigned() + .iter::() + .next() .unwrap(); + let fp = desc.fingerprint().next().expect("no KFIP in record"); + match fp.key_type().expect("KFIP has no key_type arm") { + K::P256(_) => Algorithm::P256, + K::Slhdsa(_) => Algorithm::Slhdsa, + K::Rsa2048(_) => Algorithm::Rsa2048, + K::Rsa3072(_) => Algorithm::Rsa3072, + K::Rsa4096(_) => Algorithm::Rsa4096, + K::HybridP256Slhdsa(_) => Algorithm::HybridP256Slhdsa, + K::Mldsa(_) => Algorithm::Mldsa, + K::HybridP256Mldsa(_) => Algorithm::HybridP256Mldsa, + } + } - assert!( - inspect_file(unsigned.as_bytes()) - .unwrap() - .contains("Tag: 'KROT'") - ); + #[test] + fn test_add_key_bytes_populates_every_fingerprint_arm() { + for (algorithm, key, pq) in all_algorithms() { + let bytes = if pq.is_empty() { + KeyBytes::Single(&key) + } else { + KeyBytes::Hybrid { ec: &key, pq: &pq } + }; + let unsigned = RecordBuilder::new() + .add_key_bytes(crypto_rust_proto::KeyDomain::PayloadProd, algorithm, bytes) + .unwrap_or_else(|e| panic!("{algorithm:?}: {e}")) + .build_unsigned() + .unwrap(); + assert_eq!(fingerprint_arm(unsigned.as_bytes()), algorithm); + } } #[test] @@ -524,7 +567,7 @@ mod tests { .add_key_bytes( crypto_rust_proto::KeyDomain::PayloadProd, Algorithm::P256, - &[0u8; 8], + KeyBytes::Single(&[0u8; 8]), ) .unwrap_err(); assert!(err.to_string().contains("Invalid P256 key"), "{err:#}"); diff --git a/tlvtool/src/crypto.rs b/tlvtool/src/crypto.rs index cb0926d7..d77e431e 100644 --- a/tlvtool/src/crypto.rs +++ b/tlvtool/src/crypto.rs @@ -12,13 +12,42 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Cryptographic primitives, key coordinate parsing, and signature verification. +//! Cryptographic primitives, public key parsing, and key fingerprints. +//! +//! Every parser accepts the encodings that real signing services hand out +//! (raw, DER/SPKI, PEM) and normalises them into the `crypto_tlv` public +//! material structs the firmware uses. Fingerprints are then computed by the +//! firmware's own [`FingerprintableKey`] implementation, so the tool cannot +//! disagree with the device about what a fingerprint covers. +//! +//! # Byte order +//! +//! Big integers are stored **little-endian** on the wire: `crypto.proto` says +//! so for P-256 coordinates and scalars, and RSA moduli follow the OpenPRoT +//! manifest convention the firmware verifier uses (the proto is silent on +//! them). Parsers return the big-endian bytes found in DER; the conversion +//! happens exactly once, in [`le_bytes`] / [`le_words`] / [`rsa_modulus_le`], +//! when the TLV material is built. use alloc::string::String; use alloc::vec::Vec; +use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use anyhow::bail; +use anyhow::ensure; use base64::Engine as _; +use crypto_tlv::HybridP256MlDsa44PublicMaterial; +use crypto_tlv::HybridP256SlhDsaPublicMaterial; +use crypto_tlv::MlDsa44PublicMaterial; +use crypto_tlv::P256PublicMaterial; +use crypto_tlv::Rsa2048PublicMaterial; +use crypto_tlv::Rsa3072PublicMaterial; +use crypto_tlv::Rsa4096PublicMaterial; +use crypto_tlv::SlhDsaPublicMaterial; +use mutask_fingerprints::FingerprintableKey; +use serde::Deserialize; +use serde::Serialize; // ----------------------------------------------------------------------------- // FIPS 180-4 SHA2-256 (via mutask) @@ -39,6 +68,13 @@ pub const P256_PUBLIC_KEY_LEN: usize = 64; /// Length of a raw P-256 signature (`r || s`), in bytes. pub const P256_SIGNATURE_LEN: usize = 64; +/// Length of an SLH-DSA-SHA2-128s public key (`PK.seed || PK.root`), in bytes. +pub const SLHDSA128S_PUBLIC_KEY_LEN: usize = 32; + +/// The only RSA public exponent the firmware supports (F4); it is not carried +/// on the wire, so any other exponent must be rejected at build time. +const RSA_PUBLIC_EXPONENT_F4: [u8; 3] = [0x01, 0x00, 0x01]; + // ----------------------------------------------------------------------------- // Encoding Utilities // ----------------------------------------------------------------------------- @@ -69,34 +105,81 @@ fn decode_pem(data: &[u8]) -> Option> { decode_hex_or_base64(&body) } +/// Tries `parse` on `data` as given, then on its PEM-decoded body. +fn raw_or_pem(data: &[u8], parse: impl Fn(&[u8]) -> Option) -> Option { + parse(data).or_else(|| decode_pem(data).as_deref().and_then(parse)) +} + +/// Reverses a big-endian 256-bit integer into the little-endian byte order +/// `crypto.proto` mandates for P-256 coordinates and scalars. +pub fn le_bytes(be: &[u8; 32]) -> [u8; 32] { + let mut le = *be; + le.reverse(); + le +} + +/// Packs a big-endian 256-bit integer into little-endian `[u32; 8]` +/// (`word[0]` holds the least significant 32 bits). +pub fn le_words(be: &[u8; 32]) -> [u32; 8] { + words_of(&le_bytes(be)) +} + +/// Views 32 bytes as the eight native TLV words they occupy, in memory order. +fn words_of(bytes: &[u8; 32]) -> [u32; 8] { + core::array::from_fn(|i| u32::from_le_bytes(bytes[4 * i..4 * i + 4].try_into().unwrap())) +} + // ----------------------------------------------------------------------------- -// Fingerprints +// Minimal DER reader // ----------------------------------------------------------------------------- -/// SHA2-256 over the concatenation of `parts`. -fn sha256_parts(parts: &[&[u8]]) -> [u8; 32] { - let mut hasher = Sha256::new(); - for part in parts { - hasher.update(part); - } - hasher.finalize() +const DER_INTEGER: u8 = 0x02; +const DER_BIT_STRING: u8 = 0x03; +const DER_SEQUENCE: u8 = 0x30; + +/// Reads the next DER `(tag, value)` from `data`, advancing `pos` past it. +/// +/// Handles short- and long-form lengths. Every access is bounds checked, so a +/// truncated or malformed encoding yields `None` rather than panicking on +/// untrusted input. +fn der_next<'a>(data: &'a [u8], pos: &mut usize) -> Option<(u8, &'a [u8])> { + let tag = *data.get(*pos)?; + let first = *data.get(*pos + 1)? as usize; + let (len, hdr) = if first < 0x80 { + (first, 2) + } else { + let n = first & 0x7f; + if n == 0 || n > 4 { + return None; + } + let len_bytes = data.get(*pos + 2..*pos + 2 + n)?; + ( + len_bytes + .iter() + .fold(0usize, |acc, b| acc << 8 | *b as usize), + 2 + n, + ) + }; + let value = data.get(*pos + hdr..*pos + hdr + len)?; + *pos += hdr + len; + Some((tag, value)) } -/// Fingerprints a P-256 public key as `SHA2-256(x || y)` over the coordinates -/// exactly as they appear in the parsed key. -pub fn p256_fingerprint_sha256(x: &[u8; 32], y: &[u8; 32]) -> [u8; 32] { - sha256_parts(&[x, y]) +/// Reads the next DER element, requiring it to carry `tag`. +fn der_expect<'a>(data: &'a [u8], pos: &mut usize, tag: u8) -> Option<&'a [u8]> { + match der_next(data, pos)? { + (t, value) if t == tag => Some(value), + _ => None, + } } -/// Fingerprints a hybrid signing authority as `SHA2-256(x || y || mldsa)`. -/// -/// This is the value provisioned into RoT OTP to pin the key rotation authority. -pub fn compute_hybrid_otp_fingerprint( - x: &[u8; 32], - y: &[u8; 32], - mldsa: &[u8; MLDSA44_PUBLIC_KEY_LEN], -) -> [u8; 32] { - sha256_parts(&[x, y, mldsa]) +/// Strips the sign-padding octet DER adds to non-negative INTEGERs whose top +/// bit is set. +fn der_unsigned(value: &[u8]) -> &[u8] { + match value { + [0x00, rest @ ..] if !rest.is_empty() => rest, + _ => value, + } } // ----------------------------------------------------------------------------- @@ -117,129 +200,314 @@ fn uncompressed_point(data: &[u8]) -> Option<([u8; 32], [u8; 32])> { Some(split_halves(point)) } +/// Parses a P-256 public key into its big-endian `(x, y)` coordinates. pub fn parse_p256_public_key(data: &[u8]) -> Result<([u8; 32], [u8; 32])> { // Bare `X || Y`, with no SEC1 point prefix. if let Ok(xy) = <&[u8; P256_PUBLIC_KEY_LEN]>::try_from(data) { return Ok(split_halves(xy)); } - // A bare SEC1 point, or binary DER/SPKI wrapping one. - if let Some(point) = uncompressed_point(data) { - return Ok(point); - } - // PEM-armoured hex or base64 SPKI. - if let Some(point) = decode_pem(data).as_deref().and_then(uncompressed_point) { - return Ok(point); - } - Err(anyhow!("Unrecognized P-256 public key encoding")) + // A bare SEC1 point, binary DER/SPKI wrapping one, or PEM of either. + raw_or_pem(data, uncompressed_point).context("Unrecognized P-256 public key encoding") } +/// Parses a P-256 signature into its big-endian `(r, s)` scalars. pub fn parse_p256_signature(data: &[u8]) -> Result<([u8; 32], [u8; 32])> { if let Ok(rs) = <&[u8; P256_SIGNATURE_LEN]>::try_from(data) { return Ok(split_halves(rs)); } - if let Some(rs) = parse_der_signature(data) { - return Ok(rs); - } - Err(anyhow!("Expected 64-byte raw or DER P-256 signature")) -} - -/// Reads the DER `INTEGER` at `*idx`, advancing `idx` past it. -/// -/// Every access is bounds checked, so a truncated or malformed encoding yields -/// `None` rather than panicking on untrusted input. -fn der_integer(data: &[u8], idx: &mut usize) -> Option<[u8; 32]> { - if *data.get(*idx)? != 0x02 { - return None; - } - let len = *data.get(*idx + 1)? as usize; - let value = data.get(*idx + 2..*idx + 2 + len)?; - *idx += 2 + len; - pad_or_trim_32(value) + parse_der_signature(data).context("Expected 64-byte raw or DER P-256 signature") } /// Parses a DER `SEQUENCE { INTEGER r, INTEGER s }` ECDSA signature. fn parse_der_signature(data: &[u8]) -> Option<([u8; 32], [u8; 32])> { - if *data.first()? != 0x30 { - return None; - } - // Skip the outer SEQUENCE tag and length. - let mut idx = 2; - Some((der_integer(data, &mut idx)?, der_integer(data, &mut idx)?)) + let seq = der_expect(data, &mut 0, DER_SEQUENCE)?; + let mut pos = 0; + let r = pad_to_32(der_unsigned(der_expect(seq, &mut pos, DER_INTEGER)?))?; + let s = pad_to_32(der_unsigned(der_expect(seq, &mut pos, DER_INTEGER)?))?; + Some((r, s)) } -fn pad_or_trim_32(bytes: &[u8]) -> Option<[u8; 32]> { - let trimmed = if bytes.len() > 32 && bytes[0] == 0x00 { - &bytes[1..] - } else { - bytes - }; - if trimmed.len() > 32 { +/// Left-pads a big-endian integer of at most 32 bytes to exactly 32. +fn pad_to_32(bytes: &[u8]) -> Option<[u8; 32]> { + if bytes.len() > 32 { return None; } let mut out = [0u8; 32]; - out[32 - trimmed.len()..].copy_from_slice(trimmed); + out[32 - bytes.len()..].copy_from_slice(bytes); Some(out) } // ----------------------------------------------------------------------------- -// ML-DSA-44 Public Key & Signature Parsing +// ML-DSA-44 / SLH-DSA Public Key & Signature Parsing // ----------------------------------------------------------------------------- -/// Extracts an ML-DSA-44 public key from the bare key, or from a DER/SPKI blob -/// whose trailing `MLDSA44_PUBLIC_KEY_LEN` bytes are preceded by the BIT STRING -/// unused-bits octet. -fn mldsa44_key_bytes(data: &[u8]) -> Option<[u8; MLDSA44_PUBLIC_KEY_LEN]> { - if let Ok(pk) = <[u8; MLDSA44_PUBLIC_KEY_LEN]>::try_from(data) { - return Some(pk); +/// The `subjectPublicKey` payload of an X.509 `SubjectPublicKeyInfo` +/// (`SEQUENCE { AlgorithmIdentifier, BIT STRING { 0x00, key } }`). +fn spki_key(data: &[u8]) -> Option<&[u8]> { + let spki = der_expect(data, &mut 0, DER_SEQUENCE)?; + let mut pos = 0; + der_expect(spki, &mut pos, DER_SEQUENCE)?; + match der_expect(spki, &mut pos, DER_BIT_STRING)? { + [0x00, key @ ..] => Some(key), + _ => None, } - let start = data.len().checked_sub(MLDSA44_PUBLIC_KEY_LEN)?; - if start > 0 && data[start - 1] == 0x00 { - return data[start..].try_into().ok(); - } - None +} + +/// An `N`-byte opaque public key, either bare or wrapped in DER SPKI. +fn raw_or_spki(data: &[u8]) -> Option<[u8; N]> { + data.try_into() + .ok() + .or_else(|| spki_key(data)?.try_into().ok()) } pub fn parse_mldsa44_public_key(data: &[u8]) -> Result<[u8; MLDSA44_PUBLIC_KEY_LEN]> { - if let Some(pk) = mldsa44_key_bytes(data) { - return Ok(pk); - } - if let Some(pk) = decode_pem(data).as_deref().and_then(mldsa44_key_bytes) { - return Ok(pk); - } - Err(anyhow!( - "Expected 1312-byte raw or SPKI PEM ML-DSA-44 public key" - )) + raw_or_pem(data, raw_or_spki).context("Expected 1312-byte raw or SPKI PEM ML-DSA-44 public key") +} + +/// Parses an SLH-DSA-SHA2-128s public key from raw bytes, `RAW:SPHINCS+` PEM, +/// or DER/PEM SPKI. +pub fn parse_slhdsa128s_public_key(data: &[u8]) -> Result<[u8; SLHDSA128S_PUBLIC_KEY_LEN]> { + raw_or_pem(data, raw_or_spki) + .context("Expected 32-byte raw, RAW PEM, or SPKI SLH-DSA-SHA2-128s public key") } pub fn parse_mldsa44_signature(data: &[u8]) -> Result<[u8; MLDSA44_SIGNATURE_LEN]> { - if let Ok(sig) = <[u8; MLDSA44_SIGNATURE_LEN]>::try_from(data) { - return Ok(sig); + raw_or_pem(data, |b| <[u8; MLDSA44_SIGNATURE_LEN]>::try_from(b).ok()) + .context("Expected 2420-byte raw, hex, or base64 ML-DSA-44 signature") +} + +// ----------------------------------------------------------------------------- +// RSA Public Key Parsing +// ----------------------------------------------------------------------------- + +/// Parses a PKCS#1 `RSAPublicKey ::= SEQUENCE { modulus, publicExponent }` +/// into big-endian `(n, e)` with DER sign padding removed. +fn rsa_pkcs1(data: &[u8]) -> Option<(&[u8], &[u8])> { + let seq = der_expect(data, &mut 0, DER_SEQUENCE)?; + let mut pos = 0; + let n = der_unsigned(der_expect(seq, &mut pos, DER_INTEGER)?); + let e = der_unsigned(der_expect(seq, &mut pos, DER_INTEGER)?); + Some((n, e)) +} + +/// Parses either a PKCS#1 `RSAPublicKey` or an X.509 `SubjectPublicKeyInfo` +/// wrapping one, returning big-endian `(n, e)`. +fn rsa_der(data: &[u8]) -> Option<(&[u8], &[u8])> { + rsa_pkcs1(data).or_else(|| rsa_pkcs1(spki_key(data)?)) +} + +/// Parses an RSA public key (DER/PEM, PKCS#1 or SPKI) whose modulus is exactly +/// `N` bytes and whose exponent is 65537, returning the big-endian modulus. +pub fn parse_rsa_public_key(data: &[u8]) -> Result<[u8; N]> { + let (n, e) = raw_or_pem(data, |b| rsa_der(b).map(|(n, e)| (n.to_vec(), e.to_vec()))) + .context("Unrecognized RSA public key encoding")?; + ensure!( + e == RSA_PUBLIC_EXPONENT_F4, + "RSA public exponent must be 65537 (got 0x{})", + to_hex(&e) + ); + <[u8; N]>::try_from(n.as_slice()) + .map_err(|_| anyhow!("RSA modulus must be {} bits (got {})", N * 8, n.len() * 8)) +} + +// ----------------------------------------------------------------------------- +// Key Material & Fingerprints +// ----------------------------------------------------------------------------- + +/// Signature algorithm selecting which `KeyFingerprint` arm a key populates. +/// +/// Variant names and their `snake_case` JSON spellings track the `key_type` +/// oneof in `crypto.proto` one-to-one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Algorithm { + P256, + Slhdsa, + Rsa2048, + Rsa3072, + Rsa4096, + HybridP256Slhdsa, + Mldsa, + HybridP256Mldsa, +} + +impl Algorithm { + /// Whether keys of this algorithm are supplied as an EC part and a PQ part. + pub fn is_hybrid(self) -> bool { + matches!(self, Self::HybridP256Slhdsa | Self::HybridP256Mldsa) } - if let Some(sig) = decode_pem(data) - .as_deref() - .and_then(|b| <[u8; MLDSA44_SIGNATURE_LEN]>::try_from(b).ok()) - { - return Ok(sig); +} + +/// Encoded public key input, shaped by whether the algorithm is hybrid. +#[derive(Debug, Clone, Copy)] +pub enum KeyBytes<'a> { + Single(&'a [u8]), + Hybrid { ec: &'a [u8], pq: &'a [u8] }, +} + +/// A public key in the exact form the firmware fingerprints it. +#[derive(Debug, Clone, PartialEq)] +pub enum KeyMaterial { + P256(P256PublicMaterial), + Slhdsa(SlhDsaPublicMaterial), + Rsa2048(Rsa2048PublicMaterial), + Rsa3072(Rsa3072PublicMaterial), + Rsa4096(Rsa4096PublicMaterial), + HybridP256Slhdsa(HybridP256SlhDsaPublicMaterial), + Mldsa(MlDsa44PublicMaterial), + HybridP256Mldsa(HybridP256MlDsa44PublicMaterial), +} + +/// Builds the little-endian P-256 coordinates of `key`. +fn p256_words(key: &[u8]) -> Result<([u32; 8], [u32; 8])> { + let (x, y) = parse_p256_public_key(key)?; + Ok((le_words(&x), le_words(&y))) +} + +fn slhdsa_words(key: &[u8]) -> Result<[u32; 8]> { + // Opaque hash bytes, not an integer: kept in memory order. + Ok(words_of(&parse_slhdsa128s_public_key(key)?)) +} + +/// The OpenPRoT manifest stores RSA moduli little-endian. +fn rsa_modulus_le(key: &[u8]) -> Result<[u8; N]> { + let mut modulus = parse_rsa_public_key::(key)?; + modulus.reverse(); + Ok(modulus) +} + +impl KeyMaterial { + /// Parses `bytes` as an `algorithm` public key. + pub fn parse(algorithm: Algorithm, bytes: KeyBytes<'_>) -> Result { + let (key, pq) = match (algorithm.is_hybrid(), bytes) { + (false, KeyBytes::Single(key)) => (key, None), + (true, KeyBytes::Hybrid { ec, pq }) => (ec, Some(pq)), + (true, KeyBytes::Single(_)) => { + bail!("{algorithm:?} is a hybrid algorithm and needs both an EC and a PQ key") + } + (false, KeyBytes::Hybrid { .. }) => { + bail!("{algorithm:?} takes a single key, not an EC + PQ pair") + } + }; + Ok(match algorithm { + Algorithm::P256 => { + let (x, y) = p256_words(key)?; + Self::P256(P256PublicMaterial { x, y }) + } + Algorithm::Slhdsa => Self::Slhdsa(SlhDsaPublicMaterial { + slhdsa: slhdsa_words(key)?, + }), + Algorithm::Mldsa => Self::Mldsa(MlDsa44PublicMaterial { + mldsa: parse_mldsa44_public_key(key)?, + }), + Algorithm::Rsa2048 => Self::Rsa2048(Rsa2048PublicMaterial { + modulus: rsa_modulus_le(key)?, + }), + Algorithm::Rsa3072 => Self::Rsa3072(Rsa3072PublicMaterial { + modulus: rsa_modulus_le(key)?, + }), + Algorithm::Rsa4096 => Self::Rsa4096(Rsa4096PublicMaterial { + modulus: rsa_modulus_le(key)?, + }), + Algorithm::HybridP256Slhdsa => { + let (x, y) = p256_words(key)?; + Self::HybridP256Slhdsa(HybridP256SlhDsaPublicMaterial { + x, + y, + slhdsa: slhdsa_words(pq.unwrap())?, + }) + } + Algorithm::HybridP256Mldsa => { + let (x, y) = p256_words(key)?; + Self::HybridP256Mldsa(HybridP256MlDsa44PublicMaterial { + x, + y, + mldsa: parse_mldsa44_public_key(pq.unwrap())?, + }) + } + }) } - Err(anyhow!( - "Expected 2420-byte raw, hex, or base64 ML-DSA-44 signature" - )) + + /// The firmware's SHA2-256 fingerprint of this key. + pub fn fingerprint_sha256(&self) -> [u8; 32] { + match self { + Self::P256(m) => m.fingerprint_sha256(), + Self::Slhdsa(m) => m.fingerprint_sha256(), + Self::Rsa2048(m) => m.fingerprint_sha256(), + Self::Rsa3072(m) => m.fingerprint_sha256(), + Self::Rsa4096(m) => m.fingerprint_sha256(), + Self::HybridP256Slhdsa(m) => m.fingerprint_sha256(), + Self::Mldsa(m) => m.fingerprint_sha256(), + Self::HybridP256Mldsa(m) => m.fingerprint_sha256(), + } + } +} + +/// A synthetic PKCS#1 `RSAPublicKey` with an `N`-byte modulus whose top bit +/// is set (so DER sign-pads it) and public exponent `e`. Test fixture only. +#[cfg(test)] +pub(crate) fn rsa_pkcs1_der(e: &[u8]) -> Vec { + let modulus: Vec = (0..N).map(|i| 0x80 | i as u8).collect(); + let mut n_int = alloc::vec![DER_INTEGER, 0x82, ((N + 1) >> 8) as u8, (N + 1) as u8, 0x00]; + n_int.extend_from_slice(&modulus); + let mut e_int = alloc::vec![DER_INTEGER, e.len() as u8]; + e_int.extend_from_slice(e); + let body_len = n_int.len() + e_int.len(); + let mut der = alloc::vec![DER_SEQUENCE, 0x82, (body_len >> 8) as u8, body_len as u8]; + der.extend(n_int); + der.extend(e_int); + der } #[cfg(test)] mod tests { use super::*; + /// Test P-256 public key (SPKI PEM). + const P256_SPKI_PEM: &str = "-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBYAk4OZX3K0yh4j6950NlUpCQlwZ +M97unt4Q7HG36ecljO7J3mFVWaraKzXTjM7oGqPNkKpVQg7CIcHZ6MRsaA== +-----END PUBLIC KEY----- +"; + + /// Test SLH-DSA-SHA2-128s public key, as raw PEM and + /// as SPKI PEM (the same key). + const SLHDSA_RAW_PEM: &str = "-----BEGIN RAW:SPHINCS+_SHA2_128s_simple PUBLIC KEY----- +szRCBKbhiZP2Hu/mtVAvQTdyraaLL9+cqDB6gKhS88M= +-----END RAW:SPHINCS+_SHA2_128s_simple PUBLIC KEY----- +"; + const SLHDSA_SPKI_PEM: &str = "-----BEGIN PUBLIC KEY----- +MDAwCwYJYIZIAWUDBAMjAyEAszRCBKbhiZP2Hu/mtVAvQTdyraaLL9+cqDB6gKhS +88M= +-----END PUBLIC KEY----- +"; + + /// Wraps a PKCS#1 key in an SPKI envelope with a dummy AlgorithmIdentifier. + fn rsa_spki_der(pkcs1: &[u8]) -> Vec { + let alg = [DER_SEQUENCE, 0x02, 0x05, 0x00]; + let bits_len = pkcs1.len() + 1; + let mut bits = vec![ + DER_BIT_STRING, + 0x82, + (bits_len >> 8) as u8, + bits_len as u8, + 0x00, + ]; + bits.extend_from_slice(pkcs1); + let body_len = alg.len() + bits.len(); + let mut der = vec![DER_SEQUENCE, 0x82, (body_len >> 8) as u8, body_len as u8]; + der.extend_from_slice(&alg); + der.extend(bits); + der + } + #[test] fn test_hex_conversion() { let data = [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]; let hex_str = to_hex(&data); assert_eq!(hex_str, "0123456789abcdef"); - let decoded = from_hex(&hex_str).unwrap(); - assert_eq!(decoded, data); - - let prefixed = from_hex("0x0123456789abcdef").unwrap(); - assert_eq!(prefixed, data); + assert_eq!(from_hex(&hex_str).unwrap(), data); + assert_eq!(from_hex("0x0123456789abcdef").unwrap(), data); } #[test] @@ -255,6 +523,17 @@ mod tests { assert!(decode_hex_or_base64("not valid!!").is_none()); } + #[test] + fn test_le_words_packs_least_significant_word_first() { + let be: [u8; 32] = core::array::from_fn(|i| i as u8); + let words = le_words(&be); + // The least significant 32 bits of the integer are its last four BE bytes. + assert_eq!(words[0], 0x1c1d_1e1f); + assert_eq!(words[7], 0x0001_0203); + // Round-trips through the byte form used on the SIGC wire. + assert_eq!(words_of(&le_bytes(&be)), words); + } + #[test] fn test_p256_key_parsing() { let mut raw_64 = vec![0x11u8; 32]; @@ -265,9 +544,11 @@ mod tests { let mut raw_65 = vec![0x04u8]; raw_65.extend_from_slice(&raw_64); - let (x2, y2) = parse_p256_public_key(&raw_65).unwrap(); - assert_eq!(x, x2); - assert_eq!(y, y2); + assert_eq!(parse_p256_public_key(&raw_65).unwrap(), (x, y)); + + // A real SPKI PEM: x starts 0x0580 24e0 (see the DER dump of the same key). + let (x, _) = parse_p256_public_key(P256_SPKI_PEM.as_bytes()).unwrap(); + assert_eq!(x[..4], [0x05, 0x80, 0x24, 0xe0]); assert!(parse_p256_public_key(&[0u8; 8]).is_err()); } @@ -277,14 +558,55 @@ mod tests { let raw_pk = vec![0x55u8; MLDSA44_PUBLIC_KEY_LEN]; assert_eq!(parse_mldsa44_public_key(&raw_pk).unwrap()[0], 0x55); - // SPKI-shaped: header, BIT STRING unused-bits octet, then the key. - let mut spki = vec![0x30, 0x82, 0x05, 0x26, 0x03, 0x82, 0x05, 0x21, 0x00]; + // Real SPKI shape: SEQUENCE { SEQUENCE { OID id-ml-dsa-44 }, BIT STRING { 0x00, key } }. + let mut spki = vec![0x30, 0x82, 0x05, 0x32]; + spki.extend_from_slice(&[ + 0x30, 0x0b, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x11, + ]); + spki.extend_from_slice(&[0x03, 0x82, 0x05, 0x21, 0x00]); spki.extend_from_slice(&raw_pk); assert_eq!(parse_mldsa44_public_key(&spki).unwrap(), raw_pk[..]); assert!(parse_mldsa44_public_key(&[0u8; 16]).is_err()); } + #[test] + fn test_slhdsa_key_parsing_accepts_every_shipped_shape() { + let from_raw_pem = parse_slhdsa128s_public_key(SLHDSA_RAW_PEM.as_bytes()).unwrap(); + let from_spki_pem = parse_slhdsa128s_public_key(SLHDSA_SPKI_PEM.as_bytes()).unwrap(); + assert_eq!(from_raw_pem, from_spki_pem); + assert_eq!(from_raw_pem[..3], [0xb3, 0x34, 0x42]); + assert_eq!( + parse_slhdsa128s_public_key(&from_raw_pem).unwrap(), + from_raw_pem + ); + + assert!(parse_slhdsa128s_public_key(&[0u8; 16]).is_err()); + } + + #[test] + fn test_rsa_key_parsing() { + let pkcs1 = rsa_pkcs1_der::<384>(&RSA_PUBLIC_EXPONENT_F4); + let n = parse_rsa_public_key::<384>(&pkcs1).unwrap(); + assert_eq!(n[0], 0x80); + assert_eq!(n[383], 0x80 | 0x7f); + + // The same key wrapped in SPKI, and as PEM, parses identically. + let spki = rsa_spki_der(&pkcs1); + assert_eq!(parse_rsa_public_key::<384>(&spki).unwrap(), n); + let pem = alloc::format!( + "-----BEGIN PUBLIC KEY-----\n{}\n-----END PUBLIC KEY-----\n", + base64::engine::general_purpose::STANDARD.encode(&spki) + ); + assert_eq!(parse_rsa_public_key::<384>(pem.as_bytes()).unwrap(), n); + + // Wrong size for the requested arm, and an exponent the firmware cannot use. + assert!(parse_rsa_public_key::<256>(&pkcs1).is_err()); + let e3 = rsa_pkcs1_der::<384>(&[0x03]); + let err = parse_rsa_public_key::<384>(&e3).unwrap_err().to_string(); + assert!(err.contains("65537"), "{err}"); + } + #[test] fn test_p256_signature_parsing() { let mut raw_sig = vec![0x33u8; 32]; @@ -302,6 +624,12 @@ mod tests { assert_eq!(r[31], 0x01); assert_eq!(s[31], 0x02); assert_eq!(r[..31], [0u8; 31]); + + // A 33-byte sign-padded INTEGER is accepted and the pad dropped. + let mut padded = vec![0x30, 0x26, 0x02, 0x21, 0x00]; + padded.extend_from_slice(&[0xff; 32]); + padded.extend_from_slice(&[0x02, 0x01, 0x02]); + assert_eq!(parse_p256_signature(&padded).unwrap().0, [0xff; 32]); } #[test] @@ -312,29 +640,65 @@ mod tests { assert!(parse_p256_signature(&[0x30, 0x04, 0x02, 0x01, 0x01, 0x02]).is_err()); // Not a SEQUENCE at all. assert!(parse_p256_signature(&[0x02, 0x01, 0x01]).is_err()); + // A long-form length with an absurd octet count. + assert!(parse_p256_signature(&[0x30, 0x85, 1, 1, 1, 1, 1]).is_err()); } #[test] fn test_mldsa44_signature_parsing() { let raw_sig = vec![0x66u8; MLDSA44_SIGNATURE_LEN]; assert_eq!(parse_mldsa44_signature(&raw_sig).unwrap()[0], 0x66); - assert!(parse_mldsa44_signature(&[0u8; 16]).is_err()); } #[test] - fn test_hybrid_otp_fingerprint() { - let x = [0x12u8; 32]; - let y = [0x34u8; 32]; - let mldsa = [0x77u8; MLDSA44_PUBLIC_KEY_LEN]; - let fp = compute_hybrid_otp_fingerprint(&x, &y, &mldsa); - assert_ne!(fp, [0u8; 32]); - - // The hybrid fingerprint is the streaming hash of the same concatenation. - let mut material = Vec::new(); - material.extend_from_slice(&x); - material.extend_from_slice(&y); - material.extend_from_slice(&mldsa); - assert_eq!(fp, sha256(&material)); + fn test_key_material_matches_firmware_fingerprint() { + // P-256: the fingerprint covers the little-endian words the device sees. + let x: [u8; 32] = core::array::from_fn(|i| i as u8); + let y: [u8; 32] = core::array::from_fn(|i| 0x40 + i as u8); + let mut raw = x.to_vec(); + raw.extend_from_slice(&y); + let km = KeyMaterial::parse(Algorithm::P256, KeyBytes::Single(&raw)).unwrap(); + let expected = P256PublicMaterial { + x: le_words(&x), + y: le_words(&y), + }; + assert_eq!(km, KeyMaterial::P256(expected)); + + // RSA: modulus reversed into little-endian, then hashed as the device would. + let pkcs1 = rsa_pkcs1_der::<256>(&RSA_PUBLIC_EXPONENT_F4); + let km = KeyMaterial::parse(Algorithm::Rsa2048, KeyBytes::Single(&pkcs1)).unwrap(); + let mut modulus = parse_rsa_public_key::<256>(&pkcs1).unwrap(); + modulus.reverse(); + assert_eq!( + km.fingerprint_sha256(), + Rsa2048PublicMaterial { modulus }.fingerprint_sha256() + ); + + // Hybrid: EC + PQ parts feed one digest. + let pq = [0x33u8; MLDSA44_PUBLIC_KEY_LEN]; + let km = KeyMaterial::parse( + Algorithm::HybridP256Mldsa, + KeyBytes::Hybrid { ec: &raw, pq: &pq }, + ) + .unwrap(); + assert_eq!( + km.fingerprint_sha256(), + HybridP256MlDsa44PublicMaterial { + x: le_words(&x), + y: le_words(&y), + mldsa: pq, + } + .fingerprint_sha256() + ); + } + + #[test] + fn test_key_material_rejects_wrong_arity() { + let raw = [0x11u8; P256_PUBLIC_KEY_LEN]; + assert!(KeyMaterial::parse(Algorithm::HybridP256Mldsa, KeyBytes::Single(&raw)).is_err()); + assert!( + KeyMaterial::parse(Algorithm::P256, KeyBytes::Hybrid { ec: &raw, pq: &raw }).is_err() + ); } } diff --git a/tlvtool/src/lib.rs b/tlvtool/src/lib.rs index 9e3163e9..21215606 100644 --- a/tlvtool/src/lib.rs +++ b/tlvtool/src/lib.rs @@ -24,6 +24,8 @@ pub mod signature; pub use builder::RecordBuilder; pub use builder::UnsignedRecord; +pub use crypto::Algorithm; +pub use crypto::KeyBytes; pub use inspect::inspect_file; pub use inspect::inspect_words; pub use signature::HybridMaterial; diff --git a/tlvtool/src/signature.rs b/tlvtool/src/signature.rs index e8f47f77..2f87694d 100644 --- a/tlvtool/src/signature.rs +++ b/tlvtool/src/signature.rs @@ -16,7 +16,8 @@ use crate::builder::MAX_MAILBOX_RECORD_SIZE_BYTES; use crate::builder::UnsignedRecord; -use crate::crypto::compute_hybrid_otp_fingerprint; +use crate::crypto::le_bytes; +use crate::crypto::le_words; use crate::crypto::parse_mldsa44_public_key; use crate::crypto::parse_mldsa44_signature; use crate::crypto::parse_p256_public_key; @@ -29,6 +30,8 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::ensure; +use crypto_tlv::HybridP256MlDsa44PublicMaterial; +use mutask_fingerprints::FingerprintableKey; use mutask_tlv::TlvBuilder; use mutask_tlv::TlvData; use mutask_tlv::TlvHeader; @@ -83,21 +86,28 @@ fn build_hybrid_sigc>( let mldsa_sig = parse_mldsa44_signature(material.mldsa_signature.as_ref()) .context("Failed to parse ML-DSA signature")?; - let otp_fp_hex = to_hex(&compute_hybrid_otp_fingerprint(&x, &y, &mldsa_pk)); + // The OTP value pins exactly the key material the device will see, so it + // is computed by the firmware's fingerprint over the little-endian words. + let authority = HybridP256MlDsa44PublicMaterial { + x: le_words(&x), + y: le_words(&y), + mldsa: mldsa_pk, + }; + let otp_fp_hex = to_hex(&authority.fingerprint_sha256()); let sigc = protobuf::proto!(crypto_rust_proto::SignatureContainer { public_key: crypto_rust_proto::PublicKey { key_domain: key_domain, hybrid_p256_mldsa: crypto_rust_proto::HybridP256MlDsa44PublicMaterial { - x: x.to_vec(), - y: y.to_vec(), + x: le_bytes(&x).to_vec(), + y: le_bytes(&y).to_vec(), mldsa: mldsa_pk.to_vec(), } }, signature: crypto_rust_proto::Signature { hybrid_p256_mldsa: crypto_rust_proto::HybridP256MlDsa44Signature { - r: r.to_vec(), - s: s.to_vec(), + r: le_bytes(&r).to_vec(), + s: le_bytes(&s).to_vec(), mldsa: mldsa_sig.to_vec(), } } @@ -280,6 +290,7 @@ impl SignedRecord { mod tests { use super::*; use crate::builder::RecordBuilder; + use crate::crypto::Algorithm; use crate::crypto::MLDSA44_PUBLIC_KEY_LEN; use crate::crypto::MLDSA44_SIGNATURE_LEN; use crate::crypto::P256_SIGNATURE_LEN; @@ -308,7 +319,10 @@ mod tests { /// /// Pinned rather than computed: this is the value provisioned into RoT OTP /// to name the rotation authority, so a test must not derive it with the - /// same function it is checking. Verified against an independent SHA2-256: + /// same function it is checking. The filler coordinates are byte-symmetric, + /// so the value is the same whether hashed big- or little-endian; see + /// `test_sigc_carries_little_endian_scalars` for the byte order itself. + /// Verified against an independent SHA2-256: /// /// ```text /// python3 -c "import hashlib; print(hashlib.sha256( @@ -317,6 +331,50 @@ mod tests { const EXPECTED_OTP_FP: &str = "450b9724056546773e54021936e3d34962b8d6628c0fd3801e1e6fde79342682"; + /// Whether `needle` occurs contiguously inside `haystack`. + fn contains_slice(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + #[test] + fn test_sigc_carries_little_endian_scalars() { + let x: [u8; 32] = core::array::from_fn(|i| 0x10 + i as u8); + let y: [u8; 32] = core::array::from_fn(|i| 0x50 + i as u8); + let r: [u8; 32] = core::array::from_fn(|i| 0x90 + i as u8); + let s: [u8; 32] = core::array::from_fn(|i| 0xc0 + i as u8); + let mut ec_key = x.to_vec(); + ec_key.extend_from_slice(&y); + let mut ec_sig = r.to_vec(); + ec_sig.extend_from_slice(&s); + let material = HybridMaterial { + ec_public_key: ec_key.as_slice(), + mldsa_public_key: &[3u8; MLDSA44_PUBLIC_KEY_LEN], + ec_signature: ec_sig.as_slice(), + mldsa_signature: &[2u8; MLDSA44_SIGNATURE_LEN], + }; + + let (sigc, _) = build_hybrid_sigc(crypto_rust_proto::KeyDomain::Rotr, &material).unwrap(); + let wire = mutask_tlv_proto_conv::message_to_tlv( + &crypto_rust_proto::__unstable::CONTRIB_OPENPROT_PROTO_CRYPTO_DESCRIPTOR_INFO, + &sigc, + ) + .unwrap(); + let wire = wire.as_bytes(); + + // Every 256-bit integer is on the wire least-significant byte first, + // and never in the big-endian order it was parsed from. + for be in [x, y, r, s] { + assert!( + contains_slice(wire, &le_bytes(&be)), + "{be:02x?} not LE on the wire" + ); + assert!( + !contains_slice(wire, &be), + "{be:02x?} leaked as BE onto the wire" + ); + } + } + /// Asserts that `bytes` inspect as a `KRSD` wrapping a `KROT` and a `SIGC`. fn assert_signed_shape(bytes: &[u8]) { let report = crate::inspect::inspect_file(bytes).unwrap(); @@ -336,7 +394,11 @@ mod tests { .version(3) .image_vendor("VNDR") .image_family("FAM") - .add_mldsa44_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x11u8; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::Mldsa, + [0x11u8; 32], + ) .build_unsigned() .unwrap(); @@ -358,7 +420,11 @@ mod tests { .version(1) .image_vendor("VNDR") .image_family("FAM") - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x01u8; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x01u8; 32], + ) .build_unsigned() .unwrap(); @@ -372,7 +438,11 @@ mod tests { let unsigned = RecordBuilder::new() .image_vendor("VNDR") .image_family("FAM") - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x01u8; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x01u8; 32], + ) .build_unsigned() .unwrap(); @@ -396,7 +466,11 @@ mod tests { .version(2) .image_vendor("GOOGLE") .image_family("FAMLY") - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0xAAu8; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0xAAu8; 32], + ) .build_unsigned() .unwrap(); diff --git a/tlvtool/tests/cli_test.rs b/tlvtool/tests/cli_test.rs index a28ebb39..bb820208 100644 --- a/tlvtool/tests/cli_test.rs +++ b/tlvtool/tests/cli_test.rs @@ -146,6 +146,83 @@ fn test_build_rejects_a_bad_config() { assert!(stderr.contains("Failed to parse JSON config"), "{stderr}"); } +/// Test P-256 public key (SPKI PEM). +const P256_SPKI_PEM: &str = "-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEBYAk4OZX3K0yh4j6950NlUpCQlwZ +M97unt4Q7HG36ecljO7J3mFVWaraKzXTjM7oGqPNkKpVQg7CIcHZ6MRsaA== +-----END PUBLIC KEY----- +"; + +/// Test SLH-DSA-SHA2-128s public key (raw PEM). +const SLHDSA_RAW_PEM: &str = "-----BEGIN RAW:SPHINCS+_SHA2_128s_simple PUBLIC KEY----- +szRCBKbhiZP2Hu/mtVAvQTdyraaLL9+cqDB6gKhS88M= +-----END RAW:SPHINCS+_SHA2_128s_simple PUBLIC KEY----- +"; + +/// Writes the two real public keys to scratch and returns `(ec, pq)` paths. +fn write_public_keys(prefix: &str) -> (PathBuf, PathBuf) { + let ec = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TEMPDIR is not set")) + .join(format!("{prefix}.ec.pem")); + let pq = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TEMPDIR is not set")) + .join(format!("{prefix}.slhdsa.pem")); + std::fs::write(&ec, P256_SPKI_PEM).unwrap(); + std::fs::write(&pq, SLHDSA_RAW_PEM).unwrap(); + (ec, pq) +} + +#[test] +fn test_build_fingerprints_key_files_from_json() { + let (ec, pq) = write_public_keys("build_keys"); + let config = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TEMPDIR is not set")) + .join("build_keys.json"); + std::fs::write( + &config, + format!( + r#"{{ "fingerprints": [ + {{ "algorithm": "p256", "key_file": "{ec}" }}, + {{ "algorithm": "slhdsa", "key_file": "{pq}" }}, + {{ "algorithm": "hybrid_p256_slhdsa", "key_domain": "rotr", + "key_files": {{ "ec": "{ec}", "pq": "{pq}" }} }} + ] }}"#, + ec = ec.display(), + pq = pq.display() + ), + ) + .unwrap(); + + let report = run(&["krr", "build", config.to_str().unwrap()]); + assert!(report.contains("Tag: 'KROT'"), "{report}"); +} + +#[test] +fn test_build_rejects_key_file_arity_mismatch() { + let (ec, pq) = write_public_keys("build_arity"); + let config = PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TEMPDIR is not set")) + .join("build_arity.json"); + std::fs::write( + &config, + format!( + r#"{{ "fingerprints": [ {{ "algorithm": "p256", + "key_files": {{ "ec": "{}", "pq": "{}" }} }} ] }}"#, + ec.display(), + pq.display() + ), + ) + .unwrap(); + + let out = tlvtool() + .args(["krr", "build", config.to_str().unwrap()]) + .output() + .expect("failed to run tlvtool"); + + assert!( + !out.status.success(), + "two keys for a non-hybrid algorithm must fail" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("takes a single key"), "{stderr}"); +} + /// Filler hybrid signing inputs, written to scratch as `krr sign` expects them. /// /// Returns the four paths in the order the command line takes them: EC public diff --git a/tlvtool/tests/inspect_test.rs b/tlvtool/tests/inspect_test.rs index 441f8f61..472f708d 100644 --- a/tlvtool/tests/inspect_test.rs +++ b/tlvtool/tests/inspect_test.rs @@ -26,6 +26,7 @@ //! ``` use mutask_golden::assert_eq_golden; +use tlvtool::Algorithm; use tlvtool::HybridMaterial; use tlvtool::RecordBuilder; use tlvtool::SignedRecordBuilder; @@ -50,8 +51,16 @@ fn v1_unsigned() -> Vec { .image_vendor("GOOGLE") .image_family("FAMILYV1") .legacy_image_family(1) - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, p256_fp) - .add_mldsa44_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, mldsa_fp) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + p256_fp, + ) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::Mldsa, + mldsa_fp, + ) .add_image_digest([0xaa; 32]) .build_unsigned() .expect("failed to build the v1 record") @@ -64,7 +73,11 @@ fn v2_unsigned() -> tlvtool::UnsignedRecord { .version(2) .image_vendor("GOOGLE") .image_family("FAMILYV1") - .add_p256_fingerprint(crypto_rust_proto::KeyDomain::PayloadProd, [0x50; 32]) + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x50; 32], + ) .add_image_digest([0x77; 32]) .build_unsigned() .expect("failed to build the v2 record") diff --git a/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden b/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden index 13ad3019..98674fbe 100644 --- a/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden +++ b/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden @@ -12,7 +12,7 @@ └── [1] Tag: 'SIGC' | Offset: 0x00000090 | Words: 974 (3896 bytes) | Padding: 0 Children: ├── [0] Tag: 'PKEY' | Offset: 0x00000098 | Words: 347 (1388 bytes) | Padding: 0 - │ Data: 524f54524d34503258010000303132333435363738393a3b3c3d3e3f40414243... + │ Data: 524f54524d345032580100004f4e4d4c4b4a494847464544434241403f3e3d3c... └── [1] Tag: 'SIGN' | Offset: 0x0000060c | Words: 623 (2492 bytes) | Padding: 0 Children: └── [0] Tag: 'M4P2' | Offset: 0x00000614 | Words: 621 (2484 bytes) | Padding: 0 From 1991b9e8b32b410c292a64e799e9307676b917d1 Mon Sep 17 00:00:00 2001 From: Michael Field Date: Tue, 22 Sep 2026 16:02:12 -0700 Subject: [PATCH 6/7] tlvtool: Add key fingerprint and test-key guardrails --- tlvtool/src/builder.rs | 360 ++++++++++++++++++++++++++++---------- tlvtool/src/cli.rs | 20 ++- tlvtool/src/signature.rs | 49 +++++- tlvtool/tests/cli_test.rs | 136 ++++++++++---- 4 files changed, 438 insertions(+), 127 deletions(-) diff --git a/tlvtool/src/builder.rs b/tlvtool/src/builder.rs index d3fd29fe..540575b6 100644 --- a/tlvtool/src/builder.rs +++ b/tlvtool/src/builder.rs @@ -45,32 +45,106 @@ fn check_unsigned_size(size: usize) -> Result<()> { Ok(()) } -fn read_key(path: &Path) -> Result> { - std::fs::read(path).with_context(|| format!("Failed to read key file {}", path.display())) -} +const KEY_DOMAINS: [(&str, crypto_rust_proto::KeyDomain); 6] = [ + ("prod", crypto_rust_proto::KeyDomain::PayloadProd), + ("dev", crypto_rust_proto::KeyDomain::PayloadDev), + ("test", crypto_rust_proto::KeyDomain::PayloadTest), + ("bios_prod", crypto_rust_proto::KeyDomain::BiosProd), + ("bios_dev", crypto_rust_proto::KeyDomain::BiosDev), + ("rotr", crypto_rust_proto::KeyDomain::Rotr), +]; + +const TEST_KEY_DOMAINS: [crypto_rust_proto::KeyDomain; 3] = [ + crypto_rust_proto::KeyDomain::PayloadTest, + crypto_rust_proto::KeyDomain::PayloadDev, + crypto_rust_proto::KeyDomain::BiosDev, +]; /// Key domain parsing helper. pub fn parse_key_domain(s: &str) -> Result { - Ok(match s { - "prod" => crypto_rust_proto::KeyDomain::PayloadProd, - "dev" => crypto_rust_proto::KeyDomain::PayloadDev, - "test" => crypto_rust_proto::KeyDomain::PayloadTest, - "bios_prod" => crypto_rust_proto::KeyDomain::BiosProd, - "bios_dev" => crypto_rust_proto::KeyDomain::BiosDev, - "rotr" => crypto_rust_proto::KeyDomain::Rotr, - _ => bail!( - "Unknown key domain '{s}'. Valid domains: prod, dev, test, bios_prod, bios_dev, rotr" - ), - }) + KEY_DOMAINS + .iter() + .find(|(name, _)| *name == s) + .map(|(_, domain)| *domain) + .with_context(|| { + let valid: Vec<&str> = KEY_DOMAINS.iter().map(|(name, _)| *name).collect(); + format!( + "Unknown key domain '{}'. Valid domains: {}", + s, + valid.join(", ") + ) + }) +} + +/// The name an operator would have written for `domain`. +fn key_domain_name(domain: crypto_rust_proto::KeyDomain) -> &'static str { + KEY_DOMAINS + .iter() + .find(|(_, d)| *d == domain) + .map_or("unknown", |(name, _)| name) } /// Decodes a hex string that must denote exactly one SHA2-256 digest. -fn parse_digest32(label: &str, hex_str: &str) -> Result<[u8; 32]> { +pub(crate) fn parse_digest32(label: &str, hex_str: &str) -> Result<[u8; 32]> { let raw = crate::crypto::from_hex(hex_str).with_context(|| format!("Invalid {label} hex"))?; raw.try_into() .map_err(|_| anyhow!("{label} '{hex_str}' must be 32 bytes (64 hex chars)")) } +/// Fingerprints an in-memory public key the way the firmware would. +fn key_digest(algorithm: Algorithm, key: KeyBytes<'_>) -> Result<[u8; 32]> { + KeyMaterial::parse(algorithm, key).map(|material| material.fingerprint_sha256()) +} + +fn read_key(path: &Path) -> Result> { + std::fs::read(path).with_context(|| format!("Failed to read key file {}", path.display())) +} + +fn key_file_digest(algorithm: Algorithm, path: &Path) -> Result<[u8; 32]> { + key_digest(algorithm, KeyBytes::Single(&read_key(path)?)) + .with_context(|| format!("Invalid {algorithm:?} key in {}", path.display())) +} + +fn hybrid_key_files_digest(algorithm: Algorithm, ec: &Path, pq: &Path) -> Result<[u8; 32]> { + let (ec_key, pq_key) = (read_key(ec)?, read_key(pq)?); + let key = KeyBytes::Hybrid { + ec: &ec_key, + pq: &pq_key, + }; + key_digest(algorithm, key).with_context(|| { + format!( + "Invalid {algorithm:?} key in {} + {}", + ec.display(), + pq.display() + ) + }) +} + +fn check_expected_fingerprint( + key_domain: &str, + algorithm: Algorithm, + expected_hex: &str, + actual: [u8; 32], +) -> Result<()> { + let expected = parse_digest32("Expected fingerprint", expected_hex)?; + ensure!( + expected == actual, + "Fingerprint mismatch for the {:?} key in domain '{}': config expects {}, the key hashes to {}", + algorithm, + key_domain, + crate::crypto::to_hex(&expected), + crate::crypto::to_hex(&actual) + ); + Ok(()) +} + +/// The first item that repeats one already seen, if any. +fn first_duplicate(items: &[T]) -> Option<&T> { + (1..items.len()) + .find(|&i| items[..i].contains(&items[i])) + .map(|i| &items[i]) +} + /// Validates an identity string and NUL-pads it to [`MAX_IDENTITY_STRING_LEN`]. fn identity_name(field: &str, s: &str) -> Result { ensure!(s.is_ascii(), "{field} string must be ASCII (got '{s}')"); @@ -90,9 +164,6 @@ fn default_key_domain() -> String { "prod".to_string() } -/// JSON configuration for building a Key Rotation Record. -/// -/// Every field is optional; omitted fields take their [`Default`] value. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct KrrBuildConfig { @@ -117,7 +188,6 @@ impl Default for KrrBuildConfig { } } -/// Where the fingerprint bytes for a key come from. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FingerprintSource { @@ -129,7 +199,6 @@ pub enum FingerprintSource { Fingerprint(String), } -/// The EC and post-quantum public key files making up one hybrid key. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HybridKeyFiles { pub ec: String, @@ -146,22 +215,54 @@ pub struct KeyFingerprintConfig { #[serde(flatten)] pub source: FingerprintSource, + + // Assumed hex encoding + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_fingerprint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Fingerprint { + key_domain: crypto_rust_proto::KeyDomain, + algorithm: Algorithm, + digest: [u8; 32], +} + +impl Fingerprint { + fn to_proto(self) -> crypto_rust_proto::KeyFingerprint { + let mut fp = protobuf::proto!(crypto_rust_proto::KeyFingerprint { + key_domain: self.key_domain + }); + let digest = protobuf::proto!(crypto_rust_proto::Sha2_256Digest { + data: self.digest.to_vec() + }); + match self.algorithm { + Algorithm::P256 => fp.p256_mut().set_sha2_256(digest), + Algorithm::Slhdsa => fp.slhdsa_mut().set_sha2_256(digest), + Algorithm::Rsa2048 => fp.rsa2048_mut().set_sha2_256(digest), + Algorithm::Rsa3072 => fp.rsa3072_mut().set_sha2_256(digest), + Algorithm::Rsa4096 => fp.rsa4096_mut().set_sha2_256(digest), + Algorithm::HybridP256Slhdsa => fp.hybrid_p256_slhdsa_mut().set_sha2_256(digest), + Algorithm::Mldsa => fp.mldsa_mut().set_sha2_256(digest), + Algorithm::HybridP256Mldsa => fp.hybrid_p256_mldsa_mut().set_sha2_256(digest), + } + fp + } } -/// Fluent builder for constructing canonical OpenPRoT key rotation records (`KROT`). #[derive(Debug)] pub struct RecordBuilder { version: u32, image_vendor: String, image_family: String, legacy_image_family: Option, - fingerprints: Vec, - img_descriptor_digests: Vec, + fingerprints: Vec, + img_descriptor_digests: Vec<[u8; 32]>, + allow_test_keys: bool, } impl Default for RecordBuilder { fn default() -> Self { - // Defaults live in exactly one place: `KrrBuildConfig::default()`. let config = KrrBuildConfig::default(); Self { version: config.version, @@ -170,6 +271,7 @@ impl Default for RecordBuilder { legacy_image_family: config.legacy_image_family, fingerprints: Vec::new(), img_descriptor_digests: Vec::new(), + allow_test_keys: false, } } } @@ -191,19 +293,27 @@ impl RecordBuilder { for fp_cfg in &config.fingerprints { let domain = parse_key_domain(&fp_cfg.key_domain)?; - builder = match &fp_cfg.source { - FingerprintSource::KeyFile(path) => { - builder.add_key_file(domain, fp_cfg.algorithm, path)? - } + let algorithm = fp_cfg.algorithm; + let digest = match &fp_cfg.source { + FingerprintSource::KeyFile(path) => key_file_digest(algorithm, Path::new(path))?, FingerprintSource::KeyFiles(files) => { - builder.add_hybrid_key_files(domain, fp_cfg.algorithm, &files.ec, &files.pq)? + hybrid_key_files_digest(algorithm, Path::new(&files.ec), Path::new(&files.pq))? + } + FingerprintSource::Fingerprint(hex_str) => { + ensure!( + fp_cfg.expected_fingerprint.is_none(), + "'expected_fingerprint' has nothing to check in the '{}' {:?} entry: \ + it already states the fingerprint instead of naming a key file", + fp_cfg.key_domain, + algorithm + ); + parse_digest32("Fingerprint", hex_str)? } - FingerprintSource::Fingerprint(hex_str) => builder.add_fingerprint( - domain, - fp_cfg.algorithm, - parse_digest32("Fingerprint", hex_str)?, - ), }; + if let Some(expected) = &fp_cfg.expected_fingerprint { + check_expected_fingerprint(&fp_cfg.key_domain, algorithm, expected, digest)?; + } + builder = builder.add_fingerprint(domain, algorithm, digest); } for hex_str in &config.digests { @@ -246,6 +356,11 @@ impl RecordBuilder { self } + pub fn allow_test_keys(mut self, allow: bool) -> Self { + self.allow_test_keys = allow; + self + } + /// Appends a precomputed SHA2-256 fingerprint for `algorithm` under `domain`. pub fn add_fingerprint( mut self, @@ -253,21 +368,11 @@ impl RecordBuilder { algorithm: Algorithm, digest: [u8; 32], ) -> Self { - let mut fp = protobuf::proto!(crypto_rust_proto::KeyFingerprint { key_domain: domain }); - let digest = protobuf::proto!(crypto_rust_proto::Sha2_256Digest { - data: digest.to_vec() + self.fingerprints.push(Fingerprint { + key_domain: domain, + algorithm, + digest, }); - match algorithm { - Algorithm::P256 => fp.p256_mut().set_sha2_256(digest), - Algorithm::Slhdsa => fp.slhdsa_mut().set_sha2_256(digest), - Algorithm::Rsa2048 => fp.rsa2048_mut().set_sha2_256(digest), - Algorithm::Rsa3072 => fp.rsa3072_mut().set_sha2_256(digest), - Algorithm::Rsa4096 => fp.rsa4096_mut().set_sha2_256(digest), - Algorithm::HybridP256Slhdsa => fp.hybrid_p256_slhdsa_mut().set_sha2_256(digest), - Algorithm::Mldsa => fp.mldsa_mut().set_sha2_256(digest), - Algorithm::HybridP256Mldsa => fp.hybrid_p256_mldsa_mut().set_sha2_256(digest), - } - self.fingerprints.push(fp); self } @@ -278,52 +383,13 @@ impl RecordBuilder { algorithm: Algorithm, key: KeyBytes<'_>, ) -> Result { - let material = KeyMaterial::parse(algorithm, key) - .with_context(|| format!("Invalid {algorithm:?} key"))?; - Ok(self.add_fingerprint(domain, algorithm, material.fingerprint_sha256())) - } - - /// Reads a public key file and appends its SHA2-256 fingerprint. - pub fn add_key_file( - self, - domain: crypto_rust_proto::KeyDomain, - algorithm: Algorithm, - path: impl AsRef, - ) -> Result { - let content = read_key(path.as_ref())?; - self.add_key_bytes(domain, algorithm, KeyBytes::Single(&content)) - .with_context(|| format!("Failed to fingerprint key file {}", path.as_ref().display())) - } - - /// Reads the EC and PQ halves of a hybrid public key and appends its fingerprint. - pub fn add_hybrid_key_files( - self, - domain: crypto_rust_proto::KeyDomain, - algorithm: Algorithm, - ec: impl AsRef, - pq: impl AsRef, - ) -> Result { - let (ec_key, pq_key) = (read_key(ec.as_ref())?, read_key(pq.as_ref())?); - let key = KeyBytes::Hybrid { - ec: &ec_key, - pq: &pq_key, - }; - self.add_key_bytes(domain, algorithm, key).with_context(|| { - format!( - "Failed to fingerprint key files {} + {}", - ec.as_ref().display(), - pq.as_ref().display() - ) - }) + let digest = + key_digest(algorithm, key).with_context(|| format!("Invalid {algorithm:?} key"))?; + Ok(self.add_fingerprint(domain, algorithm, digest)) } pub fn add_image_digest(mut self, digest: [u8; 32]) -> Self { - let d = protobuf::proto!(key_rotation_rust_proto::ImgDescriptorDigest { - sha2_256: crypto_rust_proto::Sha2_256Digest { - data: digest.to_vec() - } - }); - self.img_descriptor_digests.push(d); + self.img_descriptor_digests.push(digest); self } @@ -337,14 +403,49 @@ impl RecordBuilder { "Key rotation record must contain at least one key fingerprint or image descriptor digest" ); + if !self.allow_test_keys + && let Some(fp) = self + .fingerprints + .iter() + .find(|fp| TEST_KEY_DOMAINS.contains(&fp.key_domain)) + { + bail!( + "Key domain '{}' holds test or development keys; pass --allow-test-keys to rotate to them", + key_domain_name(fp.key_domain) + ); + } + + // A repeated entry is always an editing accident: the record would say + // the same thing twice and waste the flash slot doing it. + if let Some(fp) = first_duplicate(&self.fingerprints) { + bail!( + "Duplicate {:?} key fingerprint in domain '{}': {}", + fp.algorithm, + key_domain_name(fp.key_domain), + crate::crypto::to_hex(&fp.digest) + ); + } + if let Some(digest) = first_duplicate(&self.img_descriptor_digests) { + bail!( + "Duplicate image descriptor digest: {}", + crate::crypto::to_hex(digest) + ); + } + let vendor_name = identity_name("Image Vendor", &self.image_vendor)?; let family_name = identity_name("Image Family", &self.image_family)?; let mut desc = protobuf::proto!(key_rotation_rust_proto::Descriptor { version: self.version, image_vendor: identity_rust_proto::ImageVendor { name: vendor_name }, image_family: identity_rust_proto::ImageFamily { name: family_name }, - fingerprint: self.fingerprints.into_iter(), - img_descriptor_digest: self.img_descriptor_digests.into_iter(), + fingerprint: self.fingerprints.iter().map(|fp| fp.to_proto()), + img_descriptor_digest: self.img_descriptor_digests.iter().map(|digest| { + protobuf::proto!(key_rotation_rust_proto::ImgDescriptorDigest { + sha2_256: crypto_rust_proto::Sha2_256Digest { + data: digest.to_vec() + } + }) + }), }); if let Some(family) = self.legacy_image_family { desc.set_legacy_image_family(protobuf::proto!( @@ -598,6 +699,8 @@ mod tests { }"#; let unsigned = RecordBuilder::from_json(json) .expect("Failed to parse JSON") + // The config names the `dev` domain, so it needs the opt-in. + .allow_test_keys(true) .build_unsigned() .expect("Failed to build unsigned"); @@ -644,6 +747,10 @@ mod tests { r#"{"fingerprints": [{ "algorithm": "p256", "fingerprint": "not_hex" }]}"#, // Correct hex, wrong length. r#"{"fingerprints": [{ "algorithm": "p256", "fingerprint": "0102" }]}"#, + // A pinned fingerprint with no key to check it against. + r#"{"fingerprints": [{ "algorithm": "p256", + "fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "expected_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000" }]}"#, // Invalid image digest. r#"{"digests": ["0102"]}"#, ]; @@ -654,4 +761,71 @@ mod tests { ); } } + + #[test] + fn test_test_key_domains_need_the_flag() { + use crypto_rust_proto::KeyDomain; + let record = + |domain| RecordBuilder::new().add_fingerprint(domain, Algorithm::P256, [0x42u8; 32]); + + for domain in [ + KeyDomain::PayloadTest, + KeyDomain::PayloadDev, + KeyDomain::BiosDev, + ] { + let err = record(domain) + .build_unsigned() + .expect_err("a test domain must not build by default"); + assert!(err.to_string().contains("--allow-test-keys"), "{err:#}"); + assert!( + record(domain) + .allow_test_keys(true) + .build_unsigned() + .is_ok() + ); + } + + // Production domains never need the opt-in. + for domain in [KeyDomain::PayloadProd, KeyDomain::BiosProd, KeyDomain::Rotr] { + assert!(record(domain).build_unsigned().is_ok()); + } + } + + #[test] + fn test_duplicate_entries_rejected() { + use crypto_rust_proto::KeyDomain; + let digest = [0x42u8; 32]; + let base = || { + RecordBuilder::new().add_fingerprint(KeyDomain::PayloadProd, Algorithm::P256, digest) + }; + + let err = base() + .add_fingerprint(KeyDomain::PayloadProd, Algorithm::P256, digest) + .build_unsigned() + .expect_err("the same fingerprint twice says nothing new"); + assert!( + err.to_string().contains("Duplicate P256 key fingerprint"), + "{err}" + ); + + // One key can legitimately appear under two domains or two algorithms. + assert!( + base() + .add_fingerprint(KeyDomain::BiosProd, Algorithm::P256, digest) + .add_fingerprint(KeyDomain::PayloadProd, Algorithm::Mldsa, digest) + .build_unsigned() + .is_ok() + ); + + let err = base() + .add_image_digest(digest) + .add_image_digest(digest) + .build_unsigned() + .expect_err("the same image digest twice says nothing new"); + assert!( + err.to_string() + .contains("Duplicate image descriptor digest"), + "{err}" + ); + } } diff --git a/tlvtool/src/cli.rs b/tlvtool/src/cli.rs index ccebd8bc..cdcec8f2 100644 --- a/tlvtool/src/cli.rs +++ b/tlvtool/src/cli.rs @@ -15,6 +15,7 @@ //! Command line interface definitions and handlers. use crate::builder::RecordBuilder; +use crate::builder::parse_digest32; use crate::inspect::inspect_file; use crate::signature::HybridSignatureFiles; use crate::signature::SignedRecordBuilder; @@ -49,6 +50,10 @@ pub struct BuildArgs { /// output file path (.unsigned). If omitted, prints human-readable record inspection to stdout. #[argh(option, short = 'o')] pub out: Option, + + /// allow keys from test domains to be included in the record. + #[argh(switch)] + pub allow_test_keys: bool, } #[derive(FromArgs, Debug, Clone, PartialEq, Eq)] @@ -87,6 +92,10 @@ pub struct SignArgs { /// path to ML-DSA-44 signature file (raw). #[argh(option)] pub mldsa_signature: String, + + /// expected SHA2-256 hash of the target RoT OTP key material (hex string). + #[argh(option)] + pub expected_otp_fingerprint: Option, } pub fn handle_krr(args: KrrArgs) -> Result { @@ -99,6 +108,7 @@ pub fn handle_krr(args: KrrArgs) -> Result { pub fn handle_build(args: BuildArgs) -> Result { let unsigned = RecordBuilder::from_json_file(&args.config) .context("Failed to load build config")? + .allow_test_keys(args.allow_test_keys) .build_unsigned() .context("Failed to build record")?; @@ -121,8 +131,14 @@ pub fn handle_inspect(args: InspectArgs) -> Result { } pub fn handle_sign(args: SignArgs) -> Result { - let signed = SignedRecordBuilder::from_unsigned_file(&args.record) - .context("Failed to load unsigned record")? + let mut builder = SignedRecordBuilder::from_unsigned_file(&args.record) + .context("Failed to load unsigned record")?; + + if let Some(hex) = &args.expected_otp_fingerprint { + builder = builder.expect_otp_fingerprint(parse_digest32("Expected OTP fingerprint", hex)?); + } + + let signed = builder // Key rotation records are only ever countersigned by the RoT rotation // authority, so the signing key domain is fixed rather than selectable. .add_hybrid_signature_files( diff --git a/tlvtool/src/signature.rs b/tlvtool/src/signature.rs index 2f87694d..64ba6cd9 100644 --- a/tlvtool/src/signature.rs +++ b/tlvtool/src/signature.rs @@ -132,6 +132,7 @@ pub struct SignedRecordBuilder { unsigned: UnsignedRecord, signatures: Vec, otp_fingerprint: Option, + expected_otp_fingerprint: Option, } impl SignedRecordBuilder { @@ -140,6 +141,7 @@ impl SignedRecordBuilder { unsigned, signatures: Vec::new(), otp_fingerprint: None, + expected_otp_fingerprint: None, } } @@ -151,6 +153,11 @@ impl SignedRecordBuilder { Ok(Self::new(UnsignedRecord::from_file(path)?)) } + pub fn expect_otp_fingerprint(mut self, fingerprint: [u8; 32]) -> Self { + self.expected_otp_fingerprint = Some(to_hex(&fingerprint)); + self + } + pub fn add_signature_container(mut self, sigc: crypto_rust_proto::SignatureContainer) -> Self { self.signatures.push(sigc); self @@ -162,7 +169,12 @@ impl SignedRecordBuilder { material: &HybridMaterial, ) -> Result { let (sigc, otp_fp_hex) = build_hybrid_sigc(key_domain, material)?; - // The first signing authority is the one reported back to the operator. + if let Some(expected) = &self.expected_otp_fingerprint { + ensure!( + *expected == otp_fp_hex, + "Signing authority OTP fingerprint mismatch: expected {expected}, the signing key is {otp_fp_hex}" + ); + } self.otp_fingerprint.get_or_insert(otp_fp_hex); self.signatures.push(sigc); Ok(self) @@ -414,6 +426,41 @@ mod tests { assert_signed_shape(signed.as_bytes()); } + #[test] + fn test_expected_otp_fingerprint_gates_the_signing_key() { + let unsigned = RecordBuilder::new() + .add_fingerprint( + crypto_rust_proto::KeyDomain::PayloadProd, + Algorithm::P256, + [0x11u8; 32], + ) + .build_unsigned() + .unwrap(); + + let expected: [u8; 32] = crate::crypto::from_hex(EXPECTED_OTP_FP) + .unwrap() + .try_into() + .unwrap(); + + // The authority the operator named is the one holding the key. + assert!( + SignedRecordBuilder::new(unsigned.clone()) + .expect_otp_fingerprint(expected) + .add_hybrid_signature(crypto_rust_proto::KeyDomain::Rotr, &MATERIAL) + .is_ok() + ); + + // Any other key would produce a record the target RoT rejects. + let err = SignedRecordBuilder::new(unsigned) + .expect_otp_fingerprint([0x42u8; 32]) + .add_hybrid_signature(crypto_rust_proto::KeyDomain::Rotr, &MATERIAL) + .expect_err("signing with an unexpected authority must fail") + .to_string(); + assert!(err.contains("OTP fingerprint mismatch"), "{err}"); + assert!(err.contains(&to_hex(&[0x42u8; 32])), "{err}"); + assert!(err.contains(EXPECTED_OTP_FP), "{err}"); + } + #[test] fn test_empty_signatures_rejected() { let unsigned = RecordBuilder::new() diff --git a/tlvtool/tests/cli_test.rs b/tlvtool/tests/cli_test.rs index bb820208..32112a81 100644 --- a/tlvtool/tests/cli_test.rs +++ b/tlvtool/tests/cli_test.rs @@ -44,6 +44,21 @@ fn run(args: &[&str]) -> String { const TEST_TEMPDIR: &str = "TEST_TMPDIR"; +/// Runs `tlvtool` with `args`, requires it to fail, and returns stderr. +fn run_failing(args: &[&str]) -> String { + let out = tlvtool() + .args(args) + .output() + .expect("failed to run tlvtool"); + assert!( + !out.status.success(), + "`tlvtool {}` unexpectedly exited zero:\n{}", + args.join(" "), + String::from_utf8_lossy(&out.stdout) + ); + String::from_utf8_lossy(&out.stderr).into_owned() +} + /// Writes `words` to a scratch file as little-endian TLV and returns its path. fn write_words(name: &str, words: &[u32]) -> PathBuf { let mut bytes = Vec::with_capacity(words.len() * 4); @@ -70,13 +85,7 @@ fn test_inspect_reads_a_record_file() { #[test] fn test_inspect_rejects_a_missing_file() { - let out = tlvtool() - .args(["inspect", "/nonexistent/record.bin"]) - .output() - .expect("failed to run tlvtool"); - - assert!(!out.status.success(), "a missing file must not exit zero"); - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = run_failing(&["inspect", "/nonexistent/record.bin"]); assert!(stderr.contains("Failed to read"), "{stderr}"); } @@ -136,13 +145,7 @@ fn test_build_rejects_a_bad_config() { .join("build_bad.json"); std::fs::write(&config, "{ not json }").unwrap(); - let out = tlvtool() - .args(["krr", "build", config.to_str().unwrap()]) - .output() - .expect("failed to run tlvtool"); - - assert!(!out.status.success(), "a bad config must not exit zero"); - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = run_failing(&["krr", "build", config.to_str().unwrap()]); assert!(stderr.contains("Failed to parse JSON config"), "{stderr}"); } @@ -210,17 +213,75 @@ fn test_build_rejects_key_file_arity_mismatch() { ) .unwrap(); - let out = tlvtool() - .args(["krr", "build", config.to_str().unwrap()]) - .output() - .expect("failed to run tlvtool"); + let stderr = run_failing(&["krr", "build", config.to_str().unwrap()]); + assert!(stderr.contains("takes a single key"), "{stderr}"); +} - assert!( - !out.status.success(), - "two keys for a non-hybrid algorithm must fail" +/// Writes a one-fingerprint config with `entry` and returns its path. +fn write_key_config(name: &str, entry: &str) -> PathBuf { + let path = + PathBuf::from(std::env::var(TEST_TEMPDIR).expect("TEST_TEMPDIR is not set")).join(name); + std::fs::write(&path, format!(r#"{{ "fingerprints": [ {entry} ] }}"#)).unwrap(); + path +} + +#[test] +fn test_build_requires_allow_test_keys_for_a_test_domain() { + let (ec, _) = write_public_keys("build_testkey"); + let entry = format!( + r#"{{ "algorithm": "p256", "key_domain": "test", "key_file": "{}" }}"#, + ec.display() ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("takes a single key"), "{stderr}"); + let config = write_key_config("build_testkey.json", &entry); + + let stderr = run_failing(&["krr", "build", config.to_str().unwrap()]); + assert!(stderr.contains("--allow-test-keys"), "{stderr}"); + + let report = run(&[ + "krr", + "build", + config.to_str().unwrap(), + "--allow-test-keys", + ]); + assert!(report.contains("Tag: 'KROT'"), "{report}"); +} + +/// The fingerprint of [`P256_SPKI_PEM`], as the firmware computes it: +/// `SHA2-256(x || y)` over the little-endian coordinates `crypto.proto` stores. +/// +/// Pinned from an independent SHA2-256 rather than from `tlvtool`'s own output, +/// so that a tool which fingerprinted the key differently from the device would +/// fail this test: +/// +/// ```text +/// python3 -c "import base64,hashlib,sys; der=base64.b64decode(...); +/// pt=der[-65:]; print(hashlib.sha256(pt[1:33][::-1] + pt[33:][::-1]).hexdigest())" +/// ``` +const P256_SPKI_PEM_FP: &str = "605c317e50b00ec81716fd23f56651302a1d890676b7e0161026d5543ccbb24e"; + +#[test] +fn test_build_checks_an_expected_fingerprint() { + let (ec, _) = write_public_keys("build_pinned"); + let config = |name: &str, expected: &str| { + write_key_config( + name, + &format!( + r#"{{ "algorithm": "p256", "key_file": "{}", "expected_fingerprint": "{expected}" }}"#, + ec.display() + ), + ) + }; + + let good = config("build_pinned_good.json", P256_SPKI_PEM_FP); + let report = run(&["krr", "build", good.to_str().unwrap()]); + assert!(report.contains("Tag: 'KROT'"), "{report}"); + + let bad = config("build_pinned_bad.json", &"11".repeat(32)); + let stderr = run_failing(&["krr", "build", bad.to_str().unwrap()]); + assert!(stderr.contains("Fingerprint mismatch"), "{stderr}"); + // The error names the fingerprint the key really has, so the operator can + // fix the config without reaching for a second tool. + assert!(stderr.contains(P256_SPKI_PEM_FP), "{stderr}"); } /// Filler hybrid signing inputs, written to scratch as `krr sign` expects them. @@ -315,6 +376,13 @@ fn test_sign_prints_the_record_without_an_output_path() { assert!(report.contains("Target RoT OTP FP"), "{report}"); } +/// Runs `krr sign` for `record` and returns stderr, requiring it to fail. +fn sign_failing(record: &Path, keys: &[PathBuf; 4], extra: &[&str]) -> String { + let mut args = sign_args(record, keys, None); + args.extend(extra.iter().map(|s| s.to_string())); + run_failing(&args.iter().map(String::as_str).collect::>()) +} + #[test] fn test_sign_rejects_a_bad_key_file() { let record = build_unsigned("sign_bad"); @@ -323,16 +391,22 @@ fn test_sign_rejects_a_bad_key_file() { .join("sign_bad_short.pub"); std::fs::write(&keys[0], [0u8; 8]).unwrap(); - let args = sign_args(&record, &keys, None); - let out = tlvtool() - .args(&args) - .output() - .expect("failed to run tlvtool"); - - assert!(!out.status.success(), "a bad key must not exit zero"); - let stderr = String::from_utf8_lossy(&out.stderr); + let stderr = sign_failing(&record, &keys, &[]); assert!( stderr.contains("Failed to attach hybrid signature"), "{stderr}" ); } + +#[test] +fn test_sign_rejects_an_unexpected_signing_authority() { + let record = build_unsigned("sign_otp"); + let keys = write_key_material("sign_otp"); + + let stderr = sign_failing( + &record, + &keys, + &["--expected-otp-fingerprint", &"11".repeat(32)], + ); + assert!(stderr.contains("OTP fingerprint mismatch"), "{stderr}"); +} From c1866c6b69f586b16003abd930b9e8445bd5bbac Mon Sep 17 00:00:00 2001 From: Michael Field Date: Mon, 9 Nov 2026 10:18:12 -0800 Subject: [PATCH 7/7] tlvtool: Build in libhoth under tools/tlvtool tlvtool joins the other host tools under tools/. Point it at the TLV libraries in shared/ and their openprot_* crate and proto names, and find the binary under the main repository (_main) in runfiles. --- {tlvtool => tools/tlvtool}/BUILD.bazel | 20 +++++++++---------- {tlvtool => tools/tlvtool}/src/builder.rs | 10 +++++----- {tlvtool => tools/tlvtool}/src/cli.rs | 0 {tlvtool => tools/tlvtool}/src/crypto.rs | 8 ++++---- {tlvtool => tools/tlvtool}/src/inspect.rs | 8 ++++---- {tlvtool => tools/tlvtool}/src/lib.rs | 0 {tlvtool => tools/tlvtool}/src/main.rs | 0 {tlvtool => tools/tlvtool}/src/signature.rs | 20 +++++++++---------- {tlvtool => tools/tlvtool}/tests/BUILD.bazel | 10 +++++----- {tlvtool => tools/tlvtool}/tests/cli_test.rs | 2 +- .../tlvtool}/tests/inspect_test.rs | 4 ++-- .../inspect_test/krr_v1_unsigned.golden | 0 .../inspect_test/krr_v2_signed_hybrid.golden | 0 .../inspect_test/krr_v2_unsigned.golden | 0 14 files changed, 41 insertions(+), 41 deletions(-) rename {tlvtool => tools/tlvtool}/BUILD.bazel (80%) rename {tlvtool => tools/tlvtool}/src/builder.rs (98%) rename {tlvtool => tools/tlvtool}/src/cli.rs (100%) rename {tlvtool => tools/tlvtool}/src/crypto.rs (99%) rename {tlvtool => tools/tlvtool}/src/inspect.rs (98%) rename {tlvtool => tools/tlvtool}/src/lib.rs (100%) rename {tlvtool => tools/tlvtool}/src/main.rs (100%) rename {tlvtool => tools/tlvtool}/src/signature.rs (97%) rename {tlvtool => tools/tlvtool}/tests/BUILD.bazel (83%) rename {tlvtool => tools/tlvtool}/tests/cli_test.rs (99%) rename {tlvtool => tools/tlvtool}/tests/inspect_test.rs (97%) rename {tlvtool => tools/tlvtool}/tests/testdata/inspect_test/krr_v1_unsigned.golden (100%) rename {tlvtool => tools/tlvtool}/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden (100%) rename {tlvtool => tools/tlvtool}/tests/testdata/inspect_test/krr_v2_unsigned.golden (100%) diff --git a/tlvtool/BUILD.bazel b/tools/tlvtool/BUILD.bazel similarity index 80% rename from tlvtool/BUILD.bazel rename to tools/tlvtool/BUILD.bazel index f5d637fd..e5d0dd74 100644 --- a/tlvtool/BUILD.bazel +++ b/tools/tlvtool/BUILD.bazel @@ -30,16 +30,16 @@ rust_library( edition = "2024", visibility = ["//visibility:public"], deps = [ - "//contrib/crypto:sha256_sw", - "//contrib/fingerprints", - "//contrib/openprot/proto", - "//contrib/openprot/proto:crypto_rust_proto", - "//contrib/openprot/proto:crypto_tlv", - "//contrib/openprot/proto:identity_rust_proto", - "//contrib/openprot/proto:key_rotation_rust_proto", - "//contrib/tlv", - "//contrib/tlv:proto_conv", - "//contrib/tlv:proto_meta", + "//shared/crypto:sha256_sw", + "//shared/fingerprints", + "//shared/openprot/proto", + "//shared/openprot/proto:crypto_rust_proto", + "//shared/openprot/proto:crypto_tlv", + "//shared/openprot/proto:identity_rust_proto", + "//shared/openprot/proto:key_rotation_rust_proto", + "//shared/tlv", + "//shared/tlv:proto_conv", + "//shared/tlv:proto_meta", "//third_party:anyhow", "//third_party:argh", "//third_party:base64", diff --git a/tlvtool/src/builder.rs b/tools/tlvtool/src/builder.rs similarity index 98% rename from tlvtool/src/builder.rs rename to tools/tlvtool/src/builder.rs index 540575b6..4a82afe7 100644 --- a/tlvtool/src/builder.rs +++ b/tools/tlvtool/src/builder.rs @@ -453,8 +453,8 @@ impl RecordBuilder { )); } - let tlv_words = mutask_tlv_proto_conv::message_to_tlv( - &key_rotation_rust_proto::__unstable::CONTRIB_OPENPROT_PROTO_KEY_ROTATION_DESCRIPTOR_INFO, + let tlv_words = openprot_tlv_proto_conv::message_to_tlv( + &key_rotation_rust_proto::__unstable::SHARED_OPENPROT_PROTO_KEY_ROTATION_DESCRIPTOR_INFO, &desc, ) .context("Failed to convert protobuf record to TLV wire format")?; @@ -473,7 +473,7 @@ pub struct UnsignedRecord(Vec); impl UnsignedRecord { pub fn from_bytes(bytes: &[u8]) -> Result { check_unsigned_size(bytes.len())?; - let tlv_data = mutask_tlv::TlvData::overlay_bytes(bytes) + let tlv_data = openprot_tlv::TlvData::overlay_bytes(bytes) .map_err(|_| anyhow!("Unsigned record length must be 4-byte aligned"))?; let has_desc = tlv_data .iter::() @@ -505,7 +505,7 @@ mod tests { /// Parses `bytes` as TLV and returns the contained `KROT` descriptor version. fn descriptor_version(bytes: &[u8]) -> u32 { - mutask_tlv::TlvData::overlay_bytes(bytes) + openprot_tlv::TlvData::overlay_bytes(bytes) .expect("Invalid alignment") .iter::() .next() @@ -627,7 +627,7 @@ mod tests { use proto::crypto::KeyFingerprintExt; use proto::crypto::KeyFingerprintKeyType as K; use proto::key_rotation::DescriptorExt; - let desc = mutask_tlv::TlvData::overlay_bytes(bytes) + let desc = openprot_tlv::TlvData::overlay_bytes(bytes) .unwrap() .iter::() .next() diff --git a/tlvtool/src/cli.rs b/tools/tlvtool/src/cli.rs similarity index 100% rename from tlvtool/src/cli.rs rename to tools/tlvtool/src/cli.rs diff --git a/tlvtool/src/crypto.rs b/tools/tlvtool/src/crypto.rs similarity index 99% rename from tlvtool/src/crypto.rs rename to tools/tlvtool/src/crypto.rs index d77e431e..52500b8e 100644 --- a/tlvtool/src/crypto.rs +++ b/tools/tlvtool/src/crypto.rs @@ -45,16 +45,16 @@ use crypto_tlv::Rsa2048PublicMaterial; use crypto_tlv::Rsa3072PublicMaterial; use crypto_tlv::Rsa4096PublicMaterial; use crypto_tlv::SlhDsaPublicMaterial; -use mutask_fingerprints::FingerprintableKey; +use openprot_fingerprints::FingerprintableKey; use serde::Deserialize; use serde::Serialize; // ----------------------------------------------------------------------------- -// FIPS 180-4 SHA2-256 (via mutask) +// FIPS 180-4 SHA2-256 (via openprot_sha256_sw) // ----------------------------------------------------------------------------- -pub use mutask_sha256_sw::Sha256; -pub use mutask_sha256_sw::sha256; +pub use openprot_sha256_sw::Sha256; +pub use openprot_sha256_sw::sha256; /// Length of an ML-DSA-44 public key, in bytes. pub const MLDSA44_PUBLIC_KEY_LEN: usize = 1312; diff --git a/tlvtool/src/inspect.rs b/tools/tlvtool/src/inspect.rs similarity index 98% rename from tlvtool/src/inspect.rs rename to tools/tlvtool/src/inspect.rs index 4adc7773..591484af 100644 --- a/tlvtool/src/inspect.rs +++ b/tools/tlvtool/src/inspect.rs @@ -18,10 +18,10 @@ use anyhow::Result; use anyhow::ensure; use core::fmt; use core::fmt::Write as _; -use mutask_tlv::TlvAny; -use mutask_tlv::TlvData; -use mutask_tlv::TlvHeader; -use mutask_tlv::word_size_of; +use openprot_tlv::TlvAny; +use openprot_tlv::TlvData; +use openprot_tlv::TlvHeader; +use openprot_tlv::word_size_of; use zerocopy::IntoBytes; /// Maximum number of payload bytes rendered as hex in a leaf node. diff --git a/tlvtool/src/lib.rs b/tools/tlvtool/src/lib.rs similarity index 100% rename from tlvtool/src/lib.rs rename to tools/tlvtool/src/lib.rs diff --git a/tlvtool/src/main.rs b/tools/tlvtool/src/main.rs similarity index 100% rename from tlvtool/src/main.rs rename to tools/tlvtool/src/main.rs diff --git a/tlvtool/src/signature.rs b/tools/tlvtool/src/signature.rs similarity index 97% rename from tlvtool/src/signature.rs rename to tools/tlvtool/src/signature.rs index 64ba6cd9..eeb40969 100644 --- a/tlvtool/src/signature.rs +++ b/tools/tlvtool/src/signature.rs @@ -31,12 +31,12 @@ use anyhow::Result; use anyhow::anyhow; use anyhow::ensure; use crypto_tlv::HybridP256MlDsa44PublicMaterial; -use mutask_fingerprints::FingerprintableKey; -use mutask_tlv::TlvBuilder; -use mutask_tlv::TlvData; -use mutask_tlv::TlvHeader; -use mutask_tlv::TlvItem; -use mutask_tlv::TlvObject; +use openprot_fingerprints::FingerprintableKey; +use openprot_tlv::TlvBuilder; +use openprot_tlv::TlvData; +use openprot_tlv::TlvHeader; +use openprot_tlv::TlvItem; +use openprot_tlv::TlvObject; use std::path::Path; /// The four offline artefacts that make up one hybrid P-256 + ML-DSA-44 signature. @@ -205,8 +205,8 @@ impl SignedRecordBuilder { .signatures .iter() .map(|sigc| { - mutask_tlv_proto_conv::message_to_tlv( - &crypto_rust_proto::__unstable::CONTRIB_OPENPROT_PROTO_CRYPTO_DESCRIPTOR_INFO, + openprot_tlv_proto_conv::message_to_tlv( + &crypto_rust_proto::__unstable::SHARED_OPENPROT_PROTO_CRYPTO_DESCRIPTOR_INFO, sigc, ) .context("Failed to convert signature container to TLV wire format") @@ -366,8 +366,8 @@ mod tests { }; let (sigc, _) = build_hybrid_sigc(crypto_rust_proto::KeyDomain::Rotr, &material).unwrap(); - let wire = mutask_tlv_proto_conv::message_to_tlv( - &crypto_rust_proto::__unstable::CONTRIB_OPENPROT_PROTO_CRYPTO_DESCRIPTOR_INFO, + let wire = openprot_tlv_proto_conv::message_to_tlv( + &crypto_rust_proto::__unstable::SHARED_OPENPROT_PROTO_CRYPTO_DESCRIPTOR_INFO, &sigc, ) .unwrap(); diff --git a/tlvtool/tests/BUILD.bazel b/tools/tlvtool/tests/BUILD.bazel similarity index 83% rename from tlvtool/tests/BUILD.bazel rename to tools/tlvtool/tests/BUILD.bazel index e0e19e59..053213d9 100644 --- a/tlvtool/tests/BUILD.bazel +++ b/tools/tlvtool/tests/BUILD.bazel @@ -17,13 +17,13 @@ load("@rules_rust//rust:defs.bzl", "rust_test") rust_test( name = "cli_test", srcs = ["cli_test.rs"], - data = ["//tlvtool"], + data = ["//tools/tlvtool"], edition = "2024", deps = ["@rules_rust//tools/runfiles"], ) # Golden tests for the `inspect` report. Update them with: -# UPDATE_GOLDEN=1 bazel run //tlvtool/tests:inspect_test +# UPDATE_GOLDEN=1 bazel run //tools/tlvtool/tests:inspect_test rust_test( name = "inspect_test", srcs = ["inspect_test.rs"], @@ -34,8 +34,8 @@ rust_test( "link-arg=-lgcc", ], deps = [ - "//contrib/golden", - "//contrib/openprot/proto:crypto_rust_proto", - "//tlvtool:tlvtool_lib", + "//shared/golden", + "//shared/openprot/proto:crypto_rust_proto", + "//tools/tlvtool:tlvtool_lib", ], ) diff --git a/tlvtool/tests/cli_test.rs b/tools/tlvtool/tests/cli_test.rs similarity index 99% rename from tlvtool/tests/cli_test.rs rename to tools/tlvtool/tests/cli_test.rs index 32112a81..dceb1774 100644 --- a/tlvtool/tests/cli_test.rs +++ b/tools/tlvtool/tests/cli_test.rs @@ -22,7 +22,7 @@ use std::process::Command; fn tlvtool() -> Command { let r = runfiles::Runfiles::create().expect("failed to locate runfiles"); Command::new( - runfiles::rlocation!(r, "mutask/tlvtool/tlvtool").expect("tlvtool is not in the runfiles"), + runfiles::rlocation!(r, "_main/tools/tlvtool/tlvtool").expect("tlvtool is not in the runfiles"), ) } diff --git a/tlvtool/tests/inspect_test.rs b/tools/tlvtool/tests/inspect_test.rs similarity index 97% rename from tlvtool/tests/inspect_test.rs rename to tools/tlvtool/tests/inspect_test.rs index 472f708d..4089549e 100644 --- a/tlvtool/tests/inspect_test.rs +++ b/tools/tlvtool/tests/inspect_test.rs @@ -22,10 +22,10 @@ //! To update the golden files after an intended change: //! //! ```text -//! UPDATE_GOLDEN=1 bazel run //tlvtool/tests:inspect_test +//! UPDATE_GOLDEN=1 bazel run //tools/tlvtool/tests:inspect_test //! ``` -use mutask_golden::assert_eq_golden; +use openprot_golden::assert_eq_golden; use tlvtool::Algorithm; use tlvtool::HybridMaterial; use tlvtool::RecordBuilder; diff --git a/tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden b/tools/tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden similarity index 100% rename from tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden rename to tools/tlvtool/tests/testdata/inspect_test/krr_v1_unsigned.golden diff --git a/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden b/tools/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden similarity index 100% rename from tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden rename to tools/tlvtool/tests/testdata/inspect_test/krr_v2_signed_hybrid.golden diff --git a/tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden b/tools/tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden similarity index 100% rename from tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden rename to tools/tlvtool/tests/testdata/inspect_test/krr_v2_unsigned.golden