diff --git a/AGENTS.md b/AGENTS.md index 46405b9..00cd057 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,10 @@ Roadmap items should be actionable and checkable: can fetch CAS blobs from an imported signed peer card over Iroh when the peer grants `cas.fetch` on `resource:cas:local`; successful fetches record local provider metadata visible through `geth cas providers `. Full - `iroh-blobs` provider integration is still roadmap work. + `iroh-blobs` provider integration is still roadmap work. `geth cas + add-private/get-private` stores prototype encrypted CAS envelopes gated by + local resource secret epochs. The envelope is not audited AEAD and must not be + described as forward-secret or post-compromise-secure. - The CAS crate can build deterministic tree objects for local file trees and store those manifests as CAS blobs. The daemon can register and scan local file roots, reporting create/update/delete/rename changes without writing back diff --git a/README.md b/README.md index 78295de..f93a034 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ The bootstrap implementation provides: - local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`, `unpin`, `cleanup`, `providers`, `list`; remote fetch accepts `--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. - 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/src/lib.rs b/crates/geth-cas/src/lib.rs index 2c3ee56..4612adb 100644 --- a/crates/geth-cas/src/lib.rs +++ b/crates/geth-cas/src/lib.rs @@ -4,6 +4,7 @@ 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; #[derive(Debug, thiserror::Error)] pub enum CasError { @@ -27,6 +28,8 @@ pub enum CasError { InvalidFileConflictResolution(String), #[error("invalid file conflict status: {0}")] InvalidFileConflictStatus(String), + #[error("encrypted blob envelope error: {0}")] + EncryptedEnvelope(String), } #[derive(Clone, Debug)] @@ -41,6 +44,19 @@ pub struct BlobInfo { pub path: PathBuf, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EncryptedBlobEnvelope { + pub version: u16, + pub algorithm: String, + pub resource: String, + pub epoch: u64, + pub plaintext_hash: BlobHash, + pub nonce_hex: String, + pub ciphertext: Vec, + pub tag_hex: String, + pub note: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CasTreeObject { pub version: u16, @@ -164,6 +180,110 @@ impl FileConflictKind { } } +pub fn encrypt_private_blob( + resource: &str, + secret_id: &str, + epoch: u64, + nonce_hex: &str, + plaintext: &[u8], +) -> Result, CasError> { + 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 envelope = EncryptedBlobEnvelope { + version: ENCRYPTED_BLOB_ENVELOPE_VERSION, + algorithm: "geth.blake3-xor.v0.prototype".to_owned(), + resource: resource.to_owned(), + epoch, + plaintext_hash: hash_bytes(plaintext), + nonce_hex: nonce_hex.to_owned(), + ciphertext, + tag_hex, + note: "prototype private blob envelope; not an audited AEAD and does not claim forward secrecy or post-compromise security".to_owned(), + }; + geth_codec::encode_canonical(&envelope).map_err(CasError::from) +} + +pub fn decrypt_private_blob( + resource: &str, + secret_id: &str, + envelope_bytes: &[u8], +) -> Result, CasError> { + let envelope: EncryptedBlobEnvelope = geth_codec::decode_canonical(envelope_bytes)?; + if envelope.version != ENCRYPTED_BLOB_ENVELOPE_VERSION { + return Err(CasError::EncryptedEnvelope(format!( + "unsupported envelope version {}", + envelope.version + ))); + } + 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 { + return Err(CasError::EncryptedEnvelope( + "encrypted blob tag verification failed".to_owned(), + )); + } + let plaintext = xor_keystream(&key, &nonce, &envelope.ciphertext); + let plaintext_hash = hash_bytes(&plaintext); + if plaintext_hash != envelope.plaintext_hash { + return Err(CasError::EncryptedEnvelope( + "encrypted blob plaintext hash verification failed".to_owned(), + )); + } + Ok(plaintext) +} + +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}"); + *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_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(), + )); + } + let mut bytes = [0_u8; 32]; + for index in 0..32 { + bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16) + .map_err(|error| CasError::EncryptedEnvelope(format!("invalid nonce hex: {error}")))?; + } + Ok(bytes) +} + impl FileConflictResolution { pub fn parse(value: &str) -> Result { match value { @@ -561,6 +681,21 @@ mod tests { ); } + #[test] + fn private_blob_envelope_roundtrips_and_checks_resource() { + 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 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()); + } + #[test] fn cas_remove_deletes_blob_file() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 28746c5..1a82bdf 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -245,11 +245,21 @@ pub enum CasCommand { Add { path: PathBuf, }, + AddPrivate { + resource: String, + path: PathBuf, + }, Get { hash: String, #[arg(long)] out: PathBuf, }, + GetPrivate { + resource: String, + hash: String, + #[arg(long)] + out: PathBuf, + }, Fetch { node: String, hash: String, @@ -805,10 +815,22 @@ fn request_for_command(command: Command) -> Result { }, Command::Cas { command } => match command { CasCommand::Add { path } => ControlRequest::CasAdd { path }, + CasCommand::AddPrivate { resource, path } => { + ControlRequest::CasAddPrivate { resource, path } + } CasCommand::Get { hash, out } => ControlRequest::CasGet { hash: hash.into(), out, }, + CasCommand::GetPrivate { + resource, + hash, + out, + } => ControlRequest::CasGetPrivate { + resource, + hash: hash.into(), + out, + }, CasCommand::Fetch { node, hash, @@ -1299,6 +1321,21 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { ControlResponse::CasAdded { hash, size_bytes } => { println!("{hash} {size_bytes} bytes"); } + ControlResponse::CasPrivateAdded { + resource, + epoch, + plaintext_hash, + encrypted_hash, + size_bytes, + note, + } => { + println!("encrypted_hash: {encrypted_hash}"); + println!("plaintext_hash: {plaintext_hash}"); + println!("resource: {resource}"); + println!("epoch: {epoch}"); + println!("size_bytes: {size_bytes}"); + println!("note: {note}"); + } ControlResponse::CasGot { hash, out, @@ -1306,6 +1343,21 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { } => { println!("wrote {hash} to {} ({size_bytes} bytes)", out.display()); } + ControlResponse::CasPrivateGot { + resource, + hash, + plaintext_hash, + out, + size_bytes, + note, + } => { + println!( + "decrypted {hash} for {resource} to {} ({size_bytes} bytes)", + out.display() + ); + println!("plaintext_hash: {plaintext_hash}"); + println!("note: {note}"); + } ControlResponse::CasFetched { peer_node_id, peer_agent_id, diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 76fcc0e..c8b6e90 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -45,10 +45,19 @@ pub enum ControlRequest { CasAdd { path: PathBuf, }, + CasAddPrivate { + resource: String, + path: PathBuf, + }, CasGet { hash: BlobHash, out: PathBuf, }, + CasGetPrivate { + resource: String, + hash: BlobHash, + out: PathBuf, + }, CasFetch { node: String, hash: BlobHash, @@ -386,11 +395,27 @@ pub enum ControlResponse { hash: BlobHash, size_bytes: u64, }, + CasPrivateAdded { + resource: String, + epoch: u64, + plaintext_hash: BlobHash, + encrypted_hash: BlobHash, + size_bytes: u64, + note: String, + }, CasGot { hash: BlobHash, out: PathBuf, size_bytes: u64, }, + CasPrivateGot { + resource: String, + hash: BlobHash, + plaintext_hash: BlobHash, + out: PathBuf, + size_bytes: u64, + note: String, + }, CasFetched { peer_node_id: String, peer_agent_id: String, diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index bcc7f7b..c6d579b 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -4509,6 +4509,48 @@ pub fn handle_request( size_bytes: info.size_bytes, }) } + ControlRequest::CasAddPrivate { resource, path } => { + ensure_resource_exists(&store, &resource)?; + let secret = store.latest_resource_secret(&resource)?.ok_or_else(|| { + NodeError::IrohPeer(format!( + "resource {resource} has no active resource secret; run geth secret create {resource}" + )) + })?; + 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()); + let info = cas.add_bytes(&encrypted)?; + store.record_cas_object( + info.hash.as_str(), + info.size_bytes, + &info.path.to_string_lossy(), + )?; + Ok(ControlResponse::CasPrivateAdded { + resource, + epoch: secret.epoch, + 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(), + }) + } ControlRequest::CasGet { hash, out } => { let cas = LocalCas::new(node.paths.cas_dir()); let size_bytes = cas.get_to_path(&hash, &out)?; @@ -4518,6 +4560,51 @@ pub fn handle_request( size_bytes, }) } + ControlRequest::CasGetPrivate { + resource, + hash, + out, + } => { + ensure_resource_exists(&store, &resource)?; + let cas = LocalCas::new(node.paths.cas_dir()); + let encrypted = cas.read_bytes(&hash)?; + let secrets = store + .list_resource_secrets()? + .into_iter() + .filter(|secret| secret.resource_id == resource) + .collect::>(); + if secrets.is_empty() { + return Err(NodeError::IrohPeer(format!( + "resource {resource} has no resource secrets; cannot decrypt private blob" + ))); + } + let mut plaintext = None; + for secret in secrets { + if let Ok(bytes) = + geth_cas::decrypt_private_blob(&resource, &secret.secret_id, &encrypted) + { + plaintext = Some(bytes); + break; + } + } + let plaintext = plaintext.ok_or_else(|| { + NodeError::IrohPeer(format!( + "no local resource secret epoch could decrypt private blob {hash}" + )) + })?; + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&out, &plaintext)?; + Ok(ControlResponse::CasPrivateGot { + resource, + hash, + 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(), + }) + } ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash { hash: hash_path(&path)?, }), diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 660e33f..604d168 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -269,6 +269,71 @@ fn initialized_node_can_roundtrip_cas_blob() { ); } +#[test] +fn private_cas_blob_uses_resource_secret_epoch() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + let resource = "resource:cas:local".to_owned(); + geth_node::handle_request( + &node, + geth_control::ControlRequest::SecretCreate { + resource: resource.clone(), + }, + ) + .expect("create resource secret"); + let input = home.path().join("private.txt"); + std::fs::write(&input, b"private blob bytes").expect("write private input"); + + let added = geth_node::handle_request( + &node, + geth_control::ControlRequest::CasAddPrivate { + resource: resource.clone(), + path: input, + }, + ) + .expect("add private blob"); + let encrypted_hash = match added { + geth_control::ControlResponse::CasPrivateAdded { + encrypted_hash, + plaintext_hash, + epoch, + .. + } => { + assert_eq!(epoch, 1); + assert_eq!(plaintext_hash, geth_cas::hash_bytes(b"private blob bytes")); + encrypted_hash + } + other => panic!("unexpected private add response: {other:?}"), + }; + + let out = home.path().join("private-out.txt"); + let got = geth_node::handle_request( + &node, + geth_control::ControlRequest::CasGetPrivate { + resource, + hash: encrypted_hash, + out: out.clone(), + }, + ) + .expect("get private blob"); + match got { + geth_control::ControlResponse::CasPrivateGot { + plaintext_hash, + size_bytes, + .. + } => { + assert_eq!(plaintext_hash, geth_cas::hash_bytes(b"private blob bytes")); + assert_eq!(size_bytes, b"private blob bytes".len() as u64); + } + other => panic!("unexpected private get response: {other:?}"), + } + assert_eq!( + std::fs::read(out).expect("read private out"), + b"private blob bytes" + ); +} + #[test] fn cas_pin_unpin_updates_local_pin_metadata() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/roadmap.md b/docs/roadmap.md index ec4ef84..7931c56 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -306,11 +306,16 @@ authorization and durable-state boundaries clear. - `[x]` Unpinned cached blobs can be evicted by policy. - `[x]` Tests cover attempted eviction of pinned data. -- `[ ]` Encrypted private blobs. +- `[x]` Encrypted private blobs. Acceptance criteria: - - Private blob payloads are encrypted before network distribution. - - Access is gated by resource secret epoch material. - - Docs explicitly avoid claiming forward secrecy or PCS. + - `[x]` `geth cas add-private ` stores an encrypted CAS + envelope instead of plaintext payload bytes. + - `[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]` Docs explicitly avoid claiming forward secrecy or PCS. - `[~]` Iroh-docs KV integration. Acceptance criteria: