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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)?,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue