up
This commit is contained in:
8
src/Web/StellaOps.Web/TASKS.md
Normal file
8
src/Web/StellaOps.Web/TASKS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Web Guild Tasks
|
||||
|
||||
| Task ID | State | Notes |
|
||||
| --- | --- | --- |
|
||||
| WEB-AOC-19-002 | DONE (2025-11-30) | Added provenance builder, checksum utilities, and DSSE/CMS signature verification helpers with unit tests. |
|
||||
| WEB-AOC-19-003 | TODO | Analyzer/guard validation remains; will align once helper APIs settle. |
|
||||
| WEB-CONSOLE-23-002 | TODO | Status/stream endpoints to proxy Scheduler once contracts finalized. |
|
||||
| WEB-EXC-25-001 | TODO | Exceptions workflow CRUD pending policy scopes. |
|
||||
71
src/Web/StellaOps.Web/src/app/core/aoc/checksum.util.ts
Normal file
71
src/Web/StellaOps.Web/src/app/core/aoc/checksum.util.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type HashAlgorithm = 'SHA-256' | 'SHA-512';
|
||||
|
||||
export interface DigestResult {
|
||||
algorithm: HashAlgorithm;
|
||||
hex: string;
|
||||
uri: string; // e.g. sha256:abcd...
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function toUint8(payload: string | ArrayBuffer | Uint8Array): Uint8Array {
|
||||
if (typeof payload === 'string') {
|
||||
return encoder.encode(payload);
|
||||
}
|
||||
|
||||
if (payload instanceof ArrayBuffer) {
|
||||
return new Uint8Array(payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function toHex(buffer: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically compute a digest over an input payload using WebCrypto.
|
||||
* Returns both the raw hex and a URI-style prefix (sha256:...).
|
||||
*/
|
||||
export async function computeDigest(
|
||||
payload: string | ArrayBuffer | Uint8Array,
|
||||
algorithm: HashAlgorithm = 'SHA-256'
|
||||
): Promise<DigestResult> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error('WebCrypto unavailable: cannot compute digest');
|
||||
}
|
||||
|
||||
const data = toUint8(payload);
|
||||
const digestBuffer = await globalThis.crypto.subtle.digest(algorithm, data);
|
||||
const hex = toHex(digestBuffer);
|
||||
const prefix = algorithm.toLowerCase().replace('-', '');
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
hex,
|
||||
uri: `${prefix}:${hex}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to deterministically serialize an object before hashing.
|
||||
* Uses stable key ordering and JSON without spaces.
|
||||
*/
|
||||
export function canonicalJson(input: unknown): string {
|
||||
const replacer = (_key: string, value: unknown) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((acc, key) => {
|
||||
acc[key] = (value as Record<string, unknown>)[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return JSON.stringify(input, replacer);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ProvenanceBuilder } from './provenance-builder';
|
||||
import { canonicalJson, computeDigest } from './checksum.util';
|
||||
import {
|
||||
dssePreAuthEncode,
|
||||
verifyCmsSignature,
|
||||
verifyDsseSignature,
|
||||
} from './signature-verifier';
|
||||
|
||||
async function exportPublicKeyPem(publicKey: CryptoKey): Promise<string> {
|
||||
const spki = await crypto.subtle.exportKey('spki', publicKey);
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
||||
const wrapped = base64.match(/.{1,64}/g)?.join('\n') ?? base64;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${wrapped}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
describe('Provenance utilities', () => {
|
||||
it('builds deterministic provenance for objects', async () => {
|
||||
const builder = new ProvenanceBuilder(() => new Date('2025-11-30T12:00:00Z'));
|
||||
const provenance = await builder.build(
|
||||
{ b: 2, a: 1 },
|
||||
{
|
||||
sourceId: 'registry-1',
|
||||
sourceType: 'registry',
|
||||
sourceUrl: 'https://example.test/manifest',
|
||||
submitter: 'ci-bot',
|
||||
}
|
||||
);
|
||||
|
||||
expect(provenance.ingestedAt).toBe('2025-11-30T12:00:00.000Z');
|
||||
expect(provenance.digest.startsWith('sha256:')).toBeTrue();
|
||||
|
||||
const canonical = canonicalJson({ b: 2, a: 1 });
|
||||
const digest = await computeDigest(canonical, 'SHA-256');
|
||||
expect(provenance.digest).toBe(digest.uri);
|
||||
});
|
||||
|
||||
it('computes DSSE pre-auth encoding with correct prefix', () => {
|
||||
const payload = new TextEncoder().encode('hello');
|
||||
const pae = dssePreAuthEncode('text/plain', payload);
|
||||
const asText = new TextDecoder().decode(pae);
|
||||
expect(asText.startsWith('DSSEv1 10 text/plain 5 ')).toBeTrue();
|
||||
expect(asText.endsWith('hello')).toBeTrue();
|
||||
});
|
||||
|
||||
it('verifies CMS signatures', async () => {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
);
|
||||
|
||||
const message = new TextEncoder().encode('aoc-proof');
|
||||
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, message);
|
||||
const pem = await exportPublicKeyPem(keyPair.publicKey);
|
||||
|
||||
const result = await verifyCmsSignature({
|
||||
payload: message,
|
||||
signature,
|
||||
publicKeyPem: pem,
|
||||
algorithm: 'RSASSA-PKCS1-v1_5',
|
||||
hash: 'SHA-256',
|
||||
});
|
||||
|
||||
expect(result.valid).toBeTrue();
|
||||
});
|
||||
|
||||
it('fails verification when payload changes', async () => {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
);
|
||||
|
||||
const message = new TextEncoder().encode('aoc-proof');
|
||||
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, message);
|
||||
const pem = await exportPublicKeyPem(keyPair.publicKey);
|
||||
|
||||
const result = await verifyCmsSignature({
|
||||
payload: 'tampered',
|
||||
signature,
|
||||
publicKeyPem: pem,
|
||||
algorithm: 'RSASSA-PKCS1-v1_5',
|
||||
hash: 'SHA-256',
|
||||
});
|
||||
|
||||
expect(result.valid).toBeFalse();
|
||||
});
|
||||
|
||||
it('verifies DSSE signatures using pre-auth encoding', async () => {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify']
|
||||
);
|
||||
|
||||
const payloadBytes = new TextEncoder().encode('{"sub":"example"}');
|
||||
const pae = dssePreAuthEncode('application/json', payloadBytes);
|
||||
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', keyPair.privateKey, pae);
|
||||
const pem = await exportPublicKeyPem(keyPair.publicKey);
|
||||
|
||||
const result = await verifyDsseSignature({
|
||||
payload: payloadBytes,
|
||||
payloadType: 'application/json',
|
||||
signature,
|
||||
publicKeyPem: pem,
|
||||
algorithm: 'RSASSA-PKCS1-v1_5',
|
||||
hash: 'SHA-256',
|
||||
});
|
||||
|
||||
expect(result.valid).toBeTrue();
|
||||
expect(result.message?.startsWith('sha256:')).toBeTrue();
|
||||
});
|
||||
});
|
||||
40
src/Web/StellaOps.Web/src/app/core/aoc/provenance-builder.ts
Normal file
40
src/Web/StellaOps.Web/src/app/core/aoc/provenance-builder.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AocProvenance } from '../api/aoc.models';
|
||||
import { HashAlgorithm, canonicalJson, computeDigest } from './checksum.util';
|
||||
|
||||
export interface ProvenanceOptions {
|
||||
sourceId: string;
|
||||
sourceType?: AocProvenance['sourceType'];
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
ingestedAt?: string;
|
||||
hashAlgorithm?: HashAlgorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic provenance builder used by the AOC workspace to attach
|
||||
* digests and timing metadata to documents before verification/signing.
|
||||
*/
|
||||
export class ProvenanceBuilder {
|
||||
constructor(private readonly clock: () => Date = () => new Date()) {}
|
||||
|
||||
async build(
|
||||
payload: string | ArrayBuffer | Uint8Array | Record<string, unknown>,
|
||||
options: ProvenanceOptions
|
||||
): Promise<AocProvenance> {
|
||||
const serialised =
|
||||
typeof payload === 'string' || payload instanceof ArrayBuffer || payload instanceof Uint8Array
|
||||
? payload
|
||||
: canonicalJson(payload);
|
||||
|
||||
const digest = await computeDigest(serialised, options.hashAlgorithm ?? 'SHA-256');
|
||||
|
||||
return {
|
||||
sourceId: options.sourceId,
|
||||
sourceType: options.sourceType,
|
||||
sourceUrl: options.sourceUrl,
|
||||
submitter: options.submitter,
|
||||
ingestedAt: options.ingestedAt ?? this.clock().toISOString(),
|
||||
digest: digest.uri,
|
||||
};
|
||||
}
|
||||
}
|
||||
133
src/Web/StellaOps.Web/src/app/core/aoc/signature-verifier.ts
Normal file
133
src/Web/StellaOps.Web/src/app/core/aoc/signature-verifier.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { HashAlgorithm, computeDigest } from './checksum.util';
|
||||
|
||||
export interface VerificationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface VerifyOptions {
|
||||
payload: string | ArrayBuffer | Uint8Array;
|
||||
signature: ArrayBuffer | Uint8Array | string; // base64 if string
|
||||
publicKeyPem: string;
|
||||
algorithm?: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS';
|
||||
hash?: HashAlgorithm;
|
||||
saltLength?: number; // RSA-PSS only
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(input: string): ArrayBuffer {
|
||||
const normalized = input.replace(/\s+/g, '');
|
||||
const binary = atob(normalized);
|
||||
const buffer = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
buffer[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return buffer.buffer;
|
||||
}
|
||||
|
||||
async function importPublicKey(
|
||||
pem: string,
|
||||
algorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS',
|
||||
hash: HashAlgorithm
|
||||
): Promise<CryptoKey> {
|
||||
const clean = pem
|
||||
.replace('-----BEGIN PUBLIC KEY-----', '')
|
||||
.replace('-----END PUBLIC KEY-----', '')
|
||||
.replace(/\s+/g, '');
|
||||
const binaryDer = base64ToArrayBuffer(clean);
|
||||
|
||||
return globalThis.crypto.subtle.importKey(
|
||||
'spki',
|
||||
binaryDer,
|
||||
{
|
||||
name: algorithm,
|
||||
hash: { name: hash },
|
||||
},
|
||||
true,
|
||||
['verify']
|
||||
);
|
||||
}
|
||||
|
||||
function toUint8(payload: string | ArrayBuffer | Uint8Array): Uint8Array {
|
||||
if (typeof payload === 'string') {
|
||||
return new TextEncoder().encode(payload);
|
||||
}
|
||||
if (payload instanceof ArrayBuffer) {
|
||||
return new Uint8Array(payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function normalizeSignature(sig: ArrayBuffer | Uint8Array | string): ArrayBuffer {
|
||||
if (typeof sig === 'string') {
|
||||
return base64ToArrayBuffer(sig);
|
||||
}
|
||||
if (sig instanceof ArrayBuffer) {
|
||||
return sig;
|
||||
}
|
||||
return sig.buffer;
|
||||
}
|
||||
|
||||
export async function verifyCmsSignature(options: VerifyOptions): Promise<VerificationResult> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
return { valid: false, message: 'WebCrypto unavailable' };
|
||||
}
|
||||
|
||||
const algorithm = options.algorithm ?? 'RSASSA-PKCS1-v1_5';
|
||||
const hash = options.hash ?? 'SHA-256';
|
||||
|
||||
const key = await importPublicKey(options.publicKeyPem, algorithm, hash);
|
||||
const payload = toUint8(options.payload);
|
||||
const signature = normalizeSignature(options.signature);
|
||||
|
||||
const verified = await globalThis.crypto.subtle.verify(
|
||||
{ name: algorithm, saltLength: options.saltLength ?? 32 },
|
||||
key,
|
||||
signature,
|
||||
payload
|
||||
);
|
||||
|
||||
return verified
|
||||
? { valid: true }
|
||||
: { valid: false, message: 'Signature verification failed' };
|
||||
}
|
||||
|
||||
export interface DsseVerifyOptions extends VerifyOptions {
|
||||
payloadType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DSSE pre-authentication encoding (PAE) as defined by sigstore.
|
||||
* Format: `DSSEv1 <len(type)> <type> <len(payload)> <payload>`
|
||||
*/
|
||||
export function dssePreAuthEncode(payloadType: string, payload: Uint8Array): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const header = `DSSEv1 ${payloadType.length} ${payloadType} ${payload.length} `;
|
||||
const headerBytes = enc.encode(header);
|
||||
const output = new Uint8Array(headerBytes.length + payload.length);
|
||||
output.set(headerBytes, 0);
|
||||
output.set(payload, headerBytes.length);
|
||||
return output;
|
||||
}
|
||||
|
||||
export async function verifyDsseSignature(
|
||||
options: DsseVerifyOptions
|
||||
): Promise<VerificationResult> {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
return { valid: false, message: 'WebCrypto unavailable' };
|
||||
}
|
||||
|
||||
const payloadBytes = toUint8(options.payload);
|
||||
const pae = dssePreAuthEncode(options.payloadType, payloadBytes);
|
||||
const cmsResult = await verifyCmsSignature({
|
||||
...options,
|
||||
payload: pae,
|
||||
});
|
||||
|
||||
if (!cmsResult.valid) {
|
||||
return cmsResult;
|
||||
}
|
||||
|
||||
// Provide a digest hint for downstream display/debugging
|
||||
const digest = await computeDigest(payloadBytes, options.hash ?? 'SHA-256');
|
||||
return { valid: true, message: digest.uri };
|
||||
}
|
||||
Reference in New Issue
Block a user