feat(storage key rot): prepare format <cipher1>:<key2>,<cipher2>:<key2>,...
This commit is contained in:
@@ -422,6 +422,279 @@ impl EncryptionCipher {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// K1: pair-list encryption config (post-storage-multi-entry, pre-v1-header).
|
||||
// See `docs/plan/storage-key-rotation.md`.
|
||||
//
|
||||
// The types and parser below REPLACE the singular `EncryptionCipher` +
|
||||
// `encryption_key_base64` + `encryption_cipher` model with a
|
||||
// list-of-pairs model where the last pair wins on writes and every
|
||||
// pair is a candidate for reads. The `none` cipher is a first-class
|
||||
// citizen so plaintext ↔ encrypted transitions can be expressed as a
|
||||
// single pair-list evolution.
|
||||
//
|
||||
// K1 introduces these types but does NOT yet wire them into
|
||||
// `NamedStorageEntry` — that flip lands in K2 alongside the v1 header
|
||||
// read/write paths. The parser is exercised via unit tests only in
|
||||
// this slice.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The AEAD (or absence of one) used by a single [`KeyPair`].
|
||||
///
|
||||
/// Distinct from the older [`EncryptionCipher`] enum in two ways:
|
||||
///
|
||||
/// * Adds a `None` variant. A pair with `CipherKind::None` says
|
||||
/// "writes routed to this pair produce raw plaintext". This is
|
||||
/// what makes the encrypt-a-plaintext-deployment and
|
||||
/// decrypt-an-encrypted-deployment recipes expressible without a
|
||||
/// second storage entry — see `docs/plan/storage-key-rotation.md`
|
||||
/// §"Encrypting a previously-plaintext deployment" and the
|
||||
/// symmetric decrypt recipe.
|
||||
/// * Is the type carried by the v1 on-disk header's cipher field
|
||||
/// (indirectly via `<version>` in v1, but explicitly if a future
|
||||
/// v2 adds a cipher byte to the header — the enum grows without
|
||||
/// touching call sites).
|
||||
///
|
||||
/// Kept alongside [`EncryptionCipher`] during K1; K2 retires the
|
||||
/// older enum in the same slice that flips `NamedStorageEntry`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CipherKind {
|
||||
/// AES-256 in Galois/Counter Mode. 96-bit nonce + 128-bit tag.
|
||||
/// The only real AEAD OxiCloud ships today. On the wire (v1
|
||||
/// header): `[12-byte nonce] [ciphertext] [16-byte tag]`.
|
||||
AesGcm256,
|
||||
/// No cipher. Writes produce raw plaintext; reads return raw
|
||||
/// bytes. Used as the head pair for a plaintext-target rotation
|
||||
/// (`aes:K,none:`) or as a non-head legacy pair while an
|
||||
/// encrypt-in-place rotation is still upgrading old plaintext
|
||||
/// blobs (`none:,aes:K`). At most one `none` pair may appear in
|
||||
/// a list (parser-enforced).
|
||||
None,
|
||||
}
|
||||
|
||||
impl CipherKind {
|
||||
/// Parse an env-var token: `"aes-256-gcm"` (with the
|
||||
/// `aes256gcm` alias kept for continuity with
|
||||
/// [`EncryptionCipher::parse`]) or `"none"`. Case-insensitive.
|
||||
pub fn parse(raw: &str) -> Option<Self> {
|
||||
match raw.to_ascii_lowercase().as_str() {
|
||||
"aes-256-gcm" | "aes256gcm" => Some(CipherKind::AesGcm256),
|
||||
"none" => Some(CipherKind::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable env-var-friendly name — the exact string the parser
|
||||
/// accepts back and the string admin surfaces render.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CipherKind::AesGcm256 => "aes-256-gcm",
|
||||
CipherKind::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` iff this cipher carries key material. `false` for
|
||||
/// `CipherKind::None`. Used by the parser to enforce
|
||||
/// "no key after `none:`" and by K2's write path to skip the
|
||||
/// AEAD call.
|
||||
pub fn needs_key(self) -> bool {
|
||||
matches!(self, CipherKind::AesGcm256)
|
||||
}
|
||||
}
|
||||
|
||||
/// One `<cipher>:<key>` pair from an `_ENCRYPTION_KEY` list.
|
||||
///
|
||||
/// List order carries semantics — the LAST pair is the write pair
|
||||
/// (see `docs/plan/storage-key-rotation.md` §"The pair-list config").
|
||||
/// `key_material` is `Some(bytes)` when `cipher.needs_key()`, else
|
||||
/// `None`. Base64 is decoded once at parse time; downstream code
|
||||
/// takes the raw bytes directly (no re-decoding on every read).
|
||||
///
|
||||
/// Deliberately not `Copy` — a 32-byte key isn't cheap enough to
|
||||
/// silently `Copy` and cloning it in tests is a good deterrent
|
||||
/// against accidental leaks into logs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyPair {
|
||||
/// Which AEAD (or none) this pair writes with.
|
||||
pub cipher: CipherKind,
|
||||
/// Raw 32-byte AES-256 key. Always `Some` for real ciphers,
|
||||
/// always `None` for `CipherKind::None`. This invariant is
|
||||
/// enforced at parse time; downstream can `unwrap()` when
|
||||
/// `cipher.needs_key()` returns `true`.
|
||||
pub key_material: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
impl KeyPair {
|
||||
/// Truncated SHA-256 fingerprint of the key material — 12 hex
|
||||
/// chars (6 bytes of SHA output). Used at boot for the audit-
|
||||
/// line dump so operators can eyeball which key is at each
|
||||
/// position without seeing the raw material.
|
||||
///
|
||||
/// The on-blob v1 header uses a DIFFERENT truncation — 8 bytes
|
||||
/// / 16 hex chars — so this fingerprint is not usable as the
|
||||
/// header's `<key_fp>` field. Kept short here to keep boot
|
||||
/// logs tight.
|
||||
///
|
||||
/// Returns `None` for `CipherKind::None` (nothing to
|
||||
/// fingerprint) — callers render as `—` in that case.
|
||||
pub fn fingerprint_short(&self) -> Option<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mat = self.key_material.as_ref()?;
|
||||
let full = Sha256::digest(mat);
|
||||
Some(hex::encode(&full[..6]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `OXICLOUD_STORAGE_<name>_ENCRYPTION_KEY` env var value
|
||||
/// into an ordered pair list.
|
||||
///
|
||||
/// Grammar (informal):
|
||||
///
|
||||
/// ```text
|
||||
/// pair_list := pair ("," pair)*
|
||||
/// pair := (cipher ":")? material
|
||||
/// cipher := "aes-256-gcm" | "none" (case-insensitive)
|
||||
/// material := base64_key (for real ciphers)
|
||||
/// | ε (for `none:`)
|
||||
/// ```
|
||||
///
|
||||
/// * Whitespace around commas / colons is tolerated.
|
||||
/// * A pair without a colon defaults its cipher to `aes-256-gcm`
|
||||
/// (since that's the only shipping AEAD today; new ciphers
|
||||
/// MUST use the explicit `<cipher>:<key>` form).
|
||||
/// * A `none` pair MUST use the explicit `none:` form (with the
|
||||
/// trailing colon and empty material) — omitting the colon
|
||||
/// would be ambiguous with a real key that happens to base64
|
||||
/// to `none`.
|
||||
///
|
||||
/// Guaranteed non-empty on `Ok`. All error messages carry the
|
||||
/// entry name so operators see which env var failed.
|
||||
///
|
||||
/// Errors:
|
||||
/// * Empty list.
|
||||
/// * Empty pair (leading / trailing / duplicate comma).
|
||||
/// * Unknown cipher name.
|
||||
/// * `none` pair with non-empty material.
|
||||
/// * More than one `none` pair.
|
||||
/// * Real-cipher pair with empty material.
|
||||
/// * Non-base64 key material.
|
||||
/// * Wrong-length key material (≠ 32 bytes).
|
||||
/// * Duplicate key material (same 32 bytes twice).
|
||||
pub fn parse_encryption_pair_list(entry_name: &str, raw: &str) -> Result<Vec<KeyPair>, String> {
|
||||
use base64::Engine;
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY is empty — set at least \
|
||||
one `<cipher>:<key>` pair, or omit the variable entirely for an \
|
||||
unencrypted entry."
|
||||
));
|
||||
}
|
||||
|
||||
let mut pairs: Vec<KeyPair> = Vec::new();
|
||||
let mut seen_none = false;
|
||||
let mut seen_keys: Vec<[u8; 32]> = Vec::new();
|
||||
|
||||
for (idx0, pair_raw) in raw.split(',').enumerate() {
|
||||
let pos = idx0 + 1;
|
||||
let pair_raw = pair_raw.trim();
|
||||
if pair_raw.is_empty() {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY has an empty pair at \
|
||||
position {pos} — remove leading/trailing/duplicate commas."
|
||||
));
|
||||
}
|
||||
|
||||
// `split_once(':')` gives us (cipher, key); no colon = key-only,
|
||||
// implicit AES-256-GCM (the only shipping real cipher).
|
||||
let (cipher_tok, key_b64) = match pair_raw.split_once(':') {
|
||||
Some((c, k)) => (c.trim(), k.trim()),
|
||||
None => ("aes-256-gcm", pair_raw),
|
||||
};
|
||||
|
||||
let cipher = CipherKind::parse(cipher_tok).ok_or_else(|| {
|
||||
format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY pair {pos} has unknown \
|
||||
cipher `{cipher_tok}` — supported: `aes-256-gcm`, `none`."
|
||||
)
|
||||
})?;
|
||||
|
||||
if !cipher.needs_key() {
|
||||
if !key_b64.is_empty() {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY pair {pos} declares \
|
||||
cipher `none` but has key material — use `none:` (trailing \
|
||||
colon, empty key)."
|
||||
));
|
||||
}
|
||||
if seen_none {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY has more than one \
|
||||
`none` pair — at most one is allowed."
|
||||
));
|
||||
}
|
||||
seen_none = true;
|
||||
pairs.push(KeyPair {
|
||||
cipher,
|
||||
key_material: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Real cipher — decode + length-check the key.
|
||||
if key_b64.is_empty() {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY pair {pos} has empty \
|
||||
key material for cipher `{}`.",
|
||||
cipher.as_str()
|
||||
));
|
||||
}
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(key_b64)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY pair {pos} is not \
|
||||
valid base64: {e}"
|
||||
)
|
||||
})?;
|
||||
if decoded.len() != 32 {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY pair {pos} decodes to \
|
||||
{} bytes; must be exactly 32 bytes (AES-256).",
|
||||
decoded.len()
|
||||
));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&decoded);
|
||||
|
||||
if seen_keys.iter().any(|k| k == &key) {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY has the same key \
|
||||
material twice — each pair must be unique."
|
||||
));
|
||||
}
|
||||
seen_keys.push(key);
|
||||
|
||||
pairs.push(KeyPair {
|
||||
cipher,
|
||||
key_material: Some(key),
|
||||
});
|
||||
}
|
||||
|
||||
// Belt-and-braces: the loop above rejects empty pairs, so this
|
||||
// can only fire if the whole raw input was pure whitespace, which
|
||||
// we already caught at the top. Kept as an invariant guard so a
|
||||
// future refactor can't silently produce an empty vec.
|
||||
if pairs.is_empty() {
|
||||
return Err(format!(
|
||||
"OXICLOUD_STORAGE_{entry_name}_ENCRYPTION_KEY produced no pairs after \
|
||||
parsing — this should never happen; please file a bug."
|
||||
));
|
||||
}
|
||||
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// One named storage entry declared in `.env`.
|
||||
///
|
||||
/// See `docs/plan/storage-multi-entry.md`. Each entry is a fully-realised
|
||||
@@ -3357,4 +3630,210 @@ mod tests {
|
||||
self.name == other.name && self.backend == other.backend
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// K1: pair-list encryption parser tests.
|
||||
//
|
||||
// Pure function; no env-var seeding needed. Table-driven where
|
||||
// the shape allows, individual tests where the error message is
|
||||
// load-bearing.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
mod pair_list_parser {
|
||||
use super::*;
|
||||
|
||||
/// 32-byte base64 string, deterministic across tests. Two
|
||||
/// distinct valid keys for multi-pair tests.
|
||||
const K1_B64: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; // 0x00..0x1F
|
||||
const K2_B64: &str = "IHwgP2AVE1E6VwlbT8BjSggJc9OjNXJDKf8bF19HYPU="; // random
|
||||
|
||||
#[test]
|
||||
fn single_key_no_cipher_prefix_defaults_to_aes_gcm() {
|
||||
let pairs = parse_encryption_pair_list("t", K1_B64).unwrap();
|
||||
assert_eq!(pairs.len(), 1);
|
||||
assert_eq!(pairs[0].cipher, CipherKind::AesGcm256);
|
||||
assert!(pairs[0].key_material.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_key_with_explicit_cipher_prefix() {
|
||||
let pairs = parse_encryption_pair_list("t", &format!("aes-256-gcm:{K1_B64}")).unwrap();
|
||||
assert_eq!(pairs.len(), 1);
|
||||
assert_eq!(pairs[0].cipher, CipherKind::AesGcm256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cipher_prefix_is_case_insensitive() {
|
||||
for tok in [
|
||||
"aes-256-gcm",
|
||||
"AES-256-GCM",
|
||||
"Aes-256-Gcm",
|
||||
"aes256gcm",
|
||||
"AES256GCM",
|
||||
] {
|
||||
let pairs = parse_encryption_pair_list("t", &format!("{tok}:{K1_B64}")).unwrap();
|
||||
assert_eq!(pairs.len(), 1, "failed on token `{tok}`");
|
||||
assert_eq!(pairs[0].cipher, CipherKind::AesGcm256);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_pair_rotation_last_wins_on_writes() {
|
||||
let raw = format!("aes-256-gcm:{K1_B64},aes-256-gcm:{K2_B64}");
|
||||
let pairs = parse_encryption_pair_list("t", &raw).unwrap();
|
||||
assert_eq!(pairs.len(), 2);
|
||||
// Head pair (the write pair) is the LAST one — this test
|
||||
// pins that invariant. When K2 wires the head-pair
|
||||
// helpers, `pairs.last()` MUST resolve to K2's material.
|
||||
let head = pairs.last().unwrap();
|
||||
assert_eq!(head.cipher, CipherKind::AesGcm256);
|
||||
// Materials differ.
|
||||
assert_ne!(pairs[0].key_material, pairs[1].key_material);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_alone_is_legal_and_equivalent_to_unencrypted() {
|
||||
let pairs = parse_encryption_pair_list("t", "none:").unwrap();
|
||||
assert_eq!(pairs.len(), 1);
|
||||
assert_eq!(pairs[0].cipher, CipherKind::None);
|
||||
assert!(pairs[0].key_material.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_first_then_aes_is_encrypt_migration_shape() {
|
||||
// `none:,aes:K` — head is aes, writes now encrypt. Legacy
|
||||
// plaintext blobs still read via the `none` pair while the
|
||||
// rotation job walks them.
|
||||
let raw = format!("none:,aes-256-gcm:{K1_B64}");
|
||||
let pairs = parse_encryption_pair_list("t", &raw).unwrap();
|
||||
assert_eq!(pairs.len(), 2);
|
||||
assert_eq!(pairs[0].cipher, CipherKind::None);
|
||||
assert_eq!(pairs[1].cipher, CipherKind::AesGcm256);
|
||||
assert!(pairs[1].key_material.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_first_then_none_is_decrypt_migration_shape() {
|
||||
// `aes:K,none:` — head is none, writes now produce plaintext.
|
||||
let raw = format!("aes-256-gcm:{K1_B64},none:");
|
||||
let pairs = parse_encryption_pair_list("t", &raw).unwrap();
|
||||
assert_eq!(pairs.len(), 2);
|
||||
assert_eq!(pairs[0].cipher, CipherKind::AesGcm256);
|
||||
assert_eq!(pairs[1].cipher, CipherKind::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_around_separators_is_tolerated() {
|
||||
let raw = format!(" aes-256-gcm : {K1_B64} , none: ");
|
||||
let pairs = parse_encryption_pair_list("t", &raw).unwrap();
|
||||
assert_eq!(pairs.len(), 2);
|
||||
assert_eq!(pairs[0].cipher, CipherKind::AesGcm256);
|
||||
assert_eq!(pairs[1].cipher, CipherKind::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_rejected() {
|
||||
for raw in ["", " ", "\t\n "] {
|
||||
let err = parse_encryption_pair_list("t", raw).unwrap_err();
|
||||
assert!(err.contains("empty"), "raw={raw:?} err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_or_trailing_comma_rejected() {
|
||||
for raw in [
|
||||
format!(",aes-256-gcm:{K1_B64}"),
|
||||
format!("aes-256-gcm:{K1_B64},"),
|
||||
format!("aes-256-gcm:{K1_B64},,aes-256-gcm:{K2_B64}"),
|
||||
] {
|
||||
let err = parse_encryption_pair_list("t", &raw).unwrap_err();
|
||||
assert!(err.contains("empty pair"), "raw={raw:?} err={err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cipher_rejected() {
|
||||
let err = parse_encryption_pair_list("t", &format!("chacha20:{K1_B64}")).unwrap_err();
|
||||
assert!(err.contains("unknown cipher"), "err was: {err}");
|
||||
assert!(err.contains("chacha20"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_with_material_rejected() {
|
||||
// `none:<something>` — nonsense. Must be `none:` (empty
|
||||
// material after the colon).
|
||||
let err = parse_encryption_pair_list("t", &format!("none:{K1_B64}")).unwrap_err();
|
||||
assert!(
|
||||
err.contains("cipher `none` but has key material"),
|
||||
"err was: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_none_pairs_rejected() {
|
||||
let err = parse_encryption_pair_list("t", "none:,none:").unwrap_err();
|
||||
assert!(err.contains("more than one `none` pair"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_material_for_real_cipher_rejected() {
|
||||
let err = parse_encryption_pair_list("t", "aes-256-gcm:").unwrap_err();
|
||||
assert!(err.contains("empty key material"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_base64_key_rejected() {
|
||||
let err = parse_encryption_pair_list("t", "aes-256-gcm:not_base64!!").unwrap_err();
|
||||
assert!(err.contains("not valid base64"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_length_key_rejected() {
|
||||
// "AAAA" decodes to 3 bytes — valid base64, wrong length.
|
||||
let err = parse_encryption_pair_list("t", "aes-256-gcm:AAAA").unwrap_err();
|
||||
assert!(err.contains("32 bytes"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_key_material_rejected() {
|
||||
let raw = format!("aes-256-gcm:{K1_B64},aes-256-gcm:{K1_B64}");
|
||||
let err = parse_encryption_pair_list("t", &raw).unwrap_err();
|
||||
assert!(err.contains("same key material twice"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_name_appears_in_error_message() {
|
||||
let err = parse_encryption_pair_list("s3_prod", "").unwrap_err();
|
||||
assert!(err.contains("s3_prod"), "err was: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_12_hex_chars_for_real_cipher_and_none_for_none() {
|
||||
let pairs =
|
||||
parse_encryption_pair_list("t", &format!("aes-256-gcm:{K1_B64},none:")).unwrap();
|
||||
let fp0 = pairs[0].fingerprint_short().unwrap();
|
||||
assert_eq!(fp0.len(), 12);
|
||||
assert!(fp0.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert!(pairs[1].fingerprint_short().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_stable_across_calls() {
|
||||
let pairs_a = parse_encryption_pair_list("t", K1_B64).unwrap();
|
||||
let pairs_b = parse_encryption_pair_list("t", K1_B64).unwrap();
|
||||
assert_eq!(
|
||||
pairs_a[0].fingerprint_short(),
|
||||
pairs_b[0].fingerprint_short()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_differs_between_different_keys() {
|
||||
let pairs = parse_encryption_pair_list(
|
||||
"t",
|
||||
&format!("aes-256-gcm:{K1_B64},aes-256-gcm:{K2_B64}"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(pairs[0].fingerprint_short(), pairs[1].fingerprint_short());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user