Add prototype encrypted CAS blobs

This commit is contained in:
Eric Wendland 2026-05-21 01:35:00 +02:00
commit 533ffc8c2a
8 changed files with 382 additions and 6 deletions

View file

@ -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 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 grants `cas.fetch` on `resource:cas:local`; successful fetches record local
provider metadata visible through `geth cas providers <hash>`. Full provider metadata visible through `geth cas providers <hash>`. 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 - 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 store those manifests as CAS blobs. The daemon can register and scan local
file roots, reporting create/update/delete/rename changes without writing back file roots, reporting create/update/delete/rename changes without writing back

View file

@ -110,6 +110,10 @@ The bootstrap implementation provides:
- local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`, - local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`,
`unpin`, `cleanup`, `providers`, `list`; remote fetch accepts `unpin`, `cleanup`, `providers`, `list`; remote fetch accepts
`--bearer-secret <secret>` `--bearer-secret <secret>`
- private CAS envelope commands:
`geth cas add-private <resource> <path>` and
`geth cas get-private <resource> <hash> --out <path>`. 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 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 - 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 pulls authorized remote tree metadata and CAS tree bytes into a peer-qualified

View file

@ -4,6 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub const CAS_TREE_OBJECT_VERSION: u16 = 1; pub const CAS_TREE_OBJECT_VERSION: u16 = 1;
pub const ENCRYPTED_BLOB_ENVELOPE_VERSION: u16 = 1;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CasError { pub enum CasError {
@ -27,6 +28,8 @@ pub enum CasError {
InvalidFileConflictResolution(String), InvalidFileConflictResolution(String),
#[error("invalid file conflict status: {0}")] #[error("invalid file conflict status: {0}")]
InvalidFileConflictStatus(String), InvalidFileConflictStatus(String),
#[error("encrypted blob envelope error: {0}")]
EncryptedEnvelope(String),
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -41,6 +44,19 @@ pub struct BlobInfo {
pub path: PathBuf, 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<u8>,
pub tag_hex: String,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CasTreeObject { pub struct CasTreeObject {
pub version: u16, 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<Vec<u8>, 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<Vec<u8>, 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<u8> {
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 { impl FileConflictResolution {
pub fn parse(value: &str) -> Result<Self, CasError> { pub fn parse(value: &str) -> Result<Self, CasError> {
match value { 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] #[test]
fn cas_remove_deletes_blob_file() { fn cas_remove_deletes_blob_file() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");

View file

@ -245,11 +245,21 @@ pub enum CasCommand {
Add { Add {
path: PathBuf, path: PathBuf,
}, },
AddPrivate {
resource: String,
path: PathBuf,
},
Get { Get {
hash: String, hash: String,
#[arg(long)] #[arg(long)]
out: PathBuf, out: PathBuf,
}, },
GetPrivate {
resource: String,
hash: String,
#[arg(long)]
out: PathBuf,
},
Fetch { Fetch {
node: String, node: String,
hash: String, hash: String,
@ -805,10 +815,22 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
}, },
Command::Cas { command } => match command { Command::Cas { command } => match command {
CasCommand::Add { path } => ControlRequest::CasAdd { path }, CasCommand::Add { path } => ControlRequest::CasAdd { path },
CasCommand::AddPrivate { resource, path } => {
ControlRequest::CasAddPrivate { resource, path }
}
CasCommand::Get { hash, out } => ControlRequest::CasGet { CasCommand::Get { hash, out } => ControlRequest::CasGet {
hash: hash.into(), hash: hash.into(),
out, out,
}, },
CasCommand::GetPrivate {
resource,
hash,
out,
} => ControlRequest::CasGetPrivate {
resource,
hash: hash.into(),
out,
},
CasCommand::Fetch { CasCommand::Fetch {
node, node,
hash, hash,
@ -1299,6 +1321,21 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
ControlResponse::CasAdded { hash, size_bytes } => { ControlResponse::CasAdded { hash, size_bytes } => {
println!("{hash} {size_bytes} 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 { ControlResponse::CasGot {
hash, hash,
out, out,
@ -1306,6 +1343,21 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
} => { } => {
println!("wrote {hash} to {} ({size_bytes} bytes)", out.display()); 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 { ControlResponse::CasFetched {
peer_node_id, peer_node_id,
peer_agent_id, peer_agent_id,

View file

@ -45,10 +45,19 @@ pub enum ControlRequest {
CasAdd { CasAdd {
path: PathBuf, path: PathBuf,
}, },
CasAddPrivate {
resource: String,
path: PathBuf,
},
CasGet { CasGet {
hash: BlobHash, hash: BlobHash,
out: PathBuf, out: PathBuf,
}, },
CasGetPrivate {
resource: String,
hash: BlobHash,
out: PathBuf,
},
CasFetch { CasFetch {
node: String, node: String,
hash: BlobHash, hash: BlobHash,
@ -386,11 +395,27 @@ pub enum ControlResponse {
hash: BlobHash, hash: BlobHash,
size_bytes: u64, size_bytes: u64,
}, },
CasPrivateAdded {
resource: String,
epoch: u64,
plaintext_hash: BlobHash,
encrypted_hash: BlobHash,
size_bytes: u64,
note: String,
},
CasGot { CasGot {
hash: BlobHash, hash: BlobHash,
out: PathBuf, out: PathBuf,
size_bytes: u64, size_bytes: u64,
}, },
CasPrivateGot {
resource: String,
hash: BlobHash,
plaintext_hash: BlobHash,
out: PathBuf,
size_bytes: u64,
note: String,
},
CasFetched { CasFetched {
peer_node_id: String, peer_node_id: String,
peer_agent_id: String, peer_agent_id: String,

View file

@ -4509,6 +4509,48 @@ pub fn handle_request(
size_bytes: info.size_bytes, 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 } => { ControlRequest::CasGet { hash, out } => {
let cas = LocalCas::new(node.paths.cas_dir()); let cas = LocalCas::new(node.paths.cas_dir());
let size_bytes = cas.get_to_path(&hash, &out)?; let size_bytes = cas.get_to_path(&hash, &out)?;
@ -4518,6 +4560,51 @@ pub fn handle_request(
size_bytes, 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::<Vec<_>>();
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 { ControlRequest::CasHash { path } => Ok(ControlResponse::CasHash {
hash: hash_path(&path)?, hash: hash_path(&path)?,
}), }),

View file

@ -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] #[test]
fn cas_pin_unpin_updates_local_pin_metadata() { fn cas_pin_unpin_updates_local_pin_metadata() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -306,11 +306,16 @@ authorization and durable-state boundaries clear.
- `[x]` Unpinned cached blobs can be evicted by policy. - `[x]` Unpinned cached blobs can be evicted by policy.
- `[x]` Tests cover attempted eviction of pinned data. - `[x]` Tests cover attempted eviction of pinned data.
- `[ ]` Encrypted private blobs. - `[x]` Encrypted private blobs.
Acceptance criteria: Acceptance criteria:
- Private blob payloads are encrypted before network distribution. - `[x]` `geth cas add-private <resource> <path>` stores an encrypted CAS
- Access is gated by resource secret epoch material. envelope instead of plaintext payload bytes.
- Docs explicitly avoid claiming forward secrecy or PCS. - `[x]` `geth cas get-private <resource> <hash> --out <path>` 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. - `[~]` Iroh-docs KV integration.
Acceptance criteria: Acceptance criteria: