feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||
|
||||
/// <summary>
|
||||
/// Tally of an <see cref="BundleService.ImportAsync"/> run.
|
||||
/// </summary>
|
||||
/// <param name="Imported">Rows written (new inserts plus conflict winners).</param>
|
||||
/// <param name="SkippedOlder">Rows a last-writer-wins (or override) decision rejected as not newer.</param>
|
||||
/// <param name="SkippedForeignKek">
|
||||
/// Rows wrapped under a KEK other than the target's, with no matching source KEK supplied to re-wrap them.
|
||||
/// </param>
|
||||
/// <param name="Conflicts">Rows whose name already existed in the target store (won or lost).</param>
|
||||
public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
|
||||
|
||||
/// <summary>
|
||||
/// Exports and imports <see cref="SecretBundle"/> documents — ciphertext-only movement of secret rows
|
||||
/// between deployment stores. Import reconciles per row with the shared last-writer-wins ordering (an
|
||||
/// optional per-row override can force a decision) and, when a row is wrapped under a foreign KEK,
|
||||
/// re-wraps it under the target's KEK given the source KEK — otherwise it is skipped and reported.
|
||||
/// Both operations require a <b>full</b> (non-degraded) session: import needs the target cipher to
|
||||
/// re-wrap, and export is a data-movement operation that must not run half-configured.
|
||||
/// </summary>
|
||||
public sealed class BundleService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>Creates the service.</summary>
|
||||
/// <param name="timeProvider">Clock used to stamp <see cref="SecretBundle.ExportedUtc"/>; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
public BundleService(TimeProvider? timeProvider = null) =>
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <summary>
|
||||
/// Exports every secret in <paramref name="session"/>'s store to a ciphertext-only bundle at
|
||||
/// <paramref name="path"/>, written atomically (temp file then move). Tombstones are excluded unless
|
||||
/// <paramref name="includeDeleted"/> is set.
|
||||
/// </summary>
|
||||
/// <param name="session">The full session to export from.</param>
|
||||
/// <param name="path">Destination bundle path.</param>
|
||||
/// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are exported too.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>The number of rows written to the bundle.</returns>
|
||||
/// <exception cref="InvalidOperationException">The session is degraded (no KEK).</exception>
|
||||
public async Task<int> ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
IMasterKeyProvider masterKey = session.MasterKey
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot export a bundle from a degraded session (no KEK available).");
|
||||
|
||||
IReadOnlyList<SecretMetadata> metadata =
|
||||
await session.Store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
|
||||
|
||||
var entries = new List<BundleEntry>(metadata.Count);
|
||||
foreach (SecretMetadata meta in metadata)
|
||||
{
|
||||
StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false);
|
||||
if (row is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(SecretBundleCodec.FromRow(row));
|
||||
}
|
||||
|
||||
var bundle = new SecretBundle
|
||||
{
|
||||
ExportedUtc = _timeProvider.GetUtcNow(),
|
||||
SourceKekId = masterKey.KekId,
|
||||
Entries = entries,
|
||||
};
|
||||
|
||||
await WriteAtomicAsync(path, SecretBundleCodec.Serialize(bundle), ct).ConfigureAwait(false);
|
||||
return entries.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports the bundle at <paramref name="path"/> into <paramref name="session"/>'s store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per row: a row wrapped under a foreign KEK is re-wrapped under the target KEK when
|
||||
/// <paramref name="sourceKek"/> matches its <see cref="StoredSecret.KekId"/>, otherwise it is
|
||||
/// skipped (<see cref="BundleImportReport.SkippedForeignKek"/>). A row whose name does not yet
|
||||
/// exist is imported. A row whose name already exists is a conflict resolved by
|
||||
/// <paramref name="conflictOverride"/> when supplied, else by <see cref="SecretLastWriterWins"/>;
|
||||
/// the winner is written and the loser skipped (<see cref="BundleImportReport.SkippedOlder"/>).
|
||||
/// No plaintext is ever handled — the bundle is ciphertext only.
|
||||
/// </remarks>
|
||||
/// <param name="session">The full session to import into.</param>
|
||||
/// <param name="path">Source bundle path.</param>
|
||||
/// <param name="sourceKek">The KEK the bundle was exported under, to re-wrap foreign-KEK rows; may be <see langword="null"/>.</param>
|
||||
/// <param name="conflictOverride">
|
||||
/// Optional per-row decision for an existing row: given (existing, incoming), return
|
||||
/// <see langword="true"/> to take the incoming row. When <see langword="null"/>, last-writer-wins decides.
|
||||
/// </param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>A tally of the import.</returns>
|
||||
/// <exception cref="InvalidOperationException">The session is degraded (no KEK/cipher).</exception>
|
||||
public async Task<BundleImportReport> ImportAsync(
|
||||
SecretsSession session,
|
||||
string path,
|
||||
IMasterKeyProvider? sourceKek,
|
||||
Func<StoredSecret, StoredSecret, bool>? conflictOverride,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
IMasterKeyProvider targetKek = session.MasterKey
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot import a bundle into a degraded session (no KEK available).");
|
||||
ISecretCipher cipher = session.Cipher
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot import a bundle into a degraded session (no cipher available).");
|
||||
|
||||
string json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
|
||||
SecretBundle bundle = SecretBundleCodec.Deserialize(json);
|
||||
|
||||
int imported = 0, skippedOlder = 0, skippedForeignKek = 0, conflicts = 0;
|
||||
|
||||
foreach (BundleEntry entry in bundle.Entries)
|
||||
{
|
||||
StoredSecret row = SecretBundleCodec.ToRow(entry);
|
||||
|
||||
// Foreign KEK: re-wrap under the target KEK if the source KEK is available, else skip.
|
||||
if (!string.Equals(row.KekId, targetKek.KekId, StringComparison.Ordinal))
|
||||
{
|
||||
if (sourceKek is not null && string.Equals(sourceKek.KekId, row.KekId, StringComparison.Ordinal))
|
||||
{
|
||||
row = cipher.Rewrap(row, sourceKek, targetKek);
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedForeignKek++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
StoredSecret? existing = await session.Store.GetAsync(row.Name, ct).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
|
||||
imported++;
|
||||
continue;
|
||||
}
|
||||
|
||||
conflicts++;
|
||||
bool takeIncoming = conflictOverride is not null
|
||||
? conflictOverride(existing, row)
|
||||
: SecretLastWriterWins.IsNewer(row.UpdatedUtc, row.Revision, existing.UpdatedUtc, existing.Revision);
|
||||
|
||||
if (takeIncoming)
|
||||
{
|
||||
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
|
||||
imported++;
|
||||
}
|
||||
else
|
||||
{
|
||||
skippedOlder++;
|
||||
}
|
||||
}
|
||||
|
||||
return new BundleImportReport(imported, skippedOlder, skippedForeignKek, conflicts);
|
||||
}
|
||||
|
||||
// Writes content to a sibling temp file then moves it over the target, so a reader never observes
|
||||
// a half-written bundle and a crash mid-write cannot corrupt an existing bundle.
|
||||
private static async Task WriteAtomicAsync(string path, string content, CancellationToken ct)
|
||||
{
|
||||
string directory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? ".";
|
||||
Directory.CreateDirectory(directory);
|
||||
string temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(temp, content, ct).ConfigureAwait(false);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temp))
|
||||
{
|
||||
try { File.Delete(temp); } catch (IOException) { /* best-effort cleanup of the temp file */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user