using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.Audit; using ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ApiKeyAck; /// /// lmxopcua#749 — the AdminUI half of the per-key Galaxy ack identity: written ENCRYPTED to the Secrets /// store under the names the driver reads, listed without touching the password, cleared on request, and /// audited without the credential. /// public sealed class ApiKeyGalaxyAckIdentityServiceTests { private const string KeyId = "0a1b2c3d4e5f6071"; private const string Password = "pw-S3cr3t!"; private readonly InMemorySecretStore _store = new(); private readonly XorCipher _cipher = new(); private readonly RecordingAudit _audit = new(); private readonly RecordingInvalidator _invalidator = new(); private ApiKeyGalaxyAckIdentityService Service() => new(_store, _cipher, _audit, _invalidator); [Fact] public async Task Set_writes_both_secrets_encrypted_under_the_driver_names() { await Service().SetAsync(KeyId, "alice", "CORP", Password, "admin", TestContext.Current.CancellationToken); var user = _store.Rows[GalaxyAckIdentitySecrets.UserSecretName(KeyId)]; var pw = _store.Rows[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)]; _cipher.Decrypt(user).ShouldBe("CORP\\alice"); _cipher.Decrypt(pw).ShouldBe(Password); // At rest it is ciphertext, not the password. Encoding.UTF8.GetString(pw.Ciphertext).ShouldNotContain(Password); pw.Description.ShouldNotBeNull().ShouldNotContain(Password); // Password written before the user (a reader never sees a user without its password). _store.UpsertOrder.ShouldBe([GalaxyAckIdentitySecrets.PasswordSecretName(KeyId), GalaxyAckIdentitySecrets.UserSecretName(KeyId)]); _invalidator.Invalidated.ShouldContain(GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)); } [Fact] public async Task List_returns_identity_and_never_decrypts_a_password() { var svc = Service(); await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", TestContext.Current.CancellationToken); await svc.SetAsync("ffff0000ffff0000", "bob", null, "", "admin", TestContext.Current.CancellationToken); _store.GetCalls.Clear(); var list = await svc.ListAsync(TestContext.Current.CancellationToken); list[KeyId].ShouldBe("CORP\\alice"); list["ffff0000ffff0000"].ShouldBe("bob"); _store.GetCalls.ShouldAllBe(n => n.EndsWith("/galaxy-ack-user")); } [Fact] public async Task Clear_removes_both_and_audits_without_the_credential() { var svc = Service(); await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", TestContext.Current.CancellationToken); await svc.ClearAsync(KeyId, "admin", TestContext.Current.CancellationToken); _store.Rows.ShouldBeEmpty(); (await svc.ListAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty(); _audit.Events.Select(e => e.Action).ShouldBe(["apikey.galaxy-ack-identity.set", "apikey.galaxy-ack-identity.clear"]); _audit.Events.ShouldAllBe(e => e.Target == KeyId && e.Actor == "admin"); string.Join("|", _audit.Events.Select(e => e.ToString())).ShouldNotContain(Password); } [Theory] [InlineData("")] [InlineData("CORP\\alice")] public async Task Set_rejects_missing_or_domain_qualified_user(string user) { await Should.ThrowAsync(() => Service().SetAsync(KeyId, user, null, Password, "admin", default)); _store.Rows.ShouldBeEmpty(); } [Fact] public async Task Set_rejects_a_malformed_key_id() { await Should.ThrowAsync(() => Service().SetAsync("../x", "alice", null, Password, "admin", default)); _store.Rows.ShouldBeEmpty(); } /// Reversible stand-in for the AES-GCM envelope cipher — enough to prove the value is not stored in clear. private sealed class XorCipher : ISecretCipher { public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType) => new() { Name = name, ContentType = contentType, Ciphertext = Encoding.UTF8.GetBytes(plaintext).Select(b => (byte)(b ^ 0x5A)).ToArray(), Nonce = [], Tag = [], WrappedDek = [], WrapNonce = [], WrapTag = [], KekId = "test", Revision = 0, CreatedUtc = DateTimeOffset.UtcNow, UpdatedUtc = DateTimeOffset.UtcNow, }; public string Decrypt(StoredSecret secret) => Encoding.UTF8.GetString(secret.Ciphertext.Select(b => (byte)(b ^ 0x5A)).ToArray()); public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek) => throw new NotSupportedException(); } private sealed class RecordingAudit : IAuditWriter { public List Events { get; } = []; public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; } } private sealed class RecordingInvalidator : ISecretCacheInvalidator { public List Invalidated { get; } = []; public void Invalidate(SecretName name) => Invalidated.Add(name.Value); } private sealed class InMemorySecretStore : ISecretStore { public Dictionary Rows { get; } = new(); public List UpsertOrder { get; } = []; public List GetCalls { get; } = []; public Task GetAsync(SecretName name, CancellationToken ct) { GetCalls.Add(name.Value); return Task.FromResult(Rows.TryGetValue(name.Value, out var r) ? r : null); } public Task UpsertAsync(StoredSecret row, CancellationToken ct) { UpsertOrder.Add(row.Name.Value); Rows[row.Name.Value] = row; return Task.CompletedTask; } public Task DeleteAsync(SecretName name, string? actor, CancellationToken ct) => Task.FromResult(Rows.Remove(name.Value)); public Task> ListAsync(bool includeDeleted, CancellationToken ct) => Task.FromResult>(Rows.Values.Select(r => new SecretMetadata { Name = r.Name, ContentType = r.ContentType, KekId = r.KekId, Revision = r.Revision, CreatedUtc = r.CreatedUtc, UpdatedUtc = r.UpdatedUtc, }).ToList()); public Task> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException(); public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException(); public Task ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException(); } }