consolidation of some of the modules, localization fixes, product advisories work, qa work
This commit is contained in:
@@ -18,8 +18,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj" />
|
||||
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.ChangeTrace\StellaOps.Scanner.ChangeTrace.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class KeyAuditLogEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Signer.KeyManagement.Entities.KeyAuditLogEntity",
|
||||
typeof(KeyAuditLogEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 11,
|
||||
namedIndexCount: 4,
|
||||
keyCount: 1);
|
||||
|
||||
var logId = runtimeEntityType.AddProperty(
|
||||
"LogId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("LogId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<LogId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
logId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
logId.AddAnnotation("Relational:ColumnName", "log_id");
|
||||
logId.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var anchorId = runtimeEntityType.AddProperty(
|
||||
"AnchorId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("AnchorId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<AnchorId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
anchorId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
anchorId.AddAnnotation("Relational:ColumnName", "anchor_id");
|
||||
|
||||
var keyId = runtimeEntityType.AddProperty(
|
||||
"KeyId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("KeyId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<KeyId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
keyId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
keyId.AddAnnotation("Relational:ColumnName", "key_id");
|
||||
|
||||
var operation = runtimeEntityType.AddProperty(
|
||||
"Operation",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("Operation", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<Operation>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
operation.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
operation.AddAnnotation("Relational:ColumnName", "operation");
|
||||
|
||||
var actor = runtimeEntityType.AddProperty(
|
||||
"Actor",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("Actor", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<Actor>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
actor.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
actor.AddAnnotation("Relational:ColumnName", "actor");
|
||||
|
||||
var oldState = runtimeEntityType.AddProperty(
|
||||
"OldState",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("OldState", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<OldState>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
oldState.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
oldState.AddAnnotation("Relational:ColumnName", "old_state");
|
||||
oldState.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var newState = runtimeEntityType.AddProperty(
|
||||
"NewState",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("NewState", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<NewState>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
newState.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
newState.AddAnnotation("Relational:ColumnName", "new_state");
|
||||
newState.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var reason = runtimeEntityType.AddProperty(
|
||||
"Reason",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("Reason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<Reason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
reason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
reason.AddAnnotation("Relational:ColumnName", "reason");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var details = runtimeEntityType.AddProperty(
|
||||
"Details",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("Details", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<Details>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
details.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
details.AddAnnotation("Relational:ColumnName", "details");
|
||||
details.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var ipAddress = runtimeEntityType.AddProperty(
|
||||
"IpAddress",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("IpAddress", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<IpAddress>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
ipAddress.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
ipAddress.AddAnnotation("Relational:ColumnName", "ip_address");
|
||||
|
||||
var userAgent = runtimeEntityType.AddProperty(
|
||||
"UserAgent",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("UserAgent", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<UserAgent>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
userAgent.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
userAgent.AddAnnotation("Relational:ColumnName", "user_agent");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(KeyAuditLogEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyAuditLogEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: default(DateTimeOffset));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { logId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "key_audit_log_pkey");
|
||||
|
||||
var idx_key_audit_anchor = runtimeEntityType.AddIndex(
|
||||
new[] { anchorId },
|
||||
name: "idx_key_audit_anchor");
|
||||
|
||||
var idx_key_audit_key = runtimeEntityType.AddIndex(
|
||||
new[] { keyId },
|
||||
name: "idx_key_audit_key");
|
||||
idx_key_audit_key.AddAnnotation("Relational:Filter", "key_id IS NOT NULL");
|
||||
|
||||
var idx_key_audit_operation = runtimeEntityType.AddIndex(
|
||||
new[] { operation },
|
||||
name: "idx_key_audit_operation");
|
||||
|
||||
var idx_key_audit_created = runtimeEntityType.AddIndex(
|
||||
new[] { createdAt },
|
||||
name: "idx_key_audit_created");
|
||||
idx_key_audit_created.AddAnnotation("Relational:IsDescending", new[] { true });
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "signer");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "key_audit_log");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class KeyHistoryEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Signer.KeyManagement.Entities.KeyHistoryEntity",
|
||||
typeof(KeyHistoryEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 10,
|
||||
namedIndexCount: 5,
|
||||
keyCount: 1);
|
||||
|
||||
var historyId = runtimeEntityType.AddProperty(
|
||||
"HistoryId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("HistoryId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<HistoryId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
historyId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
historyId.AddAnnotation("Relational:ColumnName", "history_id");
|
||||
historyId.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var anchorId = runtimeEntityType.AddProperty(
|
||||
"AnchorId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("AnchorId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<AnchorId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
anchorId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
anchorId.AddAnnotation("Relational:ColumnName", "anchor_id");
|
||||
|
||||
var keyId = runtimeEntityType.AddProperty(
|
||||
"KeyId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("KeyId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<KeyId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
keyId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
keyId.AddAnnotation("Relational:ColumnName", "key_id");
|
||||
|
||||
var publicKey = runtimeEntityType.AddProperty(
|
||||
"PublicKey",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("PublicKey", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<PublicKey>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
publicKey.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
publicKey.AddAnnotation("Relational:ColumnName", "public_key");
|
||||
|
||||
var algorithm = runtimeEntityType.AddProperty(
|
||||
"Algorithm",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("Algorithm", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<Algorithm>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
algorithm.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
algorithm.AddAnnotation("Relational:ColumnName", "algorithm");
|
||||
|
||||
var addedAt = runtimeEntityType.AddProperty(
|
||||
"AddedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("AddedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<AddedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: default(DateTimeOffset));
|
||||
addedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
addedAt.AddAnnotation("Relational:ColumnName", "added_at");
|
||||
addedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var revokedAt = runtimeEntityType.AddProperty(
|
||||
"RevokedAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("RevokedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<RevokedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedAt.AddAnnotation("Relational:ColumnName", "revoked_at");
|
||||
|
||||
var revokeReason = runtimeEntityType.AddProperty(
|
||||
"RevokeReason",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("RevokeReason", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<RevokeReason>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokeReason.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokeReason.AddAnnotation("Relational:ColumnName", "revoke_reason");
|
||||
|
||||
var expiresAt = runtimeEntityType.AddProperty(
|
||||
"ExpiresAt",
|
||||
typeof(DateTimeOffset?),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("ExpiresAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<ExpiresAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
expiresAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
expiresAt.AddAnnotation("Relational:ColumnName", "expires_at");
|
||||
|
||||
var metadata = runtimeEntityType.AddProperty(
|
||||
"Metadata",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("Metadata", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<Metadata>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
metadata.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
metadata.AddAnnotation("Relational:ColumnName", "metadata");
|
||||
metadata.AddAnnotation("Relational:ColumnType", "jsonb");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(KeyHistoryEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(KeyHistoryEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: default(DateTimeOffset));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { historyId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "key_history_pkey");
|
||||
|
||||
var uq_key_history_anchor_key = runtimeEntityType.AddIndex(
|
||||
new[] { anchorId, keyId },
|
||||
name: "uq_key_history_anchor_key",
|
||||
unique: true);
|
||||
|
||||
var idx_key_history_anchor = runtimeEntityType.AddIndex(
|
||||
new[] { anchorId },
|
||||
name: "idx_key_history_anchor");
|
||||
|
||||
var idx_key_history_key_id = runtimeEntityType.AddIndex(
|
||||
new[] { keyId },
|
||||
name: "idx_key_history_key_id");
|
||||
|
||||
var idx_key_history_added = runtimeEntityType.AddIndex(
|
||||
new[] { addedAt },
|
||||
name: "idx_key_history_added");
|
||||
|
||||
var idx_key_history_revoked = runtimeEntityType.AddIndex(
|
||||
new[] { revokedAt },
|
||||
name: "idx_key_history_revoked");
|
||||
idx_key_history_revoked.AddAnnotation("Relational:Filter", "revoked_at IS NOT NULL");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "signer");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "key_history");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.CompiledModels;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
[assembly: DbContextModel(typeof(KeyManagementDbContext), typeof(KeyManagementDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(KeyManagementDbContext))]
|
||||
public partial class KeyManagementDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static KeyManagementDbContextModel()
|
||||
{
|
||||
var model = new KeyManagementDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (KeyManagementDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static KeyManagementDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.CompiledModels
|
||||
{
|
||||
public partial class KeyManagementDbContextModel
|
||||
{
|
||||
private KeyManagementDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("b3a1c72e-4d5f-4e8a-9b6c-1a2d3e4f5678"), entityTypeCount: 3)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var keyHistoryEntity = KeyHistoryEntityEntityType.Create(this);
|
||||
var keyAuditLogEntity = KeyAuditLogEntityEntityType.Create(this);
|
||||
var trustAnchorEntity = TrustAnchorEntityEntityType.Create(this);
|
||||
|
||||
KeyHistoryEntityEntityType.CreateAnnotations(keyHistoryEntity);
|
||||
KeyAuditLogEntityEntityType.CreateAnnotations(keyAuditLogEntity);
|
||||
TrustAnchorEntityEntityType.CreateAnnotations(trustAnchorEntity);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.CompiledModels
|
||||
{
|
||||
[EntityFrameworkInternal]
|
||||
public partial class TrustAnchorEntityEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Signer.KeyManagement.Entities.TrustAnchorEntity",
|
||||
typeof(TrustAnchorEntity),
|
||||
baseEntityType,
|
||||
propertyCount: 10,
|
||||
namedIndexCount: 2,
|
||||
keyCount: 1);
|
||||
|
||||
var anchorId = runtimeEntityType.AddProperty(
|
||||
"AnchorId",
|
||||
typeof(Guid),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("AnchorId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<AnchorId>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
afterSaveBehavior: PropertySaveBehavior.Throw,
|
||||
sentinel: new Guid("00000000-0000-0000-0000-000000000000"));
|
||||
anchorId.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
anchorId.AddAnnotation("Relational:ColumnName", "anchor_id");
|
||||
anchorId.AddAnnotation("Relational:DefaultValueSql", "gen_random_uuid()");
|
||||
|
||||
var purlPattern = runtimeEntityType.AddProperty(
|
||||
"PurlPattern",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("PurlPattern", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<PurlPattern>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
|
||||
purlPattern.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
purlPattern.AddAnnotation("Relational:ColumnName", "purl_pattern");
|
||||
|
||||
var allowedKeyIds = runtimeEntityType.AddProperty(
|
||||
"AllowedKeyIds",
|
||||
typeof(List<string>),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("AllowedKeyIds", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<AllowedKeyIds>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
allowedKeyIds.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
allowedKeyIds.AddAnnotation("Relational:ColumnName", "allowed_key_ids");
|
||||
allowedKeyIds.AddAnnotation("Relational:ColumnType", "text[]");
|
||||
|
||||
var allowedPredicateTypes = runtimeEntityType.AddProperty(
|
||||
"AllowedPredicateTypes",
|
||||
typeof(List<string>),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("AllowedPredicateTypes", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<AllowedPredicateTypes>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
allowedPredicateTypes.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
allowedPredicateTypes.AddAnnotation("Relational:ColumnName", "allowed_predicate_types");
|
||||
allowedPredicateTypes.AddAnnotation("Relational:ColumnType", "text[]");
|
||||
|
||||
var policyRef = runtimeEntityType.AddProperty(
|
||||
"PolicyRef",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("PolicyRef", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<PolicyRef>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
policyRef.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
policyRef.AddAnnotation("Relational:ColumnName", "policy_ref");
|
||||
|
||||
var policyVersion = runtimeEntityType.AddProperty(
|
||||
"PolicyVersion",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("PolicyVersion", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<PolicyVersion>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
policyVersion.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
policyVersion.AddAnnotation("Relational:ColumnName", "policy_version");
|
||||
|
||||
var revokedKeyIds = runtimeEntityType.AddProperty(
|
||||
"RevokedKeyIds",
|
||||
typeof(List<string>),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("RevokedKeyIds", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<RevokedKeyIds>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
nullable: true);
|
||||
revokedKeyIds.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
revokedKeyIds.AddAnnotation("Relational:ColumnName", "revoked_key_ids");
|
||||
revokedKeyIds.AddAnnotation("Relational:ColumnType", "text[]");
|
||||
|
||||
var isActive = runtimeEntityType.AddProperty(
|
||||
"IsActive",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("IsActive", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<IsActive>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
sentinel: false);
|
||||
isActive.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
isActive.AddAnnotation("Relational:ColumnName", "is_active");
|
||||
|
||||
var createdAt = runtimeEntityType.AddProperty(
|
||||
"CreatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("CreatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<CreatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: default(DateTimeOffset));
|
||||
createdAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
createdAt.AddAnnotation("Relational:ColumnName", "created_at");
|
||||
createdAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTimeOffset),
|
||||
propertyInfo: typeof(TrustAnchorEntity).GetProperty("UpdatedAt", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: typeof(TrustAnchorEntity).GetField("<UpdatedAt>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
valueGenerated: ValueGenerated.OnAdd,
|
||||
sentinel: default(DateTimeOffset));
|
||||
updatedAt.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var key = runtimeEntityType.AddKey(
|
||||
new[] { anchorId });
|
||||
runtimeEntityType.SetPrimaryKey(key);
|
||||
key.AddAnnotation("Relational:Name", "trust_anchors_pkey");
|
||||
|
||||
var idx_trust_anchors_purl_pattern = runtimeEntityType.AddIndex(
|
||||
new[] { purlPattern },
|
||||
name: "idx_trust_anchors_purl_pattern");
|
||||
|
||||
var idx_trust_anchors_is_active = runtimeEntityType.AddIndex(
|
||||
new[] { isActive },
|
||||
name: "idx_trust_anchors_is_active");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:FunctionName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "signer");
|
||||
runtimeEntityType.AddAnnotation("Relational:SqlQuery", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "trust_anchors");
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewName", null);
|
||||
runtimeEntityType.AddAnnotation("Relational:ViewSchema", null);
|
||||
|
||||
Customize(runtimeEntityType);
|
||||
}
|
||||
|
||||
static partial void Customize(RuntimeEntityType runtimeEntityType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Partial overlay for KeyManagementDbContext.
|
||||
/// Contains relationship configuration and additional model customizations.
|
||||
/// </summary>
|
||||
public partial class KeyManagementDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// No navigation properties or enum mappings required for the signer schema.
|
||||
// key_history.anchor_id FK to proofchain.trust_anchors is conditional and managed
|
||||
// at the SQL migration level, not via EF Core relationships.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Signer key management schema.
|
||||
/// Follows EF_CORE_MODEL_GENERATION_STANDARDS.md: partial class with schema injection.
|
||||
/// </summary>
|
||||
public partial class KeyManagementDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public KeyManagementDbContext(DbContextOptions<KeyManagementDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "signer"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key history entries (signer.key_history).
|
||||
/// </summary>
|
||||
public virtual DbSet<KeyHistoryEntity> KeyHistory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Key audit log entries (signer.key_audit_log).
|
||||
/// </summary>
|
||||
public virtual DbSet<KeyAuditLogEntity> KeyAuditLog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchors (signer.trust_anchors).
|
||||
/// </summary>
|
||||
public virtual DbSet<TrustAnchorEntity> TrustAnchors { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// ---- key_history ----
|
||||
modelBuilder.Entity<KeyHistoryEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("key_history", schemaName);
|
||||
|
||||
entity.HasKey(e => e.HistoryId).HasName("key_history_pkey");
|
||||
|
||||
entity.HasIndex(e => new { e.AnchorId, e.KeyId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("uq_key_history_anchor_key");
|
||||
|
||||
entity.HasIndex(e => e.AnchorId)
|
||||
.HasDatabaseName("idx_key_history_anchor");
|
||||
|
||||
entity.HasIndex(e => e.KeyId)
|
||||
.HasDatabaseName("idx_key_history_key_id");
|
||||
|
||||
entity.HasIndex(e => e.AddedAt)
|
||||
.HasDatabaseName("idx_key_history_added");
|
||||
|
||||
entity.HasIndex(e => e.RevokedAt)
|
||||
.HasDatabaseName("idx_key_history_revoked")
|
||||
.HasFilter("revoked_at IS NOT NULL");
|
||||
|
||||
entity.Property(e => e.HistoryId)
|
||||
.HasColumnName("history_id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
entity.Property(e => e.AnchorId)
|
||||
.HasColumnName("anchor_id");
|
||||
|
||||
entity.Property(e => e.KeyId)
|
||||
.HasColumnName("key_id");
|
||||
|
||||
entity.Property(e => e.PublicKey)
|
||||
.HasColumnName("public_key");
|
||||
|
||||
entity.Property(e => e.Algorithm)
|
||||
.HasColumnName("algorithm");
|
||||
|
||||
entity.Property(e => e.AddedAt)
|
||||
.HasColumnName("added_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
entity.Property(e => e.RevokedAt)
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
entity.Property(e => e.RevokeReason)
|
||||
.HasColumnName("revoke_reason");
|
||||
|
||||
entity.Property(e => e.ExpiresAt)
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnName("metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ---- key_audit_log ----
|
||||
modelBuilder.Entity<KeyAuditLogEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("key_audit_log", schemaName);
|
||||
|
||||
entity.HasKey(e => e.LogId).HasName("key_audit_log_pkey");
|
||||
|
||||
entity.HasIndex(e => e.AnchorId)
|
||||
.HasDatabaseName("idx_key_audit_anchor");
|
||||
|
||||
entity.HasIndex(e => e.KeyId)
|
||||
.HasDatabaseName("idx_key_audit_key")
|
||||
.HasFilter("key_id IS NOT NULL");
|
||||
|
||||
entity.HasIndex(e => e.Operation)
|
||||
.HasDatabaseName("idx_key_audit_operation");
|
||||
|
||||
entity.HasIndex(e => e.CreatedAt)
|
||||
.IsDescending()
|
||||
.HasDatabaseName("idx_key_audit_created");
|
||||
|
||||
entity.Property(e => e.LogId)
|
||||
.HasColumnName("log_id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
entity.Property(e => e.AnchorId)
|
||||
.HasColumnName("anchor_id");
|
||||
|
||||
entity.Property(e => e.KeyId)
|
||||
.HasColumnName("key_id");
|
||||
|
||||
entity.Property(e => e.Operation)
|
||||
.HasColumnName("operation");
|
||||
|
||||
entity.Property(e => e.Actor)
|
||||
.HasColumnName("actor");
|
||||
|
||||
entity.Property(e => e.OldState)
|
||||
.HasColumnName("old_state")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.NewState)
|
||||
.HasColumnName("new_state")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.Reason)
|
||||
.HasColumnName("reason");
|
||||
|
||||
entity.Property(e => e.Metadata)
|
||||
.HasColumnName("metadata")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.Details)
|
||||
.HasColumnName("details")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
entity.Property(e => e.IpAddress)
|
||||
.HasColumnName("ip_address");
|
||||
|
||||
entity.Property(e => e.UserAgent)
|
||||
.HasColumnName("user_agent");
|
||||
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
// ---- trust_anchors ----
|
||||
modelBuilder.Entity<TrustAnchorEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("trust_anchors", schemaName);
|
||||
|
||||
entity.HasKey(e => e.AnchorId).HasName("trust_anchors_pkey");
|
||||
|
||||
entity.HasIndex(e => e.PurlPattern)
|
||||
.HasDatabaseName("idx_trust_anchors_purl_pattern");
|
||||
|
||||
entity.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("idx_trust_anchors_is_active");
|
||||
|
||||
entity.Property(e => e.AnchorId)
|
||||
.HasColumnName("anchor_id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
entity.Property(e => e.PurlPattern)
|
||||
.HasColumnName("purl_pattern");
|
||||
|
||||
entity.Property(e => e.AllowedKeyIds)
|
||||
.HasColumnName("allowed_key_ids")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
entity.Property(e => e.AllowedPredicateTypes)
|
||||
.HasColumnName("allowed_predicate_types")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
entity.Property(e => e.PolicyRef)
|
||||
.HasColumnName("policy_ref");
|
||||
|
||||
entity.Property(e => e.PolicyVersion)
|
||||
.HasColumnName("policy_version");
|
||||
|
||||
entity.Property(e => e.RevokedKeyIds)
|
||||
.HasColumnName("revoked_key_ids")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
entity.Property(e => e.IsActive)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for dotnet ef CLI tooling.
|
||||
/// Does NOT use compiled models (uses reflection-based discovery).
|
||||
/// </summary>
|
||||
public sealed class KeyManagementDesignTimeDbContextFactory
|
||||
: IDesignTimeDbContextFactory<KeyManagementDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=signer,public";
|
||||
private const string ConnectionStringEnvironmentVariable =
|
||||
"STELLAOPS_SIGNER_EF_CONNECTION";
|
||||
|
||||
public KeyManagementDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<KeyManagementDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new KeyManagementDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Key history entry for tracking key lifecycle.
|
||||
/// Maps to signer.key_history table.
|
||||
/// </summary>
|
||||
[Table("key_history", Schema = "signer")]
|
||||
public class KeyHistoryEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("history_id")]
|
||||
public Guid HistoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the trust anchor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("anchor_id")]
|
||||
public Guid AnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("key_id")]
|
||||
public string KeyId { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The public key in PEM format.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("public_key")]
|
||||
public string PublicKey { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The algorithm (Ed25519, RSA-4096, etc.).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("algorithm")]
|
||||
public string Algorithm { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// When the key was added.
|
||||
/// </summary>
|
||||
[Column("added_at")]
|
||||
public DateTimeOffset AddedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was revoked (null if still active).
|
||||
/// </summary>
|
||||
[Column("revoked_at")]
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
[Column("revoke_reason")]
|
||||
public string? RevokeReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiry date.
|
||||
/// </summary>
|
||||
[Column("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata.
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public string? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this record was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key audit log entry for tracking all key operations.
|
||||
/// Maps to signer.key_audit_log table.
|
||||
/// </summary>
|
||||
[Table("key_audit_log", Schema = "signer")]
|
||||
public class KeyAuditLogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("log_id")]
|
||||
public Guid LogId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the trust anchor.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("anchor_id")]
|
||||
public Guid AnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID affected (if applicable).
|
||||
/// </summary>
|
||||
[Column("key_id")]
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The operation performed.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("operation")]
|
||||
public string Operation { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The actor who performed the operation.
|
||||
/// </summary>
|
||||
[Column("actor")]
|
||||
public string? Actor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The old state before the operation.
|
||||
/// </summary>
|
||||
[Column("old_state", TypeName = "jsonb")]
|
||||
public string? OldState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The new state after the operation.
|
||||
/// </summary>
|
||||
[Column("new_state", TypeName = "jsonb")]
|
||||
public string? NewState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the operation.
|
||||
/// </summary>
|
||||
[Column("reason")]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata about the operation.
|
||||
/// </summary>
|
||||
[Column("metadata", TypeName = "jsonb")]
|
||||
public string? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details about the operation.
|
||||
/// </summary>
|
||||
[Column("details", TypeName = "jsonb")]
|
||||
public string? Details { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IP address of the requestor.
|
||||
/// </summary>
|
||||
[Column("ip_address")]
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent of the requestor.
|
||||
/// </summary>
|
||||
[Column("user_agent")]
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this audit entry was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Trust anchor entity.
|
||||
/// Maps to signer.trust_anchors table.
|
||||
/// </summary>
|
||||
[Table("trust_anchors", Schema = "signer")]
|
||||
public class TrustAnchorEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[Column("anchor_id")]
|
||||
public Guid AnchorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL glob pattern (e.g., pkg:npm/*).
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column("purl_pattern")]
|
||||
public string PurlPattern { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Currently allowed key IDs.
|
||||
/// </summary>
|
||||
[Column("allowed_key_ids", TypeName = "text[]")]
|
||||
public List<string>? AllowedKeyIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed predicate types (null = all).
|
||||
/// </summary>
|
||||
[Column("allowed_predicate_types", TypeName = "text[]")]
|
||||
public List<string>? AllowedPredicateTypes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference.
|
||||
/// </summary>
|
||||
[Column("policy_ref")]
|
||||
public string? PolicyRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[Column("policy_version")]
|
||||
public string? PolicyVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Revoked key IDs (still valid for historical proofs).
|
||||
/// </summary>
|
||||
[Column("revoked_key_ids", TypeName = "text[]")]
|
||||
public List<string>? RevokedKeyIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anchor is active.
|
||||
/// </summary>
|
||||
[Column("is_active")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was created.
|
||||
/// </summary>
|
||||
[Column("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was last updated.
|
||||
/// </summary>
|
||||
[Column("updated_at")]
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key operation types for audit logging.
|
||||
/// </summary>
|
||||
public static class KeyOperation
|
||||
{
|
||||
public const string Add = "add";
|
||||
public const string Revoke = "revoke";
|
||||
public const string Rotate = "rotate";
|
||||
public const string Update = "update";
|
||||
public const string Verify = "verify";
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing key rotation operations.
|
||||
/// Implements advisory §8.2 key rotation workflow.
|
||||
/// </summary>
|
||||
public interface IKeyRotationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a new signing key to a trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The trust anchor ID.</param>
|
||||
/// <param name="request">The add key request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the operation.</returns>
|
||||
Task<KeyRotationResult> AddKeyAsync(
|
||||
Guid anchorId,
|
||||
AddKeyRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a signing key from a trust anchor.
|
||||
/// The key is moved to revokedKeys and remains valid for proofs signed before revocation.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The trust anchor ID.</param>
|
||||
/// <param name="keyId">The key ID to revoke.</param>
|
||||
/// <param name="request">The revoke request with reason.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the operation.</returns>
|
||||
Task<KeyRotationResult> RevokeKeyAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
RevokeKeyRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a key was valid at a specific point in time.
|
||||
/// This is used for verifying historical proofs.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The trust anchor ID.</param>
|
||||
/// <param name="keyId">The key ID to check.</param>
|
||||
/// <param name="signedAt">The time the signature was created.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The key validity result.</returns>
|
||||
Task<KeyValidityResult> CheckKeyValidityAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get rotation warnings for a trust anchor (e.g., keys approaching expiry).
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The trust anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of rotation warnings.</returns>
|
||||
Task<IReadOnlyList<KeyRotationWarning>> GetRotationWarningsAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get the full key history for a trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The trust anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The key history entries.</returns>
|
||||
Task<IReadOnlyList<KeyHistoryEntry>> GetKeyHistoryAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a new key.
|
||||
/// </summary>
|
||||
public sealed record AddKeyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID (unique identifier).
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The public key in PEM format.
|
||||
/// </summary>
|
||||
public required string PublicKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The algorithm (Ed25519, RSA-4096, etc.).
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiry date for the key.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata about the key.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke a key.
|
||||
/// </summary>
|
||||
public sealed record RevokeKeyRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason for revocation.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the revocation takes effect. Defaults to now.
|
||||
/// </summary>
|
||||
public DateTimeOffset? EffectiveAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a key rotation operation.
|
||||
/// </summary>
|
||||
public sealed record KeyRotationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The updated allowed key IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> AllowedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The updated revoked key IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> RevokedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if operation failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit log entry ID for this operation.
|
||||
/// </summary>
|
||||
public Guid? AuditLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of key validity check.
|
||||
/// </summary>
|
||||
public sealed record KeyValidityResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the key was valid at the specified time.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The status of the key.
|
||||
/// </summary>
|
||||
public required KeyStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was added.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AddedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was revoked (if applicable).
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason why the key is invalid (if applicable).
|
||||
/// </summary>
|
||||
public string? InvalidReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a key.
|
||||
/// </summary>
|
||||
public enum KeyStatus
|
||||
{
|
||||
/// <summary>Key is active and can be used for signing.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Key was revoked but may be valid for historical proofs.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Key has expired.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Key was not valid at the specified time (signed before key was added).</summary>
|
||||
NotYetValid,
|
||||
|
||||
/// <summary>Key is unknown.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warning about key rotation needs.
|
||||
/// </summary>
|
||||
public sealed record KeyRotationWarning
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID this warning applies to.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The warning type.
|
||||
/// </summary>
|
||||
public required RotationWarningType WarningType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the warning becomes critical (e.g., expiry date).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CriticalAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of rotation warnings.
|
||||
/// </summary>
|
||||
public enum RotationWarningType
|
||||
{
|
||||
/// <summary>Key is approaching expiry.</summary>
|
||||
ExpiryApproaching,
|
||||
|
||||
/// <summary>Key has been active for a long time.</summary>
|
||||
LongLived,
|
||||
|
||||
/// <summary>Algorithm is being deprecated.</summary>
|
||||
AlgorithmDeprecating,
|
||||
|
||||
/// <summary>Key has high usage count.</summary>
|
||||
HighUsage
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry in the key history.
|
||||
/// </summary>
|
||||
public sealed record KeyHistoryEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID.
|
||||
/// </summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was added.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AddedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the key was revoked (if applicable).
|
||||
/// </summary>
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for revocation (if applicable).
|
||||
/// </summary>
|
||||
public string? RevokeReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The algorithm of the key.
|
||||
/// </summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiry date.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Manages trust anchors and their key bindings.
|
||||
/// Implements advisory §8.3 trust anchor structure.
|
||||
/// </summary>
|
||||
public interface ITrustAnchorManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a trust anchor by ID.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The trust anchor or null.</returns>
|
||||
Task<TrustAnchorInfo?> GetAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Find a trust anchor matching a PURL.
|
||||
/// Uses pattern matching (e.g., pkg:npm/* matches pkg:npm/lodash@4.17.21).
|
||||
/// </summary>
|
||||
/// <param name="purl">The PURL to match.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The matching trust anchor or null.</returns>
|
||||
Task<TrustAnchorInfo?> FindAnchorForPurlAsync(
|
||||
string purl,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Create a new trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="request">The creation request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created trust anchor.</returns>
|
||||
Task<TrustAnchorInfo> CreateAnchorAsync(
|
||||
CreateTrustAnchorRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update a trust anchor.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="request">The update request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The updated trust anchor.</returns>
|
||||
Task<TrustAnchorInfo> UpdateAnchorAsync(
|
||||
Guid anchorId,
|
||||
UpdateTrustAnchorRequest request,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivate a trust anchor (soft delete).
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task DeactivateAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature against a trust anchor's allowed keys.
|
||||
/// Supports temporal verification for historical proofs.
|
||||
/// </summary>
|
||||
/// <param name="anchorId">The anchor ID.</param>
|
||||
/// <param name="keyId">The key ID that signed.</param>
|
||||
/// <param name="signedAt">When the signature was created.</param>
|
||||
/// <param name="predicateType">The predicate type (if restricted).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<TrustVerificationResult> VerifySignatureAuthorizationAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
DateTimeOffset signedAt,
|
||||
string? predicateType = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all active trust anchors.
|
||||
/// </summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of active anchors.</returns>
|
||||
Task<IReadOnlyList<TrustAnchorInfo>> GetActiveAnchorsAsync(
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full trust anchor information including key history.
|
||||
/// </summary>
|
||||
public sealed record TrustAnchorInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The anchor ID.
|
||||
/// </summary>
|
||||
public required Guid AnchorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL glob pattern.
|
||||
/// </summary>
|
||||
public required string PurlPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Currently allowed key IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> AllowedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed predicate types (null = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AllowedPredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference.
|
||||
/// </summary>
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Revoked key IDs (still valid for historical proofs).
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> RevokedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full key history.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<KeyHistoryEntry> KeyHistory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the anchor is active.
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the anchor was last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a trust anchor.
|
||||
/// </summary>
|
||||
public sealed record CreateTrustAnchorRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PURL glob pattern.
|
||||
/// </summary>
|
||||
public required string PurlPattern { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Initial allowed key IDs.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<string> AllowedKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed predicate types (null = all).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AllowedPredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reference.
|
||||
/// </summary>
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update a trust anchor.
|
||||
/// </summary>
|
||||
public sealed record UpdateTrustAnchorRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Updated predicate types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AllowedPredicateTypes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated policy reference.
|
||||
/// </summary>
|
||||
public string? PolicyRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Updated policy version.
|
||||
/// </summary>
|
||||
public string? PolicyVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of trust verification.
|
||||
/// </summary>
|
||||
public sealed record TrustVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is authorized.
|
||||
/// </summary>
|
||||
public required bool IsAuthorized { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for authorization failure (if applicable).
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key status at the time of signing.
|
||||
/// </summary>
|
||||
public required KeyStatus KeyStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the predicate type was allowed.
|
||||
/// </summary>
|
||||
public bool? PredicateTypeAllowed { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// =============================================================================
|
||||
// DEPRECATED: This file formerly contained the root-namespace KeyManagementDbContext.
|
||||
// The authoritative context has been moved to:
|
||||
// StellaOps.Signer.KeyManagement.EfCore.Context.KeyManagementDbContext
|
||||
// following EF_CORE_MODEL_GENERATION_STANDARDS.md (Sprint 070).
|
||||
//
|
||||
// All consumers have been updated to use the new namespace.
|
||||
// This file is intentionally empty and will be removed in a future cleanup pass.
|
||||
// =============================================================================
|
||||
@@ -0,0 +1,413 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeyRotationAuditRepository.cs
|
||||
// Sprint: SPRINT_20260118_018_AirGap_router_integration
|
||||
// Task: TASK-018-007 - Key Rotation Tracking
|
||||
// Description: Repository for key rotation audit with PostgreSQL storage
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Key rotation audit event types.
|
||||
/// </summary>
|
||||
public static class KeyAuditEventType
|
||||
{
|
||||
public const string Created = "created";
|
||||
public const string Activated = "activated";
|
||||
public const string Rotated = "rotated";
|
||||
public const string Revoked = "revoked";
|
||||
public const string Expired = "expired";
|
||||
public const string SignaturePerformed = "signature_performed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit entry for key operations.
|
||||
/// </summary>
|
||||
public sealed record KeyRotationAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit entry ID.
|
||||
/// </summary>
|
||||
public Guid AuditId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key fingerprint (SHA-256 of public key).
|
||||
/// </summary>
|
||||
public required string KeyFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type (created, activated, rotated, revoked).
|
||||
/// </summary>
|
||||
public required string EventType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset EventTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous key fingerprint (for rotation events).
|
||||
/// </summary>
|
||||
public string? PreviousKeyFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for the event.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor who performed the operation.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key usage statistics.
|
||||
/// </summary>
|
||||
public sealed record KeyUsageStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Key fingerprint.
|
||||
/// </summary>
|
||||
public required string KeyFingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of signatures performed.
|
||||
/// </summary>
|
||||
public long SignatureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// First signature timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? FirstSignatureAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Last signature timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastSignatureAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key status.
|
||||
/// </summary>
|
||||
public string Status { get; init; } = "active";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository interface for key rotation audit.
|
||||
/// </summary>
|
||||
public interface IKeyRotationAuditRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Records an audit event.
|
||||
/// </summary>
|
||||
Task RecordEventAsync(KeyRotationAuditEntry entry, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets audit entries for a key fingerprint.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KeyRotationAuditEntry>> GetAuditTrailAsync(
|
||||
string keyFingerprint,
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets key usage statistics.
|
||||
/// </summary>
|
||||
Task<KeyUsageStats?> GetKeyUsageAsync(string keyFingerprint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Records a signature event.
|
||||
/// </summary>
|
||||
Task RecordSignatureAsync(
|
||||
string keyFingerprint,
|
||||
string artifactDigest,
|
||||
string actor,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all keys with their current status.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KeyUsageStats>> GetAllKeyStatsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets keys approaching expiry.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<KeyExpiryWarning>> GetExpiryWarningsAsync(
|
||||
TimeSpan warningThreshold,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Key expiry warning.
|
||||
/// </summary>
|
||||
public sealed record KeyExpiryWarning
|
||||
{
|
||||
public required string KeyFingerprint { get; init; }
|
||||
public required string KeyId { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
public TimeSpan TimeUntilExpiry { get; init; }
|
||||
public string Severity { get; init; } = "warning"; // warning, critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of key rotation audit repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresKeyRotationAuditRepository : IKeyRotationAuditRepository
|
||||
{
|
||||
private readonly KeyManagementDbContext _dbContext;
|
||||
private readonly ILogger<PostgresKeyRotationAuditRepository> _logger;
|
||||
|
||||
public PostgresKeyRotationAuditRepository(
|
||||
KeyManagementDbContext dbContext,
|
||||
ILogger<PostgresKeyRotationAuditRepository> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task RecordEventAsync(KeyRotationAuditEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
var entity = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = entry.AuditId == Guid.Empty ? Guid.NewGuid() : entry.AuditId,
|
||||
AnchorId = Guid.Empty, // Will be resolved from key
|
||||
KeyId = entry.KeyFingerprint,
|
||||
Operation = entry.EventType,
|
||||
Actor = entry.Actor,
|
||||
Reason = entry.Reason,
|
||||
Metadata = entry.Metadata.Count > 0
|
||||
? JsonSerializer.Serialize(entry.Metadata)
|
||||
: null,
|
||||
Details = entry.PreviousKeyFingerprint != null
|
||||
? JsonSerializer.Serialize(new { previousKey = entry.PreviousKeyFingerprint })
|
||||
: null,
|
||||
CreatedAt = entry.EventTimestamp
|
||||
};
|
||||
|
||||
_dbContext.KeyAuditLog.Add(entity);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded key audit event: {EventType} for {KeyFingerprint} by {Actor}",
|
||||
entry.EventType,
|
||||
entry.KeyFingerprint[..Math.Min(16, entry.KeyFingerprint.Length)],
|
||||
entry.Actor);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<KeyRotationAuditEntry>> GetAuditTrailAsync(
|
||||
string keyFingerprint,
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = _dbContext.KeyAuditLog
|
||||
.Where(l => l.KeyId == keyFingerprint);
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
query = query.Where(l => l.CreatedAt >= from.Value);
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
query = query.Where(l => l.CreatedAt <= to.Value);
|
||||
}
|
||||
|
||||
var entities = await query
|
||||
.OrderByDescending(l => l.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entities.Select(e => new KeyRotationAuditEntry
|
||||
{
|
||||
AuditId = e.LogId,
|
||||
KeyFingerprint = e.KeyId ?? "",
|
||||
EventType = e.Operation,
|
||||
EventTimestamp = e.CreatedAt,
|
||||
PreviousKeyFingerprint = ParsePreviousKey(e.Details),
|
||||
Reason = e.Reason,
|
||||
Actor = e.Actor ?? "unknown",
|
||||
Metadata = ParseMetadata(e.Metadata)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<KeyUsageStats?> GetKeyUsageAsync(string keyFingerprint, CancellationToken ct = default)
|
||||
{
|
||||
var signatureEvents = await _dbContext.KeyAuditLog
|
||||
.Where(l => l.KeyId == keyFingerprint && l.Operation == KeyAuditEventType.SignaturePerformed)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (signatureEvents.Count == 0)
|
||||
{
|
||||
// Check if key exists
|
||||
var keyExists = await _dbContext.KeyHistory
|
||||
.AnyAsync(k => k.KeyId == keyFingerprint || k.PublicKey.Contains(keyFingerprint), ct);
|
||||
|
||||
if (!keyExists)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new KeyUsageStats
|
||||
{
|
||||
KeyFingerprint = keyFingerprint,
|
||||
SignatureCount = 0,
|
||||
Status = "active"
|
||||
};
|
||||
}
|
||||
|
||||
var key = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.KeyId == keyFingerprint, ct);
|
||||
|
||||
var status = key?.RevokedAt != null ? "revoked" :
|
||||
key?.ExpiresAt < DateTimeOffset.UtcNow ? "expired" : "active";
|
||||
|
||||
return new KeyUsageStats
|
||||
{
|
||||
KeyFingerprint = keyFingerprint,
|
||||
SignatureCount = signatureEvents.Count,
|
||||
FirstSignatureAt = signatureEvents.Min(e => e.CreatedAt),
|
||||
LastSignatureAt = signatureEvents.Max(e => e.CreatedAt),
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
public async Task RecordSignatureAsync(
|
||||
string keyFingerprint,
|
||||
string artifactDigest,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entry = new KeyRotationAuditEntry
|
||||
{
|
||||
AuditId = Guid.NewGuid(),
|
||||
KeyFingerprint = keyFingerprint,
|
||||
EventType = KeyAuditEventType.SignaturePerformed,
|
||||
EventTimestamp = DateTimeOffset.UtcNow,
|
||||
Actor = actor,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["artifactDigest"] = artifactDigest
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
await RecordEventAsync(entry, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<KeyUsageStats>> GetAllKeyStatsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var keys = await _dbContext.KeyHistory
|
||||
.ToListAsync(ct);
|
||||
|
||||
var signatureCounts = await _dbContext.KeyAuditLog
|
||||
.Where(l => l.Operation == KeyAuditEventType.SignaturePerformed)
|
||||
.GroupBy(l => l.KeyId)
|
||||
.Select(g => new { KeyId = g.Key, Count = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var countDict = signatureCounts.ToDictionary(x => x.KeyId ?? "", x => x.Count);
|
||||
|
||||
return keys.Select(k =>
|
||||
{
|
||||
var status = k.RevokedAt != null ? "revoked" :
|
||||
k.ExpiresAt < DateTimeOffset.UtcNow ? "expired" : "active";
|
||||
|
||||
countDict.TryGetValue(k.KeyId, out var count);
|
||||
|
||||
return new KeyUsageStats
|
||||
{
|
||||
KeyFingerprint = k.KeyId,
|
||||
SignatureCount = count,
|
||||
Status = status
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<KeyExpiryWarning>> GetExpiryWarningsAsync(
|
||||
TimeSpan warningThreshold,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var warningDate = now + warningThreshold;
|
||||
var criticalDate = now + TimeSpan.FromDays(7);
|
||||
|
||||
var expiringKeys = await _dbContext.KeyHistory
|
||||
.Where(k => k.RevokedAt == null &&
|
||||
k.ExpiresAt != null &&
|
||||
k.ExpiresAt <= warningDate)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return expiringKeys.Select(k => new KeyExpiryWarning
|
||||
{
|
||||
KeyFingerprint = ComputeFingerprint(k.PublicKey),
|
||||
KeyId = k.KeyId,
|
||||
ExpiresAt = k.ExpiresAt!.Value,
|
||||
TimeUntilExpiry = k.ExpiresAt!.Value - now,
|
||||
Severity = k.ExpiresAt <= criticalDate ? "critical" : "warning"
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static string? ParsePreviousKey(string? details)
|
||||
{
|
||||
if (string.IsNullOrEmpty(details)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(details);
|
||||
if (doc.RootElement.TryGetProperty("previousKey", out var prop))
|
||||
{
|
||||
return prop.GetString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> ParseMetadata(string? metadata)
|
||||
{
|
||||
if (string.IsNullOrEmpty(metadata))
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(metadata);
|
||||
return dict?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(string publicKey)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(publicKey);
|
||||
var hash = sha256.ComputeHash(bytes);
|
||||
return Convert.ToHexStringLower(hash)[..32];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of key rotation service.
|
||||
/// Implements advisory §8.2 key rotation workflow with full audit logging.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationService : IKeyRotationService
|
||||
{
|
||||
private readonly KeyManagementDbContext _dbContext;
|
||||
private readonly ILogger<KeyRotationService> _logger;
|
||||
private readonly KeyRotationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public KeyRotationService(
|
||||
KeyManagementDbContext dbContext,
|
||||
ILogger<KeyRotationService> logger,
|
||||
IOptions<KeyRotationOptions> options,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new KeyRotationOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyRotationResult> AddKeyAsync(
|
||||
Guid anchorId,
|
||||
AddKeyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.KeyId))
|
||||
{
|
||||
return FailedResult("KeyId is required.", [], []);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PublicKey))
|
||||
{
|
||||
return FailedResult("PublicKey is required.", [], []);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Algorithm))
|
||||
{
|
||||
return FailedResult("Algorithm is required.", [], []);
|
||||
}
|
||||
|
||||
if (_options.AllowedAlgorithms.Count > 0 &&
|
||||
!_options.AllowedAlgorithms.Contains(request.Algorithm, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return FailedResult($"Algorithm '{request.Algorithm}' is not supported.", [], []);
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
await using var transaction = await BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if anchor exists
|
||||
var anchor = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct);
|
||||
|
||||
if (anchor is null)
|
||||
{
|
||||
return FailedResult($"Trust anchor {anchorId} not found.", [], []);
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
var existingKey = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.AnchorId == anchorId && k.KeyId == request.KeyId, ct);
|
||||
|
||||
if (existingKey is not null)
|
||||
{
|
||||
return FailedResult($"Key {request.KeyId} already exists for anchor {anchorId}.", [], []);
|
||||
}
|
||||
|
||||
// Create key history entry
|
||||
var keyEntry = new KeyHistoryEntity
|
||||
{
|
||||
HistoryId = _guidProvider.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = request.KeyId,
|
||||
PublicKey = request.PublicKey,
|
||||
Algorithm = request.Algorithm,
|
||||
AddedAt = now,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.KeyHistory.Add(keyEntry);
|
||||
|
||||
// Update anchor's allowed key IDs
|
||||
var allowedKeys = anchor.AllowedKeyIds?.ToList() ?? [];
|
||||
if (!allowedKeys.Contains(request.KeyId))
|
||||
{
|
||||
allowedKeys.Add(request.KeyId);
|
||||
}
|
||||
anchor.AllowedKeyIds = allowedKeys;
|
||||
anchor.UpdatedAt = now;
|
||||
|
||||
// Create audit log entry
|
||||
var auditEntry = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = _guidProvider.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = request.KeyId,
|
||||
Operation = KeyOperation.Add,
|
||||
Actor = _options.DefaultActor,
|
||||
Reason = "Key added via rotation service",
|
||||
Metadata = null,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.KeyAuditLog.Add(auditEntry);
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Added key {KeyId} to anchor {AnchorId}. Audit log: {AuditLogId}",
|
||||
request.KeyId, anchorId, auditEntry.LogId);
|
||||
|
||||
var revokedKeys = await GetRevokedKeyIdsAsync(anchorId, ct);
|
||||
|
||||
return new KeyRotationResult
|
||||
{
|
||||
Success = true,
|
||||
AllowedKeyIds = anchor.AllowedKeyIds?.AsReadOnly() ?? [],
|
||||
RevokedKeyIds = revokedKeys,
|
||||
AuditLogId = auditEntry.LogId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
_logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", request.KeyId, anchorId);
|
||||
return FailedResult($"Failed to add key: {ex.Message}", [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyRotationResult> RevokeKeyAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
RevokeKeyRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return FailedResult("KeyId is required.", [], []);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return FailedResult("Reason is required.", [], []);
|
||||
}
|
||||
|
||||
var effectiveAt = request.EffectiveAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
await using var transaction = await BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if anchor exists
|
||||
var anchor = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct);
|
||||
|
||||
if (anchor is null)
|
||||
{
|
||||
return FailedResult($"Trust anchor {anchorId} not found.", [], []);
|
||||
}
|
||||
|
||||
// Find the key in history
|
||||
var keyEntry = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.AnchorId == anchorId && k.KeyId == keyId, ct);
|
||||
|
||||
if (keyEntry is null)
|
||||
{
|
||||
return FailedResult($"Key {keyId} not found for anchor {anchorId}.", [], []);
|
||||
}
|
||||
|
||||
if (keyEntry.RevokedAt is not null)
|
||||
{
|
||||
return FailedResult($"Key {keyId} is already revoked.", [], []);
|
||||
}
|
||||
|
||||
// Revoke the key
|
||||
keyEntry.RevokedAt = effectiveAt;
|
||||
keyEntry.RevokeReason = request.Reason;
|
||||
|
||||
// Remove from allowed keys
|
||||
var allowedKeys = anchor.AllowedKeyIds?.ToList() ?? [];
|
||||
allowedKeys.RemoveAll(k => string.Equals(k, keyId, StringComparison.Ordinal));
|
||||
anchor.AllowedKeyIds = allowedKeys;
|
||||
|
||||
// Add to revoked keys
|
||||
var revokedKeys = anchor.RevokedKeyIds?.ToList() ?? [];
|
||||
revokedKeys.Add(keyId);
|
||||
anchor.RevokedKeyIds = revokedKeys;
|
||||
anchor.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Create audit log entry
|
||||
var auditEntry = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = _guidProvider.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = keyId,
|
||||
Operation = KeyOperation.Revoke,
|
||||
Actor = _options.DefaultActor,
|
||||
Reason = request.Reason,
|
||||
Metadata = null,
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_dbContext.KeyAuditLog.Add(auditEntry);
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Revoked key {KeyId} from anchor {AnchorId}. Reason: {Reason}. Audit log: {AuditLogId}",
|
||||
keyId, anchorId, request.Reason, auditEntry.LogId);
|
||||
|
||||
return new KeyRotationResult
|
||||
{
|
||||
Success = true,
|
||||
AllowedKeyIds = anchor.AllowedKeyIds?.AsReadOnly() ?? [],
|
||||
RevokedKeyIds = anchor.RevokedKeyIds?.AsReadOnly() ?? [],
|
||||
AuditLogId = auditEntry.LogId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
}
|
||||
_logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
return FailedResult($"Failed to revoke key: {ex.Message}", [], []);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<KeyValidityResult> CheckKeyValidityAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
DateTimeOffset signedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Unknown,
|
||||
AddedAt = DateTimeOffset.MinValue,
|
||||
InvalidReason = "KeyId is required."
|
||||
};
|
||||
}
|
||||
|
||||
// Find the key in history
|
||||
var keyEntry = await _dbContext.KeyHistory
|
||||
.FirstOrDefaultAsync(k => k.AnchorId == anchorId && k.KeyId == keyId, ct);
|
||||
|
||||
if (keyEntry is null)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Unknown,
|
||||
AddedAt = DateTimeOffset.MinValue,
|
||||
InvalidReason = $"Key {keyId} not found for anchor {anchorId}."
|
||||
};
|
||||
}
|
||||
|
||||
// Check temporal validity: was the key added before the signature was made?
|
||||
if (signedAt < keyEntry.AddedAt)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.NotYetValid,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt,
|
||||
InvalidReason = $"Key was added at {keyEntry.AddedAt:O}, but signature was made at {signedAt:O}."
|
||||
};
|
||||
}
|
||||
|
||||
// Check if key was revoked before signature
|
||||
if (keyEntry.RevokedAt.HasValue && signedAt >= keyEntry.RevokedAt.Value)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Revoked,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt,
|
||||
InvalidReason = $"Key was revoked at {keyEntry.RevokedAt:O}, signature was made at {signedAt:O}."
|
||||
};
|
||||
}
|
||||
|
||||
// Check if key had expired before signature
|
||||
if (keyEntry.ExpiresAt.HasValue && signedAt >= keyEntry.ExpiresAt.Value)
|
||||
{
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = KeyStatus.Expired,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt,
|
||||
InvalidReason = $"Key expired at {keyEntry.ExpiresAt:O}, signature was made at {signedAt:O}."
|
||||
};
|
||||
}
|
||||
|
||||
// Key is valid at the specified time
|
||||
var status = keyEntry.RevokedAt.HasValue
|
||||
? KeyStatus.Revoked // Revoked but valid for this historical signature
|
||||
: KeyStatus.Active;
|
||||
|
||||
return new KeyValidityResult
|
||||
{
|
||||
IsValid = true,
|
||||
Status = status,
|
||||
AddedAt = keyEntry.AddedAt,
|
||||
RevokedAt = keyEntry.RevokedAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<KeyRotationWarning>> GetRotationWarningsAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var warnings = new List<KeyRotationWarning>();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Get all active (non-revoked) keys for the anchor
|
||||
var activeKeys = await _dbContext.KeyHistory
|
||||
.Where(k => k.AnchorId == anchorId && k.RevokedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var key in activeKeys)
|
||||
{
|
||||
// Check for expiry approaching
|
||||
if (key.ExpiresAt.HasValue)
|
||||
{
|
||||
var daysUntilExpiry = (key.ExpiresAt.Value - now).TotalDays;
|
||||
|
||||
if (daysUntilExpiry <= 0)
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.ExpiryApproaching,
|
||||
Message = $"Key {key.KeyId} has expired on {key.ExpiresAt:O}.",
|
||||
CriticalAt = key.ExpiresAt
|
||||
});
|
||||
}
|
||||
else if (daysUntilExpiry <= _options.ExpiryWarningDays)
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.ExpiryApproaching,
|
||||
Message = $"Key {key.KeyId} expires in {daysUntilExpiry:F0} days on {key.ExpiresAt:O}.",
|
||||
CriticalAt = key.ExpiresAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for long-lived keys
|
||||
var keyAge = now - key.AddedAt;
|
||||
if (keyAge.TotalDays > _options.MaxKeyAgeDays)
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.LongLived,
|
||||
Message = $"Key {key.KeyId} has been active for {keyAge.TotalDays:F0} days. Consider rotation.",
|
||||
CriticalAt = key.AddedAt.AddDays(_options.MaxKeyAgeDays + _options.ExpiryWarningDays)
|
||||
});
|
||||
}
|
||||
|
||||
// Check for deprecated algorithms
|
||||
if (_options.DeprecatedAlgorithms.Contains(key.Algorithm, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add(new KeyRotationWarning
|
||||
{
|
||||
KeyId = key.KeyId,
|
||||
WarningType = RotationWarningType.AlgorithmDeprecating,
|
||||
Message = $"Key {key.KeyId} uses deprecated algorithm {key.Algorithm}. Plan migration.",
|
||||
CriticalAt = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<KeyHistoryEntry>> GetKeyHistoryAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await _dbContext.KeyHistory
|
||||
.Where(k => k.AnchorId == anchorId)
|
||||
.OrderByDescending(k => k.AddedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return entries.Select(e => new KeyHistoryEntry
|
||||
{
|
||||
KeyId = e.KeyId,
|
||||
AddedAt = e.AddedAt,
|
||||
RevokedAt = e.RevokedAt,
|
||||
RevokeReason = e.RevokeReason,
|
||||
Algorithm = e.Algorithm,
|
||||
ExpiresAt = e.ExpiresAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> GetRevokedKeyIdsAsync(Guid anchorId, CancellationToken ct)
|
||||
{
|
||||
return await _dbContext.KeyHistory
|
||||
.Where(k => k.AnchorId == anchorId && k.RevokedAt != null)
|
||||
.Select(k => k.KeyId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async ValueTask<IDbContextTransaction?> BeginTransactionAsync(CancellationToken ct)
|
||||
{
|
||||
if (IsInMemoryProvider())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _dbContext.Database.BeginTransactionAsync(ct);
|
||||
}
|
||||
|
||||
private bool IsInMemoryProvider()
|
||||
{
|
||||
var providerName = _dbContext.Database.ProviderName;
|
||||
return providerName is not null &&
|
||||
providerName.Contains("InMemory", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static KeyRotationResult FailedResult(
|
||||
string errorMessage,
|
||||
IReadOnlyList<string> allowedKeys,
|
||||
IReadOnlyList<string> revokedKeys) => new()
|
||||
{
|
||||
Success = false,
|
||||
AllowedKeyIds = allowedKeys,
|
||||
RevokedKeyIds = revokedKeys,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for key rotation service.
|
||||
/// </summary>
|
||||
public sealed class KeyRotationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Allowed signing algorithms for key rotation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AllowedAlgorithms { get; set; } =
|
||||
[
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"ED25519",
|
||||
"EdDSA",
|
||||
"SM2",
|
||||
"GOST12-256",
|
||||
"GOST12-512",
|
||||
"DILITHIUM3",
|
||||
"FALCON512",
|
||||
"RSA-2048",
|
||||
"SHA1-RSA"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Default actor for audit log entries when not specified.
|
||||
/// </summary>
|
||||
public string DefaultActor { get; set; } = "system";
|
||||
|
||||
/// <summary>
|
||||
/// Number of days before expiry to start warning.
|
||||
/// </summary>
|
||||
public int ExpiryWarningDays { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum key age in days before warning about rotation.
|
||||
/// </summary>
|
||||
public int MaxKeyAgeDays { get; set; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// List of deprecated algorithms to warn about.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DeprecatedAlgorithms { get; set; } = ["RSA-2048", "SHA1-RSA"];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Signer Schema Migration 001: Initial Schema (Compacted)
|
||||
-- Consolidated from 20251214000001_AddKeyManagementSchema.sql for 1.0.0 release
|
||||
-- Creates the signer schema for key management, rotation, and trust anchor management
|
||||
|
||||
-- ============================================================================
|
||||
-- Extensions
|
||||
-- ============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema Creation
|
||||
-- ============================================================================
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS signer;
|
||||
|
||||
-- ============================================================================
|
||||
-- Signer Schema Tables
|
||||
-- ============================================================================
|
||||
|
||||
-- Key history table (tracks all keys ever added to trust anchors)
|
||||
CREATE TABLE IF NOT EXISTS signer.key_history (
|
||||
history_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
anchor_id UUID NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoke_reason TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_key_history_anchor_key UNIQUE (anchor_id, key_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_anchor ON signer.key_history(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_key_id ON signer.key_history(key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_added ON signer.key_history(added_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_revoked ON signer.key_history(revoked_at) WHERE revoked_at IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE signer.key_history IS 'Tracks all keys ever added to trust anchors for historical verification';
|
||||
COMMENT ON COLUMN signer.key_history.revoke_reason IS 'Reason for revocation (e.g., rotation-complete, compromised)';
|
||||
|
||||
-- Key audit log table (tracks all key operations)
|
||||
CREATE TABLE IF NOT EXISTS signer.key_audit_log (
|
||||
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
anchor_id UUID NOT NULL,
|
||||
key_id TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
old_state JSONB,
|
||||
new_state JSONB,
|
||||
details JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_anchor ON signer.key_audit_log(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_key ON signer.key_audit_log(key_id) WHERE key_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_operation ON signer.key_audit_log(operation);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_created ON signer.key_audit_log(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE signer.key_audit_log IS 'Audit log for all key management operations';
|
||||
COMMENT ON COLUMN signer.key_audit_log.operation IS 'Operation type: add_key, revoke_key, create_anchor, update_anchor, etc.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Optional Foreign Key to ProofChain
|
||||
-- ============================================================================
|
||||
-- This is conditional to avoid errors if the proofchain schema doesn't exist yet
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'proofchain' AND table_name = 'trust_anchors'
|
||||
) THEN
|
||||
-- Add foreign key if not already exists
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_key_history_anchor'
|
||||
AND table_schema = 'signer'
|
||||
AND table_name = 'key_history'
|
||||
) THEN
|
||||
ALTER TABLE signer.key_history
|
||||
ADD CONSTRAINT fk_key_history_anchor
|
||||
FOREIGN KEY (anchor_id) REFERENCES proofchain.trust_anchors(anchor_id)
|
||||
ON DELETE CASCADE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_key_audit_anchor'
|
||||
AND table_schema = 'signer'
|
||||
AND table_name = 'key_audit_log'
|
||||
) THEN
|
||||
ALTER TABLE signer.key_audit_log
|
||||
ADD CONSTRAINT fk_key_audit_anchor
|
||||
FOREIGN KEY (anchor_id) REFERENCES proofchain.trust_anchors(anchor_id)
|
||||
ON DELETE CASCADE;
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Migration: 20251214000001_AddKeyManagementSchema
|
||||
-- Creates the key management schema for key rotation and trust anchor management.
|
||||
|
||||
-- Create schema
|
||||
CREATE SCHEMA IF NOT EXISTS signer;
|
||||
|
||||
-- Key history table (tracks all keys ever added to trust anchors)
|
||||
CREATE TABLE IF NOT EXISTS signer.key_history (
|
||||
history_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
anchor_id UUID NOT NULL,
|
||||
key_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoke_reason TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Unique constraint for key_id within an anchor
|
||||
CONSTRAINT uq_key_history_anchor_key UNIQUE (anchor_id, key_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_anchor ON signer.key_history(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_key_id ON signer.key_history(key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_added ON signer.key_history(added_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_history_revoked ON signer.key_history(revoked_at) WHERE revoked_at IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE signer.key_history IS 'Tracks all keys ever added to trust anchors for historical verification';
|
||||
COMMENT ON COLUMN signer.key_history.revoke_reason IS 'Reason for revocation (e.g., rotation-complete, compromised)';
|
||||
|
||||
-- Key audit log table (tracks all key operations)
|
||||
CREATE TABLE IF NOT EXISTS signer.key_audit_log (
|
||||
log_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
anchor_id UUID NOT NULL,
|
||||
key_id TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
old_state JSONB,
|
||||
new_state JSONB,
|
||||
details JSONB,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_anchor ON signer.key_audit_log(anchor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_key ON signer.key_audit_log(key_id) WHERE key_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_operation ON signer.key_audit_log(operation);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_audit_created ON signer.key_audit_log(created_at DESC);
|
||||
|
||||
COMMENT ON TABLE signer.key_audit_log IS 'Audit log for all key management operations';
|
||||
COMMENT ON COLUMN signer.key_audit_log.operation IS 'Operation type: add_key, revoke_key, create_anchor, update_anchor, etc.';
|
||||
|
||||
-- Optional: Create foreign key to proofchain.trust_anchors if that schema exists
|
||||
-- This is conditional to avoid errors if the other schema doesn't exist yet
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'proofchain' AND table_name = 'trust_anchors'
|
||||
) THEN
|
||||
ALTER TABLE signer.key_history
|
||||
ADD CONSTRAINT fk_key_history_anchor
|
||||
FOREIGN KEY (anchor_id) REFERENCES proofchain.trust_anchors(anchor_id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE signer.key_audit_log
|
||||
ADD CONSTRAINT fk_key_audit_anchor
|
||||
FOREIGN KEY (anchor_id) REFERENCES proofchain.trust_anchors(anchor_id)
|
||||
ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,21 @@
|
||||
# Archived Pre-1.0 Migrations
|
||||
|
||||
This directory contains the original migrations that were compacted into `001_initial_schema.sql`
|
||||
for the 1.0.0 release.
|
||||
|
||||
## Original Files
|
||||
- `20251214000001_AddKeyManagementSchema.sql` - Key management schema (key_history, key_audit_log)
|
||||
|
||||
## Why Archived
|
||||
Pre-1.0, the schema evolved incrementally. For 1.0.0, migrations were compacted into a single
|
||||
initial schema to:
|
||||
- Simplify new deployments
|
||||
- Reduce startup time
|
||||
- Provide cleaner upgrade path
|
||||
|
||||
## For Existing Deployments
|
||||
If upgrading from pre-1.0, run the reset script directly with psql:
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <db> -f devops/scripts/migrations-reset-pre-1.0.sql
|
||||
```
|
||||
This updates `schema_migrations` to recognize the compacted schema.
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.CompiledModels;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating KeyManagementDbContext instances.
|
||||
/// Uses the static compiled model for the default schema path.
|
||||
/// </summary>
|
||||
internal static class KeyManagementDbContextFactory
|
||||
{
|
||||
public const string DefaultSchemaName = "signer";
|
||||
|
||||
public static EfCore.Context.KeyManagementDbContext Create(
|
||||
NpgsqlConnection connection,
|
||||
int commandTimeoutSeconds,
|
||||
string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<EfCore.Context.KeyManagementDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
// Use static compiled model ONLY for default schema path
|
||||
if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
optionsBuilder.UseModel(KeyManagementDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new EfCore.Context.KeyManagementDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.Signer.KeyManagement</RootNamespace>
|
||||
<Description>Key rotation and trust anchor management for StellaOps signing infrastructure.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embed SQL migrations as resources -->
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\KeyManagementDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\*.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Signer.KeyManagement Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Signer/__Libraries/StellaOps.Signer.KeyManagement/StellaOps.Signer.KeyManagement.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
@@ -0,0 +1,420 @@
|
||||
|
||||
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Signer.KeyManagement.EfCore.Context;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of trust anchor manager.
|
||||
/// Implements advisory §8.3 trust anchor structure with PURL pattern matching.
|
||||
/// </summary>
|
||||
public sealed class TrustAnchorManager : ITrustAnchorManager
|
||||
{
|
||||
private readonly KeyManagementDbContext _dbContext;
|
||||
private readonly IKeyRotationService _keyRotationService;
|
||||
private readonly ILogger<TrustAnchorManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public TrustAnchorManager(
|
||||
KeyManagementDbContext dbContext,
|
||||
IKeyRotationService keyRotationService,
|
||||
ILogger<TrustAnchorManager> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_keyRotationService = keyRotationService ?? throw new ArgumentNullException(nameof(keyRotationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo?> GetAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(anchorId, ct);
|
||||
return MapToInfo(entity, keyHistory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo?> FindAnchorForPurlAsync(
|
||||
string purl,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get all active anchors
|
||||
var anchors = await _dbContext.TrustAnchors
|
||||
.Where(a => a.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Find the most specific matching pattern
|
||||
TrustAnchorEntity? bestMatch = null;
|
||||
var bestSpecificity = -1;
|
||||
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
if (PurlPatternMatcher.Matches(anchor.PurlPattern, purl))
|
||||
{
|
||||
var specificity = PurlPatternMatcher.GetSpecificity(anchor.PurlPattern);
|
||||
if (specificity > bestSpecificity ||
|
||||
(specificity == bestSpecificity && IsMoreSpecificPattern(anchor.PurlPattern, bestMatch?.PurlPattern)))
|
||||
{
|
||||
bestMatch = anchor;
|
||||
bestSpecificity = specificity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(bestMatch.AnchorId, ct);
|
||||
return MapToInfo(bestMatch, keyHistory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo> CreateAnchorAsync(
|
||||
CreateTrustAnchorRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PurlPattern))
|
||||
{
|
||||
throw new ArgumentException("PurlPattern is required.", nameof(request));
|
||||
}
|
||||
|
||||
// Validate PURL pattern
|
||||
if (!PurlPatternMatcher.IsValidPattern(request.PurlPattern))
|
||||
{
|
||||
throw new ArgumentException($"Invalid PURL pattern: {request.PurlPattern}", nameof(request));
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var entity = new TrustAnchorEntity
|
||||
{
|
||||
AnchorId = _guidProvider.NewGuid(),
|
||||
PurlPattern = request.PurlPattern,
|
||||
AllowedKeyIds = request.AllowedKeyIds?.ToList() ?? [],
|
||||
AllowedPredicateTypes = request.AllowedPredicateTypes?.ToList(),
|
||||
PolicyRef = request.PolicyRef,
|
||||
PolicyVersion = request.PolicyVersion,
|
||||
RevokedKeyIds = [],
|
||||
IsActive = true,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.TrustAnchors.Add(entity);
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created trust anchor {AnchorId} with pattern {Pattern}",
|
||||
entity.AnchorId, entity.PurlPattern);
|
||||
|
||||
return MapToInfo(entity, []);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustAnchorInfo> UpdateAnchorAsync(
|
||||
Guid anchorId,
|
||||
UpdateTrustAnchorRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var entity = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct)
|
||||
?? throw new InvalidOperationException($"Trust anchor {anchorId} not found.");
|
||||
|
||||
if (request.AllowedPredicateTypes is not null)
|
||||
{
|
||||
entity.AllowedPredicateTypes = request.AllowedPredicateTypes.ToList();
|
||||
}
|
||||
|
||||
if (request.PolicyRef is not null)
|
||||
{
|
||||
entity.PolicyRef = request.PolicyRef;
|
||||
}
|
||||
|
||||
if (request.PolicyVersion is not null)
|
||||
{
|
||||
entity.PolicyVersion = request.PolicyVersion;
|
||||
}
|
||||
|
||||
entity.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("Updated trust anchor {AnchorId}", anchorId);
|
||||
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(anchorId, ct);
|
||||
return MapToInfo(entity, keyHistory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeactivateAnchorAsync(
|
||||
Guid anchorId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _dbContext.TrustAnchors
|
||||
.FirstOrDefaultAsync(a => a.AnchorId == anchorId, ct)
|
||||
?? throw new InvalidOperationException($"Trust anchor {anchorId} not found.");
|
||||
|
||||
entity.IsActive = false;
|
||||
entity.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
await _dbContext.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation("Deactivated trust anchor {AnchorId}", anchorId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TrustVerificationResult> VerifySignatureAuthorizationAsync(
|
||||
Guid anchorId,
|
||||
string keyId,
|
||||
DateTimeOffset signedAt,
|
||||
string? predicateType = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Check key validity at signing time
|
||||
var keyValidity = await _keyRotationService.CheckKeyValidityAsync(anchorId, keyId, signedAt, ct);
|
||||
|
||||
if (!keyValidity.IsValid)
|
||||
{
|
||||
return new TrustVerificationResult
|
||||
{
|
||||
IsAuthorized = false,
|
||||
FailureReason = keyValidity.InvalidReason ?? $"Key {keyId} was not valid at {signedAt:O}.",
|
||||
KeyStatus = keyValidity.Status,
|
||||
PredicateTypeAllowed = null
|
||||
};
|
||||
}
|
||||
|
||||
// Check predicate type if specified
|
||||
bool? predicateAllowed = null;
|
||||
if (predicateType is not null)
|
||||
{
|
||||
var anchor = await GetAnchorAsync(anchorId, ct);
|
||||
if (anchor is not null && anchor.AllowedPredicateTypes is not null)
|
||||
{
|
||||
predicateAllowed = anchor.AllowedPredicateTypes.Contains(predicateType);
|
||||
if (!predicateAllowed.Value)
|
||||
{
|
||||
return new TrustVerificationResult
|
||||
{
|
||||
IsAuthorized = false,
|
||||
FailureReason = $"Predicate type '{predicateType}' is not allowed for this anchor.",
|
||||
KeyStatus = keyValidity.Status,
|
||||
PredicateTypeAllowed = false
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
predicateAllowed = true; // No restriction
|
||||
}
|
||||
}
|
||||
|
||||
return new TrustVerificationResult
|
||||
{
|
||||
IsAuthorized = true,
|
||||
KeyStatus = keyValidity.Status,
|
||||
PredicateTypeAllowed = predicateAllowed
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TrustAnchorInfo>> GetActiveAnchorsAsync(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entities = await _dbContext.TrustAnchors
|
||||
.Where(a => a.IsActive)
|
||||
.OrderBy(a => a.PurlPattern)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var results = new List<TrustAnchorInfo>();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var keyHistory = await _keyRotationService.GetKeyHistoryAsync(entity.AnchorId, ct);
|
||||
results.Add(MapToInfo(entity, keyHistory));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static TrustAnchorInfo MapToInfo(TrustAnchorEntity entity, IReadOnlyList<KeyHistoryEntry> keyHistory)
|
||||
{
|
||||
return new TrustAnchorInfo
|
||||
{
|
||||
AnchorId = entity.AnchorId,
|
||||
PurlPattern = entity.PurlPattern,
|
||||
AllowedKeyIds = entity.AllowedKeyIds?.ToList() ?? [],
|
||||
AllowedPredicateTypes = entity.AllowedPredicateTypes?.ToList(),
|
||||
PolicyRef = entity.PolicyRef,
|
||||
PolicyVersion = entity.PolicyVersion,
|
||||
RevokedKeyIds = entity.RevokedKeyIds?.ToList() ?? [],
|
||||
KeyHistory = keyHistory,
|
||||
IsActive = entity.IsActive,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsMoreSpecificPattern(string candidate, string? current)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var candidateLiteral = CountLiteralCharacters(candidate);
|
||||
var currentLiteral = CountLiteralCharacters(current);
|
||||
|
||||
if (candidateLiteral != currentLiteral)
|
||||
{
|
||||
return candidateLiteral > currentLiteral;
|
||||
}
|
||||
|
||||
return candidate.Length > current.Length;
|
||||
}
|
||||
|
||||
private static int CountLiteralCharacters(string pattern)
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var ch in pattern)
|
||||
{
|
||||
if (ch != '*' && ch != '?')
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PURL pattern matching utilities.
|
||||
/// Supports glob-style patterns like pkg:npm/*, pkg:maven/org.apache/*, etc.
|
||||
/// </summary>
|
||||
public static class PurlPatternMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a PURL pattern is valid.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The pattern to validate.</param>
|
||||
/// <returns>True if valid.</returns>
|
||||
public static bool IsValidPattern(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with pkg:
|
||||
if (!pattern.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must have at least a type after pkg:
|
||||
var afterPkg = pattern.Substring(4);
|
||||
if (string.IsNullOrEmpty(afterPkg))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid patterns: pkg:type/*, pkg:type/namespace/*, pkg:type/namespace/name, etc.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a PURL matches a pattern.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The glob pattern (e.g., pkg:npm/*).</param>
|
||||
/// <param name="purl">The PURL to check (e.g., pkg:npm/lodash@4.17.21).</param>
|
||||
/// <returns>True if the PURL matches the pattern.</returns>
|
||||
public static bool Matches(string pattern, string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (pattern.Equals(purl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert glob pattern to regex
|
||||
var regexPattern = GlobToRegex(pattern);
|
||||
return Regex.IsMatch(purl, regexPattern, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the specificity of a pattern (higher = more specific).
|
||||
/// Used to select the best matching pattern when multiple match.
|
||||
/// </summary>
|
||||
/// <param name="pattern">The pattern.</param>
|
||||
/// <returns>Specificity score.</returns>
|
||||
public static int GetSpecificity(string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// More path segments = more specific
|
||||
var segments = pattern.Split('/').Length;
|
||||
|
||||
// Wildcards reduce specificity
|
||||
var wildcards = pattern.Count(c => c == '*');
|
||||
|
||||
// Score: segments * 10 - wildcards * 5
|
||||
return segments * 10 - wildcards * 5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a glob pattern to a regex pattern.
|
||||
/// </summary>
|
||||
private static string GlobToRegex(string glob)
|
||||
{
|
||||
// Escape regex special characters except * and ?
|
||||
var escaped = Regex.Escape(glob)
|
||||
.Replace("\\*", ".*") // * matches any characters
|
||||
.Replace("\\?", "."); // ? matches single character
|
||||
|
||||
return $"^{escaped}$";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AmbientOidcTokenProvider.cs
|
||||
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
|
||||
// Task: 0012 - Add OIDC token acquisition from Authority
|
||||
// Description: OIDC token provider for ambient tokens (CI runners, workload identity)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// OIDC token provider that reads ambient tokens from the filesystem.
|
||||
/// Used for CI runner tokens, Kubernetes workload identity, etc.
|
||||
/// </summary>
|
||||
public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
||||
{
|
||||
private readonly OidcAmbientConfig _config;
|
||||
private readonly ILogger<AmbientOidcTokenProvider> _logger;
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly FileSystemWatcher? _watcher;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private OidcTokenResult? _cachedToken;
|
||||
private bool _disposed;
|
||||
|
||||
public AmbientOidcTokenProvider(
|
||||
OidcAmbientConfig config,
|
||||
ILogger<AmbientOidcTokenProvider> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_tokenHandler = new JwtSecurityTokenHandler();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
if (_config.WatchForChanges && File.Exists(_config.TokenPath))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_config.TokenPath);
|
||||
var fileName = Path.GetFileName(_config.TokenPath);
|
||||
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
_watcher = new FileSystemWatcher(directory, fileName)
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
|
||||
};
|
||||
_watcher.Changed += OnTokenFileChanged;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Issuer => _config.Issuer;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OidcTokenResult> AcquireTokenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Check cache first
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_cachedToken is not null && !_cachedToken.WillExpireSoon(now, TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
return _cachedToken;
|
||||
}
|
||||
|
||||
// Read token from file
|
||||
if (!File.Exists(_config.TokenPath))
|
||||
{
|
||||
throw new OidcTokenAcquisitionException(
|
||||
_config.Issuer,
|
||||
$"Ambient token file not found: {_config.TokenPath}");
|
||||
}
|
||||
|
||||
var tokenText = await File.ReadAllTextAsync(_config.TokenPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
tokenText = tokenText.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(tokenText))
|
||||
{
|
||||
throw new OidcTokenAcquisitionException(
|
||||
_config.Issuer,
|
||||
$"Ambient token file is empty: {_config.TokenPath}");
|
||||
}
|
||||
|
||||
// Parse JWT to extract claims
|
||||
var result = ParseToken(tokenText);
|
||||
_cachedToken = result;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Acquired ambient OIDC token from {TokenPath}, expires at {ExpiresAt}",
|
||||
_config.TokenPath,
|
||||
result.ExpiresAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OidcTokenResult? GetCachedToken()
|
||||
{
|
||||
var cached = _cachedToken;
|
||||
if (cached is null || cached.IsExpiredAt(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearCache()
|
||||
{
|
||||
_cachedToken = null;
|
||||
}
|
||||
|
||||
private OidcTokenResult ParseToken(string tokenText)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwt = _tokenHandler.ReadJwtToken(tokenText);
|
||||
|
||||
var expiresAt = jwt.ValidTo != DateTime.MinValue
|
||||
? new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)
|
||||
: _timeProvider.GetUtcNow().AddHours(1); // Default if no exp claim
|
||||
|
||||
var subject = jwt.Subject;
|
||||
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
// Validate issuer if configured
|
||||
if (!string.IsNullOrEmpty(_config.Issuer))
|
||||
{
|
||||
var tokenIssuer = jwt.Issuer;
|
||||
if (!string.Equals(tokenIssuer, _config.Issuer, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new OidcTokenAcquisitionException(
|
||||
_config.Issuer,
|
||||
$"Token issuer '{tokenIssuer}' does not match expected issuer '{_config.Issuer}'");
|
||||
}
|
||||
}
|
||||
|
||||
return new OidcTokenResult
|
||||
{
|
||||
IdentityToken = tokenText,
|
||||
ExpiresAt = expiresAt,
|
||||
Subject = subject,
|
||||
Email = email
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OidcTokenAcquisitionException)
|
||||
{
|
||||
throw new OidcTokenAcquisitionException(
|
||||
_config.Issuer,
|
||||
$"Failed to parse ambient token: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTokenFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
_logger.LogDebug("Ambient token file changed, clearing cache");
|
||||
ClearCache();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_watcher?.Dispose();
|
||||
_lock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ephemeral key generation using .NET cryptographic APIs.
|
||||
/// </summary>
|
||||
public sealed class EphemeralKeyGenerator : IEphemeralKeyGenerator
|
||||
{
|
||||
private readonly ILogger<EphemeralKeyGenerator> _logger;
|
||||
|
||||
public EphemeralKeyGenerator(ILogger<EphemeralKeyGenerator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EphemeralKeyPair Generate(string algorithm)
|
||||
{
|
||||
if (!KeylessAlgorithms.IsSupported(algorithm))
|
||||
{
|
||||
throw new EphemeralKeyGenerationException(algorithm, $"Unsupported algorithm: {algorithm}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return algorithm switch
|
||||
{
|
||||
KeylessAlgorithms.EcdsaP256 => GenerateEcdsaP256(),
|
||||
KeylessAlgorithms.Ed25519 => GenerateEd25519(),
|
||||
_ => throw new EphemeralKeyGenerationException(algorithm, $"Unsupported algorithm: {algorithm}")
|
||||
};
|
||||
}
|
||||
catch (CryptographicException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate ephemeral {Algorithm} keypair", algorithm);
|
||||
throw new EphemeralKeyGenerationException(algorithm, "Cryptographic key generation failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private EphemeralKeyPair GenerateEcdsaP256()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
|
||||
var publicKey = ecdsa.ExportSubjectPublicKeyInfo();
|
||||
var privateKey = ecdsa.ExportECPrivateKey();
|
||||
|
||||
_logger.LogDebug("Generated ephemeral ECDSA P-256 keypair");
|
||||
|
||||
return new EphemeralKeyPair(publicKey, privateKey, KeylessAlgorithms.EcdsaP256);
|
||||
}
|
||||
|
||||
private EphemeralKeyPair GenerateEd25519()
|
||||
{
|
||||
// Ed25519 support requires .NET 9+ or external library
|
||||
// For now, throw NotImplementedException with guidance
|
||||
throw new EphemeralKeyGenerationException(
|
||||
KeylessAlgorithms.Ed25519,
|
||||
"Ed25519 key generation requires additional implementation. " +
|
||||
"Consider using BouncyCastle or upgrading to .NET 9+ with EdDSA support.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an ephemeral keypair that exists only in memory.
|
||||
/// Private key material is securely erased on disposal.
|
||||
/// </summary>
|
||||
public sealed class EphemeralKeyPair : IDisposable
|
||||
{
|
||||
private byte[] _privateKey;
|
||||
private readonly byte[] _publicKey;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// The public key bytes.
|
||||
/// </summary>
|
||||
public ReadOnlySpan<byte> PublicKey => _publicKey;
|
||||
|
||||
/// <summary>
|
||||
/// The private key bytes. Only accessible while not disposed.
|
||||
/// </summary>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if accessed after disposal.</exception>
|
||||
public ReadOnlySpan<byte> PrivateKey
|
||||
{
|
||||
get
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return _privateKey;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The cryptographic algorithm used for this keypair.
|
||||
/// </summary>
|
||||
public string Algorithm { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The UTC timestamp when this keypair was created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ephemeral keypair.
|
||||
/// </summary>
|
||||
/// <param name="publicKey">The public key bytes.</param>
|
||||
/// <param name="privateKey">The private key bytes (will be copied).</param>
|
||||
/// <param name="algorithm">The algorithm identifier.</param>
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamp.</param>
|
||||
public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
ArgumentNullException.ThrowIfNull(privateKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(algorithm);
|
||||
|
||||
_publicKey = (byte[])publicKey.Clone();
|
||||
_privateKey = (byte[])privateKey.Clone();
|
||||
Algorithm = algorithm;
|
||||
CreatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs the specified data using the ephemeral private key.
|
||||
/// </summary>
|
||||
/// <param name="data">The data to sign.</param>
|
||||
/// <returns>The signature bytes.</returns>
|
||||
/// <exception cref="ObjectDisposedException">Thrown if called after disposal.</exception>
|
||||
public byte[] Sign(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
return Algorithm switch
|
||||
{
|
||||
KeylessAlgorithms.EcdsaP256 => SignWithEcdsaP256(data),
|
||||
KeylessAlgorithms.Ed25519 => SignWithEd25519(data),
|
||||
_ => throw new NotSupportedException($"Unsupported algorithm: {Algorithm}")
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] SignWithEcdsaP256(ReadOnlySpan<byte> data)
|
||||
{
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportECPrivateKey(_privateKey, out _);
|
||||
return ecdsa.SignData(data.ToArray(), HashAlgorithmName.SHA256);
|
||||
}
|
||||
|
||||
private byte[] SignWithEd25519(ReadOnlySpan<byte> data)
|
||||
{
|
||||
// Ed25519 signing implementation
|
||||
// Note: .NET 9+ has native Ed25519 support via EdDSA
|
||||
throw new NotImplementedException("Ed25519 signing requires additional implementation");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Securely disposes the keypair, zeroing all private key material.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Zero out the private key memory
|
||||
if (_privateKey != null)
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(_privateKey);
|
||||
_privateKey = [];
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finalizer ensures private key is zeroed if Dispose is not called.
|
||||
/// </summary>
|
||||
~EphemeralKeyPair()
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Well-known algorithm identifiers for keyless signing.
|
||||
/// </summary>
|
||||
public static class KeylessAlgorithms
|
||||
{
|
||||
/// <summary>
|
||||
/// ECDSA with P-256 curve (NIST P-256, secp256r1).
|
||||
/// </summary>
|
||||
public const string EcdsaP256 = "ECDSA_P256";
|
||||
|
||||
/// <summary>
|
||||
/// Edwards-curve Digital Signature Algorithm with Curve25519.
|
||||
/// </summary>
|
||||
public const string Ed25519 = "Ed25519";
|
||||
|
||||
/// <summary>
|
||||
/// All supported algorithms.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlySet<string> Supported = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
EcdsaP256,
|
||||
Ed25519
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the specified algorithm is supported.
|
||||
/// </summary>
|
||||
public static bool IsSupported(string algorithm) =>
|
||||
Supported.Contains(algorithm);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for Sigstore Fulcio Certificate Authority.
|
||||
/// Implements the Fulcio v2 API for certificate signing requests.
|
||||
/// </summary>
|
||||
public sealed class HttpFulcioClient : IFulcioClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpFulcioClient> _logger;
|
||||
private readonly SignerKeylessOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public HttpFulcioClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<SignerKeylessOptions> options,
|
||||
ILogger<HttpFulcioClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FulcioCertificateResult> GetCertificateAsync(
|
||||
FulcioCertificateRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
request.Validate();
|
||||
|
||||
var fulcioUrl = _options.Fulcio.Url.TrimEnd('/');
|
||||
var endpoint = $"{fulcioUrl}/api/v2/signingCert";
|
||||
|
||||
var fulcioRequest = BuildFulcioRequest(request);
|
||||
|
||||
var attempt = 0;
|
||||
var backoff = _options.Fulcio.BackoffBase;
|
||||
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Requesting certificate from Fulcio (attempt {Attempt})", attempt);
|
||||
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, endpoint);
|
||||
httpRequest.Content = JsonContent.Create(fulcioRequest, options: JsonOptions);
|
||||
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await ParseFulcioResponse(response, request, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Obtained certificate from Fulcio, valid from {NotBefore} to {NotAfter}",
|
||||
result.NotBefore,
|
||||
result.NotAfter);
|
||||
return result;
|
||||
}
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
// Non-retryable errors
|
||||
throw new FulcioUnavailableException(
|
||||
fulcioUrl,
|
||||
(int)response.StatusCode,
|
||||
responseBody,
|
||||
$"Fulcio returned {response.StatusCode}: {responseBody}");
|
||||
}
|
||||
|
||||
// Retryable error
|
||||
if (attempt >= _options.Fulcio.Retries)
|
||||
{
|
||||
throw new FulcioUnavailableException(
|
||||
fulcioUrl,
|
||||
(int)response.StatusCode,
|
||||
responseBody,
|
||||
$"Fulcio returned {response.StatusCode} after {attempt} attempts");
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Fulcio returned {StatusCode}, retrying in {Backoff}ms (attempt {Attempt}/{MaxRetries})",
|
||||
response.StatusCode,
|
||||
backoff.TotalMilliseconds,
|
||||
attempt,
|
||||
_options.Fulcio.Retries);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
if (attempt >= _options.Fulcio.Retries)
|
||||
{
|
||||
throw new FulcioUnavailableException(
|
||||
fulcioUrl,
|
||||
$"Failed to connect to Fulcio after {attempt} attempts",
|
||||
ex);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to connect to Fulcio, retrying in {Backoff}ms (attempt {Attempt}/{MaxRetries})",
|
||||
backoff.TotalMilliseconds,
|
||||
attempt,
|
||||
_options.Fulcio.Retries);
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Timeout
|
||||
if (attempt >= _options.Fulcio.Retries)
|
||||
{
|
||||
throw new FulcioUnavailableException(
|
||||
fulcioUrl,
|
||||
$"Request to Fulcio timed out after {attempt} attempts");
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Fulcio request timed out, retrying in {Backoff}ms (attempt {Attempt}/{MaxRetries})",
|
||||
backoff.TotalMilliseconds,
|
||||
attempt,
|
||||
_options.Fulcio.Retries);
|
||||
}
|
||||
|
||||
await Task.Delay(backoff, cancellationToken);
|
||||
backoff = TimeSpan.FromMilliseconds(
|
||||
Math.Min(backoff.TotalMilliseconds * 2, _options.Fulcio.BackoffMax.TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
|
||||
private static FulcioSigningCertRequest BuildFulcioRequest(FulcioCertificateRequest request)
|
||||
{
|
||||
var algorithmId = request.Algorithm switch
|
||||
{
|
||||
KeylessAlgorithms.EcdsaP256 => "ECDSA",
|
||||
KeylessAlgorithms.Ed25519 => "ED25519",
|
||||
_ => throw new ArgumentException($"Unsupported algorithm: {request.Algorithm}")
|
||||
};
|
||||
|
||||
return new FulcioSigningCertRequest
|
||||
{
|
||||
Credentials = new FulcioCredentials
|
||||
{
|
||||
OidcIdentityToken = request.OidcIdentityToken
|
||||
},
|
||||
PublicKeyRequest = new FulcioPublicKeyRequest
|
||||
{
|
||||
PublicKey = new FulcioPublicKey
|
||||
{
|
||||
Algorithm = algorithmId,
|
||||
Content = Convert.ToBase64String(request.PublicKey)
|
||||
},
|
||||
ProofOfPossession = request.ProofOfPossession
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<FulcioCertificateResult> ParseFulcioResponse(
|
||||
HttpResponseMessage response,
|
||||
FulcioCertificateRequest originalRequest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fulcioResponse = await response.Content.ReadFromJsonAsync<FulcioSigningCertResponse>(
|
||||
JsonOptions,
|
||||
cancellationToken)
|
||||
?? throw new FulcioUnavailableException(_options.Fulcio.Url, "Empty response from Fulcio");
|
||||
|
||||
var certificates = fulcioResponse.SignedCertificateEmbeddedSct?.Chain?.Certificates
|
||||
?? throw new FulcioUnavailableException(_options.Fulcio.Url, "No certificates in Fulcio response");
|
||||
|
||||
if (certificates.Count == 0)
|
||||
{
|
||||
throw new FulcioUnavailableException(_options.Fulcio.Url, "Empty certificate chain in Fulcio response");
|
||||
}
|
||||
|
||||
var leafCertPem = certificates[0];
|
||||
var chainCertsPem = certificates.Skip(1).ToArray();
|
||||
|
||||
var leafCertBytes = ParsePemCertificate(leafCertPem);
|
||||
var chainCertsBytes = chainCertsPem.Select(ParsePemCertificate).ToArray();
|
||||
|
||||
// Parse the leaf certificate to extract validity and identity
|
||||
using var x509Cert = X509CertificateLoader.LoadCertificate(leafCertBytes);
|
||||
|
||||
var identity = ExtractIdentity(x509Cert);
|
||||
|
||||
var notBefore = new DateTimeOffset(x509Cert.NotBefore.ToUniversalTime());
|
||||
var notAfter = new DateTimeOffset(x509Cert.NotAfter.ToUniversalTime());
|
||||
|
||||
return new FulcioCertificateResult(
|
||||
Certificate: leafCertBytes,
|
||||
CertificateChain: chainCertsBytes,
|
||||
SignedCertificateTimestamp: fulcioResponse.SignedCertificateEmbeddedSct?.Sct ?? string.Empty,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
Identity: identity);
|
||||
}
|
||||
|
||||
private static byte[] ParsePemCertificate(string pem)
|
||||
{
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var start = pem.IndexOf(beginMarker, StringComparison.Ordinal);
|
||||
var end = pem.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
|
||||
if (start < 0 || end < 0)
|
||||
{
|
||||
throw new FulcioUnavailableException("", "Invalid PEM certificate format");
|
||||
}
|
||||
|
||||
var base64 = pem[(start + beginMarker.Length)..end]
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "")
|
||||
.Trim();
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
|
||||
private static FulcioIdentity ExtractIdentity(X509Certificate2 cert)
|
||||
{
|
||||
var issuer = string.Empty;
|
||||
var subject = cert.Subject;
|
||||
string? san = null;
|
||||
|
||||
// Extract SAN extension
|
||||
foreach (var extension in cert.Extensions)
|
||||
{
|
||||
if (extension.Oid?.Value == "2.5.29.17") // Subject Alternative Name
|
||||
{
|
||||
var asnData = new AsnEncodedData(extension.Oid, extension.RawData);
|
||||
san = asnData.Format(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract custom Fulcio extensions for OIDC issuer
|
||||
foreach (var extension in cert.Extensions)
|
||||
{
|
||||
// Fulcio OIDC issuer OID: 1.3.6.1.4.1.57264.1.1
|
||||
if (extension.Oid?.Value == "1.3.6.1.4.1.57264.1.1")
|
||||
{
|
||||
issuer = Encoding.UTF8.GetString(extension.RawData).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return new FulcioIdentity(issuer, subject, san);
|
||||
}
|
||||
|
||||
#region Fulcio API DTOs
|
||||
|
||||
private sealed class FulcioSigningCertRequest
|
||||
{
|
||||
public FulcioCredentials Credentials { get; set; } = new();
|
||||
public FulcioPublicKeyRequest PublicKeyRequest { get; set; } = new();
|
||||
}
|
||||
|
||||
private sealed class FulcioCredentials
|
||||
{
|
||||
public string OidcIdentityToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class FulcioPublicKeyRequest
|
||||
{
|
||||
public FulcioPublicKey PublicKey { get; set; } = new();
|
||||
public string? ProofOfPossession { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FulcioPublicKey
|
||||
{
|
||||
public string Algorithm { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class FulcioSigningCertResponse
|
||||
{
|
||||
public FulcioSignedCertificateEmbeddedSct? SignedCertificateEmbeddedSct { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FulcioSignedCertificateEmbeddedSct
|
||||
{
|
||||
public FulcioCertificateChain? Chain { get; set; }
|
||||
public string? Sct { get; set; }
|
||||
}
|
||||
|
||||
private sealed class FulcioCertificateChain
|
||||
{
|
||||
public List<string> Certificates { get; set; } = [];
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICertificateChainValidator.cs
|
||||
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
|
||||
// Task: 0011 - Implement certificate chain validation
|
||||
// Description: Interface and implementation for validating Fulcio certificate chains
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Validates certificate chains from Fulcio.
|
||||
/// </summary>
|
||||
public interface ICertificateChainValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates a certificate chain.
|
||||
/// </summary>
|
||||
/// <param name="leafCertificate">The leaf (signing) certificate in DER format.</param>
|
||||
/// <param name="chain">The intermediate certificates in DER format.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The validation result.</returns>
|
||||
Task<CertificateValidationResult> ValidateAsync(
|
||||
byte[] leafCertificate,
|
||||
IReadOnlyList<byte[]> chain,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validates identity claims in the certificate match expectations.
|
||||
/// </summary>
|
||||
/// <param name="certificate">The certificate to validate.</param>
|
||||
/// <returns>The identity validation result.</returns>
|
||||
IdentityValidationResult ValidateIdentity(X509Certificate2 certificate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of certificate chain validation.
|
||||
/// </summary>
|
||||
public sealed record CertificateValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the chain is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The validated certificate chain (if valid).
|
||||
/// </summary>
|
||||
public X509Certificate2[]? Chain { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if validation failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed chain status information.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChainStatusInfo>? ChainStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The trusted root that anchored the chain (if valid).
|
||||
/// </summary>
|
||||
public string? TrustedRootSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static CertificateValidationResult Success(
|
||||
X509Certificate2[] chain,
|
||||
string trustedRootSubject) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Chain = chain,
|
||||
TrustedRootSubject = trustedRootSubject
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result.
|
||||
/// </summary>
|
||||
public static CertificateValidationResult Failure(
|
||||
string errorMessage,
|
||||
IReadOnlyList<ChainStatusInfo>? chainStatus = null) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = errorMessage,
|
||||
ChainStatus = chainStatus
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chain status information.
|
||||
/// </summary>
|
||||
public sealed record ChainStatusInfo(
|
||||
string Status,
|
||||
string StatusInformation);
|
||||
|
||||
/// <summary>
|
||||
/// Result of identity validation.
|
||||
/// </summary>
|
||||
public sealed record IdentityValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the identity is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The OIDC issuer from the certificate.
|
||||
/// </summary>
|
||||
public string? Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject from the certificate.
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject Alternative Names from the certificate.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? SubjectAlternativeNames { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if validation failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful identity validation result.
|
||||
/// </summary>
|
||||
public static IdentityValidationResult Success(
|
||||
string issuer,
|
||||
string subject,
|
||||
IReadOnlyList<string>? sans = null) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Issuer = issuer,
|
||||
Subject = subject,
|
||||
SubjectAlternativeNames = sans
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed identity validation result.
|
||||
/// </summary>
|
||||
public static IdentityValidationResult Failure(string errorMessage) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ICertificateChainValidator"/>.
|
||||
/// </summary>
|
||||
public sealed class CertificateChainValidator : ICertificateChainValidator
|
||||
{
|
||||
private readonly SignerKeylessOptions _options;
|
||||
private readonly ILogger<CertificateChainValidator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly X509Certificate2Collection _trustedRoots;
|
||||
|
||||
// Fulcio-specific OIDs for OIDC claims in certificates
|
||||
private const string OidFulcioIssuer = "1.3.6.1.4.1.57264.1.1"; // OIDC Issuer
|
||||
private const string OidFulcioSubject = "1.3.6.1.4.1.57264.1.8"; // Subject (when no email)
|
||||
private const string OidFulcioGithubWorkflow = "1.3.6.1.4.1.57264.1.2"; // GitHub Workflow Trigger
|
||||
private const string OidFulcioGithubSha = "1.3.6.1.4.1.57264.1.3"; // GitHub Commit SHA
|
||||
|
||||
public CertificateChainValidator(
|
||||
IOptions<SignerKeylessOptions> options,
|
||||
ILogger<CertificateChainValidator> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_trustedRoots = LoadTrustedRoots();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CertificateValidationResult> ValidateAsync(
|
||||
byte[] leafCertificate,
|
||||
IReadOnlyList<byte[]> chain,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(leafCertificate);
|
||||
ArgumentNullException.ThrowIfNull(chain);
|
||||
|
||||
try
|
||||
{
|
||||
// Parse the leaf certificate
|
||||
using var leaf = X509CertificateLoader.LoadCertificate(leafCertificate);
|
||||
|
||||
// Validate certificate is not expired
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now < leaf.NotBefore)
|
||||
{
|
||||
return Task.FromResult(CertificateValidationResult.Failure(
|
||||
$"Certificate is not yet valid. NotBefore: {leaf.NotBefore:O}"));
|
||||
}
|
||||
|
||||
if (now > leaf.NotAfter)
|
||||
{
|
||||
return Task.FromResult(CertificateValidationResult.Failure(
|
||||
$"Certificate has expired. NotAfter: {leaf.NotAfter:O}"));
|
||||
}
|
||||
|
||||
// Build the chain for validation
|
||||
using var chainBuilder = new X509Chain();
|
||||
chainBuilder.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; // Fulcio certs are short-lived
|
||||
chainBuilder.ChainPolicy.VerificationTime = now.UtcDateTime;
|
||||
chainBuilder.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
if (_trustedRoots.Count > 0)
|
||||
{
|
||||
chainBuilder.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
}
|
||||
|
||||
// Add trusted roots
|
||||
foreach (var root in _trustedRoots)
|
||||
{
|
||||
chainBuilder.ChainPolicy.CustomTrustStore.Add(root);
|
||||
}
|
||||
|
||||
// Add intermediate certificates
|
||||
foreach (var intermediateDer in chain)
|
||||
{
|
||||
chainBuilder.ChainPolicy.ExtraStore.Add(X509CertificateLoader.LoadCertificate(intermediateDer));
|
||||
}
|
||||
|
||||
// Build and validate the chain
|
||||
var isValid = chainBuilder.Build(leaf);
|
||||
|
||||
if (!isValid && _options.Certificate.ValidateChain)
|
||||
{
|
||||
var statusInfo = chainBuilder.ChainStatus
|
||||
.Select(s => new ChainStatusInfo(s.Status.ToString(), s.StatusInformation))
|
||||
.ToList();
|
||||
|
||||
var errorMessage = string.Join("; ", chainBuilder.ChainStatus
|
||||
.Select(s => $"{s.Status}: {s.StatusInformation}"));
|
||||
|
||||
_logger.LogWarning(
|
||||
"Certificate chain validation failed: {Error}",
|
||||
errorMessage);
|
||||
|
||||
return Task.FromResult(CertificateValidationResult.Failure(
|
||||
$"Chain validation failed: {errorMessage}",
|
||||
statusInfo));
|
||||
}
|
||||
|
||||
// Extract the chain elements
|
||||
var validatedChain = chainBuilder.ChainElements
|
||||
.Select(e => e.Certificate)
|
||||
.ToArray();
|
||||
|
||||
var trustedRoot = chainBuilder.ChainElements.Count > 0
|
||||
? chainBuilder.ChainElements[^1].Certificate.Subject
|
||||
: "unknown";
|
||||
|
||||
_logger.LogDebug(
|
||||
"Certificate chain validated: leaf={LeafSubject}, root={TrustedRoot}, chainLength={ChainLength}",
|
||||
leaf.Subject,
|
||||
trustedRoot,
|
||||
validatedChain.Length);
|
||||
|
||||
return Task.FromResult(CertificateValidationResult.Success(validatedChain, trustedRoot));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Certificate chain validation error");
|
||||
return Task.FromResult(CertificateValidationResult.Failure(
|
||||
$"Chain validation error: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IdentityValidationResult ValidateIdentity(X509Certificate2 certificate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(certificate);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract OIDC issuer from the certificate's extensions
|
||||
var issuer = ExtractExtensionValue(certificate, OidFulcioIssuer);
|
||||
if (string.IsNullOrEmpty(issuer))
|
||||
{
|
||||
return IdentityValidationResult.Failure(
|
||||
"Certificate does not contain OIDC issuer extension");
|
||||
}
|
||||
|
||||
// Validate issuer against expected issuers
|
||||
if (_options.Identity.ExpectedIssuers.Count > 0 &&
|
||||
!_options.Identity.ExpectedIssuers.Contains(issuer, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return IdentityValidationResult.Failure(
|
||||
$"OIDC issuer '{issuer}' is not in the expected issuers list");
|
||||
}
|
||||
|
||||
// Extract subject from email SAN or Fulcio subject extension
|
||||
var subject = ExtractSubjectFromCertificate(certificate);
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
{
|
||||
return IdentityValidationResult.Failure(
|
||||
"Certificate does not contain a valid subject identifier");
|
||||
}
|
||||
|
||||
// Validate subject against patterns if configured
|
||||
if (_options.Identity.ExpectedSubjectPatterns.Count > 0)
|
||||
{
|
||||
var matchesPattern = _options.Identity.ExpectedSubjectPatterns
|
||||
.Any(pattern => System.Text.RegularExpressions.Regex.IsMatch(
|
||||
subject, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase));
|
||||
|
||||
if (!matchesPattern)
|
||||
{
|
||||
return IdentityValidationResult.Failure(
|
||||
$"Subject '{subject}' does not match any expected pattern");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all SANs
|
||||
var sans = ExtractSubjectAlternativeNames(certificate);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Certificate identity validated: issuer={Issuer}, subject={Subject}, SANs={SanCount}",
|
||||
issuer,
|
||||
subject,
|
||||
sans.Count);
|
||||
|
||||
return IdentityValidationResult.Success(issuer, subject, sans);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Identity validation error");
|
||||
return IdentityValidationResult.Failure($"Identity validation error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private X509Certificate2Collection LoadTrustedRoots()
|
||||
{
|
||||
var roots = new X509Certificate2Collection();
|
||||
|
||||
// Load from root bundle path if configured
|
||||
if (!string.IsNullOrEmpty(_options.Certificate.RootBundlePath) &&
|
||||
File.Exists(_options.Certificate.RootBundlePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundleContent = File.ReadAllText(_options.Certificate.RootBundlePath);
|
||||
var certs = ParsePemCertificates(bundleContent);
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
roots.Add(cert);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} trusted roots from {Path}",
|
||||
certs.Count,
|
||||
_options.Certificate.RootBundlePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to load trusted roots from {Path}",
|
||||
_options.Certificate.RootBundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Add additional configured roots
|
||||
foreach (var pemCert in _options.Certificate.AdditionalRoots)
|
||||
{
|
||||
try
|
||||
{
|
||||
var certs = ParsePemCertificates(pemCert);
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
roots.Add(cert);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse additional root certificate");
|
||||
}
|
||||
}
|
||||
|
||||
if (roots.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No trusted roots configured - chain validation may fail");
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static List<X509Certificate2> ParsePemCertificates(string pemContent)
|
||||
{
|
||||
var certs = new List<X509Certificate2>();
|
||||
const string beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const string endMarker = "-----END CERTIFICATE-----";
|
||||
|
||||
var startIndex = 0;
|
||||
while ((startIndex = pemContent.IndexOf(beginMarker, startIndex, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
var endIndex = pemContent.IndexOf(endMarker, startIndex, StringComparison.Ordinal);
|
||||
if (endIndex < 0) break;
|
||||
|
||||
var base64Start = startIndex + beginMarker.Length;
|
||||
var base64 = pemContent[base64Start..endIndex]
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "");
|
||||
|
||||
var derBytes = Convert.FromBase64String(base64);
|
||||
certs.Add(X509CertificateLoader.LoadCertificate(derBytes));
|
||||
|
||||
startIndex = endIndex + endMarker.Length;
|
||||
}
|
||||
|
||||
return certs;
|
||||
}
|
||||
|
||||
private static string? ExtractExtensionValue(X509Certificate2 certificate, string oid)
|
||||
{
|
||||
var extension = certificate.Extensions
|
||||
.OfType<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == oid);
|
||||
|
||||
if (extension is null) return null;
|
||||
|
||||
// The extension value is typically ASN.1 encoded
|
||||
// For simple string values, we can decode the raw data
|
||||
try
|
||||
{
|
||||
var rawData = extension.RawData;
|
||||
if (rawData.Length >= 2 && rawData[0] == 0x0C) // UTF8String
|
||||
{
|
||||
var length = rawData[1];
|
||||
if (rawData.Length >= 2 + length)
|
||||
{
|
||||
return System.Text.Encoding.UTF8.GetString(rawData, 2, length);
|
||||
}
|
||||
}
|
||||
// Try as raw UTF8 if not properly ASN.1 encoded
|
||||
return System.Text.Encoding.UTF8.GetString(rawData);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractSubjectFromCertificate(X509Certificate2 certificate)
|
||||
{
|
||||
// First, try to get email from SAN extension
|
||||
var sans = ExtractSubjectAlternativeNames(certificate);
|
||||
var emailSan = sans.FirstOrDefault(s => s.Contains('@'));
|
||||
if (!string.IsNullOrEmpty(emailSan))
|
||||
{
|
||||
return emailSan;
|
||||
}
|
||||
|
||||
// Try Fulcio subject extension
|
||||
var fulcioSubject = ExtractExtensionValue(certificate, OidFulcioSubject);
|
||||
if (!string.IsNullOrEmpty(fulcioSubject))
|
||||
{
|
||||
return fulcioSubject;
|
||||
}
|
||||
|
||||
// Fall back to certificate subject CN
|
||||
var subject = certificate.GetNameInfo(X509NameType.SimpleName, false);
|
||||
return string.IsNullOrEmpty(subject) ? null : subject;
|
||||
}
|
||||
|
||||
private static List<string> ExtractSubjectAlternativeNames(X509Certificate2 certificate)
|
||||
{
|
||||
var sans = new List<string>();
|
||||
|
||||
// Find SAN extension (OID 2.5.29.17)
|
||||
var sanExtension = certificate.Extensions
|
||||
.OfType<X509Extension>()
|
||||
.FirstOrDefault(e => e.Oid?.Value == "2.5.29.17");
|
||||
|
||||
if (sanExtension is null) return sans;
|
||||
|
||||
// Parse the SAN extension using the AsnReader
|
||||
try
|
||||
{
|
||||
var asnData = sanExtension.RawData;
|
||||
// Simple parsing - look for email addresses and URIs
|
||||
var rawString = System.Text.Encoding.UTF8.GetString(asnData);
|
||||
|
||||
// Extract email addresses (RFC822 names)
|
||||
// This is a simplified parser; a full implementation would use proper ASN.1 parsing
|
||||
// For now, we include the formatted output
|
||||
var formatted = sanExtension.Format(true);
|
||||
if (!string.IsNullOrEmpty(formatted))
|
||||
{
|
||||
var lines = formatted.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith("RFC822 Name=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sans.Add(trimmed["RFC822 Name=".Length..]);
|
||||
}
|
||||
else if (trimmed.StartsWith("URI:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sans.Add(trimmed["URI:".Length..]);
|
||||
}
|
||||
else if (trimmed.StartsWith("email:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sans.Add(trimmed["email:".Length..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore parsing errors
|
||||
}
|
||||
|
||||
return sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Generates ephemeral keypairs for keyless signing operations.
|
||||
/// Ephemeral keys exist only in memory and are securely erased after use.
|
||||
/// </summary>
|
||||
public interface IEphemeralKeyGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates an ephemeral keypair for the specified algorithm.
|
||||
/// </summary>
|
||||
/// <param name="algorithm">The algorithm to use (ECDSA_P256, Ed25519).</param>
|
||||
/// <returns>An ephemeral keypair that must be disposed after use.</returns>
|
||||
EphemeralKeyPair Generate(string algorithm);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for interacting with a Sigstore Fulcio Certificate Authority.
|
||||
/// Fulcio issues short-lived X.509 certificates based on OIDC identity tokens.
|
||||
/// </summary>
|
||||
public interface IFulcioClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Requests a signing certificate from Fulcio using an OIDC identity token.
|
||||
/// </summary>
|
||||
/// <param name="request">The certificate request containing public key and OIDC token.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The certificate result containing the issued certificate and chain.</returns>
|
||||
/// <exception cref="FulcioUnavailableException">Thrown when Fulcio is unreachable.</exception>
|
||||
/// <exception cref="OidcTokenAcquisitionException">Thrown when the OIDC token is invalid.</exception>
|
||||
Task<FulcioCertificateResult> GetCertificateAsync(
|
||||
FulcioCertificateRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to obtain a signing certificate from Fulcio.
|
||||
/// </summary>
|
||||
/// <param name="PublicKey">The public key bytes in DER or PEM format.</param>
|
||||
/// <param name="Algorithm">The algorithm identifier (ECDSA_P256, Ed25519).</param>
|
||||
/// <param name="OidcIdentityToken">The OIDC identity token for identity binding.</param>
|
||||
/// <param name="ProofOfPossession">Optional signed challenge proving key possession.</param>
|
||||
public sealed record FulcioCertificateRequest(
|
||||
byte[] PublicKey,
|
||||
string Algorithm,
|
||||
string OidcIdentityToken,
|
||||
string? ProofOfPossession = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the request parameters.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Thrown when validation fails.</exception>
|
||||
public void Validate()
|
||||
{
|
||||
if (PublicKey is null || PublicKey.Length == 0)
|
||||
throw new ArgumentException("PublicKey is required", nameof(PublicKey));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Algorithm))
|
||||
throw new ArgumentException("Algorithm is required", nameof(Algorithm));
|
||||
|
||||
if (!KeylessAlgorithms.IsSupported(Algorithm))
|
||||
throw new ArgumentException($"Unsupported algorithm: {Algorithm}", nameof(Algorithm));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(OidcIdentityToken))
|
||||
throw new ArgumentException("OidcIdentityToken is required", nameof(OidcIdentityToken));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a successful certificate request from Fulcio.
|
||||
/// </summary>
|
||||
/// <param name="Certificate">The issued signing certificate in PEM format.</param>
|
||||
/// <param name="CertificateChain">The certificate chain from leaf to root.</param>
|
||||
/// <param name="SignedCertificateTimestamp">The SCT for certificate transparency.</param>
|
||||
/// <param name="NotBefore">Certificate validity start time (UTC).</param>
|
||||
/// <param name="NotAfter">Certificate validity end time (UTC).</param>
|
||||
/// <param name="Identity">The identity bound to the certificate from the OIDC token.</param>
|
||||
public sealed record FulcioCertificateResult(
|
||||
byte[] Certificate,
|
||||
byte[][] CertificateChain,
|
||||
string SignedCertificateTimestamp,
|
||||
DateTimeOffset NotBefore,
|
||||
DateTimeOffset NotAfter,
|
||||
FulcioIdentity Identity)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the certificate validity duration.
|
||||
/// </summary>
|
||||
public TimeSpan Validity => NotAfter - NotBefore;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the certificate is valid at the specified time.
|
||||
/// </summary>
|
||||
/// <param name="at">The time to check validity against.</param>
|
||||
/// <returns>True if the certificate is valid at the specified time.</returns>
|
||||
public bool IsValidAt(DateTimeOffset at) => at >= NotBefore && at <= NotAfter;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full certificate chain including the leaf certificate.
|
||||
/// </summary>
|
||||
public IEnumerable<byte[]> FullChain
|
||||
{
|
||||
get
|
||||
{
|
||||
yield return Certificate;
|
||||
foreach (var cert in CertificateChain)
|
||||
yield return cert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity information extracted from the OIDC token and bound to the certificate.
|
||||
/// </summary>
|
||||
/// <param name="Issuer">The OIDC issuer URL.</param>
|
||||
/// <param name="Subject">The OIDC subject (user/service identifier).</param>
|
||||
/// <param name="SubjectAlternativeName">Optional SAN extension value.</param>
|
||||
public sealed record FulcioIdentity(
|
||||
string Issuer,
|
||||
string Subject,
|
||||
string? SubjectAlternativeName = null);
|
||||
@@ -0,0 +1,131 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOidcTokenProvider.cs
|
||||
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
|
||||
// Task: 0012 - Add OIDC token acquisition from Authority
|
||||
// Description: Interface for obtaining OIDC tokens for Fulcio authentication
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Provides OIDC identity tokens for Fulcio authentication.
|
||||
/// </summary>
|
||||
public interface IOidcTokenProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the OIDC issuer URL.
|
||||
/// </summary>
|
||||
string Issuer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Acquires an OIDC identity token.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The OIDC token result containing the identity token.</returns>
|
||||
Task<OidcTokenResult> AcquireTokenAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached token if available and not expired.
|
||||
/// </summary>
|
||||
/// <returns>The cached token, or null if not available or expired.</returns>
|
||||
OidcTokenResult? GetCachedToken();
|
||||
|
||||
/// <summary>
|
||||
/// Clears any cached tokens.
|
||||
/// </summary>
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of OIDC token acquisition.
|
||||
/// </summary>
|
||||
public sealed record OidcTokenResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The identity token (JWT).
|
||||
/// </summary>
|
||||
public required string IdentityToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject claim from the token.
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The email claim from the token, if present.
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the token is expired at the specified time.
|
||||
/// </summary>
|
||||
/// <param name="now">The time to check against.</param>
|
||||
/// <returns>True if the token is expired.</returns>
|
||||
public bool IsExpiredAt(DateTimeOffset now) => now >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the token will expire within the specified buffer time.
|
||||
/// </summary>
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <param name="buffer">The time buffer before expiration.</param>
|
||||
/// <returns>True if the token will expire soon.</returns>
|
||||
public bool WillExpireSoon(DateTimeOffset now, TimeSpan buffer) =>
|
||||
now.Add(buffer) >= ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for client credentials OIDC flow.
|
||||
/// </summary>
|
||||
public sealed record OidcClientCredentialsConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// The OIDC issuer URL.
|
||||
/// </summary>
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The client ID.
|
||||
/// </summary>
|
||||
public required string ClientId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The client secret.
|
||||
/// </summary>
|
||||
public required string ClientSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional scopes to request.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Scopes { get; init; } = ["openid", "email"];
|
||||
|
||||
/// <summary>
|
||||
/// Token endpoint URL (if different from discovery).
|
||||
/// </summary>
|
||||
public string? TokenEndpoint { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for ambient token OIDC (CI runner tokens, workload identity).
|
||||
/// </summary>
|
||||
public sealed record OidcAmbientConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// The OIDC issuer URL.
|
||||
/// </summary>
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the ambient token file.
|
||||
/// </summary>
|
||||
public required string TokenPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to watch the token file for changes.
|
||||
/// </summary>
|
||||
public bool WatchForChanges { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeylessDsseSigner.cs
|
||||
// Sprint: SPRINT_20251226_001_SIGNER_fulcio_keyless_client
|
||||
// Task: 0007 - Implement KeylessDsseSigner
|
||||
// Description: DSSE signer using ephemeral keys and Fulcio certificates
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestation;
|
||||
using StellaOps.Signer.Core;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signer that uses ephemeral keys with Fulcio-issued short-lived certificates.
|
||||
/// Implements Sigstore keyless signing workflow.
|
||||
/// </summary>
|
||||
public sealed class KeylessDsseSigner : IDsseSigner, IDisposable
|
||||
{
|
||||
private const string DssePayloadType = "application/vnd.in-toto+json";
|
||||
private const string InTotoStatementTypeV1 = "https://in-toto.io/Statement/v1";
|
||||
|
||||
private readonly IEphemeralKeyGenerator _keyGenerator;
|
||||
private readonly IFulcioClient _fulcioClient;
|
||||
private readonly IOidcTokenProvider _tokenProvider;
|
||||
private readonly SignerKeylessOptions _options;
|
||||
private readonly ILogger<KeylessDsseSigner> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
public KeylessDsseSigner(
|
||||
IEphemeralKeyGenerator keyGenerator,
|
||||
IFulcioClient fulcioClient,
|
||||
IOidcTokenProvider tokenProvider,
|
||||
IOptions<SignerKeylessOptions> options,
|
||||
ILogger<KeylessDsseSigner> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyGenerator);
|
||||
ArgumentNullException.ThrowIfNull(fulcioClient);
|
||||
ArgumentNullException.ThrowIfNull(tokenProvider);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
_keyGenerator = keyGenerator;
|
||||
_fulcioClient = fulcioClient;
|
||||
_tokenProvider = tokenProvider;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the algorithm used for signing.
|
||||
/// </summary>
|
||||
public string Algorithm => _options.Algorithms.Preferred;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<SigningBundle> SignAsync(
|
||||
SigningRequest request,
|
||||
ProofOfEntitlementResult entitlement,
|
||||
CallerContext caller,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(entitlement);
|
||||
ArgumentNullException.ThrowIfNull(caller);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Starting keyless signing for predicate type {PredicateType}, caller: {Caller}",
|
||||
request.PredicateType,
|
||||
caller.Subject);
|
||||
|
||||
// Step 1: Acquire OIDC token
|
||||
var oidcToken = await _tokenProvider.AcquireTokenAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Acquired OIDC token, subject: {Subject}", oidcToken.Subject);
|
||||
|
||||
// Step 2: Generate ephemeral key pair
|
||||
using var keyPair = _keyGenerator.Generate(Algorithm);
|
||||
|
||||
_logger.LogDebug("Generated ephemeral {Algorithm} key pair", keyPair.Algorithm);
|
||||
|
||||
// Step 3: Serialize the in-toto statement
|
||||
var statementType = ResolveStatementType(request.PredicateType);
|
||||
var statementBytes = SignerStatementBuilder.BuildStatementPayload(request, statementType);
|
||||
|
||||
// Step 4: Create proof of possession and request certificate from Fulcio
|
||||
var proofOfPossession = CreateProofOfPossession(statementBytes, keyPair);
|
||||
var certRequest = new FulcioCertificateRequest(
|
||||
PublicKey: keyPair.PublicKey.ToArray(),
|
||||
Algorithm: keyPair.Algorithm,
|
||||
OidcIdentityToken: oidcToken.IdentityToken,
|
||||
ProofOfPossession: Convert.ToBase64String(proofOfPossession));
|
||||
|
||||
var certResult = await _fulcioClient.GetCertificateAsync(certRequest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Obtained Fulcio certificate, valid: {NotBefore} to {NotAfter}",
|
||||
certResult.NotBefore,
|
||||
certResult.NotAfter);
|
||||
|
||||
// Step 5: Create DSSE signature using the ephemeral key
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(DssePayloadType, statementBytes);
|
||||
var signature = keyPair.Sign(pae);
|
||||
|
||||
// Step 6: Build the signing bundle
|
||||
var bundle = BuildSigningBundle(
|
||||
request,
|
||||
statementBytes,
|
||||
signature,
|
||||
certResult,
|
||||
keyPair.Algorithm);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Keyless signing complete, identity: {Subject}, subjects: {SubjectCount}",
|
||||
certResult.Identity.Subject,
|
||||
request.Subjects.Count);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
private static string ResolveStatementType(string predicateType)
|
||||
{
|
||||
if (string.Equals(predicateType, DssePayloadType, StringComparison.Ordinal))
|
||||
{
|
||||
return InTotoStatementTypeV1;
|
||||
}
|
||||
|
||||
return SignerStatementBuilder.GetRecommendedStatementType(predicateType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the signing bundle with DSSE envelope and certificates.
|
||||
/// </summary>
|
||||
private SigningBundle BuildSigningBundle(
|
||||
SigningRequest request,
|
||||
byte[] statementBytes,
|
||||
byte[] signature,
|
||||
FulcioCertificateResult certResult,
|
||||
string algorithm)
|
||||
{
|
||||
// Build DSSE envelope
|
||||
var dsseEnvelope = new DsseEnvelope(
|
||||
Payload: Convert.ToBase64String(statementBytes),
|
||||
PayloadType: DssePayloadType,
|
||||
Signatures:
|
||||
[
|
||||
new DsseSignature(
|
||||
Signature: Convert.ToBase64String(signature),
|
||||
KeyId: CreateKeyId(certResult.Certificate))
|
||||
]);
|
||||
|
||||
// Build certificate chain (Base64-encoded DER)
|
||||
var certChain = new List<string>
|
||||
{
|
||||
Convert.ToBase64String(certResult.Certificate)
|
||||
};
|
||||
certChain.AddRange(certResult.CertificateChain.Select(Convert.ToBase64String));
|
||||
|
||||
// Build signing identity
|
||||
var identity = new SigningIdentity(
|
||||
Mode: "keyless",
|
||||
Issuer: certResult.Identity.Issuer,
|
||||
Subject: certResult.Identity.Subject,
|
||||
ExpiresAtUtc: certResult.NotAfter);
|
||||
|
||||
// Build metadata
|
||||
var metadata = new SigningMetadata(
|
||||
Identity: identity,
|
||||
CertificateChain: certChain,
|
||||
ProviderName: "fulcio",
|
||||
AlgorithmId: algorithm);
|
||||
|
||||
return new SigningBundle(dsseEnvelope, metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a proof of possession by signing a hash of the payload.
|
||||
/// This proves possession of the private key to Fulcio.
|
||||
/// </summary>
|
||||
private static byte[] CreateProofOfPossession(byte[] payload, EphemeralKeyPair keyPair)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return keyPair.Sign(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a key ID from the certificate bytes.
|
||||
/// </summary>
|
||||
private static string CreateKeyId(byte[] certBytes)
|
||||
{
|
||||
var fingerprint = SHA256.HashData(certBytes);
|
||||
return $"SHA256:{Convert.ToHexString(fingerprint).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Base exception for all keyless signing errors.
|
||||
/// </summary>
|
||||
public abstract class KeylessSigningException : Exception
|
||||
{
|
||||
protected KeylessSigningException(string message) : base(message) { }
|
||||
protected KeylessSigningException(string message, Exception? innerException) : base(message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the Fulcio CA is unavailable or returns an error.
|
||||
/// </summary>
|
||||
public sealed class FulcioUnavailableException : KeylessSigningException
|
||||
{
|
||||
/// <summary>
|
||||
/// The Fulcio URL that was unreachable.
|
||||
/// </summary>
|
||||
public string FulcioUrl { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP status code returned, if any.
|
||||
/// </summary>
|
||||
public int? HttpStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The error response body, if any.
|
||||
/// </summary>
|
||||
public string? ResponseBody { get; }
|
||||
|
||||
public FulcioUnavailableException(string fulcioUrl, string message)
|
||||
: base(message)
|
||||
{
|
||||
FulcioUrl = fulcioUrl;
|
||||
}
|
||||
|
||||
public FulcioUnavailableException(string fulcioUrl, int httpStatus, string? responseBody, string message)
|
||||
: base(message)
|
||||
{
|
||||
FulcioUrl = fulcioUrl;
|
||||
HttpStatus = httpStatus;
|
||||
ResponseBody = responseBody;
|
||||
}
|
||||
|
||||
public FulcioUnavailableException(string fulcioUrl, string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
FulcioUrl = fulcioUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when OIDC token acquisition or validation fails.
|
||||
/// </summary>
|
||||
public sealed class OidcTokenAcquisitionException : KeylessSigningException
|
||||
{
|
||||
/// <summary>
|
||||
/// The OIDC issuer that was being used.
|
||||
/// </summary>
|
||||
public string Issuer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The reason for the failure.
|
||||
/// </summary>
|
||||
public string Reason { get; }
|
||||
|
||||
public OidcTokenAcquisitionException(string issuer, string reason)
|
||||
: base($"Failed to acquire OIDC token from {issuer}: {reason}")
|
||||
{
|
||||
Issuer = issuer;
|
||||
Reason = reason;
|
||||
}
|
||||
|
||||
public OidcTokenAcquisitionException(string issuer, string reason, Exception innerException)
|
||||
: base($"Failed to acquire OIDC token from {issuer}: {reason}", innerException)
|
||||
{
|
||||
Issuer = issuer;
|
||||
Reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when certificate chain validation fails.
|
||||
/// </summary>
|
||||
public sealed class CertificateChainValidationException : KeylessSigningException
|
||||
{
|
||||
/// <summary>
|
||||
/// The subjects in the certificate chain.
|
||||
/// </summary>
|
||||
public string[] ChainSubjects { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The specific validation error.
|
||||
/// </summary>
|
||||
public string ValidationError { get; }
|
||||
|
||||
public CertificateChainValidationException(string[] chainSubjects, string validationError)
|
||||
: base($"Certificate chain validation failed: {validationError}")
|
||||
{
|
||||
ChainSubjects = chainSubjects;
|
||||
ValidationError = validationError;
|
||||
}
|
||||
|
||||
public CertificateChainValidationException(string[] chainSubjects, string validationError, Exception innerException)
|
||||
: base($"Certificate chain validation failed: {validationError}", innerException)
|
||||
{
|
||||
ChainSubjects = chainSubjects;
|
||||
ValidationError = validationError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when ephemeral key generation fails.
|
||||
/// </summary>
|
||||
public sealed class EphemeralKeyGenerationException : KeylessSigningException
|
||||
{
|
||||
/// <summary>
|
||||
/// The algorithm that was being generated.
|
||||
/// </summary>
|
||||
public string Algorithm { get; }
|
||||
|
||||
public EphemeralKeyGenerationException(string algorithm, string message)
|
||||
: base(message)
|
||||
{
|
||||
Algorithm = algorithm;
|
||||
}
|
||||
|
||||
public EphemeralKeyGenerationException(string algorithm, string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Algorithm = algorithm;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering keyless signing services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds keyless signing services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddKeylessSigning(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<SignerKeylessOptions>(
|
||||
configuration.GetSection(SignerKeylessOptions.SectionName));
|
||||
|
||||
services.AddSingleton<IEphemeralKeyGenerator, EphemeralKeyGenerator>();
|
||||
services.AddSingleton<ICertificateChainValidator, CertificateChainValidator>();
|
||||
|
||||
services.AddHttpClient<IFulcioClient, HttpFulcioClient>((sp, client) =>
|
||||
{
|
||||
var options = configuration
|
||||
.GetSection(SignerKeylessOptions.SectionName)
|
||||
.Get<SignerKeylessOptions>() ?? new SignerKeylessOptions();
|
||||
|
||||
client.BaseAddress = new Uri(options.Fulcio.Url);
|
||||
client.Timeout = options.Fulcio.Timeout;
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Signer/1.0");
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds keyless signing services with custom options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configureOptions">Action to configure options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddKeylessSigning(
|
||||
this IServiceCollection services,
|
||||
Action<SignerKeylessOptions> configureOptions)
|
||||
{
|
||||
var options = new SignerKeylessOptions();
|
||||
configureOptions(options);
|
||||
|
||||
services.Configure<SignerKeylessOptions>(o =>
|
||||
{
|
||||
o.Enabled = options.Enabled;
|
||||
o.Fulcio = options.Fulcio;
|
||||
o.Oidc = options.Oidc;
|
||||
o.Algorithms = options.Algorithms;
|
||||
o.Certificate = options.Certificate;
|
||||
o.Identity = options.Identity;
|
||||
});
|
||||
|
||||
services.AddSingleton<IEphemeralKeyGenerator, EphemeralKeyGenerator>();
|
||||
services.AddSingleton<ICertificateChainValidator, CertificateChainValidator>();
|
||||
|
||||
services.AddHttpClient<IFulcioClient, HttpFulcioClient>((sp, client) =>
|
||||
{
|
||||
client.BaseAddress = new Uri(options.Fulcio.Url);
|
||||
client.Timeout = options.Fulcio.Timeout;
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-Signer/1.0");
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Signer.Keyless;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for keyless signing.
|
||||
/// </summary>
|
||||
public sealed class SignerKeylessOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Signer:Keyless";
|
||||
|
||||
/// <summary>
|
||||
/// Whether keyless signing is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fulcio CA configuration.
|
||||
/// </summary>
|
||||
public FulcioOptions Fulcio { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// OIDC configuration for token acquisition.
|
||||
/// </summary>
|
||||
public OidcOptions Oidc { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm configuration.
|
||||
/// </summary>
|
||||
public AlgorithmOptions Algorithms { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Certificate validation configuration.
|
||||
/// </summary>
|
||||
public CertificateOptions Certificate { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Identity verification configuration.
|
||||
/// </summary>
|
||||
public IdentityOptions Identity { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fulcio CA configuration options.
|
||||
/// </summary>
|
||||
public sealed class FulcioOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The Fulcio CA URL.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Url { get; set; } = "https://fulcio.sigstore.dev";
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Number of retry attempts.
|
||||
/// </summary>
|
||||
public int Retries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base duration for exponential backoff.
|
||||
/// </summary>
|
||||
public TimeSpan BackoffBase { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum backoff duration.
|
||||
/// </summary>
|
||||
public TimeSpan BackoffMax { get; set; } = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OIDC configuration for token acquisition.
|
||||
/// </summary>
|
||||
public sealed class OidcOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The OIDC issuer URL.
|
||||
/// </summary>
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The OAuth2 client ID.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the client secret (e.g., "env:SIGNER_OIDC_CLIENT_SECRET").
|
||||
/// </summary>
|
||||
public string? ClientSecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Use ambient OIDC token from CI runner.
|
||||
/// </summary>
|
||||
public bool UseAmbientToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to ambient OIDC token file.
|
||||
/// </summary>
|
||||
public string? AmbientTokenPath { get; set; } = "/var/run/secrets/tokens/oidc";
|
||||
|
||||
/// <summary>
|
||||
/// Token refresh interval before expiry.
|
||||
/// </summary>
|
||||
public TimeSpan RefreshBefore { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm configuration options.
|
||||
/// </summary>
|
||||
public sealed class AlgorithmOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Preferred algorithm for new signings.
|
||||
/// </summary>
|
||||
public string Preferred { get; set; } = KeylessAlgorithms.EcdsaP256;
|
||||
|
||||
/// <summary>
|
||||
/// Allowed algorithms for signing.
|
||||
/// </summary>
|
||||
public List<string> Allowed { get; set; } = [KeylessAlgorithms.EcdsaP256, KeylessAlgorithms.Ed25519];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Certificate validation configuration options.
|
||||
/// </summary>
|
||||
public sealed class CertificateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to Fulcio root CA bundle.
|
||||
/// </summary>
|
||||
public string? RootBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional trusted root certificates (PEM format).
|
||||
/// </summary>
|
||||
public List<string> AdditionalRoots { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate the certificate chain.
|
||||
/// </summary>
|
||||
public bool ValidateChain { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require Signed Certificate Timestamp (SCT).
|
||||
/// </summary>
|
||||
public bool RequireSct { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity verification configuration options.
|
||||
/// </summary>
|
||||
public sealed class IdentityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Expected OIDC issuers for verification.
|
||||
/// </summary>
|
||||
public List<string> ExpectedIssuers { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Expected subject patterns (regex) for SAN verification.
|
||||
/// </summary>
|
||||
public List<string> ExpectedSubjectPatterns { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Description>Keyless signing support for StellaOps Signer using Sigstore Fulcio</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Attestation\StellaOps.Attestation.csproj" />
|
||||
<ProjectReference Include="..\..\StellaOps.Signer\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
# StellaOps.Signer.Keyless Task Board
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Signer/__Libraries/StellaOps.Signer.Keyless/StellaOps.Signer.Keyless.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
Reference in New Issue
Block a user