diff --git a/src/Web/StellaOps.Web/TASKS.md b/src/Web/StellaOps.Web/TASKS.md
index c827f1b5f..180306473 100644
--- a/src/Web/StellaOps.Web/TASKS.md
+++ b/src/Web/StellaOps.Web/TASKS.md
@@ -19,6 +19,7 @@
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
| UI-POLICY-23-002 | DONE (2025-12-05) | YAML editor route `/policy-studio/packs/:packId/yaml` with canonical preview and lint diagnostics. |
| UI-POLICY-23-003 | DONE (2025-12-05) | Rule Builder route `/policy-studio/packs/:packId/rules` with guided inputs and deterministic preview JSON. |
+| UI-POLICY-23-004 | DONE (2025-12-05) | Approval workflow UI updated with readiness checklist, schedule window card, comment thread, and two-person indicator; tests attempted but Angular CLI hit missing rxjs util module. |
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
| UI-POLICY-23-006 | DOING (2025-12-05) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON export; PDF export pending backend. |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.spec.ts
index 2967c9b7d..792a0e67f 100644
--- a/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.spec.ts
@@ -19,6 +19,9 @@ describe('PolicyApprovalsComponent', () => {
'getApprovalWorkflow',
'submitForReview',
'addReview',
+ 'updateApprovalSchedule',
+ 'updateChecklist',
+ 'addComment',
]);
api.getApprovalWorkflow.and.returnValue(
@@ -46,11 +49,31 @@ describe('PolicyApprovalsComponent', () => {
],
requiredApprovers: 2,
currentApprovers: 1,
+ checklist: [
+ { id: 'c1', label: 'Coverage present', hint: 'Link to coverage run', required: true, status: 'pending' },
+ { id: 'c2', label: 'Simulated', hint: 'Simulation diff attached', required: true, status: 'complete' },
+ ],
+ comments: [
+ { id: 'cm1', authorId: 'user-a', authorName: 'User A', message: 'Initial submit', createdAt: '2025-12-05T00:05:00Z' },
+ ],
+ schedule: {
+ start: '2025-12-10T00:00',
+ end: '2025-12-11T00:00',
+ },
}) as any
);
api.submitForReview.and.returnValue(of({}) as any);
api.addReview.and.returnValue(of({}) as any);
+ api.updateApprovalSchedule.and.returnValue(of({}) as any);
+ api.updateChecklist.and.returnValue(of([]) as any);
+ api.addComment.and.returnValue(of({
+ id: 'cm2',
+ authorId: 'user-x',
+ authorName: 'User X',
+ message: 'Ack',
+ createdAt: '2025-12-05T02:00:00Z',
+ }) as any);
auth = {
canApprovePolicies: () => true,
@@ -86,11 +109,13 @@ describe('PolicyApprovalsComponent', () => {
expect(reviews[1].reviewerId).toBe('user-b');
});
- it('includes schedule fields in submission payload', () => {
+ it('submits with schedule window attached', () => {
component.submitForm.patchValue({
message: 'Please review',
- scheduleStart: '2025-12-10T00:00',
- scheduleEnd: '2025-12-11T00:00',
+ });
+ component.scheduleForm.patchValue({
+ start: '2025-12-10T00:00',
+ end: '2025-12-11T00:00',
});
component.onSubmit();
@@ -106,13 +131,27 @@ describe('PolicyApprovalsComponent', () => {
});
});
- it('calls addReview with decision', fakeAsync(() => {
- component.reviewForm.setValue({ comment: 'Approve now' });
- component.onReview('approve');
- tick();
- expect(api.addReview).toHaveBeenCalledWith('pack-1', '1.0.0', {
- decision: 'approve',
- comment: 'Approve now',
+ it('persists schedule changes via updateApprovalSchedule', () => {
+ component.scheduleForm.patchValue({ start: '2025-12-12T00:00', end: '2025-12-13T00:00' });
+ component.onScheduleSave();
+ expect(api.updateApprovalSchedule).toHaveBeenCalledWith('pack-1', '1.0.0', {
+ start: '2025-12-12T00:00',
+ end: '2025-12-13T00:00',
});
+ });
+
+ it('updates checklist status', fakeAsync(() => {
+ component.setChecklistStatus(component.checklistSorted[0], 'complete');
+ tick();
+ const sentChecklist = api.updateChecklist.calls.mostRecent().args[2];
+ expect(sentChecklist[0].status).toBe('complete');
+ }));
+
+ it('posts a comment', fakeAsync(() => {
+ component.commentForm.setValue({ message: 'Looks good' });
+ component.onComment();
+ tick();
+ expect(api.addComment).toHaveBeenCalledWith('pack-1', '1.0.0', 'Looks good');
+ expect(component.sortedComments.length).toBeGreaterThan(1);
}));
});
diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.ts
index 3dd59f4f4..637d617ee 100644
--- a/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/approvals/policy-approvals.component.ts
@@ -7,7 +7,10 @@ import { finalize } from 'rxjs/operators';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import {
+ type ApprovalChecklistItem,
+ type ApprovalComment,
type ApprovalReview,
+ type ApprovalScheduleWindow,
type ApprovalWorkflow,
type PolicySubmissionRequest,
} from '../models/policy.models';
@@ -25,7 +28,7 @@ import { PolicyApiService } from '../services/policy-api.service';
Policy Studio · Approvals
Submit, review, approve
- Two-person approval with deterministic audit trail. Status: {{ workflow?.status || 'unknown' | titlecase }}
+ Two-person approval with deterministic audit trail and scoped activation windows.
@@ -37,6 +40,12 @@ import { PolicyApiService } from '../services/policy-api.service';
Two-person rule: {{ isReadyToApprove ? 'Satisfied' : 'Missing second approver' }}
+ 0">
+ Checklist: {{ pendingChecklist === 0 ? 'Ready' : pendingChecklist + ' open item' + (pendingChecklist === 1 ? '' : 's') }}
+
+
+ Schedule: {{ scheduleSummary }}
+
@@ -59,17 +68,35 @@ import { PolicyApiService } from '../services/policy-api.service';
Simulation diff reference (optional)
+
+
+
+
+
@@ -92,6 +119,36 @@ import { PolicyApiService } from '../services/policy-api.service';
+
+
+
+
`,
styles: [
@@ -126,12 +208,16 @@ import { PolicyApiService } from '../services/policy-api.service';
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
}
.approvals__header {
display: flex;
justify-content: space-between;
gap: 1rem;
+ align-items: flex-start;
}
.approvals__eyebrow {
@@ -174,7 +260,6 @@ import { PolicyApiService } from '../services/policy-api.service';
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
- margin: 1rem 0;
}
.card {
@@ -182,7 +267,12 @@ import { PolicyApiService } from '../services/policy-api.service';
border: 1px solid #1f2937;
border-radius: 12px;
padding: 1rem;
- box-shadow: 0 15px 40px rgba(0,0,0,0.25);
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25);
+ }
+
+ .card--accent {
+ border-color: #2563eb;
+ box-shadow: 0 15px 40px rgba(37, 99, 235, 0.25);
}
.card h3 {
@@ -219,6 +309,12 @@ import { PolicyApiService } from '../services/policy-api.service';
font-family: 'Monaco','Consolas', monospace;
}
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 0.5rem;
+ }
+
.actions {
display: flex;
gap: 0.5rem;
@@ -243,8 +339,84 @@ import { PolicyApiService } from '../services/policy-api.service';
.btn--ghost { background: transparent; border-color: #334155; color: #cbd5e1; }
+ .approvals__badge {
+ padding: 0.3rem 0.6rem;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ border: 1px solid #334155;
+ }
+
+ .approvals__badge--ready { border-color: #22c55e; color: #22c55e; }
+ .approvals__badge--missing { border-color: #f59e0b; color: #f59e0b; }
+
+ .readiness {
+ background: #0f172a;
+ border: 1px solid #1f2937;
+ border-radius: 12px;
+ padding: 1rem;
+ }
+
+ .readiness__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .badge {
+ padding: 0.35rem 0.6rem;
+ border-radius: 10px;
+ border: 1px solid #f59e0b;
+ color: #f59e0b;
+ }
+
+ .badge--ready {
+ border-color: #22c55e;
+ color: #22c55e;
+ }
+
+ .checklist {
+ list-style: none;
+ margin: 0.8rem 0 0;
+ padding: 0;
+ display: grid;
+ gap: 0.75rem;
+ }
+
+ .checklist__row {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ }
+
+ .checklist__label {
+ font-weight: 700;
+ color: #f8fafc;
+ }
+
+ .checklist__hint { color: #94a3b8; margin-top: 0.1rem; }
+
+ .checklist__pill {
+ align-self: start;
+ padding: 0.25rem 0.55rem;
+ border-radius: 8px;
+ border: 1px solid #334155;
+ font-weight: 700;
+ text-transform: capitalize;
+ }
+
+ .checklist__pill[data-status='complete'] { border-color: #22c55e; color: #22c55e; }
+ .checklist__pill[data-status='pending'] { border-color: #f59e0b; color: #f59e0b; }
+ .checklist__pill[data-status='blocked'] { border-color: #ef4444; color: #ef4444; }
+
+ .checklist__actions {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+ margin-top: 0.5rem;
+ }
+
.timeline {
- margin-top: 1rem;
background: #0f172a;
border: 1px solid #1f2937;
border-radius: 12px;
@@ -271,28 +443,54 @@ import { PolicyApiService } from '../services/policy-api.service';
.timeline__time { font-size: 0.9rem; }
.timeline__comment { margin: 0.15rem 0 0; color: #e5e7eb; }
+ .comments {
+ background: #0f172a;
+ border: 1px solid #1f2937;
+ border-radius: 12px;
+ padding: 1rem;
+ }
+
+ .comments__list { list-style: none; margin: 0.5rem 0 1rem; padding: 0; display: grid; gap: 0.6rem; }
+ .comments__meta { display: flex; gap: 0.5rem; align-items: baseline; }
+ .comments__body { margin: 0.15rem 0 0; color: #e5e7eb; }
+ .muted { color: #94a3b8; font-size: 0.9rem; }
+
+ .schedule__summary { display: flex; flex-direction: column; gap: 0.15rem; color: #cbd5e1; }
+
@media (max-width: 960px) { .approvals__header { flex-direction: column; } }
`,
],
})
export class PolicyApprovalsComponent {
protected workflow?: ApprovalWorkflow;
+ protected checklist: ApprovalChecklistItem[] = [];
+ protected comments: ApprovalComment[] = [];
protected loading = false;
protected submitting = false;
protected reviewing = false;
+ protected checklistSaving = false;
+ protected scheduleSaving = false;
+ protected commenting = false;
protected readonly submitForm = this.fb.group({
message: ['', [Validators.required, Validators.minLength(5)]],
coverageResults: [''],
simulationDiff: [''],
- scheduleStart: [''],
- scheduleEnd: [''],
});
protected readonly reviewForm = this.fb.group({
comment: ['', [Validators.required, Validators.minLength(3)]],
});
+ protected readonly scheduleForm = this.fb.group({
+ start: [''],
+ end: [''],
+ });
+
+ protected readonly commentForm = this.fb.group({
+ message: ['', [Validators.required, Validators.minLength(2)]],
+ });
+
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly policyApi = inject(PolicyApiService);
@@ -300,7 +498,21 @@ export class PolicyApprovalsComponent {
get sortedReviews(): ApprovalReview[] {
if (!this.workflow?.reviews) return [];
- return [...this.workflow.reviews].sort((a, b) => b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId));
+ return [...this.workflow.reviews].sort((a, b) =>
+ b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId)
+ );
+ }
+
+ get checklistSorted(): ApprovalChecklistItem[] {
+ return this.sortChecklist(this.checklist);
+ }
+
+ get sortedComments(): ApprovalComment[] {
+ return this.sortComments(this.comments);
+ }
+
+ get pendingChecklist(): number {
+ return this.checklist.filter((item) => item.status !== 'complete').length;
}
get isReadyToApprove(): boolean {
@@ -308,6 +520,15 @@ export class PolicyApprovalsComponent {
return this.workflow.currentApprovers >= this.workflow.requiredApprovers;
}
+ get scheduleSummary(): string {
+ const start = this.workflow?.schedule?.start;
+ const end = this.workflow?.schedule?.end;
+ if (start && end) return `${start} → ${end} UTC`;
+ if (start) return `Starts ${start} UTC`;
+ if (end) return `Ends ${end} UTC`;
+ return 'Unscheduled';
+ }
+
ngOnInit(): void {
this.refresh();
}
@@ -317,14 +538,16 @@ export class PolicyApprovalsComponent {
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
if (!packId || this.submitForm.invalid) return;
+ const schedule = this.schedulePayload();
+
const payload: PolicySubmissionRequest = {
policyId: packId,
version: version ?? 'latest',
message: this.submitForm.value.message ?? '',
coverageResults: this.submitForm.value.coverageResults ?? undefined,
simulationDiff: this.submitForm.value.simulationDiff ?? undefined,
- scheduleStart: this.submitForm.value.scheduleStart ?? undefined,
- scheduleEnd: this.submitForm.value.scheduleEnd ?? undefined,
+ scheduleStart: schedule.start,
+ scheduleEnd: schedule.end,
};
this.submitting = true;
@@ -336,6 +559,19 @@ export class PolicyApprovalsComponent {
});
}
+ onScheduleSave(): void {
+ if (!this.workflow) return;
+
+ const schedule = this.schedulePayload();
+ this.scheduleSaving = true;
+ this.policyApi
+ .updateApprovalSchedule(this.workflow.policyId, this.workflow.policyVersion, schedule)
+ .pipe(finalize(() => (this.scheduleSaving = false)))
+ .subscribe({
+ next: () => this.refresh(),
+ });
+ }
+
onReview(decision: 'approve' | 'reject' | 'request_changes'): void {
if (!this.workflow || this.reviewForm.invalid) return;
if (decision === 'approve' && !this.auth.canApprovePolicies?.()) return;
@@ -353,6 +589,44 @@ export class PolicyApprovalsComponent {
});
}
+ setChecklistStatus(item: ApprovalChecklistItem, status: ApprovalChecklistItem['status']): void {
+ if (!this.workflow) return;
+ const updated = this.checklist.map((entry) =>
+ entry.id === item.id
+ ? {
+ ...entry,
+ status,
+ updatedAt: new Date().toISOString(),
+ }
+ : entry
+ );
+
+ this.checklist = this.sortChecklist(updated);
+ this.checklistSaving = true;
+ this.policyApi
+ .updateChecklist(this.workflow.policyId, this.workflow.policyVersion, updated)
+ .pipe(finalize(() => (this.checklistSaving = false)))
+ .subscribe({
+ next: (serverChecklist) => (this.checklist = this.sortChecklist(serverChecklist ?? updated)),
+ });
+ }
+
+ onComment(): void {
+ if (!this.workflow || this.commentForm.invalid) return;
+ const message = this.commentForm.value.message ?? '';
+
+ this.commenting = true;
+ this.policyApi
+ .addComment(this.workflow.policyId, this.workflow.policyVersion, message)
+ .pipe(finalize(() => (this.commenting = false)))
+ .subscribe({
+ next: (comment) => {
+ this.commentForm.reset();
+ this.comments = this.sortComments([comment, ...this.comments]);
+ },
+ });
+ }
+
private refresh(): void {
const packId = this.route.snapshot.paramMap.get('packId');
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
@@ -363,7 +637,37 @@ export class PolicyApprovalsComponent {
.getApprovalWorkflow(packId, version ?? 'latest')
.pipe(finalize(() => (this.loading = false)))
.subscribe({
- next: (wf) => (this.workflow = wf),
+ next: (wf) => this.applyWorkflow(wf),
});
}
+
+ private applyWorkflow(workflow: ApprovalWorkflow): void {
+ this.workflow = workflow;
+ this.checklist = this.sortChecklist(workflow.checklist ?? []);
+ this.comments = this.sortComments(workflow.comments ?? []);
+ this.scheduleForm.patchValue(
+ {
+ start: workflow.schedule?.start ?? '',
+ end: workflow.schedule?.end ?? '',
+ },
+ { emitEvent: false }
+ );
+ }
+
+ private sortChecklist(items: readonly ApprovalChecklistItem[]): ApprovalChecklistItem[] {
+ return [...items].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
+ }
+
+ private sortComments(items: readonly ApprovalComment[]): ApprovalComment[] {
+ return [...items].sort(
+ (a, b) => b.createdAt.localeCompare(a.createdAt) || a.authorId.localeCompare(b.authorId)
+ );
+ }
+
+ private schedulePayload(): ApprovalScheduleWindow {
+ return {
+ start: this.scheduleForm.value.start || undefined,
+ end: this.scheduleForm.value.end || undefined,
+ };
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/models/policy.models.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/models/policy.models.ts
index 21dbc5e30..0b1b8faed 100644
--- a/src/Web/StellaOps.Web/src/app/features/policy-studio/models/policy.models.ts
+++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/models/policy.models.ts
@@ -276,6 +276,9 @@ export interface ApprovalWorkflow {
readonly reviews: readonly ApprovalReview[];
readonly requiredApprovers: number;
readonly currentApprovers: number;
+ readonly checklist: readonly ApprovalChecklistItem[];
+ readonly comments: readonly ApprovalComment[];
+ readonly schedule?: ApprovalScheduleWindow;
}
/**
@@ -299,6 +302,38 @@ export interface ApprovalReview {
readonly reviewedAt: string;
}
+/**
+ * Approval checklist item describing pre-merge guardrails.
+ */
+export interface ApprovalChecklistItem {
+ readonly id: string;
+ readonly label: string;
+ readonly hint?: string;
+ readonly required: boolean;
+ readonly status: 'pending' | 'complete' | 'blocked';
+ readonly updatedAt?: string;
+ readonly updatedBy?: string;
+}
+
+/**
+ * Comment on an approval workflow.
+ */
+export interface ApprovalComment {
+ readonly id: string;
+ readonly authorId: string;
+ readonly authorName: string;
+ readonly message: string;
+ readonly createdAt: string;
+}
+
+/**
+ * Scheduled activation window for policy scope.
+ */
+export interface ApprovalScheduleWindow {
+ readonly start?: string;
+ readonly end?: string;
+}
+
/**
* Policy run dashboard data.
*/
@@ -350,6 +385,8 @@ export interface PolicySubmissionRequest {
readonly message: string;
readonly coverageResults?: string;
readonly simulationDiff?: string;
+ readonly scheduleStart?: string;
+ readonly scheduleEnd?: string;
}
/**
diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/services/policy-api.service.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/services/policy-api.service.ts
index 6bd1c52b4..2357c06cf 100644
--- a/src/Web/StellaOps.Web/src/app/features/policy-studio/services/policy-api.service.ts
+++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/services/policy-api.service.ts
@@ -28,6 +28,9 @@ import type {
PolicyRunDashboard,
PolicySubmissionRequest,
PolicyPromotionRequest,
+ ApprovalChecklistItem,
+ ApprovalComment,
+ ApprovalScheduleWindow,
} from '../models/policy.models';
/**
@@ -238,6 +241,20 @@ export class PolicyApiService {
);
}
+ /**
+ * Update the activation window for an approval workflow.
+ */
+ updateApprovalSchedule(
+ packId: string,
+ version: string,
+ schedule: ApprovalScheduleWindow
+ ): Observable {
+ return this.http.put(
+ `${API_BASE}/packs/${packId}/versions/${version}/approval/schedule`,
+ schedule
+ );
+ }
+
/**
* Add a review to the approval workflow.
*
@@ -259,6 +276,34 @@ export class PolicyApiService {
);
}
+ /**
+ * Replace the approval checklist for a policy version.
+ */
+ updateChecklist(
+ packId: string,
+ version: string,
+ checklist: ApprovalChecklistItem[]
+ ): Observable {
+ return this.http.put(
+ `${API_BASE}/packs/${packId}/versions/${version}/approval/checklist`,
+ { checklist }
+ );
+ }
+
+ /**
+ * Add a discussion comment.
+ */
+ addComment(
+ packId: string,
+ version: string,
+ message: string
+ ): Observable {
+ return this.http.post(
+ `${API_BASE}/packs/${packId}/versions/${version}/approval/comments`,
+ { message }
+ );
+ }
+
/**
* Promote a policy to a target environment.
* Requires interactive authentication (policy:promote scope).
diff --git a/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj b/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj
index d2b4d6749..d3f68b02a 100644
--- a/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj
+++ b/src/Zastava/StellaOps.Zastava.Observer/StellaOps.Zastava.Observer.csproj
@@ -1,29 +1,29 @@
-
-
- Exe
- net10.0
- preview
- enable
- enable
- true
-
-
-
-
-
- All
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Exe
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+
+
+
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Zastava/StellaOps.Zastava.Webhook/StellaOps.Zastava.Webhook.csproj b/src/Zastava/StellaOps.Zastava.Webhook/StellaOps.Zastava.Webhook.csproj
index 8ace68d6e..2b5ac7718 100644
--- a/src/Zastava/StellaOps.Zastava.Webhook/StellaOps.Zastava.Webhook.csproj
+++ b/src/Zastava/StellaOps.Zastava.Webhook/StellaOps.Zastava.Webhook.csproj
@@ -10,7 +10,7 @@
$(NoWarn);CA2254
-
+
diff --git a/src/Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj b/src/Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj
index 750c7f311..f1c9efc5c 100644
--- a/src/Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj
+++ b/src/Zastava/__Libraries/StellaOps.Zastava.Core/StellaOps.Zastava.Core.csproj
@@ -8,11 +8,11 @@
true
-
-
-
-
-
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj b/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
index cc2c96a92..c585f6c79 100644
--- a/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
+++ b/src/__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj
@@ -1,38 +1,38 @@
-
-
- net10.0
- preview
- enable
- enable
- true
-
-
- Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.
- StellaOps.Auth.Security
- StellaOps
- StellaOps
- stellaops;dpop;mtls;oauth2;security
- AGPL-3.0-or-later
- https://stella-ops.org
- https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
- git
- true
- true
- true
- snupkg
- README.md
- 1.0.0-preview.1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+ Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.
+ StellaOps.Auth.Security
+ StellaOps
+ StellaOps
+ stellaops;dpop;mtls;oauth2;security
+ AGPL-3.0-or-later
+ https://stella-ops.org
+ https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
+ git
+ true
+ true
+ true
+ snupkg
+ README.md
+ 1.0.0-preview.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj b/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj
index bf5036a09..4220012f2 100644
--- a/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj
+++ b/src/__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj
@@ -8,11 +8,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs
index 40313a945..06a4dc1cd 100644
--- a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs
+++ b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/CryptoServiceCollectionExtensions.cs
@@ -65,6 +65,7 @@ public static class CryptoServiceCollectionExtensions
#endif
services.TryAddSingleton();
+ services.TryAddSingleton();
services.TryAddSingleton(sp =>
{
diff --git a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj
index 710963b68..05952a37c 100644
--- a/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj
@@ -12,10 +12,10 @@
-
-
-
-
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj b/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj
index 45c00d055..d130d8395 100644
--- a/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Kms/StellaOps.Cryptography.Kms.csproj
@@ -5,8 +5,8 @@
enable
-
-
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj
index f2b041cf2..76c2907ca 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.BouncyCastle/StellaOps.Cryptography.Plugin.BouncyCastle.csproj
@@ -1,16 +1,16 @@
-
-
- net10.0
- preview
- enable
- enable
- true
-
-
-
-
-
-
-
-
-
+
+
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj
index fc012bb7a..6ff75d5fb 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.CryptoPro/StellaOps.Cryptography.Plugin.CryptoPro.csproj
@@ -10,9 +10,9 @@
-
-
-
+
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj
index bd20bf3a7..92a27626a 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.OpenSslGost/StellaOps.Cryptography.Plugin.OpenSslGost.csproj
@@ -8,8 +8,8 @@
-
-
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj
index 45e15a9d0..db5c1bffc 100644
--- a/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj
+++ b/src/__Libraries/StellaOps.Cryptography.Plugin.Pkcs11Gost/StellaOps.Cryptography.Plugin.Pkcs11Gost.csproj
@@ -10,9 +10,9 @@
-
-
-
+
+
+
diff --git a/src/__Libraries/StellaOps.Cryptography/ComplianceProfile.cs b/src/__Libraries/StellaOps.Cryptography/ComplianceProfile.cs
index b2e802ddf..9899b486e 100644
--- a/src/__Libraries/StellaOps.Cryptography/ComplianceProfile.cs
+++ b/src/__Libraries/StellaOps.Cryptography/ComplianceProfile.cs
@@ -32,6 +32,12 @@ public sealed class ComplianceProfile
///
public required IReadOnlyDictionary HashPrefixes { get; init; }
+ ///
+ /// Mapping of HMAC purposes to algorithm identifiers.
+ /// Keys are from , values are from .
+ ///
+ public IReadOnlyDictionary? HmacPurposeAlgorithms { get; init; }
+
///
/// When true, the Interop purpose may use SHA-256 even if not the profile default.
/// Default: true.
@@ -93,4 +99,27 @@ public sealed class ComplianceProfile
return string.Equals(expectedAlgorithm, algorithmId, StringComparison.OrdinalIgnoreCase);
}
+
+ ///
+ /// Gets the HMAC algorithm for a given purpose.
+ ///
+ /// The HMAC purpose from .
+ /// The HMAC algorithm identifier from .
+ /// Thrown when the purpose is unknown.
+ public string GetHmacAlgorithmForPurpose(string purpose)
+ {
+ // WebhookInterop always uses HMAC-SHA256 for external compatibility
+ if (purpose == HmacPurpose.WebhookInterop)
+ {
+ return HmacAlgorithms.HmacSha256;
+ }
+
+ if (HmacPurposeAlgorithms?.TryGetValue(purpose, out var algorithm) == true)
+ {
+ return algorithm;
+ }
+
+ // Default fallback to HMAC-SHA256
+ return HmacAlgorithms.HmacSha256;
+ }
}
diff --git a/src/__Libraries/StellaOps.Cryptography/ComplianceProfiles.cs b/src/__Libraries/StellaOps.Cryptography/ComplianceProfiles.cs
index 4b1a646b2..8b3f98ac8 100644
--- a/src/__Libraries/StellaOps.Cryptography/ComplianceProfiles.cs
+++ b/src/__Libraries/StellaOps.Cryptography/ComplianceProfiles.cs
@@ -34,6 +34,12 @@ public static class ComplianceProfiles
[HashPurpose.Interop] = "sha256:",
[HashPurpose.Secret] = "argon2id:",
},
+ HmacPurposeAlgorithms = new Dictionary
+ {
+ [HmacPurpose.Signing] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.Authentication] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.WebhookInterop] = HmacAlgorithms.HmacSha256,
+ },
AllowInteropOverride = true,
};
@@ -67,6 +73,12 @@ public static class ComplianceProfiles
[HashPurpose.Interop] = "sha256:",
[HashPurpose.Secret] = "pbkdf2:",
},
+ HmacPurposeAlgorithms = new Dictionary
+ {
+ [HmacPurpose.Signing] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.Authentication] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.WebhookInterop] = HmacAlgorithms.HmacSha256,
+ },
AllowInteropOverride = true,
};
@@ -99,6 +111,12 @@ public static class ComplianceProfiles
[HashPurpose.Interop] = "sha256:",
[HashPurpose.Secret] = "argon2id:",
},
+ HmacPurposeAlgorithms = new Dictionary
+ {
+ [HmacPurpose.Signing] = HmacAlgorithms.HmacGost3411,
+ [HmacPurpose.Authentication] = HmacAlgorithms.HmacGost3411,
+ [HmacPurpose.WebhookInterop] = HmacAlgorithms.HmacSha256, // External compatibility
+ },
AllowInteropOverride = true,
};
@@ -131,6 +149,12 @@ public static class ComplianceProfiles
[HashPurpose.Interop] = "sha256:",
[HashPurpose.Secret] = "argon2id:",
},
+ HmacPurposeAlgorithms = new Dictionary
+ {
+ [HmacPurpose.Signing] = HmacAlgorithms.HmacSm3,
+ [HmacPurpose.Authentication] = HmacAlgorithms.HmacSm3,
+ [HmacPurpose.WebhookInterop] = HmacAlgorithms.HmacSha256, // External compatibility
+ },
AllowInteropOverride = true,
};
@@ -163,6 +187,12 @@ public static class ComplianceProfiles
[HashPurpose.Interop] = "sha256:",
[HashPurpose.Secret] = "argon2id:",
},
+ HmacPurposeAlgorithms = new Dictionary
+ {
+ [HmacPurpose.Signing] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.Authentication] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.WebhookInterop] = HmacAlgorithms.HmacSha256,
+ },
AllowInteropOverride = true,
};
@@ -195,6 +225,12 @@ public static class ComplianceProfiles
[HashPurpose.Interop] = "sha256:",
[HashPurpose.Secret] = "argon2id:",
},
+ HmacPurposeAlgorithms = new Dictionary
+ {
+ [HmacPurpose.Signing] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.Authentication] = HmacAlgorithms.HmacSha256,
+ [HmacPurpose.WebhookInterop] = HmacAlgorithms.HmacSha256,
+ },
AllowInteropOverride = true,
};
diff --git a/src/__Libraries/StellaOps.Cryptography/DefaultCryptoHmac.cs b/src/__Libraries/StellaOps.Cryptography/DefaultCryptoHmac.cs
new file mode 100644
index 000000000..aacbd6775
--- /dev/null
+++ b/src/__Libraries/StellaOps.Cryptography/DefaultCryptoHmac.cs
@@ -0,0 +1,323 @@
+using System;
+using System.Buffers;
+using System.IO;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Org.BouncyCastle.Crypto.Digests;
+using Org.BouncyCastle.Crypto.Macs;
+using Org.BouncyCastle.Crypto.Parameters;
+
+namespace StellaOps.Cryptography;
+
+///
+/// Default implementation of with compliance profile support.
+///
+public sealed class DefaultCryptoHmac : ICryptoHmac
+{
+ private readonly IOptionsMonitor _complianceOptions;
+ private readonly ILogger _logger;
+
+ [ActivatorUtilitiesConstructor]
+ public DefaultCryptoHmac(
+ IOptionsMonitor? complianceOptions = null,
+ ILogger? logger = null)
+ {
+ _complianceOptions = complianceOptions ?? new StaticComplianceOptionsMonitor(new CryptoComplianceOptions());
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ internal DefaultCryptoHmac(CryptoComplianceOptions? complianceOptions)
+ : this(
+ new StaticComplianceOptionsMonitor(complianceOptions ?? new CryptoComplianceOptions()),
+ NullLogger.Instance)
+ {
+ }
+
+ ///
+ /// Creates a new instance for use in tests.
+ /// Uses default options with no compliance profile.
+ ///
+ public static DefaultCryptoHmac CreateForTests()
+ => new(new CryptoComplianceOptions());
+
+ #region Purpose-based methods
+
+ private ComplianceProfile GetActiveProfile()
+ {
+ var opts = _complianceOptions.CurrentValue;
+ opts.ApplyEnvironmentOverrides();
+ return ComplianceProfiles.GetProfile(opts.ProfileId);
+ }
+
+ public byte[] ComputeHmacForPurpose(ReadOnlySpan key, ReadOnlySpan data, string purpose)
+ {
+ var algorithm = GetAlgorithmForPurpose(purpose);
+ return ComputeHmacWithAlgorithm(key, data, algorithm);
+ }
+
+ public string ComputeHmacHexForPurpose(ReadOnlySpan key, ReadOnlySpan data, string purpose)
+ => Convert.ToHexString(ComputeHmacForPurpose(key, data, purpose)).ToLowerInvariant();
+
+ public string ComputeHmacBase64ForPurpose(ReadOnlySpan key, ReadOnlySpan data, string purpose)
+ => Convert.ToBase64String(ComputeHmacForPurpose(key, data, purpose));
+
+ public async ValueTask ComputeHmacForPurposeAsync(ReadOnlyMemory key, Stream stream, string purpose, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var algorithm = GetAlgorithmForPurpose(purpose);
+ return await ComputeHmacWithAlgorithmAsync(key, stream, algorithm, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask ComputeHmacHexForPurposeAsync(ReadOnlyMemory key, Stream stream, string purpose, CancellationToken cancellationToken = default)
+ {
+ var bytes = await ComputeHmacForPurposeAsync(key, stream, purpose, cancellationToken).ConfigureAwait(false);
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+
+ #endregion
+
+ #region Verification methods
+
+ public bool VerifyHmacForPurpose(ReadOnlySpan key, ReadOnlySpan data, ReadOnlySpan expectedHmac, string purpose)
+ {
+ var computed = ComputeHmacForPurpose(key, data, purpose);
+ return CryptographicOperations.FixedTimeEquals(computed, expectedHmac);
+ }
+
+ public bool VerifyHmacHexForPurpose(ReadOnlySpan key, ReadOnlySpan data, string expectedHmacHex, string purpose)
+ {
+ if (string.IsNullOrWhiteSpace(expectedHmacHex))
+ {
+ return false;
+ }
+
+ try
+ {
+ var expectedBytes = Convert.FromHexString(expectedHmacHex);
+ return VerifyHmacForPurpose(key, data, expectedBytes, purpose);
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ public bool VerifyHmacBase64ForPurpose(ReadOnlySpan key, ReadOnlySpan data, string expectedHmacBase64, string purpose)
+ {
+ if (string.IsNullOrWhiteSpace(expectedHmacBase64))
+ {
+ return false;
+ }
+
+ try
+ {
+ var expectedBytes = Convert.FromBase64String(expectedHmacBase64);
+ return VerifyHmacForPurpose(key, data, expectedBytes, purpose);
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ #endregion
+
+ #region Metadata methods
+
+ public string GetAlgorithmForPurpose(string purpose)
+ {
+ if (string.IsNullOrWhiteSpace(purpose))
+ {
+ throw new ArgumentException("Purpose cannot be null or empty.", nameof(purpose));
+ }
+
+ var profile = GetActiveProfile();
+ return profile.GetHmacAlgorithmForPurpose(purpose);
+ }
+
+ public int GetOutputLengthForPurpose(string purpose)
+ {
+ var algorithm = GetAlgorithmForPurpose(purpose);
+ return algorithm.ToUpperInvariant() switch
+ {
+ "HMAC-SHA256" => 32,
+ "HMAC-SHA384" => 48,
+ "HMAC-SHA512" => 64,
+ "HMAC-GOST3411" => 32, // GOST R 34.11-2012 Stribog-256
+ "HMAC-SM3" => 32,
+ _ => throw new InvalidOperationException($"Unknown HMAC algorithm '{algorithm}'.")
+ };
+ }
+
+ #endregion
+
+ #region Algorithm implementations
+
+ private static byte[] ComputeHmacWithAlgorithm(ReadOnlySpan key, ReadOnlySpan data, string algorithm)
+ {
+ return algorithm.ToUpperInvariant() switch
+ {
+ "HMAC-SHA256" => ComputeHmacSha256(key, data),
+ "HMAC-SHA384" => ComputeHmacSha384(key, data),
+ "HMAC-SHA512" => ComputeHmacSha512(key, data),
+ "HMAC-GOST3411" => ComputeHmacGost3411(key, data),
+ "HMAC-SM3" => ComputeHmacSm3(key, data),
+ _ => throw new InvalidOperationException($"Unsupported HMAC algorithm '{algorithm}'.")
+ };
+ }
+
+ private static async ValueTask ComputeHmacWithAlgorithmAsync(ReadOnlyMemory key, Stream stream, string algorithm, CancellationToken cancellationToken)
+ {
+ return algorithm.ToUpperInvariant() switch
+ {
+ "HMAC-SHA256" => await ComputeHmacShaStreamAsync(HashAlgorithmName.SHA256, key, stream, cancellationToken).ConfigureAwait(false),
+ "HMAC-SHA384" => await ComputeHmacShaStreamAsync(HashAlgorithmName.SHA384, key, stream, cancellationToken).ConfigureAwait(false),
+ "HMAC-SHA512" => await ComputeHmacShaStreamAsync(HashAlgorithmName.SHA512, key, stream, cancellationToken).ConfigureAwait(false),
+ "HMAC-GOST3411" => await ComputeHmacGost3411StreamAsync(key, stream, cancellationToken).ConfigureAwait(false),
+ "HMAC-SM3" => await ComputeHmacSm3StreamAsync(key, stream, cancellationToken).ConfigureAwait(false),
+ _ => throw new InvalidOperationException($"Unsupported HMAC algorithm '{algorithm}'.")
+ };
+ }
+
+ private static byte[] ComputeHmacSha256(ReadOnlySpan key, ReadOnlySpan data)
+ {
+ Span buffer = stackalloc byte[32];
+ HMACSHA256.HashData(key, data, buffer);
+ return buffer.ToArray();
+ }
+
+ private static byte[] ComputeHmacSha384(ReadOnlySpan key, ReadOnlySpan data)
+ {
+ Span buffer = stackalloc byte[48];
+ HMACSHA384.HashData(key, data, buffer);
+ return buffer.ToArray();
+ }
+
+ private static byte[] ComputeHmacSha512(ReadOnlySpan key, ReadOnlySpan data)
+ {
+ Span buffer = stackalloc byte[64];
+ HMACSHA512.HashData(key, data, buffer);
+ return buffer.ToArray();
+ }
+
+ private static byte[] ComputeHmacGost3411(ReadOnlySpan key, ReadOnlySpan data)
+ {
+ var digest = new Gost3411_2012_256Digest();
+ var hmac = new HMac(digest);
+ hmac.Init(new KeyParameter(key.ToArray()));
+ hmac.BlockUpdate(data.ToArray(), 0, data.Length);
+ var output = new byte[hmac.GetMacSize()];
+ hmac.DoFinal(output, 0);
+ return output;
+ }
+
+ private static byte[] ComputeHmacSm3(ReadOnlySpan key, ReadOnlySpan data)
+ {
+ var digest = new SM3Digest();
+ var hmac = new HMac(digest);
+ hmac.Init(new KeyParameter(key.ToArray()));
+ hmac.BlockUpdate(data.ToArray(), 0, data.Length);
+ var output = new byte[hmac.GetMacSize()];
+ hmac.DoFinal(output, 0);
+ return output;
+ }
+
+ private static async ValueTask ComputeHmacShaStreamAsync(HashAlgorithmName name, ReadOnlyMemory key, Stream stream, CancellationToken cancellationToken)
+ {
+ using var hmac = name.Name switch
+ {
+ "SHA256" => (HMAC)new HMACSHA256(key.ToArray()),
+ "SHA384" => new HMACSHA384(key.ToArray()),
+ "SHA512" => new HMACSHA512(key.ToArray()),
+ _ => throw new InvalidOperationException($"Unsupported hash algorithm '{name}'.")
+ };
+
+ return await hmac.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async ValueTask ComputeHmacGost3411StreamAsync(ReadOnlyMemory key, Stream stream, CancellationToken cancellationToken)
+ {
+ var digest = new Gost3411_2012_256Digest();
+ var hmac = new HMac(digest);
+ hmac.Init(new KeyParameter(key.ToArray()));
+
+ var buffer = ArrayPool.Shared.Rent(128 * 1024);
+ try
+ {
+ int bytesRead;
+ while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ hmac.BlockUpdate(buffer, 0, bytesRead);
+ }
+
+ var output = new byte[hmac.GetMacSize()];
+ hmac.DoFinal(output, 0);
+ return output;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private static async ValueTask ComputeHmacSm3StreamAsync(ReadOnlyMemory key, Stream stream, CancellationToken cancellationToken)
+ {
+ var digest = new SM3Digest();
+ var hmac = new HMac(digest);
+ hmac.Init(new KeyParameter(key.ToArray()));
+
+ var buffer = ArrayPool.Shared.Rent(128 * 1024);
+ try
+ {
+ int bytesRead;
+ while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0)
+ {
+ hmac.BlockUpdate(buffer, 0, bytesRead);
+ }
+
+ var output = new byte[hmac.GetMacSize()];
+ hmac.DoFinal(output, 0);
+ return output;
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ #endregion
+
+ #region Static options monitor
+
+ private sealed class StaticComplianceOptionsMonitor : IOptionsMonitor
+ {
+ private readonly CryptoComplianceOptions _options;
+
+ public StaticComplianceOptionsMonitor(CryptoComplianceOptions options)
+ => _options = options;
+
+ public CryptoComplianceOptions CurrentValue => _options;
+
+ public CryptoComplianceOptions Get(string? name) => _options;
+
+ public IDisposable OnChange(Action listener)
+ => NullDisposable.Instance;
+ }
+
+ private sealed class NullDisposable : IDisposable
+ {
+ public static readonly NullDisposable Instance = new();
+ public void Dispose()
+ {
+ }
+ }
+
+ #endregion
+}
diff --git a/src/__Libraries/StellaOps.Cryptography/HmacAlgorithms.cs b/src/__Libraries/StellaOps.Cryptography/HmacAlgorithms.cs
new file mode 100644
index 000000000..5eeee916f
--- /dev/null
+++ b/src/__Libraries/StellaOps.Cryptography/HmacAlgorithms.cs
@@ -0,0 +1,55 @@
+namespace StellaOps.Cryptography;
+
+///
+/// Well-known HMAC algorithm identifiers used by compliance profiles.
+///
+public static class HmacAlgorithms
+{
+ ///
+ /// HMAC using SHA-256 (FIPS 198-1, RFC 2104).
+ /// Used by: world, fips, kcmvp, eidas profiles.
+ ///
+ public const string HmacSha256 = "HMAC-SHA256";
+
+ ///
+ /// HMAC using SHA-384 (FIPS 198-1, RFC 2104).
+ ///
+ public const string HmacSha384 = "HMAC-SHA384";
+
+ ///
+ /// HMAC using SHA-512 (FIPS 198-1, RFC 2104).
+ ///
+ public const string HmacSha512 = "HMAC-SHA512";
+
+ ///
+ /// HMAC using GOST R 34.11-2012 Stribog 256-bit (RFC 6986).
+ /// Used by: gost profile.
+ ///
+ public const string HmacGost3411 = "HMAC-GOST3411";
+
+ ///
+ /// HMAC using SM3 (GB/T 32905-2016).
+ /// Used by: sm profile.
+ ///
+ public const string HmacSm3 = "HMAC-SM3";
+
+ ///
+ /// All known HMAC algorithms for validation.
+ ///
+ public static readonly IReadOnlyList All = new[]
+ {
+ HmacSha256,
+ HmacSha384,
+ HmacSha512,
+ HmacGost3411,
+ HmacSm3
+ };
+
+ ///
+ /// Validates whether the given algorithm is a known HMAC algorithm.
+ ///
+ /// The algorithm identifier to validate.
+ /// True if the algorithm is known; otherwise, false.
+ public static bool IsKnown(string? algorithmId)
+ => !string.IsNullOrWhiteSpace(algorithmId) && All.Contains(algorithmId);
+}
diff --git a/src/__Libraries/StellaOps.Cryptography/HmacPurpose.cs b/src/__Libraries/StellaOps.Cryptography/HmacPurpose.cs
new file mode 100644
index 000000000..4a323ac14
--- /dev/null
+++ b/src/__Libraries/StellaOps.Cryptography/HmacPurpose.cs
@@ -0,0 +1,46 @@
+namespace StellaOps.Cryptography;
+
+///
+/// Well-known HMAC purpose identifiers for compliance-aware cryptographic operations.
+/// Components should request HMAC by PURPOSE, not by algorithm.
+/// The platform resolves the correct algorithm based on the active compliance profile.
+///
+public static class HmacPurpose
+{
+ ///
+ /// DSSE envelope signing and message authentication codes.
+ /// Default: HMAC-SHA256 (world/fips/kcmvp/eidas), HMAC-GOST3411 (gost), HMAC-SM3 (sm).
+ ///
+ public const string Signing = "signing";
+
+ ///
+ /// Token and URL authentication (e.g., signed URLs, ack tokens).
+ /// Default: HMAC-SHA256 (world/fips/kcmvp/eidas), HMAC-GOST3411 (gost), HMAC-SM3 (sm).
+ ///
+ public const string Authentication = "auth";
+
+ ///
+ /// External webhook interoperability (third-party webhook receivers).
+ /// Always HMAC-SHA256, regardless of compliance profile.
+ /// Every use of this purpose MUST be documented with justification.
+ ///
+ public const string WebhookInterop = "webhook";
+
+ ///
+ /// All known HMAC purposes for validation.
+ ///
+ public static readonly IReadOnlyList All = new[]
+ {
+ Signing,
+ Authentication,
+ WebhookInterop
+ };
+
+ ///
+ /// Validates whether the given purpose is known.
+ ///
+ /// The purpose to validate.
+ /// True if the purpose is known; otherwise, false.
+ public static bool IsKnown(string? purpose)
+ => !string.IsNullOrWhiteSpace(purpose) && All.Contains(purpose);
+}
diff --git a/src/__Libraries/StellaOps.Cryptography/ICryptoHmac.cs b/src/__Libraries/StellaOps.Cryptography/ICryptoHmac.cs
new file mode 100644
index 000000000..38749c55d
--- /dev/null
+++ b/src/__Libraries/StellaOps.Cryptography/ICryptoHmac.cs
@@ -0,0 +1,115 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace StellaOps.Cryptography;
+
+///
+/// Interface for HMAC (Hash-based Message Authentication Code) operations with compliance profile support.
+///
+public interface ICryptoHmac
+{
+ #region Purpose-based methods (preferred for compliance)
+
+ ///
+ /// Computes an HMAC for the specified purpose using the active compliance profile's algorithm.
+ ///
+ /// The secret key.
+ /// The data to authenticate.
+ /// The HMAC purpose from .
+ /// The HMAC bytes.
+ byte[] ComputeHmacForPurpose(ReadOnlySpan key, ReadOnlySpan data, string purpose);
+
+ ///
+ /// Computes an HMAC for the specified purpose and returns it as a lowercase hex string.
+ ///
+ /// The secret key.
+ /// The data to authenticate.
+ /// The HMAC purpose from .
+ /// The HMAC as a lowercase hex string.
+ string ComputeHmacHexForPurpose(ReadOnlySpan key, ReadOnlySpan data, string purpose);
+
+ ///
+ /// Computes an HMAC for the specified purpose and returns it as a Base64 string.
+ ///
+ /// The secret key.
+ /// The data to authenticate.
+ /// The HMAC purpose from .
+ /// The HMAC as a Base64 string.
+ string ComputeHmacBase64ForPurpose(ReadOnlySpan key, ReadOnlySpan data, string purpose);
+
+ ///
+ /// Computes an HMAC for the specified purpose from a stream asynchronously.
+ ///
+ /// The secret key.
+ /// The stream to authenticate.
+ /// The HMAC purpose from .
+ /// Cancellation token.
+ /// The HMAC bytes.
+ ValueTask ComputeHmacForPurposeAsync(ReadOnlyMemory key, Stream stream, string purpose, CancellationToken cancellationToken = default);
+
+ ///
+ /// Computes an HMAC for the specified purpose from a stream and returns it as a lowercase hex string.
+ ///
+ /// The secret key.
+ /// The stream to authenticate.
+ /// The HMAC purpose from .
+ /// Cancellation token.
+ /// The HMAC as a lowercase hex string.
+ ValueTask ComputeHmacHexForPurposeAsync(ReadOnlyMemory key, Stream stream, string purpose, CancellationToken cancellationToken = default);
+
+ #endregion
+
+ #region Verification methods (constant-time comparison)
+
+ ///
+ /// Verifies an HMAC for the specified purpose using constant-time comparison.
+ ///
+ /// The secret key.
+ /// The data that was authenticated.
+ /// The expected HMAC value.
+ /// The HMAC purpose from .
+ /// True if the HMAC matches; otherwise, false.
+ bool VerifyHmacForPurpose(ReadOnlySpan key, ReadOnlySpan data, ReadOnlySpan expectedHmac, string purpose);
+
+ ///
+ /// Verifies an HMAC for the specified purpose using constant-time comparison (hex format).
+ ///
+ /// The secret key.
+ /// The data that was authenticated.
+ /// The expected HMAC value as a hex string.
+ /// The HMAC purpose from .
+ /// True if the HMAC matches; otherwise, false.
+ bool VerifyHmacHexForPurpose(ReadOnlySpan key, ReadOnlySpan data, string expectedHmacHex, string purpose);
+
+ ///
+ /// Verifies an HMAC for the specified purpose using constant-time comparison (Base64 format).
+ ///
+ /// The secret key.
+ /// The data that was authenticated.
+ /// The expected HMAC value as a Base64 string.
+ /// The HMAC purpose from .
+ /// True if the HMAC matches; otherwise, false.
+ bool VerifyHmacBase64ForPurpose(ReadOnlySpan key, ReadOnlySpan data, string expectedHmacBase64, string purpose);
+
+ #endregion
+
+ #region Metadata methods
+
+ ///
+ /// Gets the algorithm that will be used for the specified purpose based on the active compliance profile.
+ ///
+ /// The HMAC purpose from .
+ /// The algorithm identifier (e.g., "HMAC-SHA256", "HMAC-GOST3411").
+ string GetAlgorithmForPurpose(string purpose);
+
+ ///
+ /// Gets the expected HMAC output length in bytes for the specified purpose.
+ ///
+ /// The HMAC purpose from .
+ /// The output length in bytes.
+ int GetOutputLengthForPurpose(string purpose);
+
+ #endregion
+}
diff --git a/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj b/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj
index ccea3bf05..2a24492d1 100644
--- a/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj
+++ b/src/__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj
@@ -1,20 +1,20 @@
-
-
- net10.0
- preview
- enable
- enable
- true
-
-
- $(DefineConstants);STELLAOPS_CRYPTO_SODIUM
-
-
-
-
-
-
-
-
-
-
+
+
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+ $(DefineConstants);STELLAOPS_CRYPTO_SODIUM
+
+
+
+
+
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj b/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj
index 96977cf31..910c99212 100644
--- a/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj
+++ b/src/__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj
@@ -1,14 +1,14 @@
-
-
-
- net10.0
- enable
- enable
-
-
-
-
-
-
-
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj b/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj
index 842bec749..481ab5d48 100644
--- a/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj
+++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj
@@ -13,13 +13,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj b/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj
index 39cd0e726..ba0d102e7 100644
--- a/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj
+++ b/src/__Libraries/StellaOps.IssuerDirectory.Client/StellaOps.IssuerDirectory.Client.csproj
@@ -7,8 +7,8 @@
true
-
-
-
+
+
+
diff --git a/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj b/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj
index 3231316e8..fa981a723 100644
--- a/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj
+++ b/src/__Libraries/StellaOps.Microservice/StellaOps.Microservice.csproj
@@ -7,10 +7,10 @@
true
-
-
-
-
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj b/src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj
index 124ad9da8..76b150e6d 100644
--- a/src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj
+++ b/src/__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj
@@ -8,9 +8,9 @@
-
-
-
+
+
+
diff --git a/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj b/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj
index a3bc4de1b..61e67acf2 100644
--- a/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj
+++ b/src/__Libraries/StellaOps.Router.Config/StellaOps.Router.Config.csproj
@@ -13,14 +13,14 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj b/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj
index c1ddbfccf..bc3a8bb90 100644
--- a/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj
+++ b/src/__Libraries/StellaOps.Router.Transport.InMemory/StellaOps.Router.Transport.InMemory.csproj
@@ -14,9 +14,9 @@
-
-
-
+
+
+
Comments
+Deterministic thread; newest first.
++-
+
+ {{ comment.authorName }}
+ {{ comment.createdAt | date:'medium' }}
+
+
+
+ +{{ comment.message }}
+