General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 3m30s
Some checks failed
CI / checks (push) Failing after 3m30s
This commit is contained in:
parent
5f11d476b6
commit
59419f086f
122 changed files with 10122 additions and 4965 deletions
|
|
@ -1,10 +1,14 @@
|
|||
[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_core = { version = "0.10.1" }
|
||||
rand = "0.10.2"
|
||||
|
||||
thiserror = "1"
|
||||
zeroize = "1.9"
|
||||
|
|
|
|||
268
files/src/lib.rs
268
files/src/lib.rs
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue