From 63ea36bb51cfdffe9c389864e9a5edb6ce9bd4c3 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sun, 5 Jul 2026 23:02:59 +0200 Subject: [PATCH] feat: replace private cas envelope --- Cargo.lock | 3 + Cargo.toml | 1 + README.md | 5 +- crates/geth-cas/Cargo.toml | 2 + crates/geth-cas/src/lib.rs | 205 ++++++++++++++++++++------- crates/geth-node/src/lib.rs | 17 +-- docs/architecture.md | 4 + docs/command-stability.md | 19 +-- docs/production-readiness-roadmap.md | 18 +-- docs/roadmap.md | 8 +- 10 files changed, 193 insertions(+), 89 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 88d111c..fc8032f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -751,6 +751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1524,10 +1525,12 @@ dependencies = [ name = "geth-cas" version = "0.1.0" dependencies = [ + "aes-gcm", "blake3", "geth-codec", "geth-types", "hex", + "rand 0.9.4", "serde", "tempfile", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index e257265..c88ef7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ repository = "https://example.invalid/local/geth" rust-version = "1.91" [workspace.dependencies] +aes-gcm = "0.10" anyhow = "1" async-trait = "0.1" automerge = "0.7" diff --git a/README.md b/README.md index 5a4eacc..3862808 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,9 @@ The bootstrap implementation provides: `--bearer-secret ` - private CAS envelope commands: `geth cas add-private ` and - `geth cas get-private --out `. These use local - resource secret epochs and are a prototype envelope, not audited AEAD. + `geth cas get-private --out `. New writes use an + AES-256-GCM envelope bound to the resource and local secret epoch. This does + not claim forward secrecy or post-compromise security. - local CAS tree objects describe file trees and are stored as CAS blobs - local file-root commands: `geth cas root add/list/scan/sync/apply`; root sync pulls authorized remote tree metadata and CAS tree bytes into a peer-qualified diff --git a/crates/geth-cas/Cargo.toml b/crates/geth-cas/Cargo.toml index a0d1ebd..cdf9f0f 100644 --- a/crates/geth-cas/Cargo.toml +++ b/crates/geth-cas/Cargo.toml @@ -6,8 +6,10 @@ rust-version.workspace = true license.workspace = true [dependencies] +aes-gcm.workspace = true blake3.workspace = true hex.workspace = true +rand.workspace = true serde.workspace = true thiserror.workspace = true geth-codec = { path = "../geth-codec" } diff --git a/crates/geth-cas/src/lib.rs b/crates/geth-cas/src/lib.rs index 97f740f..12dd30c 100644 --- a/crates/geth-cas/src/lib.rs +++ b/crates/geth-cas/src/lib.rs @@ -4,7 +4,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; pub const CAS_TREE_OBJECT_VERSION: u16 = 1; -pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 1; +pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 2; +pub const PRIVATE_BLOB_AEAD_ALGORITHM: &str = "geth.aes-256-gcm.v1"; +pub const PRIVATE_BLOB_PROTOTYPE_ALGORITHM: &str = "geth.blake3-xor.v0.prototype"; #[derive(Debug, thiserror::Error)] pub enum CasError { @@ -191,23 +193,50 @@ pub fn encrypt_private_blob( resource: &str, secret_id: &str, epoch: u64, - nonce_hex: &str, plaintext: &[u8], ) -> Result, CasError> { + let nonce: [u8; 12] = rand::random(); + encrypt_private_blob_with_nonce(resource, secret_id, epoch, nonce, plaintext) +} + +fn encrypt_private_blob_with_nonce( + resource: &str, + secret_id: &str, + epoch: u64, + nonce: [u8; 12], + plaintext: &[u8], +) -> Result, CasError> { + use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit, Payload}, + }; + let key = private_blob_key(resource, secret_id, epoch); - let nonce = decode_32_byte_hex(nonce_hex)?; - let ciphertext = xor_keystream(&key, &nonce, plaintext); - let tag_hex = private_blob_tag(&key, &nonce, &ciphertext); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|error| CasError::EncryptedEnvelope(error.to_string()))?; + let plaintext_hash = hash_bytes(plaintext); + let aad = private_blob_aad(resource, epoch, &plaintext_hash); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: aad.as_bytes(), + }, + ) + .map_err(|_| { + CasError::EncryptedEnvelope("private blob AEAD encryption failed".to_owned()) + })?; let envelope = EncryptedBlobEnvelope { version: ENCRYPTED_BLOB_ENVELOPE_VERSION, - algorithm: "geth.blake3-xor.v0.prototype".to_owned(), + algorithm: PRIVATE_BLOB_AEAD_ALGORITHM.to_owned(), resource: resource.to_owned(), epoch, - plaintext_hash: hash_bytes(plaintext), - nonce_hex: nonce_hex.to_owned(), + plaintext_hash, + nonce_hex: hex::encode(nonce), ciphertext, - tag_hex, - note: "prototype private blob envelope; not an audited AEAD and does not claim forward secrecy or post-compromise security".to_owned(), + tag_hex: "included-in-aes-gcm-ciphertext".to_owned(), + note: "private blob envelope uses AES-256-GCM with a random 96-bit nonce and resource-bound associated data; it does not claim forward secrecy or post-compromise security".to_owned(), }; geth_codec::encode_canonical(&envelope).map_err(CasError::from) } @@ -219,26 +248,53 @@ pub fn decrypt_private_blob( ) -> Result, CasError> { let envelope: EncryptedBlobEnvelope = geth_codec::decode_canonical(envelope_bytes)?; if envelope.version != ENCRYPTED_BLOB_ENVELOPE_VERSION { + if envelope.version == 1 || envelope.algorithm == PRIVATE_BLOB_PROTOTYPE_ALGORITHM { + return Err(CasError::EncryptedEnvelope( + "prototype private blob envelopes are no longer accepted; recreate the private blob with this pre-release".to_owned(), + )); + } return Err(CasError::EncryptedEnvelope(format!( "unsupported envelope version {}", envelope.version ))); } + if envelope.algorithm != PRIVATE_BLOB_AEAD_ALGORITHM { + return Err(CasError::EncryptedEnvelope(format!( + "unsupported encrypted blob algorithm {}", + envelope.algorithm + ))); + } if envelope.resource != resource { return Err(CasError::EncryptedEnvelope(format!( "envelope resource {} does not match requested resource {resource}", envelope.resource ))); } - let key = private_blob_key(resource, secret_id, envelope.epoch); - let nonce = decode_32_byte_hex(&envelope.nonce_hex)?; - let expected_tag = private_blob_tag(&key, &nonce, &envelope.ciphertext); - if expected_tag != envelope.tag_hex { + if envelope.tag_hex != "included-in-aes-gcm-ciphertext" { return Err(CasError::EncryptedEnvelope( - "encrypted blob tag verification failed".to_owned(), + "invalid AES-GCM envelope tag marker".to_owned(), )); } - let plaintext = xor_keystream(&key, &nonce, &envelope.ciphertext); + use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit, Payload}, + }; + let key = private_blob_key(resource, secret_id, envelope.epoch); + let nonce = decode_hex_exact::<12>(&envelope.nonce_hex)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|error| CasError::EncryptedEnvelope(error.to_string()))?; + let aad = private_blob_aad(resource, envelope.epoch, &envelope.plaintext_hash); + let plaintext = cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: envelope.ciphertext.as_ref(), + aad: aad.as_bytes(), + }, + ) + .map_err(|_| { + CasError::EncryptedEnvelope("private blob AEAD verification failed".to_owned()) + })?; let plaintext_hash = hash_bytes(&plaintext); if plaintext_hash != envelope.plaintext_hash { return Err(CasError::EncryptedEnvelope( @@ -249,42 +305,25 @@ pub fn decrypt_private_blob( } fn private_blob_key(resource: &str, secret_id: &str, epoch: u64) -> [u8; 32] { - let material = format!("geth.private-blob.v0\0{resource}\0{secret_id}\0{epoch}"); + let material = format!("geth.private-blob.aes-256-gcm.v1\0{resource}\0{secret_id}\0{epoch}"); *blake3::hash(material.as_bytes()).as_bytes() } -fn xor_keystream(key: &[u8; 32], nonce: &[u8; 32], input: &[u8]) -> Vec { - let mut out = Vec::with_capacity(input.len()); - for (chunk_index, chunk) in input.chunks(32).enumerate() { - let mut block_input = Vec::with_capacity(40); - block_input.extend_from_slice(nonce); - block_input.extend_from_slice(&(chunk_index as u64).to_le_bytes()); - let block = blake3::keyed_hash(key, &block_input); - out.extend( - chunk - .iter() - .zip(block.as_bytes().iter()) - .map(|(byte, mask)| byte ^ mask), - ); - } - out +fn private_blob_aad(resource: &str, epoch: u64, plaintext_hash: &BlobHash) -> String { + format!( + "{}\0{}\0{}\0{}", + PRIVATE_BLOB_AEAD_ALGORITHM, resource, epoch, plaintext_hash + ) } -fn private_blob_tag(key: &[u8; 32], nonce: &[u8; 32], ciphertext: &[u8]) -> String { - let mut input = Vec::with_capacity(nonce.len() + ciphertext.len()); - input.extend_from_slice(nonce); - input.extend_from_slice(ciphertext); - blake3::keyed_hash(key, &input).to_hex().to_string() -} - -fn decode_32_byte_hex(hex: &str) -> Result<[u8; 32], CasError> { - if hex.len() != 64 { - return Err(CasError::EncryptedEnvelope( - "nonce must be 32 bytes encoded as lowercase hex".to_owned(), - )); +fn decode_hex_exact(hex: &str) -> Result<[u8; N], CasError> { + if hex.len() != N * 2 { + return Err(CasError::EncryptedEnvelope(format!( + "nonce must be {N} bytes encoded as lowercase hex" + ))); } - let mut bytes = [0_u8; 32]; - for index in 0..32 { + let mut bytes = [0_u8; N]; + for index in 0..N { bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16) .map_err(|error| CasError::EncryptedEnvelope(format!("invalid nonce hex: {error}")))?; } @@ -769,18 +808,82 @@ mod tests { } #[test] - fn private_blob_envelope_roundtrips_and_checks_resource() { + fn private_blob_envelope_uses_aead_and_checks_bound_context() { let plaintext = b"private geth bytes"; - let nonce = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; - let envelope = - encrypt_private_blob("resource:cas:private", "secret:test", 1, nonce, plaintext) - .expect("encrypt"); + let envelope = encrypt_private_blob_with_nonce( + "resource:cas:private", + "secret:test", + 1, + [7; 12], + plaintext, + ) + .expect("encrypt"); let decrypted = decrypt_private_blob("resource:cas:private", "secret:test", &envelope) .expect("decrypt"); assert_eq!(decrypted, plaintext); assert!(decrypt_private_blob("resource:cas:other", "secret:test", &envelope).is_err()); assert!(decrypt_private_blob("resource:cas:private", "secret:other", &envelope).is_err()); + + let decoded: EncryptedBlobEnvelope = + geth_codec::decode_canonical(&envelope).expect("decode envelope"); + assert_eq!(decoded.version, ENCRYPTED_BLOB_ENVELOPE_VERSION); + assert_eq!(decoded.algorithm, PRIVATE_BLOB_AEAD_ALGORITHM); + assert_eq!(decoded.nonce_hex, "070707070707070707070707"); + assert_eq!(decoded.tag_hex, "included-in-aes-gcm-ciphertext"); + + let mut tampered_ciphertext = decoded.clone(); + tampered_ciphertext.ciphertext[0] ^= 0x01; + let tampered_ciphertext = + geth_codec::encode_canonical(&tampered_ciphertext).expect("encode tampered"); + assert!( + decrypt_private_blob("resource:cas:private", "secret:test", &tampered_ciphertext) + .is_err() + ); + + let mut tampered_hash = decoded.clone(); + tampered_hash.plaintext_hash = hash_bytes(b"other plaintext"); + let tampered_hash = geth_codec::encode_canonical(&tampered_hash).expect("encode tampered"); + assert!( + decrypt_private_blob("resource:cas:private", "secret:test", &tampered_hash).is_err() + ); + } + + #[test] + fn private_blob_envelope_generates_unique_nonces() { + let first = encrypt_private_blob("resource:cas:private", "secret:test", 1, b"same") + .expect("first encrypt"); + let second = encrypt_private_blob("resource:cas:private", "secret:test", 1, b"same") + .expect("second encrypt"); + let first: EncryptedBlobEnvelope = + geth_codec::decode_canonical(&first).expect("decode first"); + let second: EncryptedBlobEnvelope = + geth_codec::decode_canonical(&second).expect("decode second"); + assert_ne!(first.nonce_hex, second.nonce_hex); + assert_ne!(first.ciphertext, second.ciphertext); + } + + #[test] + fn prototype_private_blob_envelopes_are_rejected() { + let envelope = EncryptedBlobEnvelope { + version: 1, + algorithm: PRIVATE_BLOB_PROTOTYPE_ALGORITHM.to_owned(), + resource: "resource:cas:private".to_owned(), + epoch: 1, + plaintext_hash: hash_bytes(b"private geth bytes"), + nonce_hex: "00".repeat(32), + ciphertext: vec![1, 2, 3], + tag_hex: "prototype-tag".to_owned(), + note: "old prototype".to_owned(), + }; + let encoded = geth_codec::encode_canonical(&envelope).expect("encode prototype"); + let error = decrypt_private_blob("resource:cas:private", "secret:test", &encoded) + .expect_err("reject prototype"); + assert!( + error + .to_string() + .contains("prototype private blob envelopes") + ); } #[test] diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index bb0e74e..0b8eb39 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -6174,21 +6174,10 @@ pub fn handle_request( })?; let plaintext = std::fs::read(&path)?; let plaintext_hash = geth_cas::hash_bytes(&plaintext); - let nonce = geth_crypto::blake3_hex( - format!( - "{}\0{}\0{}\0{}", - resource, - secret.secret_id, - secret.epoch, - geth_store::now_ms() - ) - .as_bytes(), - ); let encrypted = geth_cas::encrypt_private_blob( &resource, &secret.secret_id, secret.epoch, - &nonce, &plaintext, )?; let cas = LocalCas::new(node.paths.cas_dir()); @@ -6204,7 +6193,7 @@ pub fn handle_request( plaintext_hash, encrypted_hash: info.hash, size_bytes: info.size_bytes, - note: "stored prototype encrypted private blob envelope; no forward secrecy or post-compromise security is claimed".to_owned(), + note: "stored AES-256-GCM private blob envelope bound to the resource and secret epoch; no forward secrecy or post-compromise security is claimed".to_owned(), }) } ControlRequest::CasGet { hash, out } => { @@ -6258,7 +6247,9 @@ pub fn handle_request( plaintext_hash: geth_cas::hash_bytes(&plaintext), out, size_bytes: plaintext.len() as u64, - note: "decrypted prototype private blob envelope with the latest local resource secret epoch".to_owned(), + note: + "decrypted AES-256-GCM private blob envelope with a local resource secret epoch" + .to_owned(), }) } ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash { diff --git a/docs/architecture.md b/docs/architecture.md index 84947b6..8b230dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -478,6 +478,10 @@ The project is structured for future multi-user local-first authorization: ## Keyhive/BeeKEM Roadmap Resource secret epochs are the v0/v1 approximation for private payload access. +Private CAS writes use an AES-256-GCM envelope keyed from the resource, local +secret id, and epoch with resource-bound associated data and random nonces. +Prototype BLAKE3-XOR private blob envelopes from earlier pre-deployment builds +are rejected and should be recreated from plaintext. Later designs can add Keyhive-like convergent capabilities and BeeKEM/CGKA-style group key evolution. The bootstrap does not implement BeeKEM and does not claim strong forward secrecy or post-compromise security. diff --git a/docs/command-stability.md b/docs/command-stability.md index cc3a0c7..6e0095b 100644 --- a/docs/command-stability.md +++ b/docs/command-stability.md @@ -57,22 +57,17 @@ The following commands are tested but may still change while the subsystem settles: - `geth overlay status|plan|join|leave|interface-plan|up|down|peers|send|recv` +- `geth cas add-private` +- `geth cas get-private` Migration expectation: overlay membership records and authorization resources should remain readable, but packet runtime flags, platform activation details, and route metadata may change before the first deployment tag. - -## Prototype - -The following commands intentionally do not claim a stable security or storage -contract yet: - -- `geth cas add-private` -- `geth cas get-private` - -Migration expectation: prototype private CAS envelopes may be replaced by an -audited AEAD/key-envelope design. Existing prototype blobs may need an explicit -re-encryption or export/import workflow. +Private CAS writes use an AES-256-GCM envelope, but the command family remains +experimental while key envelopes, remote sharing, and forward-secrecy/PCS +properties are explicitly out of scope. Prototype BLAKE3-XOR envelopes from +earlier pre-deployment builds are rejected with a clear error and should be +recreated from plaintext. ## Adding Commands diff --git a/docs/production-readiness-roadmap.md b/docs/production-readiness-roadmap.md index c71c034..c868f8a 100644 --- a/docs/production-readiness-roadmap.md +++ b/docs/production-readiness-roadmap.md @@ -191,22 +191,22 @@ Goal: finish the authorization and remote-input audit before deployment. Goal: remove prototype cryptography from paths users may treat as real confidential storage. -- `[ ]` Replace prototype private CAS envelope. +- `[x]` Replace prototype private CAS envelope. Acceptance criteria: - - `[ ]` The BLAKE3-XOR prototype envelope is not used for new private CAS + - `[x]` The BLAKE3-XOR prototype envelope is not used for new private CAS writes. - - `[ ]` New private CAS writes use a reviewed AEAD construction or an + - `[x]` New private CAS writes use a reviewed AEAD construction or an established envelope format such as age. - - `[ ]` Key derivation, nonce generation, and envelope versioning are + - `[x]` Key derivation, nonce generation, and envelope versioning are documented. - - `[ ]` Tests cover tamper detection, wrong resource, wrong key, and nonce + - `[x]` Tests cover tamper detection, wrong resource, wrong key, and nonce uniqueness behavior. -- `[ ]` Define pre-release encrypted blob migration behavior. +- `[x]` Define pre-release encrypted blob migration behavior. Acceptance criteria: - - `[ ]` Existing prototype envelopes are either rejected with a clear error + - `[x]` Existing prototype envelopes are either rejected with a clear error or migrated through an explicit command. - - `[ ]` Docs state that prototype envelopes made before deployment are not a + - `[x]` Docs state that prototype envelopes made before deployment are not a durable security format. ## Phase 6: Sync Correctness And Fault Testing @@ -343,7 +343,7 @@ Goal: prove the system works as an actual base layer before broader use. 3. `[x]` Add stable contract and golden JSON tests. 4. `[x]` Harden store migrations and backup. 5. `[ ]` Complete security-boundary test coverage. -6. `[ ]` Replace prototype private CAS cryptography. +6. `[x]` Replace prototype private CAS cryptography. 7. `[ ]` Add fault-injection sync tests. 8. `[ ]` Improve automation commands and JSON errors. 9. `[ ]` Add operational health, doctor, and release gates. diff --git a/docs/roadmap.md b/docs/roadmap.md index 58276f2..03e7da7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -648,8 +648,12 @@ authorization and durable-state boundaries clear. - `[x]` `geth cas get-private --out ` decrypts with a matching local resource secret epoch. - `[x]` Access is gated by local resource secret epoch material. - - `[x]` Tests verify encrypted blob roundtrip and wrong resource/secret - rejection. + - `[x]` New writes use an AES-256-GCM envelope with resource-bound + associated data and random nonces. + - `[x]` Prototype BLAKE3-XOR envelopes from earlier pre-deployment builds are + rejected with a clear error. + - `[x]` Tests verify encrypted blob roundtrip, tamper detection, wrong + resource/secret rejection, old-envelope rejection, and nonce uniqueness. - `[x]` Docs explicitly avoid claiming forward secrecy or PCS. - `[x]` Iroh-docs KV integration.