General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 5m18s

This commit is contained in:
Alex Emmet 2026-07-18 03:08:03 +02:00
commit 6e5c985719
122 changed files with 10309 additions and 5206 deletions

View file

@ -1,10 +1,13 @@
[package]
name = "mtp-files"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
# needed here; those are always compiled, so no crypto features are required.
mtp-crypto = { version = "0.1.0", path = "../crypto", default-features = false }
mtp-crypto = { version = "0.2.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
rand = "0.10.2"
thiserror = "1"
zeroize = "1.9"

View file

@ -1,18 +1,22 @@
/*
* On-disk storage for methanium key material.
*
* `.mk` files hold a full Keyring (public and secret keys) and are written with
* owner-only permissions (0600) on Unix. `.mpkb` files hold a PublicKeyBundle
* (public keys only) and are safe to share. Each file opens with a 4-byte magic
* that doubles as a type tag, so a bundle never loads as a keyring, followed by
* a version byte for future format changes.
* `.mk` files hold a passphrase-protected Keyring and are written atomically
* with owner-only permissions (0600) on Unix. `.mpkb` files hold a
* PublicKeyBundle (public keys only) and are safe to share. Each file opens
* with a 4-byte magic that doubles as a type tag, so a bundle never loads as a
* keyring, followed by a version byte.
*/
use std::fs;
use std::io;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, derive_encryption_key};
use rand::RngExt;
use thiserror::Error;
use zeroize::Zeroizing;
pub use mtp_crypto::{CryptoError, Keyring, PublicKeyBundle};
@ -24,8 +28,12 @@ pub const BUNDLE_EXTENSION: &str = "mpkb";
/* Container layout: magic (4 bytes) || version (1 byte) || payload. */
const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */
const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */
const FORMAT_VERSION: u8 = 1;
const RAW_FORMAT_VERSION: u8 = 1;
const PROTECTED_FORMAT_VERSION: u8 = 2;
const BUNDLE_FORMAT_VERSION: u8 = 1;
const HEADER_LEN: usize = 4 + 1;
const SALT_LEN: usize = 32;
const KEYRING_KDF_CONTEXT: &[u8] = b"mtp-keyring-at-rest-v2";
#[derive(Error, Debug)]
pub enum FileError {
@ -35,69 +43,187 @@ pub enum FileError {
Crypto(#[from] CryptoError),
#[error("not a valid methanium {expected} file (bad magic)")]
BadMagic { expected: &'static str },
#[error("unsupported {kind} format version {found} (expected {FORMAT_VERSION})")]
#[error("unsupported {kind} format version {found}")]
UnsupportedVersion { kind: &'static str, found: u8 },
#[error("file is truncated: {0} bytes, need at least {HEADER_LEN}")]
Truncated(usize),
#[error("passphrase must not be empty")]
EmptyPassphrase,
#[error(
"keyring is stored in the unprotected raw format; use load_keyring_raw only for trusted development or migration"
)]
UnprotectedKeyring,
#[error("keyring is passphrase-protected and cannot be loaded as raw")]
ProtectedKeyring,
}
fn encode(magic: [u8; 4], payload: &[u8]) -> Vec<u8> {
fn encode(magic: [u8; 4], version: u8, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
out.extend_from_slice(&magic);
out.push(FORMAT_VERSION);
out.push(version);
out.extend_from_slice(payload);
out
}
fn decode<'a>(bytes: &'a [u8], magic: [u8; 4], kind: &'static str) -> Result<&'a [u8], FileError> {
fn decode<'a>(
bytes: &'a [u8],
magic: [u8; 4],
kind: &'static str,
) -> Result<(u8, &'a [u8]), FileError> {
if bytes.len() < HEADER_LEN {
return Err(FileError::Truncated(bytes.len()));
}
if bytes[..4] != magic {
return Err(FileError::BadMagic { expected: kind });
}
let found = bytes[4];
if found != FORMAT_VERSION {
return Err(FileError::UnsupportedVersion { kind, found });
}
Ok(&bytes[HEADER_LEN..])
Ok((bytes[4], &bytes[HEADER_LEN..]))
}
/*
* `OpenOptions::mode` only applies when the file is created, so the mode is
* re-set afterwards to also tighten a pre-existing, more-permissive file.
*/
/* The temporary secret file is owner-only from the instant it is created. */
#[cfg(unix)]
fn write_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
fn create_secret_file(path: &Path) -> io::Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
file.write_all(bytes)?;
file.sync_all()
.open(path)
}
#[cfg(not(unix))]
fn write_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
fs::write(path, bytes)
fn create_secret_file(path: &Path) -> io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
}
// Writes secret keys, so the file is created owner-only (0600) on Unix.
pub fn save_keyring(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
let bytes = encode(KEYRING_MAGIC, &keyring.to_bytes());
write_secret(path.as_ref(), &bytes)?;
fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
let mut temporary_name = name.to_os_string();
temporary_name.push(format!(
".tmp-{}-{}-{attempt}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
Ok(parent.join(temporary_name))
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
let (temporary, mut file) = (0..100)
.find_map(|attempt| {
let temporary = temporary_path(path, attempt).ok()?;
match create_secret_file(&temporary) {
Ok(file) => Some(Ok((temporary, file))),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => None,
Err(error) => Some(Err(error)),
}
})
.transpose()?
.ok_or_else(|| {
io::Error::new(io::ErrorKind::AlreadyExists, "no temporary name available")
})?;
if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(error);
}
drop(file);
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
Ok(())
}
pub fn load_keyring(path: impl AsRef<Path>) -> Result<Keyring, FileError> {
/// Save a keyring encrypted with XChaCha20-Poly1305 under an HKDF-derived key.
pub fn save_keyring(
keyring: &Keyring,
path: impl AsRef<Path>,
passphrase: &[u8],
) -> Result<(), FileError> {
if passphrase.is_empty() {
return Err(FileError::EmptyPassphrase);
}
let mut salt = [0u8; SALT_LEN];
rand::rng().fill(&mut salt);
let key = Zeroizing::new(derive_encryption_key(
passphrase,
&salt,
KEYRING_KDF_CONTEXT,
)?);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = keyring.to_bytes();
let encrypted = cipher.encrypt(&plaintext, &KEYRING_MAGIC)?;
let mut payload = Vec::with_capacity(SALT_LEN + encrypted.len());
payload.extend_from_slice(&salt);
payload.extend_from_slice(&encrypted);
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
}
pub fn load_keyring(path: impl AsRef<Path>, passphrase: &[u8]) -> Result<Keyring, FileError> {
if passphrase.is_empty() {
return Err(FileError::EmptyPassphrase);
}
let bytes = fs::read(path)?;
let payload = decode(&bytes, KEYRING_MAGIC, "keyring")?;
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
if version == RAW_FORMAT_VERSION {
return Err(FileError::UnprotectedKeyring);
}
if version != PROTECTED_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "keyring",
found: version,
});
}
let salt = payload
.get(..SALT_LEN)
.ok_or(FileError::Truncated(bytes.len()))?;
let encrypted = payload
.get(SALT_LEN..)
.ok_or(FileError::Truncated(bytes.len()))?;
let key = Zeroizing::new(derive_encryption_key(
passphrase,
salt,
KEYRING_KDF_CONTEXT,
)?);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = Zeroizing::new(cipher.decrypt(encrypted, &KEYRING_MAGIC)?);
Ok(Keyring::from_bytes(&plaintext)?)
}
/// Explicitly save the legacy plaintext format for tests and development.
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
let payload = keyring.to_bytes();
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
}
/// Explicitly load the legacy plaintext format for tests and development.
pub fn load_keyring_raw(path: impl AsRef<Path>) -> Result<Keyring, FileError> {
let bytes = Zeroizing::new(fs::read(path)?);
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
if version == PROTECTED_FORMAT_VERSION {
return Err(FileError::ProtectedKeyring);
}
if version != RAW_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "keyring",
found: version,
});
}
Ok(Keyring::from_bytes(payload)?)
}
@ -105,14 +231,20 @@ pub fn save_public_key_bundle(
bundle: &PublicKeyBundle,
path: impl AsRef<Path>,
) -> Result<(), FileError> {
let bytes = encode(BUNDLE_MAGIC, &bundle.as_bytes());
let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle.as_bytes());
fs::write(path, bytes)?;
Ok(())
}
pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle, FileError> {
let bytes = fs::read(path)?;
let payload = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?;
let (version, payload) = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?;
if version != BUNDLE_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "public key bundle",
found: version,
});
}
Ok(PublicKeyBundle::from_bytes(payload)?)
}
@ -149,8 +281,8 @@ mod tests {
fn keyring_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
save_keyring(&keyring, &path)?;
let loaded = load_keyring(&path)?;
save_keyring(&keyring, &path, b"correct horse battery staple")?;
let loaded = load_keyring(&path, b"correct horse battery staple")?;
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
let _ = fs::remove_file(&path);
Ok(())
@ -172,7 +304,7 @@ mod tests {
let path = temp_path(BUNDLE_EXTENSION);
save_public_key_bundle(&sample_keyring().public_key_bundle(), &path)?;
assert!(matches!(
load_keyring(&path),
load_keyring(&path, b"passphrase"),
Err(FileError::BadMagic { .. })
));
let _ = fs::remove_file(&path);
@ -183,7 +315,10 @@ mod tests {
fn truncated_file_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
fs::write(&path, b"MT")?;
assert!(matches!(load_keyring(&path), Err(FileError::Truncated(2))));
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::Truncated(2))
));
let _ = fs::remove_file(&path);
Ok(())
}
@ -193,10 +328,53 @@ mod tests {
fn keyring_file_is_owner_only() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path)?;
save_keyring(&sample_keyring(), &path, b"passphrase")?;
let mode = fs::metadata(&path)?.permissions().mode();
assert_eq!(mode & 0o777, 0o600);
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn wrong_passphrase_cannot_load_keyring() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path, b"right passphrase")?;
assert!(matches!(
load_keyring(&path, b"wrong passphrase"),
Err(FileError::Crypto(CryptoError::DecryptionFailed))
));
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn raw_keyring_requires_explicit_api() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
save_keyring_raw(&keyring, &path)?;
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::UnprotectedKeyring)
));
let loaded = load_keyring_raw(&path)?;
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
let serialized = keyring.to_bytes();
save_keyring(&keyring, &path, b"passphrase")?;
let stored = fs::read(&path)?;
assert!(
!stored
.windows(serialized.len())
.any(|window| window == serialized.as_slice())
);
let _ = fs::remove_file(&path);
Ok(())
}
}