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

@ -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<u8>,
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<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 {
pub fn parse(value: &str) -> Result<Self, CasError> {
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");

View file

@ -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<ControlRequest> {
},
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,

View file

@ -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,

View file

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