feat(rotate-key): show finger print + way to know if can remove key
This commit is contained in:
@@ -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
@@ -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
@@ -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!();
|
||||
|
||||
Reference in New Issue
Block a user