FDS/OS 1.0

This commit is contained in:
2026-09-21 22:29:23 +08:00
commit 99bc3d15c5
430 changed files with 34876 additions and 0 deletions
+402
View File
@@ -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());
}
}
+109
View File
@@ -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());
}
}