consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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))]

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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.
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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.
// =============================================================================

View File

@@ -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];
}
}

View File

@@ -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"];
}

View File

@@ -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 $$;

View File

@@ -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 $$;

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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}$";
}
}

View File

@@ -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();
}
}

View File

@@ -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.");
}
}

View File

@@ -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);
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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; } = [];
}

View File

@@ -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>

View File

@@ -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. |