packages/erasure-ledger.ts
Rama main · réplica de lectura del repositorio original.
import { createHash } from "node:crypto";
import { z } from "zod";
const MAX_BYTES = 8192;
// Lower case only, unlike UUID_RE (contracts/uuid.ts): the ledger compares ids as strings.
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const SHA256 = /^[a-f0-9]{64}$/;
const POLICY = /^[A-Za-z0-9._-]{1,80}$/;
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const uuid = z.string().regex(UUID);
const sha256 = z.string().regex(SHA256);
const timestamp = z.string().refine((value) => {
if (!TIMESTAMP.test(value)) return false;
const date = new Date(value);
return Number.isFinite(date.getTime()) && date.toISOString() === value;
}, "invalid canonical UTC timestamp");
const common = {
schemaVersion: z.literal(1),
ledgerId: uuid,
requestId: uuid,
accountId: uuid,
tenantId: uuid,
requestedAt: timestamp,
revokedAt: timestamp,
policyVersion: z.string().regex(POLICY),
workingDataPolicyDeadline: timestamp,
backupRetentionPolicyDeadline: timestamp,
} as const;
const revokeSchema = z.object({ ...common, event: z.literal("revoke") }).strict();
const purgedSchema = z
.object({
...common,
event: z.literal("purged"),
revokeSha256: sha256,
sourceEmptyVerifiedAt: timestamp,
localMailClearedAt: timestamp,
metadataPurgedAt: timestamp,
})
.strict();
const recordSchema = z.discriminatedUnion("event", [revokeSchema, purgedSchema]);
export type RevokeRecord = z.infer<typeof revokeSchema>;
export type PurgedRecord = z.infer<typeof purgedSchema>;
export type ErasureRecord = RevokeRecord | PurgedRecord;
export type ErasureEntry = {
requestId: string;
accountId: string;
tenantId: string;
state: "revoked" | "purged";
revoke: RevokeRecord;
purged?: PurgedRecord;
};
function canonicalValue(value: unknown): string {
if (Array.isArray(value)) throw new Error("arrays are not allowed");
if (value === null || typeof value !== "object") {
if (typeof value === "undefined" || typeof value === "function") throw new Error("unsupported JSON value");
return JSON.stringify(value);
}
const object = value as Record<string, unknown>;
return `{${Object.keys(object)
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
.map((key) => `${JSON.stringify(key)}:${canonicalValue(object[key])}`)
.join(",")}}`;
}
function canonicalBytes(record: ErasureRecord): Uint8Array {
return new TextEncoder().encode(canonicalValue(record));
}
function hashBytes(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function assertTimeline(record: ErasureRecord) {
const requested = Date.parse(record.requestedAt);
const revoked = Date.parse(record.revokedAt);
if (requested > revoked) throw new Error("requestedAt must not be after revokedAt");
for (const deadline of [record.workingDataPolicyDeadline, record.backupRetentionPolicyDeadline])
if (Date.parse(deadline) < requested) throw new Error("policy deadline precedes request");
if (record.event === "purged") {
const metadata = Date.parse(record.metadataPurgedAt);
if (Date.parse(record.sourceEmptyVerifiedAt) < revoked || Date.parse(record.localMailClearedAt) < revoked)
throw new Error("purge proof precedes revoke");
if (Date.parse(record.sourceEmptyVerifiedAt) > metadata || Date.parse(record.localMailClearedAt) > metadata)
throw new Error("purge proof follows metadata purge");
}
}
function parseRecord(input: unknown): ErasureRecord {
const record = recordSchema.parse(input);
assertTimeline(record);
return record;
}
function recordKey(record: ErasureRecord): string {
return `erasure/v1/${record.ledgerId}/${record.requestId}/${record.event}.json`;
}
export function erasureKey(recordInput: unknown): string {
return recordKey(parseRecord(recordInput));
}
export function encodeErasureRecord(recordInput: unknown) {
const record = parseRecord(recordInput);
const bytes = canonicalBytes(record);
if (bytes.byteLength > MAX_BYTES) throw new Error("record exceeds 8192 bytes");
return { record, key: recordKey(record), bytes, sha256: hashBytes(bytes) };
}
export function decodeErasureRecord(
bytesInput: Uint8Array,
expectedKey: string,
expectedLedgerId: string,
): ErasureRecord {
if (bytesInput.byteLength > MAX_BYTES) throw new Error("record exceeds 8192 bytes");
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytesInput);
} catch {
throw new Error("invalid UTF-8");
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("invalid JSON");
}
const encoded = encodeErasureRecord(parsed);
if (encoded.key !== expectedKey || encoded.record.ledgerId !== expectedLedgerId)
throw new Error("ledger key mismatch");
if (encoded.bytes.length !== bytesInput.length || encoded.bytes.some((byte, index) => byte !== bytesInput[index]))
throw new Error("non-canonical JSON");
return encoded.record;
}
function commonOf(record: ErasureRecord) {
const {
event: _event,
revokeSha256: _hash,
sourceEmptyVerifiedAt: _source,
localMailClearedAt: _mail,
metadataPurgedAt: _metadata,
...value
} = record as ErasureRecord & Partial<PurgedRecord>;
return value;
}
export function validateErasurePair(revokeInput: unknown, purgedInput?: unknown): ErasureEntry {
const revoke = parseRecord(revokeInput);
if (revoke.event !== "revoke") throw new Error("revoke record required");
if (purgedInput === undefined)
return {
requestId: revoke.requestId,
accountId: revoke.accountId,
tenantId: revoke.tenantId,
state: "revoked",
revoke,
};
const purged = parseRecord(purgedInput);
if (purged.event !== "purged") throw new Error("purged record required");
if (JSON.stringify(commonOf(revoke)) !== JSON.stringify(commonOf(purged)))
throw new Error("common ledger fields mismatch");
if (purged.revokeSha256 !== encodeErasureRecord(revoke).sha256) throw new Error("revoke hash mismatch");
return {
requestId: revoke.requestId,
accountId: revoke.accountId,
tenantId: revoke.tenantId,
state: "purged",
revoke,
purged,
};
}
export function validateErasureLedger(recordsInput: readonly unknown[], expectedLedgerId: string): ErasureEntry[] {
if (!UUID.test(expectedLedgerId)) throw new Error("invalid expected ledger id");
const records = recordsInput.map(parseRecord);
const groups = new Map<string, { revoke?: RevokeRecord; purged?: PurgedRecord; hashes: Set<string> }>();
for (const record of records) {
if (record.ledgerId !== expectedLedgerId) throw new Error("ledger namespace mismatch");
const encoded = encodeErasureRecord(record);
const group = groups.get(record.requestId) ?? { hashes: new Set<string>() };
if (group.hashes.has(encoded.sha256)) continue;
group.hashes.add(encoded.sha256);
if (record.event === "revoke") {
if (group.revoke) throw new Error("conflicting revoke records");
group.revoke = record;
} else {
if (group.purged) throw new Error("conflicting purged records");
group.purged = record;
}
groups.set(record.requestId, group);
}
const entries: ErasureEntry[] = [];
const accounts = new Map<string, string>();
const tenants = new Map<string, string>();
for (const [requestId, group] of groups) {
if (!group.revoke) throw new Error("purged record has no revoke");
const entry = validateErasurePair(group.revoke, group.purged);
const previousAccount = accounts.get(entry.accountId);
const previousTenant = tenants.get(entry.tenantId);
if ((previousAccount && previousAccount !== requestId) || (previousTenant && previousTenant !== requestId))
throw new Error("account or tenant appears in multiple requests");
accounts.set(entry.accountId, requestId);
tenants.set(entry.tenantId, requestId);
entries.push(entry);
}
return entries.sort((a, b) => a.requestId.localeCompare(b.requestId));
}