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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user