From 75f4139dea7d807334db3be930c4150d2d93278f Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:56:54 +0200 Subject: [PATCH] mk & mpkb files --- Cargo.lock | 44 +++++--- Cargo.toml | 7 ++ example/.gitignore | 3 + example/Cargo.lock | 11 +- example/client/Cargo.toml | 4 +- example/client/src/auth.rs | 71 ++++++------ example/client/src/main.rs | 20 +--- example/client/src/messages.rs | 22 +++- example/keygen/Cargo.toml | 2 +- example/keygen/src/main.rs | 36 ++++-- example/server/Cargo.toml | 2 +- example/server/src/handlers.rs | 13 ++- example/server/src/keys.rs | 55 ++++----- example/server/src/main.rs | 13 ++- files/Cargo.toml | 10 ++ files/src/lib.rs | 197 +++++++++++++++++++++++++++++++++ src/lib.rs | 3 + 17 files changed, 382 insertions(+), 131 deletions(-) create mode 100644 files/Cargo.toml create mode 100644 files/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2e8d29d..853e843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,9 +95,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -106,14 +106,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -982,6 +983,7 @@ dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", + "mtp-files", "mtp-host", "mtp-transport", "mtp-type-map", @@ -1043,6 +1045,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mtp-files" +version = "0.1.0" +dependencies = [ + "mtp-crypto", + "thiserror 1.0.69", +] + [[package]] name = "mtp-host" version = "0.1.0" @@ -1117,9 +1127,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" dependencies = [ "num-integer", "num-traits", @@ -1263,6 +1273,12 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "poly1305" version = "0.8.0" @@ -1501,9 +1517,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -1553,9 +1569,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -1896,9 +1912,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -1916,9 +1932,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", diff --git a/Cargo.toml b/Cargo.toml index bf82e29..275a012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "host", "client", "wasm", + "files", ] # `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs # (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the @@ -26,6 +27,7 @@ default-members = [ "transport", "host", "client", + "files", ] resolver = "3" @@ -58,6 +60,7 @@ mtp-crypto = { version = "0.1.0", path = "crypto", optional = true, features = [ ] } mtp-host = { version = "0.1.0", path = "host", optional = true } mtp-client = { version = "0.1.0", path = "client", optional = true } +mtp-files = { version = "0.1.0", path = "files", optional = true } [features] default = [] @@ -80,6 +83,10 @@ host = ["dep:mtp-host", "mtp-codec/registry", "mtp-transport/host"] # MTP client - outgoing QUIC connections to a host. client = ["dep:mtp-client"] +# On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`). +# Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope. +files = ["dep:mtp-files", "crypto"] + [dev-dependencies] tokio = { version = "1", features = ["full"] } rcgen = "0.14" diff --git a/example/.gitignore b/example/.gitignore index 0d3408c..94b9ca3 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -10,3 +10,6 @@ web-client/node_modules web-client/public/host_public_key_bundle.hex web-client/public/mtp_dev_cert_hash.txt web-client/dist/ + +*.mk +*.mpkb diff --git a/example/Cargo.lock b/example/Cargo.lock index e07c673..0696b49 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -207,9 +207,7 @@ dependencies = [ name = "client" version = "0.1.0" dependencies = [ - "hex", "mtp", - "serde_json", "tokio", ] @@ -902,6 +900,7 @@ dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", + "mtp-files", "mtp-host", "mtp-transport", "mtp-type-map", @@ -959,6 +958,14 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mtp-files" +version = "0.1.0" +dependencies = [ + "mtp-crypto", + "thiserror 1.0.69", +] + [[package]] name = "mtp-host" version = "0.1.0" diff --git a/example/client/Cargo.toml b/example/client/Cargo.toml index 2e0556b..d68f33e 100644 --- a/example/client/Cargo.toml +++ b/example/client/Cargo.toml @@ -8,7 +8,5 @@ name = "client" path = "src/main.rs" [dependencies] -mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto"] } +mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto", "files"] } tokio = { version = "1", features = ["full"] } -serde_json = "1" -hex = "0.4" diff --git a/example/client/src/auth.rs b/example/client/src/auth.rs index 5a4cf35..edb5f8a 100644 --- a/example/client/src/auth.rs +++ b/example/client/src/auth.rs @@ -1,52 +1,49 @@ use std::fs; use mtp::client::{ClientConfig, MTPClient, MTPConnection}; -use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner, PublicKeyBundle}; +use mtp::crypto::{ + Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle, +}; +use mtp::files::{load_keyring, save_keyring}; pub async fn connect_or_register( mut config: ClientConfig, host_public_key: PublicKeyBundle, - client_key_path: &str, + key_prefix: &str, ) -> Result<(MTPConnection, Keyring), Box> { - if let Ok(data) = fs::read_to_string(client_key_path) { - let json: serde_json::Value = serde_json::from_str(&data)?; - let client_id = json["client_id"].as_u64().expect("Invalid client_id"); - let keyring = Keyring::from_bytes(&hex::decode( - json["keyring"].as_str().expect("Missing keyring"), - )?)?; + let keyring_path = format!("{key_prefix}.mk"); + let id_path = format!("{key_prefix}.id"); - println!("Loaded client keys (ID: {})", client_id); + if let (Ok(keyring), Ok(id)) = (load_keyring(&keyring_path), fs::read_to_string(&id_path)) { + let client_id: u64 = id.trim().parse()?; + println!("Loaded client keys (ID: {client_id})"); config.client_id = client_id; let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?; println!("Authenticated (version {})", conn.version); - Ok((conn, keyring)) - } else { - println!("No existing keys found: registering new client"); - - let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); - let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); - let keyring = Keyring::new( - mtp::crypto::KemPublicKey::new(vec![]), - mtp::crypto::KemPrivateKey::new(vec![]), - sig_pq_pk, - sig_pq_sk, - sig_pk, - sig_sk, - ); - - let keyring_bytes = keyring.to_bytes(); - let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; - println!("Registered with ID: {}", conn.client_id); - - let json = serde_json::json!({ - "client_id": conn.client_id, - "keyring": hex::encode(&keyring_bytes), - }); - fs::write(client_key_path, serde_json::to_string_pretty(&json)?)?; - println!("Saved client keys -> {client_key_path}"); - - let keyring = Keyring::from_bytes(&keyring_bytes)?; - Ok((conn, keyring)) + return Ok((conn, keyring)); } + + println!("No existing keys found: registering new client"); + + /* The client authenticates with signatures only, so the KEM slot is empty. */ + let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); + let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); + let keyring = Keyring::new( + KemPublicKey::new(vec![]), + KemPrivateKey::new(vec![]), + sig_pq_pk, + sig_pq_sk, + sig_pk, + sig_sk, + ); + + let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; + println!("Registered with ID: {}", conn.client_id); + + save_keyring(&keyring, &keyring_path)?; + fs::write(&id_path, conn.client_id.to_string())?; + println!("Saved client keys -> {keyring_path}"); + + Ok((conn, keyring)) } diff --git a/example/client/src/main.rs b/example/client/src/main.rs index b44e05d..1a67cac 100644 --- a/example/client/src/main.rs +++ b/example/client/src/main.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::Path; use mtp::client::ClientConfig; -use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey}; +use mtp::files::load_public_key_bundle; fn dev_cert_path() -> String { std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| { @@ -25,19 +25,8 @@ async fn main() -> Result<(), Box> { "Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}" ) }); - let host_public_key = PublicKeyBundle::new( - KemPublicKey::new( - fs::read("host_enc_kem_pk.bin") - .expect("Missing host_enc_kem_pk.bin: run server first"), - ), - SignaturePqPublicKey::new( - fs::read("host_sig_pq_pk.bin") - .expect("Missing host_sig_pq_pk.bin: run server first"), - ), - SignaturePublicKey::new( - fs::read("host_sig_pk.bin").expect("Missing host_sig_pk.bin: run server first"), - ), - ); + let host_public_key = load_public_key_bundle("host.mpkb") + .expect("Missing host.mpkb: run the server first to export it"); println!("Connecting to 127.0.0.1:8080 ..."); @@ -46,8 +35,7 @@ async fn main() -> Result<(), Box> { .with_description("MTP example client"); let server_bundle = host_public_key.clone(); - let (conn, keyring) = - auth::connect_or_register(config, host_public_key, "client_keys.json").await?; + let (conn, keyring) = auth::connect_or_register(config, host_public_key, "client").await?; messages::send_and_receive(&conn, &keyring, &server_bundle).await?; println!("\nDone"); diff --git a/example/client/src/messages.rs b/example/client/src/messages.rs index ca6ea50..45f1dc0 100644 --- a/example/client/src/messages.rs +++ b/example/client/src/messages.rs @@ -10,20 +10,26 @@ pub fn build_demo_message( ) -> CommunicationValue { // Encrypt to the server's KEM public key; the server decrypts with its keyring. let enc_type = EncryptionType::MlKemChaCha20Poly1305; - let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key) - .expect("Ed25519 signer from keyring"); + let signer = + Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer from keyring"); let tm = TypeMap::latest(); let inner_enc = DataValue::Container(vec![ - (DataType::Version.to_id(&tm), DataValue::Str("secret inner data".into())), + ( + DataType::Version.to_id(&tm), + DataValue::Str("secret inner data".into()), + ), (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)), ]); let mut dv_enc = inner_enc; dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad"); let inner_sig = DataValue::Container(vec![ - (DataType::Version.to_id(&tm), DataValue::Str("signed by client".into())), + ( + DataType::Version.to_id(&tm), + DataValue::Str("signed by client".into()), + ), (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(99)), ]); let mut dv_sig = inner_sig; @@ -37,7 +43,13 @@ pub fn build_demo_message( (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(7)), ]); let mut dv_sec = inner_sec; - dv_sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, server_bundle, b"demo-aad"); + dv_sec.sign_and_encrypt_container( + SigAlgorithm::ED25519, + &signer, + enc_type, + server_bundle, + b"demo-aad", + ); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/example/keygen/Cargo.toml b/example/keygen/Cargo.toml index ddfadad..fd4d284 100644 --- a/example/keygen/Cargo.toml +++ b/example/keygen/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2024" [dependencies] -mtp = { version = "0.1.0", path = "../../", features = ["crypto"] } +mtp = { version = "0.1.0", path = "../../", features = ["files"] } diff --git a/example/keygen/src/main.rs b/example/keygen/src/main.rs index 938a3e0..bbaa1e3 100644 --- a/example/keygen/src/main.rs +++ b/example/keygen/src/main.rs @@ -1,13 +1,35 @@ +use std::path::PathBuf; + use mtp::crypto::Keyring; +use mtp::files::{ + self, BUNDLE_EXTENSION, KEYRING_EXTENSION, load_keyring, load_public_key_bundle, save_keyring, + save_public_key_bundle, +}; + +fn main() -> Result<(), files::FileError> { + let prefix = std::env::args() + .nth(1) + .unwrap_or_else(|| "keyring".to_string()); + let keyring_path = PathBuf::from(format!("{prefix}.{KEYRING_EXTENSION}")); + let bundle_path = PathBuf::from(format!("{prefix}.{BUNDLE_EXTENSION}")); -fn main() { let keyring = Keyring::generate(); - let bundle = keyring.public_key_bundle(); + save_keyring(&keyring, &keyring_path)?; + save_public_key_bundle(&keyring.public_key_bundle(), &bundle_path)?; - // The `Debug` impl redacts private keys by design, so use the encoding - // methods to emit the full keyring (public + secret keys) instead. - println!("Keyring (hex):\n{}\n", keyring.to_hex()); - println!("Keyring (base64):\n{}\n", keyring.to_base64()); + /* Read both back to confirm the files round-trip through the on-disk format. */ + let loaded_keyring = load_keyring(&keyring_path)?; + let loaded_bundle = load_public_key_bundle(&bundle_path)?; + assert_eq!(keyring.to_bytes(), loaded_keyring.to_bytes()); + assert_eq!( + keyring.public_key_bundle().as_bytes(), + loaded_bundle.as_bytes() + ); + println!("\nPrivateKeyRing (base64):\n{}", keyring.to_base64()); - println!("PublicKeyBundle (base64):\n{}", bundle.to_base64()); + println!("\nPublicKeyBundle (base64):\n{}", loaded_bundle.to_base64()); + + println!("Wrote keyring -> {}", keyring_path.display()); + println!("Wrote bundle -> {}", bundle_path.display()); + Ok(()) } diff --git a/example/server/Cargo.toml b/example/server/Cargo.toml index cf87364..494f36f 100644 --- a/example/server/Cargo.toml +++ b/example/server/Cargo.toml @@ -8,7 +8,7 @@ name = "server" path = "src/main.rs" [dependencies] -mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host"] } +mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host", "files"] } rcgen = "0.14" tokio = { version = "1", features = ["full"] } serde_json = { version = "1" } diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 5a797d3..789cd4a 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -1,7 +1,5 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; -use mtp::crypto::{ - CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519, -}; +use mtp::crypto::{CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519}; struct Ed25519Verifier(SignaturePublicKey); @@ -57,7 +55,10 @@ pub fn process_and_respond( let enc = msg.get_data(DataType::EncryptedPayload); if matches!(enc, DataValue::EncryptedContainer(_)) { let mut dv = enc.clone(); - if dv.decrypt_into_container(host_keyring, b"demo-aad").is_some() { + if dv + .decrypt_into_container(host_keyring, b"demo-aad") + .is_some() + { if let Some(entries) = dv.as_container() { println!(" Decrypted EncryptedPayload: {:?}", entries); enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len()); @@ -93,7 +94,9 @@ pub fn process_and_respond( if let Some(pk_bundle) = client_pk { let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); let mut dv = secure.clone(); - if dv.decrypt_signed_encrypted_container(host_keyring, b"demo-aad").is_some() + if dv + .decrypt_signed_encrypted_container(host_keyring, b"demo-aad") + .is_some() && dv.verify_into_container(&verifier).is_some() { if let Some(entries) = dv.as_container() { diff --git a/example/server/src/keys.rs b/example/server/src/keys.rs index 133eeba..1cf391f 100644 --- a/example/server/src/keys.rs +++ b/example/server/src/keys.rs @@ -1,50 +1,33 @@ use std::fs; -use mtp::crypto::kem::HybridKem; -use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner}; +use mtp::crypto::Keyring; +use mtp::files::{load_keyring, save_keyring, save_public_key_bundle}; + +/* Host id is fixed for the example; only the keyring itself is persisted. */ +const HOST_ID: u64 = 1; pub fn load_or_generate_host_keys( - path: &str, + keyring_path: &str, ) -> Result<(u64, Keyring), Box> { - if let Ok(data) = fs::read_to_string(path) { - let json: serde_json::Value = serde_json::from_str(&data)?; - let hid = json["host_id"].as_u64().unwrap_or(1); - let keyring = Keyring::from_bytes(&hex::decode(json["keyring"].as_str().unwrap())?)?; - println!("Loaded host keys (ID: {})", hid); - return Ok((hid, keyring)); + if let Ok(keyring) = load_keyring(keyring_path) { + println!("Loaded host keyring from {keyring_path}"); + return Ok((HOST_ID, keyring)); } - let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); - let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); - let (kem_sk, kem_pk) = HybridKem::generate_keypair(); - let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); - - let json = serde_json::json!({ - "host_id": 1, - "keyring": hex::encode(keyring.to_bytes()), - }); - fs::write(path, serde_json::to_string_pretty(&json)?)?; - println!("Generated host keys -> {path}"); - Ok((1u64, keyring)) + let keyring = Keyring::generate(); + save_keyring(&keyring, keyring_path)?; + println!("Generated host keyring -> {keyring_path}"); + Ok((HOST_ID, keyring)) } pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box> { - let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes()); + let bundle = host_keyring.public_key_bundle(); + save_public_key_bundle(&bundle, "host.mpkb")?; - fs::write("host_public_key_bundle.hex", &public_key_bundle_hex)?; + /* The web client fetches the bundle as hex over HTTP. */ + let bundle_hex = hex::encode(bundle.as_bytes()); + fs::write("host_public_key_bundle.hex", &bundle_hex)?; fs::create_dir_all("web-client/public")?; - fs::write( - "web-client/public/host_public_key_bundle.hex", - &public_key_bundle_hex, - )?; - fs::write( - "host_enc_kem_pk.bin", - host_keyring.kem_public_key.as_bytes(), - )?; - fs::write("host_sig_pk.bin", host_keyring.sig_cl_public_key.as_bytes())?; - fs::write( - "host_sig_pq_pk.bin", - host_keyring.sig_pq_public_key.as_bytes(), - )?; + fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex)?; Ok(()) } diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 776b2b6..86aafdc 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -36,7 +36,7 @@ async fn main() -> Result<(), Box> { tls::export_webtransport_cert_hash(&cert_hash)?; println!("WebTransport certificate sha256: {cert_hash}"); - let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?; + let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?; keys::export_host_public_keys(&host_keyring)?; // The keyring is moved into the host config; keep a copy for decrypting the @@ -47,7 +47,7 @@ async fn main() -> Result<(), Box> { let (clients, next_id) = clients::load_client_db("clients.json")?; let clients_for_get = clients.clone(); - let get_existing_user = move |id: u64| { + let get_existing_user = move |id: u64, _description: Option| { let clients = clients_for_get.clone(); Box::pin(async move { let result = clients.lock().unwrap().get(&id).cloned(); @@ -63,7 +63,8 @@ async fn main() -> Result<(), Box> { let clients_for_register = clients.clone(); let next_id_for_register = next_id.clone(); let clients_path = "clients.json".to_string(); - let complete_register = move |bundle: mtp::crypto::PublicKeyBundle| { + let complete_register = move |bundle: mtp::crypto::PublicKeyBundle, + _description: Option| { let db_arc = clients_for_register.clone(); let nid_arc = next_id_for_register.clone(); let path = clients_path.clone(); @@ -95,7 +96,11 @@ async fn main() -> Result<(), Box> { cert_pem, key_pem, ) - .with_authentication(host_keyring, get_existing_user, complete_register) + .with_authentication( + host_keyring, + Box::new(get_existing_user), + Box::new(complete_register), + ) .with_authentication_policy(AuthenticationPolicy::ForceAuthentication); let mut host = MTPHost::new(config).await?; diff --git a/files/Cargo.toml b/files/Cargo.toml new file mode 100644 index 0000000..2ea9f40 --- /dev/null +++ b/files/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "mtp-files" +version = "0.1.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 } +thiserror = "1" diff --git a/files/src/lib.rs b/files/src/lib.rs new file mode 100644 index 0000000..1e40f26 --- /dev/null +++ b/files/src/lib.rs @@ -0,0 +1,197 @@ +/* + * 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. + */ + +use std::fs; +use std::io; +use std::path::Path; + +use thiserror::Error; + +pub use mtp_crypto::{CryptoError, Keyring, PublicKeyBundle}; + +/// File extension for a stored [`Keyring`]. +pub const KEYRING_EXTENSION: &str = "mk"; +/// File extension for a stored [`PublicKeyBundle`]. +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 HEADER_LEN: usize = 4 + 1; + +#[derive(Error, Debug)] +pub enum FileError { + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("crypto error: {0}")] + 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})")] + UnsupportedVersion { kind: &'static str, found: u8 }, + #[error("file is truncated: {0} bytes, need at least {HEADER_LEN}")] + Truncated(usize), +} + +fn encode(magic: [u8; 4], payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(HEADER_LEN + payload.len()); + out.extend_from_slice(&magic); + out.push(FORMAT_VERSION); + out.extend_from_slice(payload); + out +} + +fn decode<'a>(bytes: &'a [u8], magic: [u8; 4], kind: &'static str) -> Result<&'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..]) +} + +/* + * `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. + */ +#[cfg(unix)] +fn write_secret(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(bytes)?; + file.sync_all() +} + +#[cfg(not(unix))] +fn write_secret(path: &Path, bytes: &[u8]) -> io::Result<()> { + fs::write(path, bytes) +} + +// Writes secret keys, so the file is created owner-only (0600) on Unix. +pub fn save_keyring(keyring: &Keyring, path: impl AsRef) -> Result<(), FileError> { + let bytes = encode(KEYRING_MAGIC, &keyring.to_bytes()); + write_secret(path.as_ref(), &bytes)?; + Ok(()) +} + +pub fn load_keyring(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + let payload = decode(&bytes, KEYRING_MAGIC, "keyring")?; + Ok(Keyring::from_bytes(payload)?) +} + +pub fn save_public_key_bundle( + bundle: &PublicKeyBundle, + path: impl AsRef, +) -> Result<(), FileError> { + let bytes = encode(BUNDLE_MAGIC, &bundle.as_bytes()); + fs::write(path, bytes)?; + Ok(()) +} + +pub fn load_public_key_bundle(path: impl AsRef) -> Result { + let bytes = fs::read(path)?; + let payload = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?; + Ok(PublicKeyBundle::from_bytes(payload)?) +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp_crypto::keypair::{ + KemPrivateKey, KemPublicKey, SignaturePqPrivateKey, SignaturePqPublicKey, + SignaturePrivateKey, SignaturePublicKey, + }; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn temp_path(ext: &str) -> PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut path = std::env::temp_dir(); + path.push(format!("mtp-files-test-{}-{n}.{ext}", std::process::id())); + path + } + + fn sample_keyring() -> Keyring { + Keyring::new( + KemPublicKey::new(vec![1u8; 32]), + KemPrivateKey::new(vec![2u8; 32]), + SignaturePqPublicKey::new(vec![3u8; 64]), + SignaturePqPrivateKey::new(vec![4u8; 64]), + SignaturePublicKey::new(vec![5u8; 32]), + SignaturePrivateKey::new(vec![6u8; 32]), + ) + } + + #[test] + fn keyring_save_load_roundtrip() { + let path = temp_path(KEYRING_EXTENSION); + let keyring = sample_keyring(); + save_keyring(&keyring, &path).unwrap(); + let loaded = load_keyring(&path).unwrap(); + assert_eq!(keyring.to_bytes(), loaded.to_bytes()); + let _ = fs::remove_file(&path); + } + + #[test] + fn bundle_save_load_roundtrip() { + let path = temp_path(BUNDLE_EXTENSION); + let bundle = sample_keyring().public_key_bundle(); + save_public_key_bundle(&bundle, &path).unwrap(); + let loaded = load_public_key_bundle(&path).unwrap(); + assert_eq!(bundle.as_bytes(), loaded.as_bytes()); + let _ = fs::remove_file(&path); + } + + #[test] + fn loading_bundle_as_keyring_fails_on_magic() { + let path = temp_path(BUNDLE_EXTENSION); + save_public_key_bundle(&sample_keyring().public_key_bundle(), &path).unwrap(); + assert!(matches!( + load_keyring(&path), + Err(FileError::BadMagic { .. }) + )); + let _ = fs::remove_file(&path); + } + + #[test] + fn truncated_file_is_rejected() { + let path = temp_path(KEYRING_EXTENSION); + fs::write(&path, b"MT").unwrap(); + assert!(matches!(load_keyring(&path), Err(FileError::Truncated(2)))); + let _ = fs::remove_file(&path); + } + + #[cfg(unix)] + #[test] + fn keyring_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let path = temp_path(KEYRING_EXTENSION); + save_keyring(&sample_keyring(), &path).unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + let _ = fs::remove_file(&path); + } +} diff --git a/src/lib.rs b/src/lib.rs index 03314f0..c8ec1b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,6 @@ pub use mtp_host as host; #[cfg(feature = "client")] pub use mtp_client as client; + +#[cfg(feature = "files")] +pub use mtp_files as files;