Add prototype encrypted CAS blobs
This commit is contained in:
parent
6e04e786c2
commit
533ffc8c2a
8 changed files with 382 additions and 6 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue