feat(storage key rot): add blob/chunk creation with the header

This commit is contained in:
Edouard Vanbelle
2026-08-01 23:25:27 +02:00
parent 9485ee5540
commit 30b0000c26
@@ -216,6 +216,101 @@ impl EncryptedBlobBackend {
OsRng.fill_bytes(&mut 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:
@@ -1055,4 +1150,120 @@ mod tests {
"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());
}
}