feat(storage key rot): add blob/chunk creation with the header
This commit is contained in:
@@ -216,6 +216,101 @@ impl EncryptedBlobBackend {
|
|||||||
OsRng.fill_bytes(&mut key);
|
OsRng.fill_bytes(&mut key);
|
||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The format `storage_rotate` should normalise every blob TO —
|
||||||
|
/// derived from the wrapper's head pair. When
|
||||||
|
/// `head_cipher.is_some()` we're writing encrypted-v1 with the
|
||||||
|
/// head pair's `key_fp`; when it's `None` we're writing
|
||||||
|
/// plaintext-v1 (all-zero `key_fp`).
|
||||||
|
///
|
||||||
|
/// K3 uses this as the "target format" that a per-blob decision
|
||||||
|
/// tree compares against `BlobFormat::classify(bytes)` — any
|
||||||
|
/// mismatch means the blob needs rewriting.
|
||||||
|
pub fn head_format(&self) -> BlobFormat {
|
||||||
|
if self.head_cipher.is_some() {
|
||||||
|
BlobFormat::EncryptedV1 {
|
||||||
|
key_fp: self.head_key_fp,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BlobFormat::PlaintextV1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch, classify, and decrypt a blob in one round-trip. Used by
|
||||||
|
/// K3's `storage_rotate` per-blob step: it needs both the
|
||||||
|
/// plaintext (to re-encrypt under the head pair) AND the current
|
||||||
|
/// on-disk format (to decide whether a rewrite is needed at all).
|
||||||
|
///
|
||||||
|
/// The inner backend is read once; the raw bytes are inspected
|
||||||
|
/// for their format before being consumed by `read_dispatch`. No
|
||||||
|
/// duplicated I/O.
|
||||||
|
///
|
||||||
|
/// Returned tuple: `(plaintext, current_format)`. Rotate compares
|
||||||
|
/// `current_format` against [`Self::head_format`]; equal → skip,
|
||||||
|
/// different → rewrite via the standard write path.
|
||||||
|
pub async fn read_and_classify(&self, hash: &str) -> Result<(Bytes, BlobFormat), DomainError> {
|
||||||
|
let enc_stream = self.inner.get_blob_stream(hash).await?;
|
||||||
|
let raw = collect_stream(enc_stream).await?;
|
||||||
|
let format = BlobFormat::classify(&raw);
|
||||||
|
let fp_ciphers = self.fp_ciphers.clone();
|
||||||
|
let head_cipher = self.head_cipher.clone();
|
||||||
|
let len = raw.len();
|
||||||
|
let plaintext = offload_crypto(len, move || {
|
||||||
|
read_dispatch(&fp_ciphers, head_cipher.as_deref(), raw)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok((plaintext, format))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classification of a raw blob's on-disk format. Exposed for K3's
|
||||||
|
/// `storage_rotate` decision tree; not used on the hot request path.
|
||||||
|
///
|
||||||
|
/// PartialEq is derived so `current == head_format` collapses the
|
||||||
|
/// plan's six-case decision tree into a single equality check:
|
||||||
|
///
|
||||||
|
/// * `Legacy != anything v1` → always rewrite.
|
||||||
|
/// * `EncryptedV1{fp_a} != EncryptedV1{fp_b}` when fps differ → rewrite (key rotation).
|
||||||
|
/// * `PlaintextV1 != EncryptedV1` and vice-versa → rewrite (encrypt / decrypt in place).
|
||||||
|
/// * Match cases → skip (already normalised).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BlobFormat {
|
||||||
|
/// No `OXCPT` magic. Pre-K2 shape — either raw plaintext or raw
|
||||||
|
/// `nonce | ct | tag` AES-GCM output; the wrapper's legacy read
|
||||||
|
/// path handles both.
|
||||||
|
Legacy,
|
||||||
|
/// v1 with a `none:`-style all-zero `key_fp`. Post-header bytes
|
||||||
|
/// are raw plaintext.
|
||||||
|
PlaintextV1,
|
||||||
|
/// v1 with a real cipher pair. `key_fp` identifies which pair
|
||||||
|
/// (matches [`KeyPair::key_fp`]).
|
||||||
|
EncryptedV1 { key_fp: [u8; KEY_FP_SIZE] },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlobFormat {
|
||||||
|
/// Inspect the first `HEADER_SIZE` bytes and classify the blob.
|
||||||
|
/// O(1), no allocation. Used by [`EncryptedBlobBackend::read_and_classify`]
|
||||||
|
/// but also useful in isolation for offline tools.
|
||||||
|
pub fn classify(bytes: &[u8]) -> Self {
|
||||||
|
if bytes.len() < 5 || &bytes[..5] != OXCPT_MAGIC {
|
||||||
|
return BlobFormat::Legacy;
|
||||||
|
}
|
||||||
|
// Magic OK. If the rest of the header isn't present the blob
|
||||||
|
// is malformed — treat as Legacy so the decision tree marks
|
||||||
|
// it for rewrite (and the actual read will surface the error
|
||||||
|
// to the finding stream).
|
||||||
|
if bytes.len() < HEADER_SIZE {
|
||||||
|
return BlobFormat::Legacy;
|
||||||
|
}
|
||||||
|
// v1 magic + at least a full header. `key_fp` == 0 → plaintext.
|
||||||
|
let mut key_fp = [0u8; KEY_FP_SIZE];
|
||||||
|
key_fp.copy_from_slice(&bytes[7..HEADER_SIZE]);
|
||||||
|
if key_fp == [0u8; KEY_FP_SIZE] {
|
||||||
|
BlobFormat::PlaintextV1
|
||||||
|
} else {
|
||||||
|
BlobFormat::EncryptedV1 { key_fp }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble an encrypted-v1 blob:
|
/// Assemble an encrypted-v1 blob:
|
||||||
@@ -1055,4 +1150,120 @@ mod tests {
|
|||||||
"expected UnsupportedBlobVersion error, got: {msg}"
|
"expected UnsupportedBlobVersion error, got: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// K3 tests — BlobFormat classifier + head_format + read_and_classify.
|
||||||
|
//
|
||||||
|
// These pin the format-inspection contract that `storage_rotate`
|
||||||
|
// depends on. The rotate job's per-blob decision tree collapses
|
||||||
|
// to `current != head_format ? rewrite : skip`, so any drift in
|
||||||
|
// either helper would silently change rotation semantics.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_recognises_encrypted_v1() {
|
||||||
|
let mut blob = Vec::from(*OXCPT_MAGIC);
|
||||||
|
blob.extend_from_slice(&V1_VERSION_BYTES);
|
||||||
|
let key_fp = [0x11u8; KEY_FP_SIZE];
|
||||||
|
blob.extend_from_slice(&key_fp);
|
||||||
|
blob.extend_from_slice(b"nonce_ct_tag_bytes_would_go_here");
|
||||||
|
assert_eq!(
|
||||||
|
BlobFormat::classify(&blob),
|
||||||
|
BlobFormat::EncryptedV1 { key_fp }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_recognises_plaintext_v1() {
|
||||||
|
let mut blob = Vec::from(*OXCPT_MAGIC);
|
||||||
|
blob.extend_from_slice(&V1_VERSION_BYTES);
|
||||||
|
blob.extend_from_slice(&[0u8; KEY_FP_SIZE]);
|
||||||
|
blob.extend_from_slice(b"raw payload after header");
|
||||||
|
assert_eq!(BlobFormat::classify(&blob), BlobFormat::PlaintextV1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_recognises_legacy_no_magic() {
|
||||||
|
// Random bytes with no OXCPT prefix.
|
||||||
|
let raw = b"some legacy bytes not starting with the magic";
|
||||||
|
assert_eq!(BlobFormat::classify(raw), BlobFormat::Legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_treats_short_magic_only_blob_as_legacy() {
|
||||||
|
// 5 bytes = magic only, no room for version+key_fp. Malformed
|
||||||
|
// v1; treated as Legacy so the decision tree flags it for
|
||||||
|
// rewrite instead of pretending it's a real v1 blob.
|
||||||
|
let raw = Vec::from(*OXCPT_MAGIC);
|
||||||
|
assert_eq!(BlobFormat::classify(&raw), BlobFormat::Legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_empty_is_legacy() {
|
||||||
|
assert_eq!(BlobFormat::classify(&[]), BlobFormat::Legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn head_format_matches_encrypted_head_pair_fp() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
|
||||||
|
local.initialize().await.unwrap();
|
||||||
|
|
||||||
|
let key = [0x77u8; 32];
|
||||||
|
let backend = EncryptedBlobBackend::new_single_aes(local, &key);
|
||||||
|
match backend.head_format() {
|
||||||
|
BlobFormat::EncryptedV1 { key_fp } => {
|
||||||
|
assert_eq!(key_fp, KeyPair::new_aes_gcm(key).key_fp());
|
||||||
|
}
|
||||||
|
other => panic!("expected EncryptedV1, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn head_format_is_plaintext_v1_for_none_head() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
|
||||||
|
local.initialize().await.unwrap();
|
||||||
|
|
||||||
|
// Empty pair list → wrapper synthesises a single `none:` pair.
|
||||||
|
let backend = EncryptedBlobBackend::new(local, vec![]);
|
||||||
|
assert_eq!(backend.head_format(), BlobFormat::PlaintextV1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_and_classify_returns_plaintext_and_current_format() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
|
||||||
|
local.initialize().await.unwrap();
|
||||||
|
|
||||||
|
let k_old = [0xAAu8; 32];
|
||||||
|
let k_new = [0xBBu8; 32];
|
||||||
|
let hash = "7777777777777777777777777777777777777777777777777777777777777777";
|
||||||
|
|
||||||
|
// Write under the OLD key.
|
||||||
|
let writer = EncryptedBlobBackend::new_single_aes(local.clone(), &k_old);
|
||||||
|
writer
|
||||||
|
.put_blob_from_bytes(hash, Bytes::from_static(b"secret payload"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Reader has BOTH keys, k_new at head. Rotate scenario:
|
||||||
|
// classifier should report EncryptedV1{k_old_fp}, decrypt
|
||||||
|
// succeeds via key_fp lookup, plaintext round-trips.
|
||||||
|
let reader = EncryptedBlobBackend::new(
|
||||||
|
local,
|
||||||
|
vec![KeyPair::new_aes_gcm(k_old), KeyPair::new_aes_gcm(k_new)],
|
||||||
|
);
|
||||||
|
let (plaintext, current) = reader.read_and_classify(hash).await.unwrap();
|
||||||
|
assert_eq!(plaintext, b"secret payload".as_slice());
|
||||||
|
match current {
|
||||||
|
BlobFormat::EncryptedV1 { key_fp } => {
|
||||||
|
assert_eq!(key_fp, KeyPair::new_aes_gcm(k_old).key_fp());
|
||||||
|
}
|
||||||
|
other => panic!("expected EncryptedV1 with old fp, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Confirms the rotate decision: current != head_format →
|
||||||
|
// rewrite (key rotation case).
|
||||||
|
assert_ne!(current, reader.head_format());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user