This commit is contained in:
StellaOps Bot
2025-11-30 21:01:00 +02:00
parent 25254e3831
commit 808ab87b21
54 changed files with 1163 additions and 8 deletions

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

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

View File

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

View 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,
};
}
}

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