feat(acl): ApiKey entity + key format, and put Core on the server's graph (#520)
Stage 1b. Still no enforcement wired — this adds the credential model the identity cutover (stage 2) will read. ApiKey holds one principal per key, deliberately. The reason to separate keys from principals is so many keys share a grant set and rotating a key never touches an ACL row, and that holds with one principal per key since nothing stops two keys naming the same principal. A key belonging to several principals at once buys little and costs a join table. UserAuthorizationState.Principals stays a list, so adding it later needs no ACL-side change. ApiKeySecret is the security-critical half and is pure, so it is tested on its own: otua_<keyId>_<secret>, peppered HMAC-SHA256, FixedTimeEquals. Notes why a plain HMAC rather than an iterated KDF (the secret is 32 bytes of CSPRNG, and the call runs inline on session activation, which the SDK serializes server-wide behind its SessionManager event lock), and fails closed on an unset pepper rather than hashing under an empty key. Found and fixed a bug in my own first draft: the key id was url-safe base64, whose alphabet maps '/' to '_' — the very character TryParse splits on. Roughly one key in five would have had its id truncated at a random position, an intermittent auth failure reproducing only for SOME keys. The id is hex now, and the guard is falsified: reverting the alphabet reds both The_generated_key_id_never_contains_the_separator and, as a bonus signal, Two_generated_keys_never_collide, because truncated ids collide. ApiKeyId is NOT NULL, unlike the nullable NodeAclId precedent whose filtered unique index would admit two NULL ids. Fine for a config row, not for a credentials lookup key. OpcUaServer.csproj now references Core. Until now it did not, so IPermissionEvaluator was literally uncallable from the node manager — the place enforcement has to happen. No new transitive package surface. Schema doc updated for both, and its NodeAcl block flagged for pre-existing drift (it still documents the GenerationId column v3 dropped) without fixing it here. Core 247, OpcUaServer 364, Configuration 128 — all green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -472,13 +472,19 @@ All five are exposed as **OPC UA properties** on the equipment node so external
|
||||
|
||||
### `NodeAcl`
|
||||
|
||||
> ⚠️ **Pre-existing drift, noted 2026-07-31 and NOT fixed here:** the block below still shows the
|
||||
> `GenerationId` column and its `ConfigGeneration` FK. v3 made the config tables single live-state and
|
||||
> dropped generation versioning (see `sp_ComputeGenerationDiff`, which now ignores its generation
|
||||
> parameters), so the real table has no such column and the index names differ accordingly. Only the
|
||||
> `LdapGroup` → `Principal` rename below is current.
|
||||
|
||||
```sql
|
||||
CREATE TABLE dbo.NodeAcl (
|
||||
NodeAclRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||||
GenerationId bigint NOT NULL FOREIGN KEY REFERENCES dbo.ConfigGeneration(GenerationId),
|
||||
NodeAclId nvarchar(64) NOT NULL, -- stable logical ID across generations
|
||||
ClusterId nvarchar(64) NOT NULL FOREIGN KEY REFERENCES dbo.ServerCluster(ClusterId),
|
||||
LdapGroup nvarchar(256) NOT NULL,
|
||||
Principal nvarchar(256) NOT NULL, -- API-key principal, or the reserved 'anonymous' (#520)
|
||||
ScopeKind nvarchar(16) NOT NULL CHECK (ScopeKind IN ('Cluster', 'Namespace', 'UnsArea', 'UnsLine', 'Equipment', 'Tag')),
|
||||
ScopeId nvarchar(64) NULL, -- NULL when ScopeKind='Cluster'; logical ID otherwise
|
||||
PermissionFlags int NOT NULL, -- bitmask of NodePermissions enum
|
||||
@@ -487,17 +493,51 @@ CREATE TABLE dbo.NodeAcl (
|
||||
|
||||
CREATE INDEX IX_NodeAcl_Generation_Cluster
|
||||
ON dbo.NodeAcl (GenerationId, ClusterId);
|
||||
CREATE INDEX IX_NodeAcl_Generation_Group
|
||||
ON dbo.NodeAcl (GenerationId, LdapGroup);
|
||||
CREATE INDEX IX_NodeAcl_Generation_Principal
|
||||
ON dbo.NodeAcl (GenerationId, Principal);
|
||||
CREATE INDEX IX_NodeAcl_Generation_Scope
|
||||
ON dbo.NodeAcl (GenerationId, ScopeKind, ScopeId) WHERE ScopeId IS NOT NULL;
|
||||
CREATE UNIQUE INDEX UX_NodeAcl_Generation_LogicalId
|
||||
ON dbo.NodeAcl (GenerationId, NodeAclId);
|
||||
-- Within a generation, a (Group, Scope) pair has at most one row
|
||||
CREATE UNIQUE INDEX UX_NodeAcl_Generation_GroupScope
|
||||
ON dbo.NodeAcl (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId);
|
||||
-- Within a generation, a (Principal, Scope) pair has at most one row
|
||||
CREATE UNIQUE INDEX UX_NodeAcl_Generation_PrincipalScope
|
||||
ON dbo.NodeAcl (GenerationId, ClusterId, Principal, ScopeKind, ScopeId);
|
||||
```
|
||||
|
||||
### `ApiKey`
|
||||
|
||||
OPC UA data-plane credentials (#520). The data plane is machine-to-machine, so a consuming system
|
||||
authenticates with a key rather than an LDAP user; the key resolves to a `Principal` that `NodeAcl`
|
||||
rows grant permissions to. One principal per key — two keys may name the same principal, which is
|
||||
what lets a key be rotated without touching an ACL row.
|
||||
|
||||
```sql
|
||||
CREATE TABLE dbo.ApiKey (
|
||||
ApiKeyRowId uniqueidentifier NOT NULL PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
|
||||
ApiKeyId nvarchar(64) NOT NULL, -- public id; middle segment of otua_<keyId>_<secret>
|
||||
Name nvarchar(128) NOT NULL, -- human label, e.g. the consuming system
|
||||
SecretHash nvarchar(128) NOT NULL, -- base64 peppered HMAC-SHA256; NEVER the secret
|
||||
Principal nvarchar(256) NOT NULL,
|
||||
Enabled bit NOT NULL,
|
||||
CreatedUtc datetime2 NOT NULL,
|
||||
ExpiresUtc datetime2 NULL,
|
||||
Notes nvarchar(512) NULL,
|
||||
RowVersion rowversion NOT NULL
|
||||
);
|
||||
|
||||
-- The presented key carries ApiKeyId in the clear precisely so verification is this seek.
|
||||
CREATE UNIQUE INDEX UX_ApiKey_ApiKeyId ON dbo.ApiKey (ApiKeyId);
|
||||
CREATE INDEX IX_ApiKey_Principal ON dbo.ApiKey (Principal);
|
||||
```
|
||||
|
||||
`ApiKeyId` is `NOT NULL` here, deliberately unlike the nullable `NodeAclId` precedent: that shape
|
||||
permits two NULL ids under a filtered unique index, which is acceptable for a config row and not for
|
||||
a credentials lookup key.
|
||||
|
||||
⚠️ **Revocation requires a deploy.** Per-cluster mesh Phase 4 leaves driver-only nodes with no
|
||||
ConfigDb, so keys reach them inside the deployment artifact exactly as `NodeAcl` rows do. Clearing
|
||||
`Enabled` takes effect on a node only once a new artifact reaches it.
|
||||
|
||||
`NodeAcl` is **generation-versioned** (decision #130). ACL changes go through draft → diff → publish → rollback like every other content table. Cross-generation invariant: `NodeAclId` once published with `(LdapGroup, ScopeKind, ScopeId)` cannot have any of those columns change in a future generation; rename an LDAP group by disabling the old grant and creating a new one.
|
||||
|
||||
`PermissionFlags` is a bitmask of the `NodePermissions` enum defined in `acl-design.md` (Browse, Read, Subscribe, HistoryRead, WriteOperate, WriteTune, WriteConfigure, AlarmRead, AlarmAcknowledge, AlarmConfirm, AlarmShelve, MethodCall). Common bundles (`ReadOnly`, `Operator`, `Engineer`, `Admin`) expand to specific flag combinations at evaluation time.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One OPC UA data-plane API key. The OPC UA data plane is machine-to-machine (#520), so a
|
||||
/// consuming system authenticates with a key rather than an LDAP user, and the key resolves to a
|
||||
/// <see cref="Principal"/> that <see cref="NodeAcl"/> rows grant permissions to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One principal per key, deliberately.</b> The reason to separate keys from principals is so
|
||||
/// that many keys can share one grant set and rotating a key never touches an ACL row — and that
|
||||
/// holds with a single principal per key, because nothing stops two keys naming the same
|
||||
/// principal. A key belonging to several principals at once (union of grants) buys little and
|
||||
/// costs a join table, so it is not modelled. <c>UserAuthorizationState.Principals</c> is still a
|
||||
/// list, so adding it later needs no change on the ACL side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The secret is never stored.</b> <see cref="SecretHash"/> holds a peppered HMAC-SHA256 of the
|
||||
/// secret half only; the plaintext is shown once at creation and is unrecoverable afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>Revocation requires a deploy.</b> Phase 4 leaves driver-only nodes with no ConfigDb, so
|
||||
/// keys reach them inside the deployment artifact exactly as <see cref="NodeAcl"/> rows do.
|
||||
/// Setting <see cref="Enabled"/> to false takes effect on a node only once a new artifact reaches
|
||||
/// it. This is an operational sharp edge, not an oversight — see the #520 design doc.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ApiKey
|
||||
{
|
||||
/// <summary>Gets or sets the database row ID for this key.</summary>
|
||||
public Guid ApiKeyRowId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the public key id — the middle segment of <c>otua_<keyId>_<secret></c>.
|
||||
/// Carried in the clear so verification is an index seek rather than a scan over every stored hash.
|
||||
/// </summary>
|
||||
public required string ApiKeyId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the human-readable label, e.g. the consuming system's name.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base64 peppered HMAC-SHA256 of the key's secret segment. Never the secret.
|
||||
/// </summary>
|
||||
public required string SecretHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the principal this key authenticates as. <see cref="NodeAcl.Principal"/> rows
|
||||
/// naming this value grant it permissions.
|
||||
/// </summary>
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether the key may authenticate. See the revocation caveat on the type.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Gets or sets when the key was created (UTC).</summary>
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets when the key expires (UTC); null never expires.</summary>
|
||||
public DateTime? ExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets optional operator notes.</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Optimistic concurrency token, matching the rest of the live-edit config model.</summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
Generated
+1618
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddApiKeyTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKey",
|
||||
columns: table => new
|
||||
{
|
||||
ApiKeyRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
ApiKeyId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SecretHash = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Principal = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ExpiresUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ApiKey", x => x.ApiKeyRowId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ApiKey_Principal",
|
||||
table: "ApiKey",
|
||||
column: "Principal");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ApiKey_ApiKeyId",
|
||||
table: "ApiKey",
|
||||
column: "ApiKeyId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ApiKey");
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -41,6 +41,64 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.ToTable("DataProtectionKeys", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey", b =>
|
||||
{
|
||||
b.Property<Guid>("ApiKeyRowId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
|
||||
b.Property<string>("ApiKeyId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("ExpiresUtc")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("Principal")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("rowversion");
|
||||
|
||||
b.Property<string>("SecretHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("ApiKeyRowId");
|
||||
|
||||
b.HasIndex("ApiKeyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ApiKey_ApiKeyId");
|
||||
|
||||
b.HasIndex("Principal")
|
||||
.HasDatabaseName("IX_ApiKey_Principal");
|
||||
|
||||
b.ToTable("ApiKey", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
|
||||
{
|
||||
b.Property<string>("NodeId")
|
||||
|
||||
@@ -40,6 +40,9 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
|
||||
/// <summary>Gets the DbSet of node ACLs.</summary>
|
||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||
|
||||
/// <summary>OPC UA data-plane API keys (#520). One principal per key; secret stored as a peppered hash.</summary>
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
/// <summary>Gets the DbSet of configuration audit logs.</summary>
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
/// <summary>Gets the DbSet of external ID reservations.</summary>
|
||||
@@ -84,6 +87,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureUnsTagReference(modelBuilder);
|
||||
ConfigurePollGroup(modelBuilder);
|
||||
ConfigureNodeAcl(modelBuilder);
|
||||
ConfigureApiKey(modelBuilder);
|
||||
ConfigureConfigAuditLog(modelBuilder);
|
||||
ConfigureExternalIdReservation(modelBuilder);
|
||||
ConfigureLdapGroupRoleMapping(modelBuilder);
|
||||
@@ -440,6 +444,29 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureApiKey(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ApiKey>(e =>
|
||||
{
|
||||
e.ToTable("ApiKey");
|
||||
e.HasKey(x => x.ApiKeyRowId);
|
||||
e.Property(x => x.ApiKeyRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
// IsRequired() deliberately deviates from the NodeAclId precedent, which is a nullable column
|
||||
// with a filtered unique index. That shape lets two NULL ids coexist; harmless for a config
|
||||
// row, not something to carry into a credentials table where the id is the lookup key.
|
||||
e.Property(x => x.ApiKeyId).HasMaxLength(64).IsRequired();
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.SecretHash).HasMaxLength(128);
|
||||
e.Property(x => x.Principal).HasMaxLength(256);
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
e.Property(x => x.RowVersion).IsRowVersion();
|
||||
|
||||
// The presented key carries ApiKeyId in the clear precisely so verification is this seek.
|
||||
e.HasIndex(x => x.ApiKeyId).IsUnique().HasDatabaseName("UX_ApiKey_ApiKeyId");
|
||||
e.HasIndex(x => x.Principal).HasDatabaseName("IX_ApiKey_Principal");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<NodeAcl>(e =>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the OPC UA data-plane API key format <c>otua_<keyId>_<secret></c>
|
||||
/// (#520): generation, parsing, and constant-time verification. No I/O and no DI, so the
|
||||
/// security-critical half is unit-testable on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The format mirrors the family convention — <c>histgw_<id>_<secret></c> in
|
||||
/// HistorianGateway, <c>apikey create-key</c> in mxaccessgw. The <c>keyId</c> travels in the clear
|
||||
/// so a lookup is an index seek; only the secret half is hashed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a plain HMAC and not PBKDF2/Argon2.</b> Slow password hashing exists to make guessing a
|
||||
/// low-entropy human secret expensive. A key secret here is <see cref="SecretBytes"/> bytes of
|
||||
/// CSPRNG output, so guessing is already infeasible and an iterated KDF would only add latency to
|
||||
/// a call that runs inline on OPC UA session activation — which the SDK serializes server-wide
|
||||
/// behind its SessionManager event lock. The pepper is what defends a leaked database: an
|
||||
/// attacker with the table but not the pepper cannot verify a guess offline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>An unset pepper fails closed.</b> <see cref="ComputeHash"/> throws rather than hashing
|
||||
/// under an empty key, so a deployment that forgets to configure one cannot silently accept keys
|
||||
/// whose hashes were all computed under the same empty pepper.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ApiKeySecret
|
||||
{
|
||||
/// <summary>The literal prefix identifying an OtOpcUa data-plane key.</summary>
|
||||
public const string Prefix = "otua";
|
||||
|
||||
/// <summary>Bytes of CSPRNG output behind the public key id.</summary>
|
||||
public const int KeyIdBytes = 8;
|
||||
|
||||
/// <summary>Bytes of CSPRNG output behind the secret segment.</summary>
|
||||
public const int SecretBytes = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new key. The returned <c>Presented</c> value is the only time the secret exists
|
||||
/// outside the caller — it is not recoverable from <c>SecretHash</c>.
|
||||
/// </summary>
|
||||
/// <param name="pepper">Server-side pepper; must be non-empty.</param>
|
||||
/// <returns>The presented key string, its public key id, and the hash to persist.</returns>
|
||||
public static (string Presented, string ApiKeyId, string SecretHash) Generate(string pepper)
|
||||
{
|
||||
// The key id is HEX, not url-safe base64: that alphabet maps '/' to '_', so a base64 key id could
|
||||
// contain the very character TryParse splits on and would be truncated at a random position. The
|
||||
// secret is the whole remainder after the second '_', so underscores there are harmless.
|
||||
var keyId = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(KeyIdBytes));
|
||||
var secret = ToUrlSafeBase64(RandomNumberGenerator.GetBytes(SecretBytes));
|
||||
return ($"{Prefix}_{keyId}_{secret}", keyId, ComputeHash(secret, pepper));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a presented key into its public id and secret halves.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Splits on the FIRST two underscores only, so a secret that itself contains an underscore round
|
||||
/// trips. Returns false rather than throwing — a malformed credential is an ordinary
|
||||
/// authentication failure, not an exceptional condition, and it arrives from the network.
|
||||
/// </remarks>
|
||||
/// <param name="presented">The presented key string.</param>
|
||||
/// <param name="apiKeyId">The parsed public key id.</param>
|
||||
/// <param name="secret">The parsed secret segment.</param>
|
||||
/// <returns><see langword="true"/> when the value is well-formed.</returns>
|
||||
public static bool TryParse(string? presented, out string apiKeyId, out string secret)
|
||||
{
|
||||
apiKeyId = string.Empty;
|
||||
secret = string.Empty;
|
||||
if (string.IsNullOrEmpty(presented)) return false;
|
||||
|
||||
var first = presented.IndexOf('_');
|
||||
if (first <= 0) return false;
|
||||
if (!presented.AsSpan(0, first).SequenceEqual(Prefix)) return false;
|
||||
|
||||
var second = presented.IndexOf('_', first + 1);
|
||||
if (second < 0) return false;
|
||||
|
||||
apiKeyId = presented[(first + 1)..second];
|
||||
secret = presented[(second + 1)..];
|
||||
return apiKeyId.Length > 0 && secret.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>Computes the peppered HMAC-SHA256 of a secret segment, base64-encoded.</summary>
|
||||
/// <param name="secret">The secret segment.</param>
|
||||
/// <param name="pepper">Server-side pepper; must be non-empty.</param>
|
||||
/// <returns>The base64 hash.</returns>
|
||||
/// <exception cref="ArgumentException">The pepper is null, empty, or whitespace.</exception>
|
||||
public static string ComputeHash(string secret, string pepper)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pepper))
|
||||
{
|
||||
// Fail closed. Hashing under an empty pepper would "work" and produce a stable value, so a
|
||||
// misconfigured deployment would authenticate normally while offering no protection at all.
|
||||
throw new ArgumentException(
|
||||
"An API key pepper must be configured (Security:ApiKeys:Pepper). Refusing to hash under an empty pepper.",
|
||||
nameof(pepper));
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(pepper));
|
||||
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(secret)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant-time comparison of a presented secret against a stored hash.
|
||||
/// </summary>
|
||||
/// <param name="secret">The presented secret segment.</param>
|
||||
/// <param name="pepper">Server-side pepper.</param>
|
||||
/// <param name="storedHash">The persisted base64 hash.</param>
|
||||
/// <returns><see langword="true"/> when they match.</returns>
|
||||
public static bool Verify(string secret, string pepper, string? storedHash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(storedHash)) return false;
|
||||
|
||||
byte[] expected;
|
||||
try { expected = Convert.FromBase64String(storedHash); }
|
||||
catch (FormatException) { return false; }
|
||||
|
||||
var actual = Convert.FromBase64String(ComputeHash(secret, pepper));
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
|
||||
private static string ToUrlSafeBase64(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
|
||||
}
|
||||
@@ -19,6 +19,12 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<!-- #520: the node manager is where ACL enforcement has to happen, and until now Core was not on
|
||||
this project's reference graph at all — IPermissionEvaluator was literally uncallable from
|
||||
here. Referenced ahead of the enforcement stages so the seams can be wired incrementally.
|
||||
Adds no new transitive package surface: Core pulls Abstractions/Commons/Configuration, all
|
||||
three of which are already referenced above. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -32,7 +32,9 @@ public sealed class SchemaComplianceTests
|
||||
// v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added)
|
||||
"RawFolder", "TagGroup", "UnsTagReference",
|
||||
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
|
||||
"NodeAcl", "ExternalIdReservation",
|
||||
// ApiKey added in Gitea #520 — the OPC UA data plane is machine-to-machine, so a consuming
|
||||
// system authenticates with a key resolving to a Principal that NodeAcl rows grant to.
|
||||
"NodeAcl", "ApiKey", "ExternalIdReservation",
|
||||
// DriverHostStatus was dropped in Gitea #521 — nothing ever wrote a row, and per-cluster
|
||||
// mesh Phase 4 made the publisher its entity doc described unbuildable (a driver-only node
|
||||
// has no ConfigDb connection). Per-host connectivity now rides DriverHealthChanged to /hosts.
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the pure API-key format helpers (#520). These are the security-critical half of the
|
||||
/// data-plane identity change, so they are pinned independently of any store or DI.
|
||||
/// </summary>
|
||||
public sealed class ApiKeySecretTests
|
||||
{
|
||||
private const string Pepper = "test-pepper-not-a-real-one";
|
||||
|
||||
[Fact]
|
||||
public void A_generated_key_round_trips_through_parse_and_verify()
|
||||
{
|
||||
var (presented, apiKeyId, hash) = ApiKeySecret.Generate(Pepper);
|
||||
|
||||
ApiKeySecret.TryParse(presented, out var parsedId, out var secret).ShouldBeTrue();
|
||||
parsedId.ShouldBe(apiKeyId);
|
||||
ApiKeySecret.Verify(secret, Pepper, hash).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_generated_key_id_never_contains_the_separator()
|
||||
{
|
||||
// Regression guard. The secret is url-safe base64, whose alphabet maps '/' to '_'. If the key id
|
||||
// used that same alphabet it could contain the character TryParse splits on, and the id would be
|
||||
// silently truncated at a random position for roughly 1 key in 5 — an intermittent auth failure
|
||||
// that only ever reproduces for SOME keys. Hence hex. 400 draws makes a '_' near-certain if the
|
||||
// alphabet ever regresses to base64.
|
||||
for (var i = 0; i < 400; i++)
|
||||
{
|
||||
var (_, apiKeyId, _) = ApiKeySecret.Generate(Pepper);
|
||||
apiKeyId.ShouldNotContain("_", Case.Sensitive,
|
||||
$"key id '{apiKeyId}' contains the separator — TryParse would truncate it");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_secret_containing_the_separator_still_round_trips()
|
||||
{
|
||||
// The secret IS allowed to contain '_' (it is the whole remainder), so prove the split is
|
||||
// first-two-underscores rather than split-on-every-underscore.
|
||||
const string secret = "aa_bb_cc__dd";
|
||||
var presented = $"otua_deadbeef_{secret}";
|
||||
|
||||
ApiKeySecret.TryParse(presented, out var id, out var parsed).ShouldBeTrue();
|
||||
id.ShouldBe("deadbeef");
|
||||
parsed.ShouldBe(secret);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("otua")]
|
||||
[InlineData("otua_")]
|
||||
[InlineData("otua_onlyid")]
|
||||
[InlineData("_noprefix_secret")]
|
||||
[InlineData("wrong_id_secret")]
|
||||
[InlineData("otua__emptyid")]
|
||||
public void A_malformed_key_is_rejected_without_throwing(string? presented)
|
||||
{
|
||||
// These arrive from the network, so malformed input must be an ordinary auth failure.
|
||||
ApiKeySecret.TryParse(presented, out _, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_wrong_secret_does_not_verify()
|
||||
{
|
||||
var (_, _, hash) = ApiKeySecret.Generate(Pepper);
|
||||
ApiKeySecret.Verify("not-the-secret", Pepper, hash).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_secret_under_a_different_pepper_does_not_verify()
|
||||
{
|
||||
// This is what the pepper buys: a leaked ApiKey table cannot be verified against offline.
|
||||
var (presented, _, hash) = ApiKeySecret.Generate(Pepper);
|
||||
ApiKeySecret.TryParse(presented, out _, out var secret).ShouldBeTrue();
|
||||
|
||||
ApiKeySecret.Verify(secret, "a-different-pepper", hash).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Hashing_under_an_absent_pepper_throws_rather_than_failing_open(string? pepper)
|
||||
{
|
||||
// Fail closed. An empty pepper would still produce a stable hash, so a misconfigured deployment
|
||||
// would authenticate normally while offering none of the protection it believes it has.
|
||||
Should.Throw<ArgumentException>(() => ApiKeySecret.ComputeHash("secret", pepper!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_base64_stored_hash_is_rejected_rather_than_throwing()
|
||||
{
|
||||
// A corrupted or hand-edited row must deny, not crash session activation for everyone.
|
||||
ApiKeySecret.Verify("secret", Pepper, "!!!not base64!!!").ShouldBeFalse();
|
||||
ApiKeySecret.Verify("secret", Pepper, null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_generated_keys_never_collide()
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
var secrets = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var (presented, apiKeyId, _) = ApiKeySecret.Generate(Pepper);
|
||||
ApiKeySecret.TryParse(presented, out _, out var secret).ShouldBeTrue();
|
||||
ids.Add(apiKeyId).ShouldBeTrue($"duplicate key id '{apiKeyId}'");
|
||||
secrets.Add(secret).ShouldBeTrue("duplicate secret");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user