feat(rotate-key): show finger print + way to know if can remove key

This commit is contained in:
Edouard Vanbelle
2026-08-02 04:26:47 +02:00
parent 47246592b7
commit dd1528de92
7 changed files with 429 additions and 50 deletions
+34
View File
@@ -211,6 +211,40 @@ pub struct StorageEntrySummaryDto {
/// Azure). Cosmetic — helps the admin distinguish two Local
/// entries pointing at different disks.
pub location_hint: Option<String>,
/// Ordered pair-list summary — one entry per configured pair in
/// `OXICLOUD_STORAGE_<NAME>_ENCRYPTION_KEY`, oldest first, head
/// last. Empty vec means the entry has no `_ENCRYPTION_KEY`
/// declared at all (pure plaintext-v1 writes today, no crypto).
///
/// Frontend renders this on the entry card so operators can:
/// - See which pairs are configured + their SSH-style
/// fingerprints without inspecting `.env`.
/// - Cross-reference the head pair against the `head_key_fp`
/// from the last `storage_rotate` completion — if they
/// match AND `failed = 0`, every on-disk blob is under the
/// head, and non-head pairs are safe to remove.
#[serde(default)]
pub encryption_pairs: Vec<StorageEncryptionPairDto>,
}
/// One `<cipher>:<key>` pair rendered for the admin UI. Never
/// carries key material — only cipher name + a truncated fingerprint
/// safe to show operators.
#[derive(Debug, Serialize, Deserialize)]
pub struct StorageEncryptionPairDto {
/// `"aes-256-gcm"` for a real-cipher pair, `"none"` for a
/// `none:` sentinel (writes as plaintext-v1).
pub cipher: String,
/// SSH-style colon-hex 8-byte truncation of `sha256(key)`.
/// Matches the v1 header's `<key_fp>` field and the CLI's
/// `oxicloud --fingerprint <key>` output. `None` for `none:`
/// pairs (no key material to fingerprint).
#[serde(skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// True for the LAST pair in the list — the write pair. UI
/// badges it distinctly ("← head" or an arrow). Exactly one
/// pair has `is_head = true` when the list is non-empty.
pub is_head: bool,
}
/// Request body for saving storage settings from the admin panel
@@ -265,16 +265,36 @@ impl StorageSettingsService {
let entries: Vec<StorageEntrySummaryDto> = self
.storage_entries
.iter()
.map(|e| StorageEntrySummaryDto {
name: e.name.clone(),
backend: match e.backend {
StorageBackendType::Local => "local".to_string(),
StorageBackendType::S3 => "s3".to_string(),
StorageBackendType::Azure => "azure".to_string(),
},
is_active: e.name == active_entry_name,
encryption_enabled: e.is_encrypted(),
location_hint: entry_location_hint(e),
.map(|e| {
// Render the pair-list summary — one row per pair,
// head marked. Never emits key material; only
// cipher + fingerprint. See `StorageEncryptionPairDto`
// for the display contract.
let pairs = e.encryption_pairs();
let head_idx = pairs.len().saturating_sub(1);
let encryption_pairs = pairs
.iter()
.enumerate()
.map(|(i, kp)| {
crate::application::dtos::settings_dto::StorageEncryptionPairDto {
cipher: kp.cipher.as_str().to_string(),
fingerprint: kp.fingerprint_short(),
is_head: i == head_idx,
}
})
.collect();
StorageEntrySummaryDto {
name: e.name.clone(),
backend: match e.backend {
StorageBackendType::Local => "local".to_string(),
StorageBackendType::S3 => "s3".to_string(),
StorageBackendType::Azure => "azure".to_string(),
},
is_active: e.name == active_entry_name,
encryption_enabled: e.is_encrypted(),
location_hint: entry_location_hint(e),
encryption_pairs,
}
})
.collect();
+107 -21
View File
@@ -507,15 +507,13 @@ 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.
/// SSH-style colon-hex fingerprint of the key material — 8 bytes
/// of SHA-256 truncation rendered as `xx:yy:zz:...`. Same
/// truncation as the v1 header's `<key_fp>` field and the
/// `head_key_fp` reported by `storage_rotate` on completion, so
/// operators can cross-reference the boot log against a rotate
/// report or the CLI's `oxicloud --fingerprint <base64key>`
/// output without any format conversion.
///
/// Returns `None` for `CipherKind::None` (nothing to
/// fingerprint) — callers render as `—` in that case.
@@ -523,7 +521,13 @@ impl KeyPair {
use sha2::{Digest, Sha256};
let mat = self.key_material.as_ref()?;
let full = Sha256::digest(mat);
Some(hex::encode(&full[..6]))
Some(
full[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":"),
)
}
/// 8-byte SHA-256 truncation used as the v1 header's `<key_fp>`
@@ -712,6 +716,38 @@ pub fn parse_encryption_pair_list(entry_name: &str, raw: &str) -> Result<Vec<Key
Ok(pairs)
}
/// One-shot helper that computes the SSH-style colon-hex fingerprint
/// of a base64-encoded AES-256 key.
///
/// Wraps [`KeyPair::new_aes_gcm`] + [`KeyPair::fingerprint_short`]
/// with the same base64 / length validation the pair-list parser
/// uses, so callers don't have to reimplement it.
///
/// Used by the `oxicloud --fingerprint <base64>` CLI subcommand so
/// admins can identify which key in their `.env` corresponds to the
/// `head_key_fp` a `storage_rotate` run reported on completion —
/// see `docs/plan/storage-key-rotation.md`.
///
/// Errors on non-base64 input or on decoded length ≠ 32 bytes (the
/// AES-256 key size constraint).
pub fn fingerprint_from_base64_key(key_b64: &str) -> Result<String, String> {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(key_b64.trim())
.map_err(|e| format!("input is not valid base64: {e}"))?;
if decoded.len() != 32 {
return Err(format!(
"decoded key is {} bytes; AES-256 requires exactly 32",
decoded.len()
));
}
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
Ok(KeyPair::new_aes_gcm(key)
.fingerprint_short()
.expect("aes_gcm pair always has key material"))
}
/// Emit a per-entry boot line summarising the pair-list — one line
/// per encrypted entry, with each pair's cipher and truncated
/// fingerprint, and a `←` marker on the head pair (the write pair).
@@ -3891,12 +3927,22 @@ mod tests {
}
#[test]
fn fingerprint_is_12_hex_chars_for_real_cipher_and_none_for_none() {
fn fingerprint_is_ssh_style_colon_hex_for_real_cipher_and_none_for_none() {
// K3.7: display fp switched from 12-char raw hex to
// SSH-style 8-byte colon-hex (16 hex + 7 colons = 23 chars)
// so operators can cross-reference against the v1 header's
// `<key_fp>` field + `storage_rotate`'s `head_key_fp`
// output + the `oxicloud --fingerprint` CLI.
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_eq!(
fp0.len(),
23,
"expected xx:yy:… shape (23 chars), got {fp0:?}"
);
assert_eq!(fp0.matches(':').count(), 7);
assert!(fp0.chars().all(|c| c == ':' || c.is_ascii_hexdigit()));
assert!(pairs[1].fingerprint_short().is_none());
}
@@ -3957,17 +4003,57 @@ mod tests {
}
#[test]
fn key_fp_and_fingerprint_short_share_prefix() {
// Both derive from the same sha256(key); the on-blob fp is
// 8 raw bytes, the log fp is hex of the first 6. Pinning
fn key_fp_and_fingerprint_short_are_the_same_underlying_bytes() {
// K3.7 unified: both render the FIRST 8 bytes of
// sha256(key). `key_fp` returns them raw for the header;
// `fingerprint_short` renders them as colon-hex for
// display. Stripping the colons from the display form
// should match `hex::encode(key_fp)` exactly. Pinning
// this alignment protects against a future refactor that
// accidentally switches one to a different hash / offset.
// silently switches one to a different truncation.
let pairs = parse_encryption_pair_list("t", K1_B64).unwrap();
let hex_fp = pairs[0].fingerprint_short().unwrap();
let display_fp = pairs[0].fingerprint_short().unwrap();
let raw_fp = pairs[0].key_fp();
// First 12 hex chars of the log fp = hex of the first 6
// bytes of the raw fp.
assert_eq!(&hex_fp[..12], &hex::encode(&raw_fp[..6]));
let display_stripped: String = display_fp.chars().filter(|c| *c != ':').collect();
assert_eq!(display_stripped, hex::encode(raw_fp));
}
// ── `fingerprint_from_base64_key` (CLI helper) ───────────────
#[test]
fn fingerprint_from_base64_key_all_zero_key() {
// 32 zero bytes → deterministic sha256; the first 8 bytes
// truncation is the SSH-style prefix that ships everywhere
// (v1 header, rotate output, boot log, CLI).
let fp = fingerprint_from_base64_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.unwrap();
assert_eq!(fp, "66:68:7a:ad:f8:62:bd:77");
}
#[test]
fn fingerprint_from_base64_key_wrong_length_rejected() {
// "AAAA" base64-decodes to 3 bytes, not 32.
let err = fingerprint_from_base64_key("AAAA").unwrap_err();
assert!(err.contains("32"), "err was: {err}");
}
#[test]
fn fingerprint_from_base64_key_non_base64_rejected() {
let err = fingerprint_from_base64_key("not@base64!").unwrap_err();
assert!(err.contains("base64"), "err was: {err}");
}
#[test]
fn fingerprint_from_base64_key_matches_pair_list_fp() {
// Passing the same key material through both paths — the
// CLI helper and the pair-list parser — MUST yield the
// same fingerprint. Guards against a future refactor that
// silently switches truncation or hash between the two
// consumers.
let cli_fp = fingerprint_from_base64_key(K1_B64).unwrap();
let pairs = parse_encryption_pair_list("t", K1_B64).unwrap();
let parser_fp = pairs[0].fingerprint_short().unwrap();
assert_eq!(cli_fp, parser_fp);
}
}
}
@@ -65,6 +65,7 @@ use crate::infrastructure::scheduler::{
JobRegistry, JobRunArgs, JobStore, JobStoreProvider, RecoverableJobHandler, RunOutcome,
RunStatus, record_or_log,
};
use crate::infrastructure::services::encrypted_blob_backend::BlobFormat;
use crate::infrastructure::services::entry_backend::build_entry_backend_typed;
pub const STORAGE_ROTATE_JOB_NAME: &str = "storage_rotate";
@@ -332,6 +333,7 @@ impl RecoverableJobHandler for StorageRotateService {
.finish_completed(
store,
&target_name,
head_format,
rewritten_count,
skipped_count,
failed_count,
@@ -454,6 +456,7 @@ impl RecoverableJobHandler for StorageRotateService {
.finish_completed(
store,
&target_name,
head_format,
rewritten_count,
skipped_count,
failed_count,
@@ -469,15 +472,40 @@ impl StorageRotateService {
/// final audit line. Unlike `storage_migration::finish_completed`
/// there's no cutover / hot-swap step: rotation writes in place
/// on the entry that's already there.
///
/// `head_format` — the target format at run completion. Persisted
/// into the run row's `stats` as `head_format` (Display) +
/// `head_key_fp` (raw hex) so operators have a durable record of
/// "at time T, all blobs on entry E were normalised to fingerprint
/// F". Combined with `failed = 0`, that's the signal to remove
/// obsolete keys from `.env` — any key NOT matching `head_key_fp`
/// no longer decrypts any live blob and can be safely dropped.
async fn finish_completed(
&self,
store: &dyn JobStore,
target_name: &str,
head_format: BlobFormat,
rewritten: u64,
skipped: u64,
failed: u64,
) -> RunOutcome {
self.clear_progress();
// Render two fingerprint shapes:
// * `head_format` — Display impl, e.g.
// `encrypted-v1 key_fp=15:f3:8f:80:2c:ae:2c:50` — human
// friendly for audit logs + admin UI.
// * `head_key_fp` — bare 16-hex string, matches what an
// operator gets from `openssl dgst -sha256 <keyfile> | head -c 16`
// so post-hoc verification against the raw key material
// is trivial.
let head_display = format!("{head_format}");
let head_key_fp_hex = match head_format {
BlobFormat::EncryptedV1 { key_fp } => hex::encode(key_fp),
BlobFormat::PlaintextV1 => String::new(), // all-zero, uninformative
BlobFormat::Legacy => String::new(), // never emitted at head
};
tracing::info!(
target: "audit",
event = "storage_rotate.run_completed",
@@ -486,17 +514,25 @@ impl StorageRotateService {
rewritten = rewritten,
skipped = skipped,
failed = failed,
"storage_rotate completed on `{target_name}` — {rewritten} rewritten, {skipped} skipped, {failed} failed"
head_format = %head_display,
"storage_rotate completed on `{target_name}` — {rewritten} rewritten, {skipped} skipped, {failed} failed; head = {head_display}"
);
// Surface the per-run summary counters as extras merged into
// the run row's `stats` JSONB. Frontend renders whatever keys
// are present, so no wire-format bumping is needed — the
// admin UI's run drawer just picks these up alongside the
// engine-owned `finding_count` + `scanned_count`.
//
// `head_key_fp` empty string when head is not an encrypted
// pair (plaintext-v1) — frontend can render "all in clear"
// vs "all under fp <X>" based on that discriminator.
RunOutcome::completed_with(serde_json::json!({
"rewritten": rewritten,
"skipped": skipped,
"failed": failed,
"rewritten": rewritten,
"skipped": skipped,
"failed": failed,
"head_format": head_display,
"head_key_fp": head_key_fp_hex,
}))
}
+64
View File
@@ -168,6 +168,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
};
select_storage = Some(name);
}
"--fingerprint" => {
// One-shot helper: compute the SSH-style colon-hex
// fingerprint of a base64-encoded AES-256 key and
// print to stdout. Same truncation used by the v1
// header's `<key_fp>` field + the `storage_rotate`
// completion summary — so an admin can:
// 1. Look at the `head_key_fp` reported by the
// last rotate run.
// 2. Run `oxicloud --fingerprint <base64key>` for
// each candidate in `.env`.
// 3. Match — the key that produces the reported
// fingerprint is the current head; any other
// key in `_ENCRYPTION_KEY` no longer decrypts
// any live blob and can be dropped.
//
// Also accepts `-` for stdin so keys never touch the
// shell history:
// echo -n '<base64>' | oxicloud --fingerprint -
let Some(key_b64) = args.next() else {
eprintln!("--fingerprint requires a base64 key argument (or `-` for stdin)");
std::process::exit(2);
};
let key_b64 = if key_b64 == "-" {
use std::io::Read;
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("failed to read key from stdin: {e}");
std::process::exit(2);
}
buf.trim().to_string()
} else {
key_b64
};
match oxicloud::common::config::fingerprint_from_base64_key(&key_b64) {
Ok(fp) => {
println!("{fp}");
return Ok(());
}
Err(e) => {
eprintln!("--fingerprint: {e}");
std::process::exit(2);
}
}
}
"--help" | "-h" => {
print_help();
return Ok(());
@@ -250,6 +294,13 @@ fn print_help() {
println!(" oxicloud --select-storage <name> One-shot repair — set the active");
println!(" storage entry in the DB and exit.");
println!();
println!(" oxicloud --fingerprint <base64key|-> One-shot helper — print the SSH-style");
println!(" fingerprint of a base64 AES-256 key.");
println!(" Same shape used by the v1 blob header");
println!(" + `storage_rotate` completion summary.");
println!(" Read stdin with `-` to keep keys out");
println!(" of shell history.");
println!();
println!(" oxicloud --version Print version + commit and exit.");
println!();
println!(" oxicloud --help Print this help and exit.");
@@ -273,6 +324,19 @@ fn print_help() {
println!(" when that happens). See `docs/plan/storage-multi-entry.md`");
println!(" §Fallback for the full recovery flow.");
println!();
println!(" --fingerprint <base64key | ->");
println!(" Compute the SSH-style colon-hex fingerprint (16-hex, 8-byte");
println!(" truncation of sha256) of a base64-encoded AES-256 key. Matches the");
println!(" `head_key_fp` field the `storage_rotate` job reports on completion,");
println!(" and the raw <key_fp> field embedded in every v1 blob header. Used");
println!(" to identify which key in `OXICLOUD_STORAGE_<N>_ENCRYPTION_KEY`");
println!(" corresponds to the current on-disk head — safe to drop any key");
println!(" whose fingerprint does NOT match the last-successful rotate's");
println!(" `head_key_fp`. Pass `-` to read the key from stdin so it never");
println!(" touches shell history:");
println!();
println!(" echo -n '<base64>' | oxicloud --fingerprint -");
println!();
println!(" --version, -V");
println!(" Print the version, git branch, and commit hash. Exits 0.");
println!();