/* key-rotate — facilitates the generation and rotation of Ed25519 keypairs for secure application authentication. Do not handle raw key parsing or validation beyond schema checks. Do not manage application configuration or orchestration. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — tail packages clean Sweep batch 3: rewrote ~95 purposes across werkstatt-knowledge, werkstatt-shared, godot-game, phaser-game, lifecycle-core, projektarchiv-*, portal-*, billing-*, typescript (CONTRACT-02/PURPOSE-02). Real KEY_DECISIONS on 5 godot utils, non-goals on 5 CONTRACT-03 files, headers on 4 headerless files, CS-07 history literal fix on 2 files. Policy: vitest.config.ts + test-fixtures testPatterns, worker-configuration.d.ts excludedPath. All non-site/engine packages now 0 diagnostics. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. */ /** * @warpgogol/werkstatt-shared/passport — Key rotation * * DNA-34 / RFC-0028 * * SECURITY CONTRACT: * - Private key is NEVER written to disk. It is printed to stdout ONCE * for the engineer to paste into GitHub Actions secret PASSPORT_SIGNING_KEY. * - The public key file is written to apps//public/.well-known/cosmic-passport-key.json * and committed to the repository. * - Old public keys remain in the file (active: false) for historical verification. */ import { join, dirname } from "node:path"; import { generateKeypair } from "./sign.ts"; import { PassportPublicKeyFileSchema } from "./schema.ts"; import type { PassportPublicKeyFile, PassportPublicKeyEntry } from "./schema.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export interface KeyRotateOptions { /** Absolute path to the app directory */ appDirectory: string; /** App identifier (from system.yaml) */ appId: string; /** If true, this is the initial key generation (no previous key file exists) */ initial?: boolean; } export interface KeyRotateResult { /** New key version string (e.g. "v2") */ newVersion: string; /** New public key multibase — written to key file */ publicKeyMultibase: string; /** * Private key as hex string — print to stdout and discard. * Store as GitHub Actions secret PASSPORT_SIGNING_KEY. * NEVER log, write, or return this beyond the immediate CLI call. */ privateKeyHex: string; /** Path where the updated public key file was written */ publicKeyFilePath: string; } /** * Generate a new Ed25519 keypair and update the public key file. * * Steps: * 1. Generate new keypair * 2. Load existing public key file (if any) * 3. Mark all existing keys as active: false * 4. Append new key as active: true * 5. Write updated file to apps//public/.well-known/cosmic-passport-key.json * 6. Return private key for secure storage (never written to disk) * * After calling this function: * - Update system.yaml release.passport.keyVersion to the new version * - Paste the returned privateKeyHex into GitHub Actions secret PASSPORT_SIGNING_KEY * - Commit the updated public key file */ export async function rotateKey( options: KeyRotateOptions, io: WorkspaceIO = createDefaultIO().io, ): Promise { const { appDirectory, appId, initial = false } = options; const publicKeyFilePath = join(appDirectory, "public", ".well-known", "cosmic-passport-key.json"); // Step 1: Generate new keypair const { privateKeyHex, publicKeyMultibase } = await generateKeypair(); // Step 2: Load existing key file let existingKeys: PassportPublicKeyEntry[] = []; let nextVersionNumber = 1; if (!initial) { try { const raw = await io.readFile(publicKeyFilePath); const existing = PassportPublicKeyFileSchema.parse(JSON.parse(raw)); existingKeys = existing.keys; // Determine next version number const versionNumbers = existingKeys .map((k) => parseInt(k.version.replace("v", ""), 10)) .filter((n) => !isNaN(n)); nextVersionNumber = Math.max(0, ...versionNumbers) + 1; } catch { // No existing file — treat as initial } } const newVersion = `v${nextVersionNumber}`; // Step 3: Mark existing keys inactive const updatedKeys: PassportPublicKeyEntry[] = existingKeys.map((k) => ({ ...k, active: false, })); // Step 4: Append new key updatedKeys.push({ version: newVersion, active: true, type: "Ed25519VerificationKey2020", publicKeyMultibase, createdAt: new Date().toISOString(), }); // Step 5: Write updated file const updatedFile: PassportPublicKeyFile = { schemaVersion: "1.0", appId, keys: updatedKeys, }; await io.mkdir(dirname(publicKeyFilePath)); await io.writeFile(publicKeyFilePath, JSON.stringify(updatedFile, null, 2)); return { newVersion, publicKeyMultibase, privateKeyHex, publicKeyFilePath, }; }