Add prototype encrypted CAS blobs
This commit is contained in:
parent
6e04e786c2
commit
533ffc8c2a
8 changed files with 382 additions and 6 deletions
|
|
@ -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 <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
|
||||
store those manifests as CAS blobs. The daemon can register and scan local
|
||||
file roots, reporting create/update/delete/rename changes without writing back
|
||||
|
|
|
|||
|
|
@ -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 <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 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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 <resource> <path>` stores an encrypted CAS
|
||||
envelope instead of plaintext payload bytes.
|
||||
- `[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.
|
||||
Acceptance criteria:
|
||||
|
|
|
|||
Loading…
Reference in a new issue