Initial commit: Elydora Open Source — self-hosted audit platform for AI agents
Full self-hosted port of the Elydora tamper-evident audit protocol: - Hono API server with PostgreSQL, Redis, MinIO adapters - Ed25519 signed operations, chain hashing, Merkle epoch rollups - PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC - Next.js management console - Node.js, Python, and Go SDKs - Docker Compose for local deployment - Helm chart for Kubernetes - Comprehensive security hardening and audit fixes applied Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
15547a8c35
commit
e390e68594
207 files changed
+32790
-63
No files matched your search
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Cryptographic utilities for the Elydora API.
|
||||
*
|
||||
* All operations use the Web Crypto API (SubtleCrypto) which is available
|
||||
* natively in Node.js 20+ with full Ed25519 support.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url helpers (RFC 4648 section 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Decode a base64url string to a Uint8Array. */
|
||||
export function base64urlDecode(input: string): Uint8Array {
|
||||
// Convert base64url to standard base64
|
||||
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Pad to a multiple of 4
|
||||
while (base64.length % 4 !== 0) {
|
||||
base64 += '=';
|
||||
}
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Encode a Uint8Array to a base64url string (no padding). */
|
||||
export function base64urlEncode(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SHA-256
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compute SHA-256 of arbitrary data and return base64url. */
|
||||
export async function sha256Base64url(data: BufferSource | string): Promise<string> {
|
||||
const input = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
const hash = await crypto.subtle.digest('SHA-256', input);
|
||||
return base64urlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 signature verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
|
||||
*
|
||||
* @param publicKeyBytes - 32-byte raw Ed25519 public key
|
||||
*/
|
||||
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
publicKeyBytes as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
true, // extractable
|
||||
['verify'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an Ed25519 signature over data.
|
||||
*
|
||||
* @param publicKeyBytes - 32-byte raw public key
|
||||
* @param signatureBytes - 64-byte Ed25519 signature
|
||||
* @param data - The signed data
|
||||
* @returns true if the signature is valid
|
||||
*/
|
||||
export async function verifyEd25519Signature(
|
||||
publicKeyBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array,
|
||||
data: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = await importEd25519PublicKey(publicKeyBytes);
|
||||
return crypto.subtle.verify(
|
||||
{ name: 'Ed25519' },
|
||||
key,
|
||||
signatureBytes as unknown as Uint8Array<ArrayBuffer>,
|
||||
data as unknown as Uint8Array<ArrayBuffer>,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 signing (server-side receipt signing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key.
|
||||
*
|
||||
* SEQUENCE {
|
||||
* INTEGER 0
|
||||
* SEQUENCE { OID 1.3.101.112 }
|
||||
* OCTET STRING { OCTET STRING { <32 bytes> } }
|
||||
* }
|
||||
*/
|
||||
const ED25519_PKCS8_PREFIX = new Uint8Array([
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0
|
||||
0x30, 0x05, // SEQUENCE (5 bytes)
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
|
||||
0x04, 0x22, // OCTET STRING (34 bytes)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||
]);
|
||||
|
||||
/** Wrap a 32-byte Ed25519 seed in a PKCS8 envelope. */
|
||||
function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
|
||||
const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed.length);
|
||||
pkcs8.set(ED25519_PKCS8_PREFIX);
|
||||
pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);
|
||||
return pkcs8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a raw Ed25519 private key for signing.
|
||||
*
|
||||
* Node.js expects PKCS8 format for Ed25519 private keys.
|
||||
* We wrap the 32-byte seed in a minimal PKCS8 envelope.
|
||||
*
|
||||
* @param privateKeyBase64url - base64url-encoded 32-byte Ed25519 private key seed
|
||||
*/
|
||||
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with an Ed25519 private key.
|
||||
*
|
||||
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
|
||||
* @param data - data to sign
|
||||
* @returns base64url-encoded 64-byte signature
|
||||
*/
|
||||
export async function signEd25519(
|
||||
privateKeyBase64url: string,
|
||||
data: Uint8Array,
|
||||
): Promise<string> {
|
||||
const key = await importEd25519PrivateKey(privateKeyBase64url);
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: 'Ed25519' },
|
||||
key,
|
||||
data as unknown as Uint8Array<ArrayBuffer>,
|
||||
);
|
||||
return base64urlEncode(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the public key from an Ed25519 private key (for JWKS).
|
||||
*
|
||||
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
|
||||
* @returns base64url-encoded 32-byte public key
|
||||
*/
|
||||
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
true, // extractable — needed to export the public key
|
||||
['sign'],
|
||||
);
|
||||
// Export as JWK to get the public 'x' parameter
|
||||
const jwk = await crypto.subtle.exportKey('jwk', key) as JsonWebKey;
|
||||
// The 'x' field in a JWK for OKP/Ed25519 is the base64url-encoded public key
|
||||
return jwk.x ?? '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JCS (JSON Canonicalization Scheme - RFC 8785)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Canonicalize a JSON value according to JCS (RFC 8785).
|
||||
*
|
||||
* JCS specifies:
|
||||
* - Object keys sorted lexicographically by UTF-16 code units
|
||||
* - No whitespace
|
||||
* - Numbers serialized using ES2015 Number serialization
|
||||
* - Strings serialized with minimal escaping per JSON spec
|
||||
*/
|
||||
export function jcsCanonicalise(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' || typeof value === 'number') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const elements = value.map((v) => jcsCanonicalise(v));
|
||||
return '[' + elements.join(',') + ']';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const pairs: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (obj[key] !== undefined) {
|
||||
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(obj[key]));
|
||||
}
|
||||
}
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the chain hash for an operation.
|
||||
*
|
||||
* chain_hash = SHA-256(prev_chain_hash + payload_hash + operation_id + issued_at)
|
||||
*
|
||||
* All inputs are concatenated as UTF-8 strings separated by '|'.
|
||||
*/
|
||||
export async function computeChainHash(
|
||||
prevChainHash: string,
|
||||
payloadHash: string,
|
||||
operationId: string,
|
||||
issuedAt: number,
|
||||
): Promise<string> {
|
||||
const input = `${prevChainHash}|${payloadHash}|${operationId}|${issuedAt}`;
|
||||
return sha256Base64url(input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receipt hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the hash of an EAR receipt for signing.
|
||||
*
|
||||
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
|
||||
*/
|
||||
export async function computeReceiptHash(receiptFields: {
|
||||
receipt_version: string;
|
||||
receipt_id: string;
|
||||
operation_id: string;
|
||||
org_id: string;
|
||||
agent_id: string;
|
||||
server_received_at: number;
|
||||
seq_no: number;
|
||||
chain_hash: string;
|
||||
queue_message_id: string;
|
||||
}): Promise<string> {
|
||||
const canonical = jcsCanonicalise(receiptFields);
|
||||
return sha256Base64url(canonical);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* SHA-256 Merkle tree implementation for epoch rollups.
|
||||
*
|
||||
* Leaf hashes are sorted lexicographically before tree construction.
|
||||
* Internal nodes are computed by concatenating the raw bytes (not base64url
|
||||
* strings) of their children and hashing the result with SHA-256.
|
||||
*/
|
||||
|
||||
import { sha256Base64url, base64urlDecode } from './crypto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MerkleTree {
|
||||
/** Base64url SHA-256 root hash */
|
||||
root: string;
|
||||
/** Sorted leaf hashes */
|
||||
leaves: string[];
|
||||
/** Operation IDs matching sorted leaves */
|
||||
leafOps: string[];
|
||||
/** All tree layers from leaves (index 0) to root (last index) */
|
||||
layers: string[][];
|
||||
}
|
||||
|
||||
export interface MerkleProof {
|
||||
/** The leaf hash being proved */
|
||||
leaf: string;
|
||||
/** Sibling hashes along the path to the root */
|
||||
siblings: string[];
|
||||
/** Direction of each sibling ('left' if sibling is on the left) */
|
||||
directions: ('left' | 'right')[];
|
||||
/** The expected root hash */
|
||||
root: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the parent hash from two child hashes.
|
||||
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
|
||||
*/
|
||||
async function hashPair(left: string, right: string): Promise<string> {
|
||||
const leftBytes = base64urlDecode(left);
|
||||
const rightBytes = base64urlDecode(right);
|
||||
const combined = new Uint8Array(leftBytes.length + rightBytes.length);
|
||||
combined.set(leftBytes);
|
||||
combined.set(rightBytes, leftBytes.length);
|
||||
return sha256Base64url(combined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a Merkle tree from leaf hashes and their corresponding operation IDs.
|
||||
*
|
||||
* Leaves are sorted lexicographically (with operationIds reordered to match).
|
||||
* If a layer has an odd number of nodes the last node is duplicated.
|
||||
*/
|
||||
export async function buildMerkleTree(
|
||||
leafHashes: string[],
|
||||
operationIds: string[],
|
||||
): Promise<MerkleTree> {
|
||||
// Pair up leaves with their operation IDs and sort by leaf hash
|
||||
const paired = leafHashes.map((hash, i) => ({
|
||||
hash,
|
||||
opId: operationIds[i]!,
|
||||
}));
|
||||
paired.sort((a, b) => (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0));
|
||||
|
||||
const sortedLeaves = paired.map((p) => p.hash);
|
||||
const sortedOps = paired.map((p) => p.opId);
|
||||
|
||||
const layers: string[][] = [sortedLeaves];
|
||||
|
||||
let currentLayer = sortedLeaves;
|
||||
while (currentLayer.length > 1) {
|
||||
const nextLayer: string[] = [];
|
||||
// If odd, duplicate the last node
|
||||
if (currentLayer.length % 2 !== 0) {
|
||||
currentLayer = [...currentLayer, currentLayer[currentLayer.length - 1]!];
|
||||
}
|
||||
for (let i = 0; i < currentLayer.length; i += 2) {
|
||||
nextLayer.push(await hashPair(currentLayer[i]!, currentLayer[i + 1]!));
|
||||
}
|
||||
layers.push(nextLayer);
|
||||
currentLayer = nextLayer;
|
||||
}
|
||||
|
||||
return {
|
||||
root: currentLayer[0]!,
|
||||
leaves: sortedLeaves,
|
||||
leafOps: sortedOps,
|
||||
layers,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a Merkle inclusion proof for a given leaf hash.
|
||||
*
|
||||
* Returns null if the leaf is not found in the tree.
|
||||
*/
|
||||
export function getMerkleProof(
|
||||
tree: MerkleTree,
|
||||
leafHash: string,
|
||||
): MerkleProof | null {
|
||||
let index = tree.leaves.indexOf(leafHash);
|
||||
if (index === -1) return null;
|
||||
|
||||
const siblings: string[] = [];
|
||||
const directions: ('left' | 'right')[] = [];
|
||||
|
||||
for (let layerIdx = 0; layerIdx < tree.layers.length - 1; layerIdx++) {
|
||||
let layer = tree.layers[layerIdx]!;
|
||||
// Duplicate last node if odd (mirrors build logic)
|
||||
if (layer.length % 2 !== 0) {
|
||||
layer = [...layer, layer[layer.length - 1]!];
|
||||
}
|
||||
const siblingIndex = index % 2 === 0 ? index + 1 : index - 1;
|
||||
siblings.push(layer[siblingIndex]!);
|
||||
directions.push(index % 2 === 0 ? 'right' : 'left');
|
||||
index = Math.floor(index / 2);
|
||||
}
|
||||
|
||||
return {
|
||||
leaf: leafHash,
|
||||
siblings,
|
||||
directions,
|
||||
root: tree.root,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verify a Merkle inclusion proof by recomputing the root.
|
||||
*/
|
||||
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
|
||||
let current = proof.leaf;
|
||||
|
||||
for (let i = 0; i < proof.siblings.length; i++) {
|
||||
const sibling = proof.siblings[i]!;
|
||||
const direction = proof.directions[i]!;
|
||||
if (direction === 'left') {
|
||||
current = await hashPair(sibling, current);
|
||||
} else {
|
||||
current = await hashPair(current, sibling);
|
||||
}
|
||||
}
|
||||
|
||||
return current === proof.root;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Cursor-based pagination helpers.
|
||||
*
|
||||
* Cursors are opaque base64url-encoded JSON objects containing the
|
||||
* created_at timestamp and primary key of the last item in the current
|
||||
* page. This design avoids OFFSET-based pagination which degrades at
|
||||
* scale and is susceptible to drift when rows are inserted concurrently.
|
||||
*/
|
||||
|
||||
import { base64urlDecode, base64urlEncode } from './crypto.js';
|
||||
|
||||
/** Shape of the decoded cursor payload. */
|
||||
export interface CursorPayload {
|
||||
/** created_at of the last item on the previous page */
|
||||
readonly created_at: number;
|
||||
/** Primary key of the last item on the previous page */
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a cursor payload into an opaque cursor string.
|
||||
*
|
||||
* @param payload - The cursor data to encode
|
||||
* @returns An opaque base64url-encoded cursor string
|
||||
*/
|
||||
export function encodeCursor(payload: CursorPayload): string {
|
||||
const json = JSON.stringify(payload);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
return base64urlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque cursor string back into its payload.
|
||||
*
|
||||
* @param cursor - The opaque cursor string
|
||||
* @returns The decoded cursor payload, or null if invalid
|
||||
*/
|
||||
export function decodeCursor(cursor: string): CursorPayload | null {
|
||||
try {
|
||||
const bytes = base64urlDecode(cursor);
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof parsed.created_at !== 'number' ||
|
||||
typeof parsed.id !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { created_at: parsed.created_at, id: parsed.id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Branded PDF generator for Elydora compliance exports.
|
||||
*
|
||||
* Produces professional multi-page PDF/1.4 documents with:
|
||||
* - Elydora logo and brand header bar
|
||||
* - Structured metadata section
|
||||
* - Formatted operations table with alternating row stripes
|
||||
* - Branded footer with page numbers
|
||||
*
|
||||
* Uses only built-in PDF Type1 fonts (Helvetica, Courier) —
|
||||
* no external dependencies, works entirely in the Node.js runtime.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PDFReportData {
|
||||
export_id: string;
|
||||
org_id: string;
|
||||
generated_at: string;
|
||||
time_range: string;
|
||||
agent_filter: string;
|
||||
type_filter: string;
|
||||
total: number;
|
||||
operations: Array<{
|
||||
operation_id: string;
|
||||
agent_id: string;
|
||||
operation_type: string;
|
||||
seq_no: number;
|
||||
chain_hash: string;
|
||||
payload_hash: string;
|
||||
created_at: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page layout constants (US Letter: 612 x 792 pt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PW = 612;
|
||||
const PH = 792;
|
||||
const ML = 56; // left margin
|
||||
const MR = 56; // right margin
|
||||
const CW = PW - ML - MR; // 500pt content width
|
||||
|
||||
// Header bar
|
||||
const HDR_H = 56;
|
||||
const HDR_Y = PH - HDR_H; // 736 — bottom edge of header
|
||||
|
||||
// Footer
|
||||
const FTR_LINE = 44; // y of separator line
|
||||
const FTR_TEXT = 28; // y of footer text baseline
|
||||
|
||||
// Content area
|
||||
const CT = HDR_Y - 20; // 716 — content top
|
||||
const CB = FTR_LINE + 12; // 56 — content bottom
|
||||
|
||||
// Operations table
|
||||
const ROW_H = 12; // row height
|
||||
const TBL_HDR = 22; // table header + separator + gap
|
||||
|
||||
// Metadata section (first page only)
|
||||
const META_H = 172;
|
||||
|
||||
// Table column x-positions
|
||||
const COL_SEQ = ML;
|
||||
const COL_TIME = ML + 38;
|
||||
const COL_OPID = ML + 142;
|
||||
const COL_AGENT = ML + 296;
|
||||
const COL_TYPE = ML + 398;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand colours (RGB 0‑1 scale)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INK = '0.071 0.071 0.071'; // #121212
|
||||
const BG = '0.918 0.918 0.898'; // #EAEAE5
|
||||
const DIM = '0.400 0.400 0.400'; // #666666
|
||||
const BORDER = '0.753 0.753 0.729'; // #C0C0BA
|
||||
const HDR_SUB = '0.620 0.620 0.600'; // subtitle on dark header
|
||||
const STRIPE = '0.955 0.955 0.942'; // subtle even-row background
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF drawing primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Escape special chars and strip non-ASCII for PDF text strings. */
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/[^\x20-\x7E]/g, '?')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
/** Truncate string with ".." suffix. */
|
||||
function trunc(s: string, max: number): string {
|
||||
return s.length <= max ? s : s.slice(0, max - 2) + '..';
|
||||
}
|
||||
|
||||
/** Filled rectangle. */
|
||||
function fillRect(x: number, y: number, w: number, h: number, rgb: string): string {
|
||||
return `${rgb} rg ${x} ${y} ${w} ${h} re f`;
|
||||
}
|
||||
|
||||
/** Horizontal line. */
|
||||
function hline(x1: number, y: number, x2: number, rgb: string, w = 0.5): string {
|
||||
return `${rgb} RG ${w} w ${x1} ${y} m ${x2} ${y} l S`;
|
||||
}
|
||||
|
||||
/** Text at absolute position. */
|
||||
function txt(s: string, x: number, y: number, font: string, size: number, rgb: string): string {
|
||||
return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Elydora "E" logo mark.
|
||||
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
|
||||
* Transform flips Y for PDF coordinate system.
|
||||
*/
|
||||
function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
||||
const sc = size / 24;
|
||||
return [
|
||||
'q',
|
||||
`${sc} 0 0 ${-sc} ${x} ${y + size} cm`,
|
||||
`${rgb} rg`,
|
||||
'3 2 m 21 2 l 21 7 l 8 7 l 8 10 l 17 10 l 17 15 l 8 15 l 8 18 l 21 18 l 21 23 l 3 23 l h f',
|
||||
'Q',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section renderers — each appends drawing instructions to the page array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dark header bar with logo and brand text (every page). */
|
||||
function renderHeader(p: string[]): void {
|
||||
// Full-width dark bar
|
||||
p.push(fillRect(0, HDR_Y, PW, HDR_H, INK));
|
||||
// Thin accent line at bottom edge
|
||||
p.push(hline(0, HDR_Y, PW, BG, 1.5));
|
||||
|
||||
// Logo mark
|
||||
const sz = 30;
|
||||
const lx = ML;
|
||||
const ly = HDR_Y + (HDR_H - sz) / 2; // vertically centred
|
||||
p.push(drawLogo(lx, ly, sz, BG));
|
||||
|
||||
// Brand name
|
||||
const tx = lx + sz + 12;
|
||||
p.push(txt('ELYDORA', tx, ly + sz - 16, '/F1', 18, BG));
|
||||
// Subtitle
|
||||
p.push(txt('COMPLIANCE EXPORT REPORT', tx, ly + 2, '/F2', 8, HDR_SUB));
|
||||
}
|
||||
|
||||
/** Footer with confidentiality note and page number (every page). */
|
||||
function renderFooter(p: string[], pageNum: number, totalPages: number, exportId: string): void {
|
||||
p.push(hline(ML, FTR_LINE, PW - MR, BORDER, 0.5));
|
||||
p.push(txt('CONFIDENTIAL', ML, FTR_TEXT, '/F2', 7, DIM));
|
||||
p.push(txt(`Export ${exportId.slice(0, 8)}`, PW / 2 - 28, FTR_TEXT, '/F3', 7, DIM));
|
||||
p.push(txt(`Page ${pageNum} of ${totalPages}`, PW - MR - 56, FTR_TEXT, '/F2', 7, DIM));
|
||||
}
|
||||
|
||||
/** Metadata key-value section (first page only). Returns new y position. */
|
||||
function renderMetadata(p: string[], data: PDFReportData): number {
|
||||
let y = CT;
|
||||
|
||||
// Section title
|
||||
p.push(txt('EXPORT DETAILS', ML, y, '/F1', 10, INK));
|
||||
y -= 6;
|
||||
p.push(hline(ML, y, PW - MR, BORDER, 0.5));
|
||||
y -= 18;
|
||||
|
||||
// Key-value rows
|
||||
const valX = ML + 112;
|
||||
const fields: [string, string][] = [
|
||||
['Export ID', data.export_id],
|
||||
['Organization', data.org_id],
|
||||
['Generated', data.generated_at],
|
||||
['Time Range', data.time_range],
|
||||
['Agent Filter', data.agent_filter],
|
||||
['Type Filter', data.type_filter],
|
||||
['Total Records', String(data.total)],
|
||||
];
|
||||
for (const [label, value] of fields) {
|
||||
p.push(txt(label, ML, y, '/F2', 8.5, DIM));
|
||||
p.push(txt(value, valX, y, '/F3', 8.5, INK));
|
||||
y -= 16;
|
||||
}
|
||||
|
||||
y -= 8;
|
||||
|
||||
// Operations section title
|
||||
p.push(txt('OPERATIONS', ML, y, '/F1', 10, INK));
|
||||
y -= 6;
|
||||
p.push(hline(ML, y, PW - MR, BORDER, 0.5));
|
||||
y -= 4;
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
/** Table column headers. Returns y of first data row. */
|
||||
function renderTableHeader(p: string[], y: number): number {
|
||||
const hy = y - 14;
|
||||
p.push(txt('#', COL_SEQ, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('TIMESTAMP', COL_TIME, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('OPERATION ID', COL_OPID, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('AGENT', COL_AGENT, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('TYPE', COL_TYPE, hy, '/F1', 7.5, DIM));
|
||||
|
||||
const sy = hy - 5;
|
||||
p.push(hline(ML, sy, PW - MR, BORDER, 0.3));
|
||||
|
||||
return sy - 3;
|
||||
}
|
||||
|
||||
/** Single operation data row. */
|
||||
function renderRow(
|
||||
p: string[],
|
||||
y: number,
|
||||
op: PDFReportData['operations'][number],
|
||||
index: number,
|
||||
): void {
|
||||
// Alternating stripe background
|
||||
if (index % 2 === 0) {
|
||||
p.push(fillRect(ML - 4, y - 3, CW + 8, ROW_H, STRIPE));
|
||||
}
|
||||
|
||||
const ts = new Date(op.created_at).toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
p.push(txt(String(op.seq_no).padStart(4), COL_SEQ, y, '/F3', 8, DIM));
|
||||
p.push(txt(ts, COL_TIME, y, '/F3', 8, INK));
|
||||
p.push(txt(trunc(op.operation_id, 30), COL_OPID, y, '/F3', 8, INK));
|
||||
p.push(txt(trunc(op.agent_id, 18), COL_AGENT, y, '/F3', 8, INK));
|
||||
p.push(txt(trunc(op.operation_type, 20), COL_TYPE, y, '/F3', 8, INK));
|
||||
}
|
||||
|
||||
/** End-of-report note (last page, if space allows). */
|
||||
function renderEndNote(p: string[], y: number): void {
|
||||
y -= 20;
|
||||
p.push(hline(ML, y, PW - MR, BORDER, 0.3));
|
||||
y -= 18;
|
||||
p.push(txt('End of Report', ML, y, '/F1', 9, DIM));
|
||||
y -= 14;
|
||||
p.push(txt(
|
||||
'Full cryptographic chain and payload hashes are available in JSON export format.',
|
||||
ML, y, '/F2', 7.5, DIM,
|
||||
));
|
||||
y -= 12;
|
||||
p.push(txt(
|
||||
'Elydora - The Responsibility Layer for AI Agents',
|
||||
ML, y, '/F2', 7.5, DIM,
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildBrandedPDF(data: PDFReportData): Uint8Array {
|
||||
const ops = data.operations;
|
||||
const totalOps = ops.length;
|
||||
|
||||
// Calculate pagination
|
||||
const firstPageArea = CT - META_H - CB; // ~488pt
|
||||
const opsFirstPage = Math.max(0, Math.floor((firstPageArea - TBL_HDR) / ROW_H));
|
||||
const fullPageArea = CT - CB; // ~660pt
|
||||
const opsPerPage = Math.floor((fullPageArea - TBL_HDR) / ROW_H);
|
||||
|
||||
const totalPages = totalOps <= opsFirstPage
|
||||
? 1
|
||||
: 1 + Math.ceil((totalOps - opsFirstPage) / Math.max(1, opsPerPage));
|
||||
|
||||
// Build content streams (one per page)
|
||||
const streams: string[] = [];
|
||||
let opIdx = 0;
|
||||
|
||||
for (let pg = 0; pg < totalPages; pg++) {
|
||||
const p: string[] = [];
|
||||
|
||||
renderHeader(p);
|
||||
|
||||
let y: number;
|
||||
if (pg === 0) {
|
||||
y = renderMetadata(p, data);
|
||||
} else {
|
||||
y = CT;
|
||||
}
|
||||
|
||||
y = renderTableHeader(p, y);
|
||||
|
||||
const maxOps = pg === 0 ? opsFirstPage : opsPerPage;
|
||||
const endIdx = Math.min(opIdx + maxOps, totalOps);
|
||||
|
||||
while (opIdx < endIdx) {
|
||||
renderRow(p, y, ops[opIdx]!, opIdx);
|
||||
y -= ROW_H;
|
||||
opIdx++;
|
||||
}
|
||||
|
||||
// End note on last page if enough space
|
||||
if (pg === totalPages - 1 && y > CB + 70) {
|
||||
renderEndNote(p, y);
|
||||
}
|
||||
|
||||
renderFooter(p, pg + 1, totalPages, data.export_id);
|
||||
streams.push(p.join('\n'));
|
||||
}
|
||||
|
||||
return assemblePDF(streams);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF document assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assemblePDF(pageStreams: string[]): Uint8Array {
|
||||
const objContents: string[] = [];
|
||||
|
||||
// Obj 1: Catalog
|
||||
objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
|
||||
|
||||
// Obj 2: Pages (placeholder — patched after page objects are created)
|
||||
objContents.push('');
|
||||
|
||||
// Obj 3–5: Built-in fonts
|
||||
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>'); // /F1
|
||||
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); // /F2
|
||||
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>'); // /F3
|
||||
|
||||
const fontRes = '/F1 3 0 R /F2 4 0 R /F3 5 0 R';
|
||||
|
||||
// Content stream + page object for each page
|
||||
const pageObjNums: number[] = [];
|
||||
|
||||
for (const stream of pageStreams) {
|
||||
const csNum = objContents.length + 1;
|
||||
objContents.push(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
|
||||
|
||||
const pgNum = objContents.length + 1;
|
||||
objContents.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PW} ${PH}] ` +
|
||||
`/Contents ${csNum} 0 R /Resources << /Font << ${fontRes} >> >> >>`,
|
||||
);
|
||||
pageObjNums.push(pgNum);
|
||||
}
|
||||
|
||||
// Patch Pages object
|
||||
const kids = pageObjNums.map((n) => `${n} 0 R`).join(' ');
|
||||
objContents[1] = `<< /Type /Pages /Kids [${kids}] /Count ${pageStreams.length} >>`;
|
||||
|
||||
// Assemble byte stream
|
||||
const header = '%PDF-1.4\n';
|
||||
const parts: string[] = [header];
|
||||
const offsets: number[] = [];
|
||||
let pos = header.length;
|
||||
|
||||
for (let i = 0; i < objContents.length; i++) {
|
||||
offsets.push(pos);
|
||||
const obj = `${i + 1} 0 obj\n${objContents[i]}\nendobj\n`;
|
||||
parts.push(obj);
|
||||
pos += obj.length;
|
||||
}
|
||||
|
||||
// Cross-reference table
|
||||
const xrefPos = pos;
|
||||
const numEntries = objContents.length + 1;
|
||||
parts.push(`xref\n0 ${numEntries}\n`);
|
||||
parts.push('0000000000 65535 f \n');
|
||||
for (const off of offsets) {
|
||||
parts.push(`${String(off).padStart(10, '0')} 00000 n \n`);
|
||||
}
|
||||
|
||||
// Trailer
|
||||
parts.push(
|
||||
`trailer\n<< /Size ${numEntries} /Root 1 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`,
|
||||
);
|
||||
|
||||
return new TextEncoder().encode(parts.join(''));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* RFC 3161 Timestamping Authority (TSA) client utilities.
|
||||
*
|
||||
* Constructs a minimal DER-encoded TimeStampReq and submits it to a
|
||||
* public TSA endpoint. No ASN.1 library required — the 59-byte request
|
||||
* is hand-assembled using the same DER-prefix pattern as the PKCS8
|
||||
* wrapper in crypto.ts.
|
||||
*/
|
||||
|
||||
/** Default public TSA endpoint (Sectigo / Comodo). */
|
||||
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
|
||||
|
||||
/**
|
||||
* Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes).
|
||||
*
|
||||
* Structure:
|
||||
* SEQUENCE {
|
||||
* INTEGER 1 -- version
|
||||
* SEQUENCE { -- messageImprint
|
||||
* SEQUENCE { -- hashAlgorithm (SHA-256)
|
||||
* OID 2.16.840.1.101.3.4.2.1
|
||||
* NULL
|
||||
* }
|
||||
* OCTET STRING <32 bytes> -- hashedMessage
|
||||
* }
|
||||
* BOOLEAN TRUE -- certReq
|
||||
* }
|
||||
*/
|
||||
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||
if (sha256Hash.length !== 32) {
|
||||
throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`);
|
||||
}
|
||||
|
||||
// DER prefix (24 bytes) — everything before the 32-byte hash
|
||||
const prefix = new Uint8Array([
|
||||
0x30, 0x39, // SEQUENCE (57 bytes)
|
||||
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
|
||||
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint
|
||||
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier
|
||||
0x06, 0x09, // OID (9 bytes)
|
||||
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1)
|
||||
0x05, 0x00, // NULL (parameters)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||
]);
|
||||
|
||||
// DER suffix (3 bytes) — certReq = TRUE
|
||||
const suffix = new Uint8Array([0x01, 0x01, 0xff]);
|
||||
|
||||
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
|
||||
const req = new Uint8Array(prefix.length + sha256Hash.length + suffix.length);
|
||||
req.set(prefix);
|
||||
req.set(sha256Hash, prefix.length);
|
||||
req.set(suffix, prefix.length + sha256Hash.length);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp.
|
||||
*
|
||||
* @param sha256Hash - 32-byte SHA-256 digest to timestamp
|
||||
* @param tsaUrl - TSA endpoint (defaults to Sectigo)
|
||||
* @returns Raw DER-encoded TimeStampResp bytes
|
||||
*/
|
||||
export async function requestTimestamp(
|
||||
sha256Hash: Uint8Array,
|
||||
tsaUrl: string = DEFAULT_TSA_URL,
|
||||
): Promise<Uint8Array> {
|
||||
const body = buildTimeStampReq(sha256Hash);
|
||||
|
||||
const response = await fetch(tsaUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/timestamp-query',
|
||||
},
|
||||
body: body as unknown as BodyInit,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`TSA request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
if (!contentType.includes('application/timestamp-reply')) {
|
||||
throw new Error(`Unexpected TSA response content-type: ${contentType}`);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* UUIDv7 generator conforming to RFC 9562.
|
||||
*
|
||||
* UUIDv7 embeds a Unix-millisecond timestamp in the most-significant 48 bits,
|
||||
* making identifiers lexicographically sortable and eliminating the need for
|
||||
* a separate created_at column in many cases.
|
||||
*
|
||||
* This implementation uses the Web Crypto API (crypto.getRandomValues) which
|
||||
* is available natively in Node.js 20+.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a UUIDv7 string.
|
||||
*
|
||||
* Layout (128 bits total):
|
||||
* bits 0-47 : Unix timestamp in milliseconds
|
||||
* bits 48-51 : version (0b0111 = 7)
|
||||
* bits 52-63 : random_a (12 bits)
|
||||
* bits 64-65 : variant (0b10)
|
||||
* bits 66-127: random_b (62 bits)
|
||||
*/
|
||||
export function generateUUIDv7(): string {
|
||||
const now = Date.now();
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
// Fill with random bytes first
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Encode 48-bit timestamp (big-endian) into bytes 0..5
|
||||
const high32 = Math.floor(now / 0x10000); // upper 32 bits of the 48-bit ts
|
||||
const low16 = now & 0xffff; // lower 16 bits of the 48-bit ts
|
||||
|
||||
bytes[0] = (high32 >>> 24) & 0xff;
|
||||
bytes[1] = (high32 >>> 16) & 0xff;
|
||||
bytes[2] = (high32 >>> 8) & 0xff;
|
||||
bytes[3] = high32 & 0xff;
|
||||
bytes[4] = (low16 >>> 8) & 0xff;
|
||||
bytes[5] = low16 & 0xff;
|
||||
|
||||
// Set version 7 (bits 48-51)
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x70;
|
||||
|
||||
// Set variant 10xx (bits 64-65)
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
|
||||
return formatUUID(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a 16-byte array as a standard UUID string (8-4-4-4-12).
|
||||
*/
|
||||
function formatUUID(bytes: Uint8Array): string {
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return (
|
||||
hex.slice(0, 8) +
|
||||
'-' +
|
||||
hex.slice(8, 12) +
|
||||
'-' +
|
||||
hex.slice(12, 16) +
|
||||
'-' +
|
||||
hex.slice(16, 20) +
|
||||
'-' +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user