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();