FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
//! Detached Ed25519 signatures bind bounded manifests and streamed artifact hashes.
|
||||
//! The verifier's trust anchor is always an explicit public-key file.
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use fds_common::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
ffi::CString,
|
||||
fs::{File, OpenOptions},
|
||||
io::{self, Read, Write},
|
||||
os::{
|
||||
fd::{AsRawFd, FromRawFd},
|
||||
unix::fs::{MetadataExt, OpenOptionsExt},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub const MANIFEST: &str = "manifest.json";
|
||||
pub const SIGNATURE: &str = "manifest.sig";
|
||||
pub const CONTEXT: &[u8] = b"FDS/OS release manifest v1\0";
|
||||
const MAX_MANIFEST: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Artifact {
|
||||
pub name: String,
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub format: u8,
|
||||
pub version: String,
|
||||
pub source_epoch: u64,
|
||||
pub source_sha256: String,
|
||||
pub void_commit: String,
|
||||
pub hardware_validation: String,
|
||||
pub files: Vec<Artifact>,
|
||||
}
|
||||
fn hex_valid(value: &str, length: usize) -> bool {
|
||||
value.len() == length
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
pub fn filename(value: &str) -> Result<()> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| value.starts_with('.')
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
|
||||
{
|
||||
return Err(Error(
|
||||
"Release artifact names must be simple filenames without paths or leading dots".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl Manifest {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.format != 1
|
||||
|| self.version != fds_common::VERSION
|
||||
|| self.source_epoch == 0
|
||||
|| !hex_valid(&self.source_sha256, 64)
|
||||
|| !hex_valid(&self.void_commit, 40)
|
||||
|| self.hardware_validation != "deferred"
|
||||
|| self.files.is_empty()
|
||||
|| self.files.len() > 64
|
||||
{
|
||||
return Err(Error("Unsupported or invalid FDS release manifest".into()));
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
for artifact in &self.files {
|
||||
filename(&artifact.name)?;
|
||||
if [MANIFEST, SIGNATURE].contains(&artifact.name.as_str())
|
||||
|| !names.insert(&artifact.name)
|
||||
|| artifact.bytes == 0
|
||||
|| !hex_valid(&artifact.sha256, 64)
|
||||
{
|
||||
return Err(Error(
|
||||
"Invalid, reserved or duplicate release artifact".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
fn unhex<const N: usize>(text: &str) -> Result<[u8; N]> {
|
||||
if !hex_valid(text, N * 2) {
|
||||
return Err(Error("Invalid lowercase hexadecimal encoding".into()));
|
||||
}
|
||||
let mut bytes = [0; N];
|
||||
for (byte, pair) in bytes.iter_mut().zip(text.as_bytes().chunks_exact(2)) {
|
||||
*byte = u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16)
|
||||
.map_err(|e| Error(e.to_string()))?;
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
fn regular(file: File) -> Result<File> {
|
||||
if !file.metadata()?.is_file() {
|
||||
return Err(Error("Release inputs must be regular files".into()));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
fn open(path: &Path) -> Result<File> {
|
||||
regular(
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||
.open(path)?,
|
||||
)
|
||||
}
|
||||
fn bounded(mut file: File, limit: u64) -> Result<Vec<u8>> {
|
||||
if file.metadata()?.len() > limit {
|
||||
return Err(Error("Release input exceeds its size limit".into()));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
(&mut file).take(limit + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > limit {
|
||||
return Err(Error("Release input exceeds its size limit".into()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
struct Directory {
|
||||
fd: File,
|
||||
}
|
||||
impl Directory {
|
||||
fn open(path: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
fd: OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
|
||||
.open(path)?,
|
||||
})
|
||||
}
|
||||
fn file(&self, name: &str) -> Result<File> {
|
||||
filename(name)?;
|
||||
let name = CString::new(name).map_err(|_| Error("NUL in filename".into()))?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
self.fd.as_raw_fd(),
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
regular(unsafe { File::from_raw_fd(fd) })
|
||||
}
|
||||
fn create(&self, name: &str, bytes: &[u8]) -> Result<()> {
|
||||
filename(name)?;
|
||||
let name = CString::new(name).map_err(|_| Error("NUL in filename".into()))?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
self.fd.as_raw_fd(),
|
||||
name.as_ptr(),
|
||||
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC,
|
||||
0o644,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error().into());
|
||||
}
|
||||
let mut file = unsafe { File::from_raw_fd(fd) };
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
self.fd.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn manifest(&self) -> Result<(Vec<u8>, Manifest)> {
|
||||
let bytes = bounded(self.file(MANIFEST)?, MAX_MANIFEST)?;
|
||||
let manifest: Manifest = serde_json::from_slice(&bytes)
|
||||
.map_err(|e| Error(format!("Invalid release manifest: {e}")))?;
|
||||
manifest.validate()?;
|
||||
Ok((bytes, manifest))
|
||||
}
|
||||
fn verify_files(&self, manifest: &Manifest) -> Result<()> {
|
||||
for artifact in &manifest.files {
|
||||
let mut file = self.file(&artifact.name)?;
|
||||
let before = file.metadata()?;
|
||||
if before.len() != artifact.bytes {
|
||||
return Err(Error(format!("Artifact size mismatch: {}", artifact.name)));
|
||||
}
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = vec![0u8; 1024 * 1024];
|
||||
let mut remaining = artifact.bytes;
|
||||
while remaining > 0 {
|
||||
let size = (remaining as usize).min(buffer.len());
|
||||
file.read_exact(&mut buffer[..size])?;
|
||||
hash.update(&buffer[..size]);
|
||||
remaining -= size as u64;
|
||||
}
|
||||
let after = file.metadata()?;
|
||||
if after.len() != before.len()
|
||||
|| after.mtime() != before.mtime()
|
||||
|| after.mtime_nsec() != before.mtime_nsec()
|
||||
|| after.ctime() != before.ctime()
|
||||
|| after.ctime_nsec() != before.ctime_nsec()
|
||||
|| hex(&hash.finalize()) != artifact.sha256
|
||||
{
|
||||
return Err(Error(format!(
|
||||
"Artifact changed or SHA-256 mismatch: {}",
|
||||
artifact.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn message(manifest: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(CONTEXT.len() + manifest.len());
|
||||
bytes.extend_from_slice(CONTEXT);
|
||||
bytes.extend_from_slice(manifest);
|
||||
bytes
|
||||
}
|
||||
fn secret(path: &Path) -> Result<SigningKey> {
|
||||
let mut file = open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o077 != 0 || meta.len() != 32 {
|
||||
return Err(Error(
|
||||
"Signing key must be an owner-only 32-byte file belonging to the current user".into(),
|
||||
));
|
||||
}
|
||||
let mut bytes = Zeroizing::new([0u8; 32]);
|
||||
file.read_exact(&mut *bytes)?;
|
||||
Ok(SigningKey::from_bytes(&bytes))
|
||||
}
|
||||
fn public(path: &Path) -> Result<VerifyingKey> {
|
||||
let bytes = bounded(open(path)?, 128)?;
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| Error("Public key must be lowercase hex followed by one newline".into()))?;
|
||||
let key = unhex::<32>(
|
||||
text.strip_suffix('\n')
|
||||
.ok_or_else(|| Error("Public key needs a final newline".into()))?,
|
||||
)?;
|
||||
let key =
|
||||
VerifyingKey::from_bytes(&key).map_err(|e| Error(format!("Invalid public key: {e}")))?;
|
||||
if key.is_weak() {
|
||||
return Err(Error("Weak release public keys are rejected".into()));
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8], mode: u32) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(mode)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()?;
|
||||
File::open(
|
||||
path.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new(".")),
|
||||
)?
|
||||
.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn public_key(private: &Path, output: &Path) -> Result<String> {
|
||||
let key = secret(private)?.verifying_key();
|
||||
write_new(
|
||||
output,
|
||||
format!("{}\n", hex(key.as_bytes())).as_bytes(),
|
||||
0o644,
|
||||
)?;
|
||||
Ok(hex(&Sha256::digest(key.as_bytes())))
|
||||
}
|
||||
pub fn keygen(prefix: &Path) -> Result<String> {
|
||||
let mut private_name = prefix.as_os_str().to_os_string();
|
||||
private_name.push(".key");
|
||||
let mut public_name = prefix.as_os_str().to_os_string();
|
||||
public_name.push(".pub");
|
||||
let (private, public) = (PathBuf::from(private_name), PathBuf::from(public_name));
|
||||
if private.symlink_metadata().is_ok() || public.symlink_metadata().is_ok() {
|
||||
return Err(Error(
|
||||
"Key output already exists; keys are never overwritten".into(),
|
||||
));
|
||||
}
|
||||
let mut seed = Zeroizing::new([0u8; 32]);
|
||||
let mut offset = 0;
|
||||
while offset < seed.len() {
|
||||
let count =
|
||||
unsafe { libc::getrandom(seed[offset..].as_mut_ptr().cast(), seed.len() - offset, 0) };
|
||||
if count < 0 {
|
||||
let error = io::Error::last_os_error();
|
||||
if error.kind() == io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(Error("Kernel randomness returned no bytes".into()));
|
||||
}
|
||||
offset += count as usize;
|
||||
}
|
||||
write_new(&private, &*seed, 0o600)?;
|
||||
public_key(&private, &public)
|
||||
}
|
||||
pub fn sign(directory: &Path, private: &Path) -> Result<String> {
|
||||
let directory = Directory::open(directory)?;
|
||||
let (bytes, manifest) = directory.manifest()?;
|
||||
directory.verify_files(&manifest)?;
|
||||
let key = secret(private)?;
|
||||
let signature: Signature = key.sign(&message(&bytes));
|
||||
directory.create(
|
||||
SIGNATURE,
|
||||
format!("{}\n", hex(&signature.to_bytes())).as_bytes(),
|
||||
)?;
|
||||
Ok(hex(&Sha256::digest(key.verifying_key().as_bytes())))
|
||||
}
|
||||
pub fn verify(directory: &Path, trusted_public: &Path) -> Result<Manifest> {
|
||||
let key = public(trusted_public)?;
|
||||
let directory = Directory::open(directory)?;
|
||||
let (bytes, manifest) = directory.manifest()?;
|
||||
let encoded = bounded(directory.file(SIGNATURE)?, 256)?;
|
||||
let signature =
|
||||
std::str::from_utf8(&encoded).map_err(|_| Error("Invalid signature encoding".into()))?;
|
||||
let signature = Signature::from_bytes(&unhex::<64>(
|
||||
signature
|
||||
.strip_suffix('\n')
|
||||
.ok_or_else(|| Error("Signature needs a final newline".into()))?,
|
||||
)?);
|
||||
key.verify_strict(&message(&bytes), &signature)
|
||||
.map_err(|_| Error("Release signature does not match the supplied trusted key".into()))?;
|
||||
directory.verify_files(&manifest)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rfc8032_test_vector_one_and_malleability_rejection() {
|
||||
// RFC 8032 section 7.1 public test data, never a release signing key.
|
||||
let seed = unhex::<32>("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
|
||||
.unwrap();
|
||||
let expected_key =
|
||||
unhex::<32>("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a")
|
||||
.unwrap();
|
||||
let expected_signature = unhex::<64>("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b").unwrap();
|
||||
let key = SigningKey::from_bytes(&seed);
|
||||
assert_eq!(key.verifying_key().to_bytes(), expected_key);
|
||||
let signature: Signature = key.sign(b"");
|
||||
assert_eq!(signature.to_bytes(), expected_signature);
|
||||
assert!(key.verifying_key().verify_strict(b"", &signature).is_ok());
|
||||
assert!(
|
||||
key.verifying_key()
|
||||
.verify_strict(b"changed", &signature)
|
||||
.is_err()
|
||||
);
|
||||
let mut altered = expected_signature;
|
||||
altered[63] |= 0x80;
|
||||
assert!(
|
||||
key.verifying_key()
|
||||
.verify_strict(b"", &Signature::from_bytes(&altered))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
key.verifying_key()
|
||||
.verify_strict(&message(b""), &signature)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn manifest_rejects_duplicate_reserved_and_traversing_paths() {
|
||||
for name in ["", "../root", "/absolute", ".hidden", "a/b", "a\\b"] {
|
||||
assert!(filename(name).is_err());
|
||||
}
|
||||
let mut manifest = Manifest {
|
||||
format: 1,
|
||||
version: "0.1.0".into(),
|
||||
source_epoch: 1,
|
||||
source_sha256: "a".repeat(64),
|
||||
void_commit: "b".repeat(40),
|
||||
hardware_validation: "deferred".into(),
|
||||
files: vec![Artifact {
|
||||
name: "image.img".into(),
|
||||
bytes: 1,
|
||||
sha256: "c".repeat(64),
|
||||
}],
|
||||
};
|
||||
assert!(manifest.validate().is_ok());
|
||||
manifest.files.push(Artifact {
|
||||
name: "image.img".into(),
|
||||
bytes: 1,
|
||||
sha256: "d".repeat(64),
|
||||
});
|
||||
assert!(manifest.validate().is_err());
|
||||
manifest.files.pop();
|
||||
manifest.files[0].name = MANIFEST.into();
|
||||
assert!(manifest.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use fds_common::Result;
|
||||
use std::{path::PathBuf, process::ExitCode};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Sign and verify FDS release artifacts",
|
||||
after_help = "Keys and signatures are never overwritten. Verification checks every listed artifact.
|
||||
An independently trusted public key is required; a bundled key is not automatically trusted."
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Action>,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// Create a new private/public key pair (PREFIX.key and PREFIX.pub).
|
||||
Keygen { new_prefix: PathBuf },
|
||||
/// Derive a new public-key file from an existing private key.
|
||||
PublicKey {
|
||||
private_key: PathBuf,
|
||||
new_public_key: PathBuf,
|
||||
},
|
||||
/// Sign a release directory using an explicit private key.
|
||||
Sign {
|
||||
directory: PathBuf,
|
||||
#[arg(long)]
|
||||
key: PathBuf,
|
||||
},
|
||||
/// Verify the signature and every artifact using a trusted public key.
|
||||
Verify {
|
||||
directory: PathBuf,
|
||||
#[arg(long)]
|
||||
key: PathBuf,
|
||||
},
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
None => {
|
||||
Cli::command().print_help()?;
|
||||
println!();
|
||||
}
|
||||
Some(Action::Keygen { new_prefix }) => println!(
|
||||
"Created signing key and public key. Public-key SHA-256: {}",
|
||||
fds_release::keygen(&new_prefix)?
|
||||
),
|
||||
Some(Action::PublicKey {
|
||||
private_key,
|
||||
new_public_key,
|
||||
}) => println!(
|
||||
"Public-key SHA-256: {}",
|
||||
fds_release::public_key(&private_key, &new_public_key)?
|
||||
),
|
||||
Some(Action::Sign { directory, key }) => println!(
|
||||
"Signed release manifest. Public-key SHA-256: {}",
|
||||
fds_release::sign(&directory, &key)?
|
||||
),
|
||||
Some(Action::Verify { directory, key }) => {
|
||||
let manifest = fds_release::verify(&directory, &key)?;
|
||||
println!(
|
||||
"VERIFIED FDS/OS {}: signature and {} artifact hashes match the supplied key.\nHardware validation: {}.",
|
||||
manifest.version,
|
||||
manifest.files.len(),
|
||||
manifest.hardware_validation
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("fds-release: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cli_tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
#[test]
|
||||
fn typed_command_contract() {
|
||||
Cli::command().debug_assert();
|
||||
for action in ["sign", "verify"] {
|
||||
assert!(
|
||||
Cli::try_parse_from(["fds-release", action, "release", "--key", "key"]).is_ok()
|
||||
);
|
||||
assert!(Cli::try_parse_from(["fds-release", action, "--key=key", "release"]).is_ok());
|
||||
assert!(Cli::try_parse_from(["fds-release", action, "release"]).is_err());
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"fds-release",
|
||||
action,
|
||||
"release",
|
||||
"--key",
|
||||
"one",
|
||||
"--key",
|
||||
"two"
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(Cli::try_parse_from(["fds-release", "keygen", "new", "--force"]).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user