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");