fix(decypher): try to decipher even with old format

This commit is contained in:
Edouard Vanbelle
2026-08-02 02:20:21 +02:00
parent 0a41f561d0
commit 120dd09885
@@ -138,10 +138,11 @@ pub struct EncryptedBlobBackend {
inner: Arc<dyn BlobStorageBackend>,
/// The pair list as declared by the operator. Guaranteed
/// non-empty by ctor (an empty input auto-synthesises a single
/// `none:` pair, so the invariant holds). Preserved verbatim for
/// observability and for downstream K3 `storage_rotate` which
/// needs to walk pair indices.
#[allow(dead_code)]
/// `none:` pair, so the invariant holds). Used by:
/// * `read_dispatch` legacy-fallback path — iterates in order
/// (oldest → newest) to try every real-cipher pair when a
/// legacy blob's head-key decrypt fails.
/// * K3 `storage_rotate` — needs to walk pair indices.
pairs: Vec<KeyPair>,
/// `<key_fp>` → per-pair cipher, for O(1) read dispatch on v1
/// blobs. Excludes any `none:` pair (nothing to build). Cloned
@@ -252,11 +253,19 @@ impl EncryptedBlobBackend {
let enc_stream = self.inner.get_blob_stream(hash).await?;
let raw = collect_stream(enc_stream).await?;
let format = BlobFormat::classify(&raw);
let pairs = self.pairs.clone();
let fp_ciphers = self.fp_ciphers.clone();
let head_cipher = self.head_cipher.clone();
let head_key_fp = self.head_key_fp;
let len = raw.len();
let plaintext = offload_crypto(len, move || {
read_dispatch(&fp_ciphers, head_cipher.as_deref(), raw)
read_dispatch(
&pairs,
&fp_ciphers,
head_cipher.as_deref(),
head_key_fp,
raw,
)
})
.await?;
Ok((plaintext, format))
@@ -397,10 +406,18 @@ fn write_plaintext_v1(data: &[u8]) -> Bytes {
/// * else `key_fp` lookup in `fp_ciphers` → AEAD decrypt over
/// the post-header body.
/// * anything else → **legacy path**:
/// * `head_cipher = Some` → AES-GCM decrypt over pre-K2 shape
/// (`nonce | ct | tag`) — the pre-v1 world had exactly one key
/// per entry, and that key is the head pair by construction of
/// any pair-list upgraded from a pre-K1 config;
/// * `head_cipher = Some` → AES-GCM decrypt attempts, head-first,
/// then every OTHER real-cipher pair in the wrapper's list.
/// Head-first is the fast path (pre-v1 world used exactly one
/// key which becomes head on upgrade, so it's the correct key
/// for legacy blobs the first time you rotate). The extra
/// fallbacks cover post-rotation scenarios where an operator
/// restores a pre-v1 backup encrypted under a now-non-head
/// pair — that blob was encrypted with K1 but head is now K2;
/// without the fallback the read would fail with tag error
/// even though K1 is still in the pair-list. Each failed tag
/// check is microseconds — bounded by the pair count (1-3 in
/// practice).
/// * `head_cipher = None` → return raw bytes (pre-K2 plaintext
/// deployment).
///
@@ -410,15 +427,59 @@ fn write_plaintext_v1(data: &[u8]) -> Bytes {
/// subsequent version/key_fp check with a hard error, not silent
/// garbage.
fn read_dispatch(
pairs: &[KeyPair],
fp_ciphers: &HashMap<[u8; KEY_FP_SIZE], Arc<Aes256Gcm>>,
head_cipher: Option<&Aes256Gcm>,
head_key_fp: [u8; KEY_FP_SIZE],
encrypted: Vec<u8>,
) -> Result<Bytes, DomainError> {
if encrypted.len() >= 5 && &encrypted[..5] == OXCPT_MAGIC {
return read_v1(fp_ciphers, encrypted);
}
match head_cipher {
Some(cipher) => decrypt_aead_in_place(cipher, encrypted),
Some(head) => {
// Fast path: try the head pair first. This is the
// pre-v1-upgrade case (single key = head; correct by
// construction) and the most common case even
// post-rotation (head was head just before the operator
// rotated it to a new pair).
if let Ok(pt) = decrypt_aead_in_place(head, encrypted.clone()) {
return Ok(pt);
}
// Fallback: try every other real-cipher pair in
// list order — oldest → newest. Legacy blobs are OLD
// by definition (pre-K2, no header), so an older pair
// is more likely to have encrypted them than a newer
// one. Head is skipped (already tried above). Each
// failed AEAD tag check is µs; the loop is bounded by
// the pair count (1-3 in practice).
//
// `Vec<KeyPair>` ordering is stable (env-declaration
// order), unlike `HashMap` iteration which is randomised.
//
// Clone per attempt because `decrypt_in_place_detached`
// leaves the buffer in an undefined state on tag failure
// — retrying against another key needs a fresh copy.
for pair in pairs {
if !pair.cipher.needs_key() {
continue; // `none` pair — no cipher to try
}
let fp = pair.key_fp();
if fp == head_key_fp {
continue; // already tried above
}
if let Some(cipher) = fp_ciphers.get(&fp)
&& let Ok(pt) = decrypt_aead_in_place(cipher, encrypted.clone())
{
return Ok(pt);
}
}
Err(DomainError::internal_error(
"Encryption",
"legacy blob failed to decrypt under any configured key — \
wrong key removed from pair-list, or blob is corrupt",
))
}
None => Ok(Bytes::from(encrypted)),
}
}
@@ -621,8 +682,10 @@ impl BlobStorageBackend for EncryptedBlobBackend {
{
let inner = self.inner.clone();
let hash = hash.to_string();
let pairs = self.pairs.clone();
let fp_ciphers = self.fp_ciphers.clone();
let head_cipher = self.head_cipher.clone();
let head_key_fp = self.head_key_fp;
Box::pin(async move {
// Collect the full blob, dispatch on magic bytes off the
// runtime, then stream zero-copy plaintext slices.
@@ -630,7 +693,13 @@ impl BlobStorageBackend for EncryptedBlobBackend {
let encrypted = collect_stream(enc_stream).await?;
let len = encrypted.len();
let plaintext = offload_crypto(len, move || {
read_dispatch(&fp_ciphers, head_cipher.as_deref(), encrypted)
read_dispatch(
&pairs,
&fp_ciphers,
head_cipher.as_deref(),
head_key_fp,
encrypted,
)
})
.await?;
Ok(plaintext_stream(plaintext))
@@ -646,8 +715,10 @@ impl BlobStorageBackend for EncryptedBlobBackend {
{
let inner = self.inner.clone();
let hash = hash.to_string();
let pairs = self.pairs.clone();
let fp_ciphers = self.fp_ciphers.clone();
let head_cipher = self.head_cipher.clone();
let head_key_fp = self.head_key_fp;
Box::pin(async move {
// Decrypt (or unwrap) the full blob, then slice the plaintext
// range without copying. For CDC chunks (every blob written
@@ -657,7 +728,13 @@ impl BlobStorageBackend for EncryptedBlobBackend {
let encrypted = collect_stream(enc_stream).await?;
let len = encrypted.len();
let plaintext = offload_crypto(len, move || {
read_dispatch(&fp_ciphers, head_cipher.as_deref(), encrypted)
read_dispatch(
&pairs,
&fp_ciphers,
head_cipher.as_deref(),
head_key_fp,
encrypted,
)
})
.await?;
@@ -1315,4 +1392,51 @@ mod tests {
// rewrite (key rotation case).
assert_ne!(current, reader.head_format());
}
/// Legacy blob (no OXCPT header) encrypted under a NON-head pair
/// still decrypts via the fallback loop. This is the
/// "operator restored a pre-K2 backup post-rotation" case:
/// the blob was originally encrypted with K1, then K2 was added
/// and rotated to head. Reading the restored bytes under a
/// `[K1, K2]` pair-list where K2 is head should succeed by
/// falling through to K1.
#[tokio::test]
async fn legacy_blob_under_non_head_key_still_readable() {
let tmp = TempDir::new().unwrap();
let local = Arc::new(LocalBlobBackend::new(&tmp.path().join("blobs")));
local.initialize().await.unwrap();
let k_old = [0x11u8; 32]; // will be non-head after rotation
let k_new = [0x22u8; 32]; // will be head after rotation
// Craft a legacy blob by hand: AES-GCM with K_OLD, no OXCPT
// header. Matches exactly what pre-K2 code wrote.
let plaintext = b"pre-K2 secret restored post-rotation";
let cipher_old = Aes256Gcm::new_from_slice(&k_old).unwrap();
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let mut legacy = Vec::new();
legacy.extend_from_slice(nonce.as_slice());
legacy.extend_from_slice(plaintext);
let tag = cipher_old
.encrypt_in_place_detached(&nonce, b"", &mut legacy[NONCE_SIZE..])
.unwrap();
legacy.extend_from_slice(&tag);
let hash = "9999999999999999999999999999999999999999999999999999999999999999";
local
.put_blob_from_bytes(hash, Bytes::from(legacy))
.await
.unwrap();
// Reader has BOTH keys, with K_NEW as head. The legacy blob's
// head decrypt attempt (with K_NEW) will fail on the tag —
// the fallback loop then tries K_OLD (the only other pair)
// and succeeds.
let reader = EncryptedBlobBackend::new(
local,
vec![KeyPair::new_aes_gcm(k_old), KeyPair::new_aes_gcm(k_new)],
);
let stream = reader.get_blob_stream(hash).await.unwrap();
let got = collect_stream(stream).await.unwrap();
assert_eq!(got, plaintext);
}
}