From 79f128ca28da6020d460883b2239c340138feb26 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 11:37:22 +0200 Subject: [PATCH 1/6] feat: --provision-keys flag for owner key on pod creation (#437 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `--provision-keys` flag (and `provisionKeys: true` body field for POST /.pods) that, on pod creation, generates a Schnorr secp256k1 keypair and writes it as a W3C CID v1.0 Multikey document to /private/privkey.jsonld. Off by default — keys-on-disk is a security tradeoff and we want operators to opt in deliberately. The single key is intended to serve as: a Solid signing identity, a Nostr identity (same curve), and a did:nostr DID controller (Phase 2). One CLI flag → pod-resident self-sovereign identity ready for both Solid and Nostr / agentic use cases. Phase 1 scope (per #437 design resolutions): - Encoding: f-form Multikey (multibase `f` + multicodec varint + payload, base16-lower) — matches src/auth/nostr-keys.js's existing decoder, no new deps. Round-trips through decodeFFormSecp256k1. - Controller: pod owner WebID. Phase 2 will swap in did:nostr once the resolver lands; the document is self-consistent at every phase. - File mode: 0o600 on POSIX. WAC restricts HTTP access; the file mode blocks a separate exposure class (other unix users on the box, backups that don't preserve permissions). Defence-in-depth, not a replacement for FDE / OS keyring. - No `nostr` extension block (no nsec/npub). Bech32 npub is a one-line derivation in any Nostr-aware tool; including it would double the secret-on-disk surface for log/error/dump leaks. - Opt-in: defaulting on would silently ship a plaintext secret on every `jss start --single-user`. Visible choice required. - HTTP response surfaces `ownerKey: { keyDocument, publicKeyMultibase }` on success — never the secret. Single-user banner logs the same and a prominent BACK UP warning. Files: - src/keys/provision.js — generator + Multikey serializer + the decideRevealForRegisterStatus-style pure helpers for unit testing. - src/handlers/container.js — createPodStructure accepts { provisionKeys: true }; handleCreatePod reads provisionKeys body field; response includes the key info when set. - src/server.js — createRootPodStructure accepts the option via the new provisionKeysEnabled local; single-user onReady captures the key result and logs the public side + backup warning. - src/storage/filesystem.js — write() takes an optional { mode } so callers can request 0o600 without a separate chmod call. Backward compatible (every existing caller passes nothing, mode no-ops). - src/config.js — defaults.provisionKeys, BOOLEAN_KEYS membership, JSS_PROVISION_KEYS env var mapping. - bin/jss.js — --provision-keys / --no-provision-keys CLI options, threaded through to createServer. - docs/provision-keys.md — usage, file format, threat model with the filesystem-protection table, credible-exit framing for the upgrade path, backup instructions. - test/keys-provision.test.js — 13 unit tests: keypair generation (sign/verify roundtrip, uniqueness), encoder fixed vectors, decoder round-trip via src/auth/nostr-keys, JSON-LD shape, no nostr block, error cases. - test/keys-provision-integration.test.js — 9 integration tests: POST /.pods writes the file + Multikey shape, file mode is 0o600 on POSIX, WAC blocks unauthenticated reads + serves authenticated owner, opt-in respected (no flag, false, string "true"), direct createPodStructure call returns ownerKey only when option set. 840/840 tests pass. Out of scope (deferred to later phases per #437): - did:nostr controller (Phase 2) - WebID profile auto-population with the public key (Phase 2) - --key-passphrase / scrypt+aesgcm wrap (Phase 3) - BIP-39 seed phrase derivation (Phase 3) - Hardware wallets / KMS / encrypted-at-rest (Phase 4) --- bin/jss.js | 3 + docs/provision-keys.md | 137 ++++++++++++++++++ src/config.js | 8 + src/handlers/container.js | 53 ++++++- src/keys/provision.js | 128 ++++++++++++++++ src/server.js | 50 ++++++- src/storage/filesystem.js | 16 +- test/keys-provision-integration.test.js | 185 ++++++++++++++++++++++++ test/keys-provision.test.js | 141 ++++++++++++++++++ 9 files changed, 711 insertions(+), 10 deletions(-) create mode 100644 docs/provision-keys.md create mode 100644 src/keys/provision.js create mode 100644 test/keys-provision-integration.test.js create mode 100644 test/keys-provision.test.js diff --git a/bin/jss.js b/bin/jss.js index 48134651..15fc31a8 100755 --- a/bin/jss.js +++ b/bin/jss.js @@ -95,6 +95,8 @@ program .option('--no-notifications', 'Disable WebSocket notifications') .option('--idp', 'Enable built-in Identity Provider') .option('--no-idp', 'Disable built-in Identity Provider') + .option('--provision-keys', 'Generate a Schnorr secp256k1 owner key on pod creation, written to /private/privkey.jsonld in W3C CID v1.0 Multikey format (off by default)') + .option('--no-provision-keys', 'Do not auto-generate an owner key on pod creation') .option('--idp-issuer ', 'IdP issuer URL (defaults to server URL)') .option('--subdomains', 'Enable subdomain-based pods (XSS protection)') .option('--no-subdomains', 'Disable subdomain-based pods') @@ -218,6 +220,7 @@ program singleUser: config.singleUser, singleUserName: config.singleUserName, singleUserPassword: config.singleUserPassword, + provisionKeys: config.provisionKeys, public: config.public, readOnly: config.readOnly, liveReload: config.liveReload, diff --git a/docs/provision-keys.md b/docs/provision-keys.md new file mode 100644 index 00000000..a6827e0a --- /dev/null +++ b/docs/provision-keys.md @@ -0,0 +1,137 @@ +# Owner-key provisioning (`--provision-keys`) + +Phase 1 of [#427 / #437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437). Generates a Schnorr secp256k1 keypair on pod creation and writes it as a W3C [Controlled Identifiers v1.0 Multikey](https://www.w3.org/TR/cid-1.0/) document at `/private/privkey.jsonld`. + +The same key is intended to serve, over time, as: a Solid signing identity, a Nostr identity (same curve), and a `did:nostr:` DID controller (Phase 2). One CLI flag → pod-resident self-sovereign identity ready for both Solid and Nostr / agentic use cases. + +## Usage + +### Single-user mode (CLI flag) + +```bash +jss start --single-user --provision-keys +``` + +On first start the server creates the pod and writes the key file. Subsequent starts re-use the existing pod and don't touch the key (skip-if-exists semantics, like the rest of the seeded structure). + +### Multi-user mode (HTTP body field) + +```bash +curl -X POST http://localhost:4444/.pods \ + -H "Content-Type: application/json" \ + -d '{ "name": "alice", "provisionKeys": true }' +``` + +Response includes the public side of the key under `ownerKey`: + +```json +{ + "name": "alice", + "webId": "http://localhost:4444/alice/profile/card.jsonld#me", + "podUri": "http://localhost:4444/alice/", + "token": "…", + "ownerKey": { + "keyDocument": "http://localhost:4444/alice/private/privkey.jsonld", + "publicKeyMultibase": "fe70102…" + } +} +``` + +The secret is **never** echoed in the response — it lives only at the `keyDocument` path on disk, behind the pod's owner-only WAC. + +### Environment variable + +```bash +JSS_PROVISION_KEYS=true jss start --single-user +``` + +Same effect as the CLI flag; useful in containerised deployments. + +## What gets written + +`/private/privkey.jsonld`: + +```json +{ + "@context": "https://www.w3.org/ns/cid/v1", + "type": "Multikey", + "controller": "https://your.example/profile/card.jsonld#me", + "publicKeyMultibase": "fe7010287a1b…", + "secretKeyMultibase": "f81260a1b2c…" +} +``` + +- **`@context` + `type`** — W3C CID v1.0 conformant Multikey document. CID-aware tools recognise the shape natively. +- **`controller`** — In Phase 1 this is the pod owner's WebID. Phase 2 will swap in a `did:nostr:` controller once the resolver lands; the document is self-consistent at every phase. +- **`publicKeyMultibase`** — f-form (multibase `f` + multicodec `e701` for secp256k1-pub + parity byte + 32-byte x-only pubkey, all base16-lower). Round-trips through jss's existing `decodeFFormSecp256k1` decoder. +- **`secretKeyMultibase`** — f-form (multibase `f` + multicodec `8126` for secp256k1-priv + 32-byte secret scalar). The secret IS this scalar; no parity byte. + +The file mode is set to `0o600` on POSIX. WAC restricts HTTP access to the pod owner via `/private/.acl` (the standard private-folder ACL JSS writes anyway). + +## Threat model and protection + +The web user (pod owner) reads the key over HTTP, authenticated against their WebID. WAC checks the request and serves the file: + +```bash +curl -H "Authorization: Bearer " \ + http://localhost:4444/alice/private/privkey.jsonld +``` + +That path is fully protected by ACL. **What WAC cannot protect against is filesystem access.** Anyone with read on the data directory bypasses WAC entirely: + +- `root` on the host +- Container layer dumps and exfiltrated images +- Backup tapes / snapshots that copy file contents +- Other unix users on a shared box (mitigated by the `0o600` mode) + +For any pod that matters, layer in **filesystem-level protection**: + +| Mechanism | What it adds | +|---|---| +| Full-disk encryption (LUKS / FileVault / BitLocker) | Protects offline copies of the disk | +| Encrypted volume / per-directory encryption | Protects against host-level snapshots | +| OS keyring (gnome-keyring, macOS Keychain) | Out-of-process secret storage; bigger lift | +| Restrictive `umask` + container user namespacing | Stops other users on a shared host | +| Filesystem ACLs (`setfacl`) | Fine-grained per-user limits | + +Phase 4 of [#437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437) will add encrypted-at-rest and hardware-wallet support. Until then, treat the file as a credential and protect it accordingly. + +## Credible exit + +Phase 1 ships the simplest workable model: plaintext key on disk, owner-only WAC, `0o600`. This is intentional — it lets you start now and migrate later without losing the underlying identity: + +| Phase | What changes | What stays | +|---|---|---| +| 1 (now) | plaintext on disk, WAC + 0o600 | the keypair, the WebID | +| 3 | optional `--key-passphrase` wraps the secret with scrypt+aesgcm | the keypair | +| 4 | hardware wallet / KMS / encrypted at rest | the keypair | + +Each upgrade is a wrapper around the same secret material. You're not picking a final answer when you turn `--provision-keys` on — you're picking a starting point that can move with your security posture. ActivityPub took the same path: instances managed keys themselves at first, then later layered hardware-backed signing on top once the ecosystem matured. + +## Backup + +This is not optional. + +```bash +# Authenticate as owner via HTTP (preferred — works even on a remote host) +curl -H "Authorization: Bearer " \ + http://your.example/private/privkey.jsonld \ + -o pod-key-backup.jsonld +chmod 600 pod-key-backup.jsonld + +# Or copy from disk if you have local access (preserves perms) +cp -p /path/to/data/private/privkey.jsonld pod-key-backup.jsonld +``` + +Store the backup somewhere that survives the pod's host: another machine, encrypted cloud storage, a hardware token, a sealed envelope in a safe — whatever you'd use for an SSH key you actually care about. Losing this file means losing this identity permanently. There is no recovery flow in Phase 1. + +## Why opt-in by default + +Keys-on-disk is a real security tradeoff. Defaulting `--provision-keys` on would mean every `jss start --single-user` ships a plaintext secret to disk silently — a bad surprise for an operator who only meant to spin up a server. Opt-in keeps the choice visible. (See [#437 design resolution #6](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437).) + +## See also + +- [#437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437) — the umbrella issue and design resolutions +- [W3C Controlled Identifiers v1.0 (REC)](https://www.w3.org/TR/cid-1.0/) +- [`src/auth/nostr-keys.js`](../src/auth/nostr-keys.js) — the existing f-form Multikey decoder this code aligns with +- [BIP-340 (Schnorr signatures over secp256k1)](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) diff --git a/src/config.js b/src/config.js index a0d2225d..d2dd298f 100644 --- a/src/config.js +++ b/src/config.js @@ -92,6 +92,12 @@ export const defaults = { // is not yet loggable until a password is set). singleUserPassword: null, + // Provision a Schnorr secp256k1 owner key on pod creation, written + // to /private/privkey.jsonld in W3C CID v1.0 Multikey format + // (Phase 1 of #437). Off by default — keys-on-disk is a security + // tradeoff and we want operators to opt in deliberately. + provisionKeys: false, + // WebID-TLS client certificate authentication webidTls: false, @@ -175,6 +181,7 @@ const envMap = { JSS_SINGLE_USER: 'singleUser', JSS_SINGLE_USER_NAME: 'singleUserName', JSS_SINGLE_USER_PASSWORD: 'singleUserPassword', + JSS_PROVISION_KEYS: 'provisionKeys', JSS_WEBID_TLS: 'webidTls', JSS_DEFAULT_QUOTA: 'defaultQuota', JSS_PUBLIC: 'public', @@ -228,6 +235,7 @@ const BOOLEAN_KEYS = new Set([ 'inviteOnly', 'multiuser', 'singleUser', + 'provisionKeys', 'webidTls', 'public', 'readOnly', diff --git a/src/handlers/container.js b/src/handlers/container.js index 698ad8e0..b1829480 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -4,6 +4,7 @@ import { getAllHeaders } from '../ldp/headers.js'; import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js'; import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js'; import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } from '../wac/parser.js'; +import { provisionOwnerKey } from '../keys/provision.js'; import { createToken } from '../auth/token.js'; import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js'; import { emitChange } from '../notifications/events.js'; @@ -165,8 +166,19 @@ export async function handlePost(request, reply) { * @param {string} podUri - Pod root URI (e.g., https://alice.example.com/ or https://example.com/alice/) * @param {string} issuer - OIDC issuer URI * @param {number} defaultQuota - Default storage quota in bytes (optional) + * @param {object} [options] + * @param {boolean} [options.provisionKeys=false] - When true, generate a + * Schnorr secp256k1 keypair and write it to `/private/privkey.jsonld` + * in W3C CID v1.0 Multikey format. Phase 1 of #437. The secret lands on + * disk in plaintext under owner-only WAC + file mode 0600 — operators + * should add filesystem-level protection (FDE / OS keyring) for any pod + * that matters. + * @returns {Promise<{ podPath, podUri, ownerKey?: { document, publicHex, secretHex, publicMultibase } }>} + * When `provisionKeys` is true, the return value includes the freshly + * minted key material so the caller can surface the public side in CLI + * output (the secret should NOT be displayed or logged). */ -export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0) { +export async function createPodStructure(name, webId, podUri, issuer, defaultQuota = 0, options = {}) { const podPath = `/${name}/`; // Create pod directory structure @@ -236,7 +248,19 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo await initializeQuota(name, defaultQuota); } - return { podPath, podUri }; + // Optional: provision a Schnorr secp256k1 owner key in /private/. + // Phase 1 of #437. See src/keys/provision.js for the design notes. + let ownerKey; + if (options.provisionKeys) { + ownerKey = provisionOwnerKey({ controllerWebId: webId }); + await storage.write( + `${podPath}private/privkey.jsonld`, + JSON.stringify(ownerKey.document, null, 2), + { mode: 0o600 } + ); + } + + return { podPath, podUri, ownerKey }; } /** @@ -260,7 +284,7 @@ export async function handleCreatePod(request, reply) { return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' }); } - const { name, email, password } = request.body || {}; + const { name, email, password, provisionKeys } = request.body || {}; const idpEnabled = request.idpEnabled; if (!name || typeof name !== 'string') { @@ -310,9 +334,14 @@ export async function handleCreatePod(request, reply) { // Issuer needs trailing slash for CTH compatibility const issuer = baseUri + '/'; + let podCreation; try { - // Use shared pod creation function - await createPodStructure(name, webId, podUri, issuer); + // Use shared pod creation function. Coerce provisionKeys to a + // strict boolean so a JSON `null` / missing value defaults to off. + podCreation = await createPodStructure( + name, webId, podUri, issuer, 0, + { provisionKeys: provisionKeys === true } + ); } catch (err) { console.error('Pod creation error:', err); // Cleanup on failure @@ -326,6 +355,16 @@ export async function handleCreatePod(request, reply) { Object.entries(headers).forEach(([k, v]) => reply.header(k, v)); + // Surface a summary of any provisioned key so callers can display + // the public side. The secret is NEVER echoed in the response — + // it lives only on disk under the pod's owner-only ACL. + const keyInfo = podCreation?.ownerKey + ? { + keyDocument: `${podUri}private/privkey.jsonld`, + publicKeyMultibase: podCreation.ownerKey.publicMultibase + } + : null; + // If IdP is enabled, create account and return token + login URL if (idpEnabled) { try { @@ -340,6 +379,7 @@ export async function handleCreatePod(request, reply) { token, idpIssuer: issuer, loginUrl: `${baseUri}/idp/auth`, + ...(keyInfo && { ownerKey: keyInfo }) }); } catch (err) { console.error('Account creation error:', err); @@ -356,6 +396,7 @@ export async function handleCreatePod(request, reply) { name, webId, podUri, - token + token, + ...(keyInfo && { ownerKey: keyInfo }) }); } diff --git a/src/keys/provision.js b/src/keys/provision.js new file mode 100644 index 00000000..f7e7d2fc --- /dev/null +++ b/src/keys/provision.js @@ -0,0 +1,128 @@ +/** + * Owner-key provisioning for new pods. + * + * Generates a Schnorr secp256k1 keypair and serialises it as a + * W3C Controlled Identifiers (CID) v1.0 Multikey document. The same + * curve serves as a Solid signing identity, a Nostr identity, and a + * future did:nostr DID controller (Phase 2). + * + * Phase 1 of #437 — see the issue for design resolutions: + * - controller in Phase 1: the pod owner's WebID (did:nostr in Phase 2) + * - secret key on disk: plaintext, owner-only ACL, file mode 0600; + * filesystem-level protection (FDE / LUKS / OS keyring) recommended; + * wrapped-secret support deferred to Phase 3 + * - encoding: multibase `f` (base16-lower) + multicodec; matches the + * style already used in src/auth/nostr-keys.js for did:nostr + * Multikey verification methods. Pure hex with ~5 bytes of + * self-describing metadata at the front, no new deps. + * - no `nostr` extension block: bech32 npub is a one-line derivation + * in any Nostr-aware tool, not worth the additional secret-on-disk + * surface area or a new dependency. + */ + +import { schnorr } from '@noble/curves/secp256k1'; + +/** + * Multicodec varints (lower-hex). The CCG / W3C CID v1.0 Multikey + * registry uses these to identify the algorithm of a key blob inside + * a multibase-encoded value. + * secp256k1-pub → 0xe7 → varint "e701" + * secp256k1-priv → 0x1301 → varint "8126" + */ +const MULTICODEC_SECP256K1_PUB_HEX = 'e701'; +const MULTICODEC_SECP256K1_PRIV_HEX = '8126'; + +/** + * Compressed-SEC1 parity byte for the *even-y* canonical point. BIP-340 + * (Schnorr / Nostr) keys are x-only and pick the even-y point at each + * x; pairing the f-form prefix with `02` makes the Multikey value + * round-trip with src/auth/nostr-keys.js's decoder. + */ +const EVEN_Y_PARITY_HEX = '02'; + +/** + * Generate a fresh secp256k1 keypair suitable for Schnorr signing. + * + * @returns {{ secretHex: string, publicHex: string }} + * `secretHex` — 32-byte secret scalar, lower-hex. + * `publicHex` — 32-byte x-only Schnorr pubkey, lower-hex (BIP-340). + */ +export function generateOwnerKeypair() { + const secretBytes = schnorr.utils.randomPrivateKey(); + const publicBytes = schnorr.getPublicKey(secretBytes); + return { + secretHex: bytesToHex(secretBytes), + publicHex: bytesToHex(publicBytes) + }; +} + +/** + * Encode a 32-byte hex value as an f-form Multikey value. + * For pub keys we prepend the parity byte (BIP-340 convention: 02); + * for priv keys the secret scalar IS the 32-byte payload — no parity. + */ +export function publicKeyMultibase(publicHex) { + if (!/^[0-9a-f]{64}$/.test(publicHex)) { + throw new Error('publicKeyMultibase: expected 64-char lower-hex pubkey'); + } + return 'f' + MULTICODEC_SECP256K1_PUB_HEX + EVEN_Y_PARITY_HEX + publicHex; +} + +export function secretKeyMultibase(secretHex) { + if (!/^[0-9a-f]{64}$/.test(secretHex)) { + throw new Error('secretKeyMultibase: expected 64-char lower-hex secret'); + } + return 'f' + MULTICODEC_SECP256K1_PRIV_HEX + secretHex; +} + +/** + * Build the W3C CID v1.0 Multikey JSON-LD document for a fresh pod + * owner key. The `controller` is the pod owner's WebID — Phase 1 + * keeps the document self-consistent at every phase (Phase 2 will + * swap in a `did:nostr:` controller once the resolver lands). + * + * @param {object} args + * @param {string} args.controllerWebId - Absolute owner WebID URI. + * @param {string} args.publicHex - 32-byte x-only Schnorr pubkey hex. + * @param {string} args.secretHex - 32-byte secret scalar hex. + * @returns {object} JSON-LD Multikey document, ready for `JSON.stringify`. + */ +export function buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }) { + if (typeof controllerWebId !== 'string' || !controllerWebId) { + throw new Error('buildOwnerKeyDocument: controllerWebId required'); + } + return { + '@context': 'https://www.w3.org/ns/cid/v1', + type: 'Multikey', + controller: controllerWebId, + publicKeyMultibase: publicKeyMultibase(publicHex), + secretKeyMultibase: secretKeyMultibase(secretHex) + }; +} + +/** + * One-shot helper: generate a fresh keypair and produce both the + * Multikey document and the raw key material (for log lines / CLI + * output that wants to display the pubkey). + * + * The returned `secretHex` should be considered sensitive and not + * logged; the public `multibase` IS safe to print. + */ +export function provisionOwnerKey({ controllerWebId }) { + const { publicHex, secretHex } = generateOwnerKeypair(); + const document = buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }); + return { + document, + publicHex, + secretHex, + publicMultibase: document.publicKeyMultibase + }; +} + +function bytesToHex(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return s; +} diff --git a/src/server.js b/src/server.js index 47f04ed4..b9a44781 100644 --- a/src/server.js +++ b/src/server.js @@ -137,6 +137,11 @@ export function createServer(options = {}) { const liveReloadEnabled = options.liveReload ?? false; // MongoDB-backed /db/ route is OFF by default const mongoEnabled = options.mongo ?? false; + // Provision a Schnorr secp256k1 owner key in /private/privkey.jsonld + // when a single-user pod is first created. Phase 1 of #437. Off by + // default: keys-on-disk is a real security tradeoff, opt-in keeps + // the choice visible to the operator. + const provisionKeysEnabled = options.provisionKeys ?? false; const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017'; const mongoDatabase = options.mongoDatabase ?? 'solid'; // HTTP 402 paid /pay/ routes are OFF by default @@ -840,14 +845,36 @@ export function createServer(options = {}) { if (!profileExists) { fastify.log.info(`Creating single-user pod at ${podUri}...`); + let creation; if (isRootPod) { // Root-level pod - create structure directly at / - await createRootPodStructure(webId, podUri, issuer, displayName); + creation = await createRootPodStructure(webId, podUri, issuer, displayName); } else { // Named pod at /{name}/ - await createPodStructure(singleUserName, webId, podUri, issuer, defaultQuota); + creation = await createPodStructure( + singleUserName, webId, podUri, issuer, defaultQuota, + { provisionKeys: provisionKeysEnabled } + ); } fastify.log.info(`Single-user pod created at ${podUri}`); + + // Surface the public side of any provisioned owner key, plus + // a prominent backup reminder. The secret is NOT logged — it + // lives on disk only, under /private/privkey.jsonld with + // owner-only WAC and file mode 0600. + if (creation?.ownerKey) { + const keyPath = isRootPod + ? `${podUri}private/privkey.jsonld` + : `${podUri}private/privkey.jsonld`; + fastify.log.info(`Provisioned Schnorr secp256k1 owner key`); + fastify.log.info(` Public key file: ${keyPath}`); + fastify.log.info(` publicKeyMultibase: ${creation.ownerKey.publicMultibase}`); + fastify.log.warn( + `BACK UP ${keyPath} — losing this file means losing this identity. ` + + 'Filesystem reads bypass WAC; use FDE / OS keyring / restrictive umask ' + + 'for any pod that matters. See docs/provision-keys.md.' + ); + } } // Seed an IDP account so the operator can actually log in. Without @@ -980,11 +1007,15 @@ export function createServer(options = {}) { } /** - * Create root-level pod structure (for single-user mode with pod at /) + * Create root-level pod structure (for single-user mode with pod at /). + * Returns `{ ownerKey }` when --provision-keys is set so the caller can + * surface the public side in the startup banner. The secret is never + * returned (it's only on disk under /private/, mode 0600). */ async function createRootPodStructure(webId, podUri, issuer, displayName) { const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js'); const { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } = await import('./wac/parser.js'); + const { provisionOwnerKey } = await import('./keys/provision.js'); // Create directories at root await storage.createContainer('/inbox/'); @@ -1038,7 +1069,20 @@ export function createServer(options = {}) { const profileAcl = generatePublicFolderAcl('./', owner('profile/')); await storage.write('/profile/.acl', serializeAcl(profileAcl)); + // Optional: provision a Schnorr secp256k1 owner key in /private/. + // Phase 1 of #437. See src/keys/provision.js for the design notes. + let ownerKey; + if (provisionKeysEnabled) { + ownerKey = provisionOwnerKey({ controllerWebId: webId }); + await storage.write( + '/private/privkey.jsonld', + JSON.stringify(ownerKey.document, null, 2), + { mode: 0o600 } + ); + } + // Note: Quota not initialized for root-level pods (no user directory) + return { ownerKey }; } // Start file watcher for live reload (watches filesystem for external changes) diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 907da0a4..4b36dee3 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -79,13 +79,27 @@ export function createReadStream(urlPath, options = {}) { * @param {Buffer | string} content * @returns {Promise} */ -export async function write(urlPath, content) { +export async function write(urlPath, content, options = {}) { const filePath = urlToPath(urlPath); try { // Ensure parent directory exists await fs.ensureDir(path.dirname(filePath)); await fs.writeFile(filePath, content); + // Optional file-mode tightening (e.g. 0o600 for secret material). + // No-op on Windows, where chmod permissions are coarse — callers + // should not rely on POSIX modes for cross-platform security. + if (typeof options.mode === 'number') { + try { + await fs.chmod(filePath, options.mode); + } catch (chmodErr) { + // Don't fail the write because the chmod didn't take — log and + // continue. Callers that care about strict permissions (secret + // material) should additionally rely on filesystem-level + // protection (FDE, OS keyring, container user namespacing). + console.warn(`chmod ${options.mode.toString(8)} on ${filePath} failed:`, chmodErr.message); + } + } return true; } catch (err) { console.error('Write error:', err); diff --git a/test/keys-provision-integration.test.js b/test/keys-provision-integration.test.js new file mode 100644 index 00000000..e5555d72 --- /dev/null +++ b/test/keys-provision-integration.test.js @@ -0,0 +1,185 @@ +/** + * Integration tests for the wired --provision-keys / `provisionKeys: true` + * pod-creation flow (Phase 1 of #437). + * + * Covers: + * - POST /.pods with provisionKeys: true writes a Multikey doc + * to /private/privkey.jsonld + * - The seeded ACL on /private/ keeps the file private (401 unauth, + * 200 with the owner token) — defence-in-depth alongside the + * 0o600 file mode set by storage.write. + * - The HTTP response surfaces the public side (publicKeyMultibase) + * and the file URL, but never echoes the secret. + * - Default behaviour (no flag) does NOT write the file — opt-in + * stays opt-in. + * - createPodStructure called directly with provisionKeys returns + * the freshly-minted key material so a CLI caller can display it. + * - createPodStructure with provisionKeys=false returns no ownerKey. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs-extra'; +import path from 'path'; +import { + startTestServer, + stopTestServer, + request, + assertStatus, + getBaseUrl +} from './helpers.js'; + +// Local token stash — the createTestPod helper hardwires its own +// fetch call so we can't use it for the provisionKeys-true variant; +// just keep the token returned by our manual POST around for the +// authenticated WAC test below. +let ownerToken = null; +import { createPodStructure } from '../src/handlers/container.js'; +import { decodeFFormSecp256k1 } from '../src/auth/nostr-keys.js'; + +describe('POST /.pods — provisionKeys: true (Phase 1 of #437)', () => { + before(async () => { + await startTestServer(); + }); + after(async () => { + await stopTestServer(); + }); + + it('creates the pod and writes /private/privkey.jsonld', async () => { + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'keyowner', provisionKeys: true }) + }); + assertStatus(res, 201); + const body = await res.json(); + ownerToken = body.token; + + // Response surfaces the public side, never the secret. + assert.ok(body.ownerKey, 'response should include ownerKey summary'); + assert.strictEqual(body.ownerKey.keyDocument, `${body.podUri}private/privkey.jsonld`); + assert.match(body.ownerKey.publicKeyMultibase, /^fe70102[0-9a-f]{64}$/, + 'publicKeyMultibase should be the f-form secp256k1-pub Multikey'); + // No secret key on the wire under any field name. + const flat = JSON.stringify(body); + assert.doesNotMatch(flat, /secretKey|secret_key|nsec|privateKey/i, + 'response must not echo any secret material'); + + // The file exists on disk and parses as the expected Multikey doc. + const filePath = path.join('./data', 'keyowner', 'private', 'privkey.jsonld'); + assert.ok(await fs.pathExists(filePath), '/private/privkey.jsonld must exist'); + const doc = JSON.parse(await fs.readFile(filePath, 'utf8')); + assert.strictEqual(doc['@context'], 'https://www.w3.org/ns/cid/v1'); + assert.strictEqual(doc.type, 'Multikey'); + assert.strictEqual(doc.controller, body.webId, + 'Phase 1 controller is the pod owner WebID (did:nostr lands in Phase 2)'); + assert.match(doc.publicKeyMultibase, /^fe70102[0-9a-f]{64}$/); + assert.match(doc.secretKeyMultibase, /^f8126[0-9a-f]{64}$/); + // Round-trip the public side through jss's existing decoder. + const xonly = decodeFFormSecp256k1(doc.publicKeyMultibase); + assert.match(xonly, /^[0-9a-f]{64}$/); + }); + + it('writes the secret with file mode 0o600 (POSIX defence-in-depth)', { skip: process.platform === 'win32' }, async () => { + const filePath = path.join('./data', 'keyowner', 'private', 'privkey.jsonld'); + const stat = await fs.stat(filePath); + // Strip the file-type bits (0o170000) and assert the perm bits. + const mode = stat.mode & 0o777; + assert.strictEqual(mode, 0o600, + `Expected 0o600 on the secret key file, got 0o${mode.toString(8)}`); + }); + + it('blocks unauthenticated reads of the secret key (WAC)', async () => { + const res = await request('/keyowner/private/privkey.jsonld'); + assertStatus(res, 401); + }); + + it('serves the secret to the authenticated owner (WAC)', async () => { + assert.ok(ownerToken, 'pod owner token captured from the create response'); + const res = await fetch(`${getBaseUrl()}/keyowner/private/privkey.jsonld`, { + headers: { Authorization: `Bearer ${ownerToken}` } + }); + assertStatus(res, 200); + const doc = await res.json(); + // The web user (pod owner) reads via HTTP/WAC — file mode 0o600 + // restricts unix-user-on-the-box access only, never this path. + assert.strictEqual(doc.type, 'Multikey'); + assert.match(doc.secretKeyMultibase, /^f8126[0-9a-f]{64}$/); + }); + + it('does NOT write a key file when provisionKeys is omitted (opt-in)', async () => { + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'nokeys' }) + }); + assertStatus(res, 201); + const body = await res.json(); + assert.strictEqual(body.ownerKey, undefined, + 'opt-in: response must not include ownerKey when flag is omitted'); + const filePath = path.join('./data', 'nokeys', 'private', 'privkey.jsonld'); + assert.strictEqual(await fs.pathExists(filePath), false, + 'opt-in: file must not be written when flag is omitted'); + }); + + it('does NOT write a key file when provisionKeys is false (explicit opt-out)', async () => { + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'nokeys2', provisionKeys: false }) + }); + assertStatus(res, 201); + const body = await res.json(); + assert.strictEqual(body.ownerKey, undefined); + const filePath = path.join('./data', 'nokeys2', 'private', 'privkey.jsonld'); + assert.strictEqual(await fs.pathExists(filePath), false); + }); + + it('does NOT trigger on truthy non-true values (e.g. string "true")', async () => { + // Defensive: only literal `true` activates the flag — keeps an + // accidentally-string-coerced value from silently turning it on. + const res = await fetch(`${getBaseUrl()}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'stringtrue', provisionKeys: 'true' }) + }); + assertStatus(res, 201); + const body = await res.json(); + assert.strictEqual(body.ownerKey, undefined, + 'string "true" must not trigger key provisioning'); + const filePath = path.join('./data', 'stringtrue', 'private', 'privkey.jsonld'); + assert.strictEqual(await fs.pathExists(filePath), false); + }); +}); + +describe('createPodStructure — provisionKeys option (direct call)', () => { + before(async () => { + await startTestServer(); + }); + after(async () => { + await stopTestServer(); + }); + + it('returns ownerKey when provisionKeys is true', async () => { + const podUri = 'http://127.0.0.1:0/direct1/'; + const webId = `${podUri}profile/card.jsonld#me`; + const result = await createPodStructure( + 'direct1', webId, podUri, podUri.replace(/\/$/, '/'), 0, + { provisionKeys: true } + ); + assert.ok(result.ownerKey, 'createPodStructure must return ownerKey when provisionKeys is true'); + assert.match(result.ownerKey.publicHex, /^[0-9a-f]{64}$/); + assert.match(result.ownerKey.secretHex, /^[0-9a-f]{64}$/); + assert.strictEqual(result.ownerKey.publicMultibase, result.ownerKey.document.publicKeyMultibase); + assert.strictEqual(result.ownerKey.document.controller, webId); + }); + + it('returns no ownerKey when the option is omitted', async () => { + const podUri = 'http://127.0.0.1:0/direct2/'; + const webId = `${podUri}profile/card.jsonld#me`; + const result = await createPodStructure( + 'direct2', webId, podUri, podUri.replace(/\/$/, '/'), 0 + ); + assert.strictEqual(result.ownerKey, undefined); + }); +}); diff --git a/test/keys-provision.test.js b/test/keys-provision.test.js new file mode 100644 index 00000000..564688e6 --- /dev/null +++ b/test/keys-provision.test.js @@ -0,0 +1,141 @@ +/** + * Unit tests for owner-key provisioning (Phase 1 of #437). + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { schnorr } from '@noble/curves/secp256k1'; +import { + generateOwnerKeypair, + publicKeyMultibase, + secretKeyMultibase, + buildOwnerKeyDocument, + provisionOwnerKey +} from '../src/keys/provision.js'; +import { decodeFFormSecp256k1 } from '../src/auth/nostr-keys.js'; + +describe('generateOwnerKeypair', () => { + it('produces a 32-byte x-only pubkey and a 32-byte secret', () => { + const { publicHex, secretHex } = generateOwnerKeypair(); + assert.match(publicHex, /^[0-9a-f]{64}$/); + assert.match(secretHex, /^[0-9a-f]{64}$/); + }); + + it('produces a working Schnorr keypair (sign + verify)', () => { + const { publicHex, secretHex } = generateOwnerKeypair(); + const message = new TextEncoder().encode('jss provision-keys roundtrip'); + const sig = schnorr.sign(message, hexToBytes(secretHex)); + assert.strictEqual( + schnorr.verify(sig, message, hexToBytes(publicHex)), + true, + 'signature must verify under the generated pubkey' + ); + }); + + it('produces a different keypair on every call', () => { + const a = generateOwnerKeypair(); + const b = generateOwnerKeypair(); + assert.notStrictEqual(a.publicHex, b.publicHex); + assert.notStrictEqual(a.secretHex, b.secretHex); + }); +}); + +describe('publicKeyMultibase / secretKeyMultibase', () => { + // Fixed test vectors so the encoding format is pinned (any change to + // multicodec/multibase would shift these values and fail the assertion). + const publicHex = '87a1c6f0e9b3d2456789abcdef0123456789abcdef0123456789abcdef012345'; + const secretHex = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + it('encodes a public key as f + e701 + 02 + 64-hex-pubkey', () => { + const mb = publicKeyMultibase(publicHex); + // f (multibase) + e701 (multicodec varint for secp256k1-pub) + + // 02 (BIP-340 even-y parity) + 64-char pubkey hex + assert.strictEqual(mb, 'fe70102' + publicHex); + }); + + it('encodes a secret key as f + 8126 + 64-hex-secret', () => { + const mb = secretKeyMultibase(secretHex); + // f (multibase) + 8126 (multicodec varint for secp256k1-priv) + + // 64-char secret hex (no parity byte — secret IS the scalar) + assert.strictEqual(mb, 'f8126' + secretHex); + }); + + it('round-trips through src/auth/nostr-keys decoder for the public side', () => { + // The Multikey value we write must be readable by jss's existing + // f-form decoder (used by the did:nostr verifier). This pins the + // shape so a future change to either side fails loud. + const mb = publicKeyMultibase(publicHex); + const decoded = decodeFFormSecp256k1(mb); + assert.strictEqual(decoded, publicHex); + }); + + it('rejects non-hex / wrong-length inputs', () => { + assert.throws(() => publicKeyMultibase('not-hex'), /64-char lower-hex/); + assert.throws(() => publicKeyMultibase(publicHex.slice(0, 63)), /64-char/); + assert.throws(() => secretKeyMultibase(''), /64-char/); + assert.throws(() => secretKeyMultibase(publicHex.toUpperCase()), /64-char lower-hex/); + }); +}); + +describe('buildOwnerKeyDocument', () => { + const args = { + controllerWebId: 'https://alice.example/profile/card.jsonld#me', + publicHex: '87a1c6f0e9b3d2456789abcdef0123456789abcdef0123456789abcdef012345', + secretHex: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + }; + + it('emits a W3C CID v1.0 Multikey JSON-LD document', () => { + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc['@context'], 'https://www.w3.org/ns/cid/v1'); + assert.strictEqual(doc.type, 'Multikey'); + }); + + it('uses the WebID as controller in Phase 1 (not did:nostr)', () => { + // Phase 1 keeps the document self-consistent. did:nostr controllers + // require the Phase 2 resolver to be useful; until then a CID + // consumer would dereference an unresolvable URI. + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc.controller, args.controllerWebId); + assert.doesNotMatch(doc.controller, /^did:nostr:/); + }); + + it('embeds both the public and secret Multikey values', () => { + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc.publicKeyMultibase, publicKeyMultibase(args.publicHex)); + assert.strictEqual(doc.secretKeyMultibase, secretKeyMultibase(args.secretHex)); + }); + + it('does NOT include a `nostr` extension block (npub etc.)', () => { + // Resolution #2 of #437: bech32 npub is derivable from the public + // multibase value in any Nostr-aware tool. Including it would + // double the secret-on-disk surface for log/error/dump leaks + // (the symmetric `nsec` field was originally proposed alongside). + const doc = buildOwnerKeyDocument(args); + assert.strictEqual(doc.nostr, undefined); + }); + + it('rejects a missing or empty controller WebID', () => { + assert.throws(() => buildOwnerKeyDocument({ ...args, controllerWebId: '' }), /controllerWebId required/); + assert.throws(() => buildOwnerKeyDocument({ ...args, controllerWebId: undefined }), /controllerWebId required/); + }); +}); + +describe('provisionOwnerKey', () => { + it('returns the document together with raw key material for CLI display', () => { + const out = provisionOwnerKey({ controllerWebId: 'https://alice.example/profile/card.jsonld#me' }); + assert.match(out.publicHex, /^[0-9a-f]{64}$/); + assert.match(out.secretHex, /^[0-9a-f]{64}$/); + assert.strictEqual(out.publicMultibase, out.document.publicKeyMultibase); + assert.strictEqual(out.document.type, 'Multikey'); + assert.strictEqual(out.document.controller, 'https://alice.example/profile/card.jsonld#me'); + }); +}); + +function hexToBytes(hex) { + if (hex.length % 2 !== 0) throw new Error('hex length must be even'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} From 2de873ee6c8a75a30a057606edc2b3a5fa3718fa Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 11:53:31 +0200 Subject: [PATCH 2/6] review: address Copilot pickups on #442 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six small but real fixes from the first review pass. 1. storage.write created the file with the process umask (often 0644) and only then chmod'd to the requested mode. Race window where another local process could read freshly created secret material before chmod ran. Pass `mode` to fs.writeFile at create time so the file is *born* with the right perms; keep the chmod afterward as belt-and-braces for the overwrite case (Node only honours mode at create time, not on overwrite). 2. createRootPodStructure's docstring said the secret was "never returned", but the returned ownerKey object includes secretHex and secretKeyMultibase. Tightened the doc to match reality — "callers must not log the secret" — and noted why we keep the internal representation full (tests, future one-shot signing). 3. Single-user onReady built the same `keyPath` string in both branches of an isRootPod ternary. Collapsed to one expression (podUri already carries the trailing slash + name segment). 4. docs/provision-keys.md intro referenced "#427 / #437" — but #427 is the unrelated ACL-portability umbrella. Just #437. 5. docs backup example only showed the root-pod path. Added the named-pod variants for both the HTTP curl backup and the on-disk cp backup, since the named-pod layout is the multi-user case. 6. Updated storage.write JSDoc to document the new `options.mode` argument with the rationale. 840/840 tests pass. --- docs/provision-keys.md | 17 +++++++++++---- src/server.js | 19 ++++++++++------ src/storage/filesystem.js | 46 +++++++++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/docs/provision-keys.md b/docs/provision-keys.md index a6827e0a..ae2c8353 100644 --- a/docs/provision-keys.md +++ b/docs/provision-keys.md @@ -1,6 +1,6 @@ # Owner-key provisioning (`--provision-keys`) -Phase 1 of [#427 / #437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437). Generates a Schnorr secp256k1 keypair on pod creation and writes it as a W3C [Controlled Identifiers v1.0 Multikey](https://www.w3.org/TR/cid-1.0/) document at `/private/privkey.jsonld`. +Phase 1 of [#437](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/437). Generates a Schnorr secp256k1 keypair on pod creation and writes it as a W3C [Controlled Identifiers v1.0 Multikey](https://www.w3.org/TR/cid-1.0/) document at `/private/privkey.jsonld`. The same key is intended to serve, over time, as: a Solid signing identity, a Nostr identity (same curve), and a `did:nostr:` DID controller (Phase 2). One CLI flag → pod-resident self-sovereign identity ready for both Solid and Nostr / agentic use cases. @@ -113,14 +113,23 @@ Each upgrade is a wrapper around the same secret material. You're not picking a This is not optional. ```bash -# Authenticate as owner via HTTP (preferred — works even on a remote host) +# Authenticate as owner via HTTP (preferred — works even on a remote host). +# For a single-user / root pod the key is at /private/privkey.jsonld; +# for a named pod (multi-user) it's at //private/privkey.jsonld. curl -H "Authorization: Bearer " \ http://your.example/private/privkey.jsonld \ -o pod-key-backup.jsonld +# named-pod variant: +curl -H "Authorization: Bearer " \ + http://your.example/alice/private/privkey.jsonld \ + -o pod-key-backup.jsonld chmod 600 pod-key-backup.jsonld -# Or copy from disk if you have local access (preserves perms) -cp -p /path/to/data/private/privkey.jsonld pod-key-backup.jsonld +# Or copy from disk if you have local access (preserves perms). +# Root pod (single-user, default since #348): +cp -p /private/privkey.jsonld pod-key-backup.jsonld +# Named pod (multi-user, or single-user with --single-user-name=alice): +cp -p /alice/private/privkey.jsonld pod-key-backup.jsonld ``` Store the backup somewhere that survives the pod's host: another machine, encrypted cloud storage, a hardware token, a sealed envelope in a safe — whatever you'd use for an SSH key you actually care about. Losing this file means losing this identity permanently. There is no recovery flow in Phase 1. diff --git a/src/server.js b/src/server.js index b9a44781..07cfe071 100644 --- a/src/server.js +++ b/src/server.js @@ -861,11 +861,12 @@ export function createServer(options = {}) { // Surface the public side of any provisioned owner key, plus // a prominent backup reminder. The secret is NOT logged — it // lives on disk only, under /private/privkey.jsonld with - // owner-only WAC and file mode 0600. + // owner-only WAC and file mode 0o600. if (creation?.ownerKey) { - const keyPath = isRootPod - ? `${podUri}private/privkey.jsonld` - : `${podUri}private/privkey.jsonld`; + // `podUri` already includes the trailing slash + any pod + // name segment, so the same expression covers root and + // named single-user pods. + const keyPath = `${podUri}private/privkey.jsonld`; fastify.log.info(`Provisioned Schnorr secp256k1 owner key`); fastify.log.info(` Public key file: ${keyPath}`); fastify.log.info(` publicKeyMultibase: ${creation.ownerKey.publicMultibase}`); @@ -1008,9 +1009,13 @@ export function createServer(options = {}) { /** * Create root-level pod structure (for single-user mode with pod at /). - * Returns `{ ownerKey }` when --provision-keys is set so the caller can - * surface the public side in the startup banner. The secret is never - * returned (it's only on disk under /private/, mode 0600). + * When --provision-keys is set, returns `{ ownerKey }` so the caller + * can surface the public side in the startup banner. The returned + * `ownerKey` includes secretHex and secretKeyMultibase — needed by + * tests, present in case a future caller needs to perform a one-shot + * sign before the file is read back via WAC. **Callers must not log + * the secret.** The secret's only durable home is the on-disk file + * under /private/ (mode 0o600, owner-only WAC). */ async function createRootPodStructure(webId, podUri, issuer, displayName) { const { generateProfile, generatePreferences, generateTypeIndex, serialize } = await import('./webid/profile.js'); diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 4b36dee3..9eb94ea7 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -74,10 +74,22 @@ export function createReadStream(urlPath, options = {}) { } /** - * Write resource content - * @param {string} urlPath - * @param {Buffer | string} content - * @returns {Promise} + * Write resource content. + * + * @param {string} urlPath - URL path of the resource being written + * (translated to a filesystem path internally). + * @param {Buffer | string} content - Bytes / text to write. Replaces + * the file if it already exists. + * @param {object} [options] + * @param {number} [options.mode] - POSIX file mode (e.g. `0o600`) to + * apply to the created file. Passed to `fs.writeFile` at create + * time so the file is never visible to other unix users with a + * looser default; an additional `chmod` runs afterward to tighten + * the file when overwriting an existing path that was created with + * a wider mode. No-op on Windows. See #437 for the secret-material + * use case. + * @returns {Promise} `true` on success, `false` on write + * failure (chmod failures are logged but do not fail the write). */ export async function write(urlPath, content, options = {}) { const filePath = urlToPath(urlPath); @@ -85,18 +97,28 @@ export async function write(urlPath, content, options = {}) { try { // Ensure parent directory exists await fs.ensureDir(path.dirname(filePath)); - await fs.writeFile(filePath, content); - // Optional file-mode tightening (e.g. 0o600 for secret material). - // No-op on Windows, where chmod permissions are coarse — callers - // should not rely on POSIX modes for cross-platform security. + + // Pass `mode` to writeFile so the file is *created* with the + // requested permissions, closing the race window where another + // local process could read a freshly created secret-material + // file before a follow-up `chmod` ran. (Node only honours `mode` + // at create time, never on overwrite.) + if (typeof options.mode === 'number') { + await fs.writeFile(filePath, content, { mode: options.mode }); + } else { + await fs.writeFile(filePath, content); + } + + // Belt-and-braces: when overwriting an existing file, writeFile + // does NOT change the existing mode — apply chmod so a stale 0644 + // file gets tightened to 0600 on subsequent writes. Logged but + // non-fatal: callers that care about strict permissions should + // also rely on filesystem-level protection (FDE / OS keyring / + // container user namespacing). if (typeof options.mode === 'number') { try { await fs.chmod(filePath, options.mode); } catch (chmodErr) { - // Don't fail the write because the chmod didn't take — log and - // continue. Callers that care about strict permissions (secret - // material) should additionally rely on filesystem-level - // protection (FDE, OS keyring, container user namespacing). console.warn(`chmod ${options.mode.toString(8)} on ${filePath} failed:`, chmodErr.message); } } From 14c543d7de17937e0ff0edb2da9b16b10942be03 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 12:13:17 +0200 Subject: [PATCH 3/6] review: refuse provisionKeys + --public combination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot pickup on #442. --public bypasses WAC and grants unauthenticated access to every resource — including any /private/privkey.jsonld we'd just written. The two flags are explicitly incompatible; refuse the combination at every entry point so the operator hits the contradiction immediately rather than by reading a leaked key from logs or the public web. - New assertProvisionKeysCompatible({ provisionKeys, isPublic }) exported from src/keys/provision.js. Throws with a clear error message naming both flags and the WAC bypass. - src/server.js calls the assertion at server-create time. A `jss start --single-user --provision-keys --public` invocation now fails to start instead of silently writing a public secret. - src/handlers/container.js handleCreatePod returns HTTP 400 when request.config?.public is true and provisionKeys: true is sent. Pod is not created; error explains why. - docs/provision-keys.md gains an "Incompatible with --public" section spelling out both refusal points. - 4 new unit tests for the assertion (full truth table). - 1 new HTTP integration test exercising the 400 path on a --public test server. - 1 new createServer-throw test asserting the synchronous refusal on construction. 847/847 tests pass. --- docs/provision-keys.md | 11 +++++ src/handlers/container.js | 15 +++++- src/keys/provision.js | 29 +++++++++++ src/server.js | 9 ++++ test/keys-provision-integration.test.js | 66 +++++++++++++++++++++++++ test/keys-provision.test.js | 33 ++++++++++++- 6 files changed, 161 insertions(+), 2 deletions(-) diff --git a/docs/provision-keys.md b/docs/provision-keys.md index ae2c8353..ae53998f 100644 --- a/docs/provision-keys.md +++ b/docs/provision-keys.md @@ -68,6 +68,17 @@ Same effect as the CLI flag; useful in containerised deployments. The file mode is set to `0o600` on POSIX. WAC restricts HTTP access to the pod owner via `/private/.acl` (the standard private-folder ACL JSS writes anyway). +## Incompatible with `--public` + +JSS refuses to provision keys when `--public` mode is on. `--public` tells JSS to skip WAC entirely and grant unauthenticated access to every resource — including `/private/privkey.jsonld`. Combined with `--provision-keys`, that would write a plaintext secret straight to a publicly-readable URL. + +The two flags are explicitly incompatible: + +- **At server start**: `jss start --single-user --provision-keys --public` throws at server-create time with a clear error. The server doesn't start. +- **At pod creation over HTTP**: `POST /.pods` with `provisionKeys: true` returns **400 Bad Request** when the server is in `--public` mode. + +If you genuinely want both behaviours, you don't actually want both: either pick `--public` (no auth, no secrets on disk) or pick `--provision-keys` (WAC-protected secrets). There's no middle ground that's safe. + ## Threat model and protection The web user (pod owner) reads the key over HTTP, authenticated against their WebID. WAC checks the request and serves the file: diff --git a/src/handlers/container.js b/src/handlers/container.js index b1829480..23dedcef 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -4,7 +4,7 @@ import { getAllHeaders } from '../ldp/headers.js'; import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js'; import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js'; import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } from '../wac/parser.js'; -import { provisionOwnerKey } from '../keys/provision.js'; +import { provisionOwnerKey, assertProvisionKeysCompatible } from '../keys/provision.js'; import { createToken } from '../auth/token.js'; import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js'; import { emitChange } from '../notifications/events.js'; @@ -306,6 +306,19 @@ export async function handleCreatePod(request, reply) { return reply.code(400).send({ error: 'Invalid pod name. Use alphanumeric, dash, or underscore only.' }); } + // Refuse provisionKeys + --public: WAC would be bypassed, exposing the + // freshly written secret to anyone. Surfaces the contradiction at + // request time rather than after a key leak. + if (provisionKeys === true && request.config?.public) { + return reply.code(400).send({ + error: 'provisionKeys cannot be used in --public mode', + message: + '--public bypasses WAC, which would make /private/privkey.jsonld ' + + 'publicly readable. Use provisionKeys with WAC enforcement (the ' + + 'default), or drop --public.' + }); + } + const podPath = `/${name}/`; // Check if pod already exists diff --git a/src/keys/provision.js b/src/keys/provision.js index f7e7d2fc..2cd236a6 100644 --- a/src/keys/provision.js +++ b/src/keys/provision.js @@ -119,6 +119,35 @@ export function provisionOwnerKey({ controllerWebId }) { }; } +/** + * Refuse to provision keys when WAC is being bypassed. + * + * `--public` (and `request.config.public`) tells JSS to skip WAC and + * grant unauthenticated access to everything. Combined with + * `--provision-keys`, it would mean a plaintext secret at + * `/private/privkey.jsonld` is readable by anyone over HTTP — the + * exact opposite of what the seeded owner-only ACL is supposed to do. + * + * Throws a clear error so the operator hits the contradiction at + * startup (or pod-creation) rather than discovering it by reading + * their own server logs after a key leak. + * + * @param {object} args + * @param {boolean} args.provisionKeys + * @param {boolean} args.isPublic - jss `--public` mode + * @throws {Error} when both flags are true + */ +export function assertProvisionKeysCompatible({ provisionKeys, isPublic }) { + if (provisionKeys && isPublic) { + throw new Error( + '--provision-keys cannot be combined with --public. --public bypasses ' + + 'WAC, which would make /private/privkey.jsonld readable by anyone over ' + + 'HTTP. Use --provision-keys with WAC enforcement (the default), or drop ' + + '--public.' + ); + } +} + function bytesToHex(bytes) { let s = ''; for (let i = 0; i < bytes.length; i++) { diff --git a/src/server.js b/src/server.js index 07cfe071..68775c76 100644 --- a/src/server.js +++ b/src/server.js @@ -29,6 +29,7 @@ import { tunnelPlugin } from './tunnel/index.js'; import { terminalPlugin } from './terminal/index.js'; import { registerErrorHandler } from './utils/error-handler.js'; import { seedServerRoot } from './ui/server-root.js'; +import { assertProvisionKeysCompatible } from './keys/provision.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -141,7 +142,15 @@ export function createServer(options = {}) { // when a single-user pod is first created. Phase 1 of #437. Off by // default: keys-on-disk is a real security tradeoff, opt-in keeps // the choice visible to the operator. + // + // Refuse the --provision-keys + --public combination at server-create + // time so the operator hits the contradiction immediately rather than + // by reading a leaked key from logs / the public web. See #442 review. const provisionKeysEnabled = options.provisionKeys ?? false; + assertProvisionKeysCompatible({ + provisionKeys: provisionKeysEnabled, + isPublic: !!options.public + }); const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017'; const mongoDatabase = options.mongoDatabase ?? 'solid'; // HTTP 402 paid /pay/ routes are OFF by default diff --git a/test/keys-provision-integration.test.js b/test/keys-provision-integration.test.js index e5555d72..3f75e79c 100644 --- a/test/keys-provision-integration.test.js +++ b/test/keys-provision-integration.test.js @@ -152,6 +152,72 @@ describe('POST /.pods — provisionKeys: true (Phase 1 of #437)', () => { }); }); +describe('POST /.pods — provisionKeys + --public (footgun guard)', () => { + // --public mode bypasses WAC: every resource becomes publicly + // readable. Combined with provisionKeys, /private/privkey.jsonld + // would be the world's worst secret store. Refuse the combination + // at request time with 400 so the operator hits the contradiction + // immediately. See #442 review. + let server; + let baseUrl; + let savedDataRoot; + const DATA_DIR = './test-data-provision-public'; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + await fs.remove(DATA_DIR); + await fs.ensureDir(DATA_DIR); + const { createServer } = await import('../src/server.js'); + server = createServer({ + logger: false, + forceCloseConnections: true, + root: DATA_DIR, + public: true + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + baseUrl = `http://127.0.0.1:${server.server.address().port}`; + }); + after(async () => { + await server.close(); + await fs.remove(DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('rejects POST /.pods with provisionKeys: true on a --public server (400)', async () => { + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'shouldnotexist', provisionKeys: true }) + }); + assert.strictEqual(res.status, 400); + const body = await res.json(); + assert.match(body.message || body.error || '', /public/i, + 'error must explain why provisionKeys is rejected'); + // Pod must not have been created. + assert.strictEqual(await fs.pathExists(`${DATA_DIR}/shouldnotexist/`), false); + }); + + it('still allows POST /.pods without provisionKeys on a --public server', async () => { + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'publicpod' }) + }); + assert.strictEqual(res.status, 201); + }); +}); + +describe('createServer — provisionKeys + --public refused at start', () => { + it('throws synchronously when both options are set', async () => { + const { createServer } = await import('../src/server.js'); + assert.throws( + () => createServer({ logger: false, provisionKeys: true, public: true }), + /cannot be combined with --public/ + ); + }); +}); + describe('createPodStructure — provisionKeys option (direct call)', () => { before(async () => { await startTestServer(); diff --git a/test/keys-provision.test.js b/test/keys-provision.test.js index 564688e6..f4997253 100644 --- a/test/keys-provision.test.js +++ b/test/keys-provision.test.js @@ -10,7 +10,8 @@ import { publicKeyMultibase, secretKeyMultibase, buildOwnerKeyDocument, - provisionOwnerKey + provisionOwnerKey, + assertProvisionKeysCompatible } from '../src/keys/provision.js'; import { decodeFFormSecp256k1 } from '../src/auth/nostr-keys.js'; @@ -131,6 +132,36 @@ describe('provisionOwnerKey', () => { }); }); +describe('assertProvisionKeysCompatible', () => { + // Refuse the provisionKeys + --public combination — public mode + // bypasses WAC, which would expose the freshly-written secret on + // /private/privkey.jsonld to anyone over HTTP. + it('throws when both provisionKeys and isPublic are true', () => { + assert.throws( + () => assertProvisionKeysCompatible({ provisionKeys: true, isPublic: true }), + /cannot be combined with --public/ + ); + }); + + it('does NOT throw for provisionKeys alone (the supported case)', () => { + assert.doesNotThrow(() => + assertProvisionKeysCompatible({ provisionKeys: true, isPublic: false }) + ); + }); + + it('does NOT throw for --public alone (no secrets being written)', () => { + assert.doesNotThrow(() => + assertProvisionKeysCompatible({ provisionKeys: false, isPublic: true }) + ); + }); + + it('does NOT throw when neither flag is set', () => { + assert.doesNotThrow(() => + assertProvisionKeysCompatible({ provisionKeys: false, isPublic: false }) + ); + }); +}); + function hexToBytes(hex) { if (hex.length % 2 !== 0) throw new Error('hex length must be even'); const out = new Uint8Array(hex.length / 2); From eaa25d2b1e4bcceeb34f19653d5efb363445361e Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 12:23:34 +0200 Subject: [PATCH 4/6] review: use assertProvisionKeysCompatible + fail loud on key-write error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Copilot pickups on #442 (round 3). 1. handleCreatePod imported assertProvisionKeysCompatible last round but was still doing the public-mode check inline — dead import. Switch the call site to the helper and convert the throw into a 400 response. Single source of truth for the error message, shared with createServer's startup-time check. 2. createPodStructure called storage.write() for the secret key but ignored the boolean return. storage.write returns false (without throwing) on failure, so a write that silently failed would have left the pod in a state where the response advertised an `ownerKey` against an on-disk file that didn't exist. Throw on `!ok` so the existing handleCreatePod cleanup path (storage.remove + 500) runs. 3. createRootPodStructure had the same gap: single-user startup could log "Provisioned Schnorr secp256k1 owner key" against a missing file. Throw on `!ok` so the start fails loud — the operator explicitly asked for --provision-keys, a silent failure would surface only when they later tried to read the key. 847/847 tests pass. --- src/handlers/container.js | 36 +++++++++++++++++++++++++----------- src/server.js | 9 ++++++++- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/handlers/container.js b/src/handlers/container.js index 23dedcef..e9863033 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -250,14 +250,22 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo // Optional: provision a Schnorr secp256k1 owner key in /private/. // Phase 1 of #437. See src/keys/provision.js for the design notes. + // Throw on write failure so the caller's cleanup path runs and the + // pod isn't left with a phantom `ownerKey` in the response that + // doesn't correspond to any on-disk file. let ownerKey; if (options.provisionKeys) { ownerKey = provisionOwnerKey({ controllerWebId: webId }); - await storage.write( + const ok = await storage.write( `${podPath}private/privkey.jsonld`, JSON.stringify(ownerKey.document, null, 2), { mode: 0o600 } ); + if (!ok) { + throw new Error( + `Failed to write owner key file at ${podPath}private/privkey.jsonld` + ); + } } return { podPath, podUri, ownerKey }; @@ -307,16 +315,22 @@ export async function handleCreatePod(request, reply) { } // Refuse provisionKeys + --public: WAC would be bypassed, exposing the - // freshly written secret to anyone. Surfaces the contradiction at - // request time rather than after a key leak. - if (provisionKeys === true && request.config?.public) { - return reply.code(400).send({ - error: 'provisionKeys cannot be used in --public mode', - message: - '--public bypasses WAC, which would make /private/privkey.jsonld ' + - 'publicly readable. Use provisionKeys with WAC enforcement (the ' + - 'default), or drop --public.' - }); + // freshly written secret to anyone. Use the same assertion helper as + // createServer's startup-time check so the error message stays in + // one place — converted to a 400 here because we're in an HTTP + // request context, not the constructor. + if (provisionKeys === true) { + try { + assertProvisionKeysCompatible({ + provisionKeys: true, + isPublic: !!request.config?.public + }); + } catch (err) { + return reply.code(400).send({ + error: 'provisionKeys cannot be used in --public mode', + message: err.message + }); + } } const podPath = `/${name}/`; diff --git a/src/server.js b/src/server.js index 68775c76..fdb0a427 100644 --- a/src/server.js +++ b/src/server.js @@ -1085,14 +1085,21 @@ export function createServer(options = {}) { // Optional: provision a Schnorr secp256k1 owner key in /private/. // Phase 1 of #437. See src/keys/provision.js for the design notes. + // Throw on write failure so single-user startup fails loud rather + // than logging "Provisioned …" against a missing on-disk file. let ownerKey; if (provisionKeysEnabled) { ownerKey = provisionOwnerKey({ controllerWebId: webId }); - await storage.write( + const ok = await storage.write( '/private/privkey.jsonld', JSON.stringify(ownerKey.document, null, 2), { mode: 0o600 } ); + if (!ok) { + throw new Error( + 'Failed to write owner key file at /private/privkey.jsonld' + ); + } } // Note: Quota not initialized for root-level pods (no user directory) From ac78daddb8d16936a66eccc2e9f6ce293d955998 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 12:32:24 +0200 Subject: [PATCH 5/6] review: strict-boolean check at direct entry + chmod doc fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more Copilot pickups on #442. 1. createPodStructure activated key provisioning on any truthy options.provisionKeys — inconsistent with handleCreatePod's strict === true check on the request body. A misconfigured direct caller passing 'true' / 1 / etc. would have silently provisioned a plaintext secret. Match the HTTP-side behaviour: strict === true at both entry points. New regression test asserts string 'true' doesn't trigger at the direct entry. 2. storage.write JSDoc said the chmod was a 'No-op on Windows', but the implementation always attempts chmod regardless of platform. Update the doc to reflect the actual behaviour: chmod is best- effort everywhere, NTFS approximates POSIX modes coarsely (read- only flag based on owner perms), callers should not rely on it for cross-platform secrecy. Inline comment also clarified. 848/848 tests pass. --- src/handlers/container.js | 6 +++++- src/storage/filesystem.js | 13 ++++++++----- test/keys-provision-integration.test.js | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/handlers/container.js b/src/handlers/container.js index e9863033..8fb4d5a7 100644 --- a/src/handlers/container.js +++ b/src/handlers/container.js @@ -253,8 +253,12 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo // Throw on write failure so the caller's cleanup path runs and the // pod isn't left with a phantom `ownerKey` in the response that // doesn't correspond to any on-disk file. + // + // Strict `=== true` (not just truthy) so a misconfigured caller + // passing `'true'` / `1` / etc. doesn't silently activate. Matches + // handleCreatePod's HTTP-side check on the body field. let ownerKey; - if (options.provisionKeys) { + if (options.provisionKeys === true) { ownerKey = provisionOwnerKey({ controllerWebId: webId }); const ok = await storage.write( `${podPath}private/privkey.jsonld`, diff --git a/src/storage/filesystem.js b/src/storage/filesystem.js index 9eb94ea7..e478d100 100644 --- a/src/storage/filesystem.js +++ b/src/storage/filesystem.js @@ -86,8 +86,10 @@ export function createReadStream(urlPath, options = {}) { * time so the file is never visible to other unix users with a * looser default; an additional `chmod` runs afterward to tighten * the file when overwriting an existing path that was created with - * a wider mode. No-op on Windows. See #437 for the secret-material - * use case. + * a wider mode. On Windows, NTFS approximates POSIX modes coarsely + * (effectively read-only flag based on owner perms) — `chmod` is + * still attempted there but should not be relied on for + * cross-platform secrecy. See #437 for the secret-material use case. * @returns {Promise} `true` on success, `false` on write * failure (chmod failures are logged but do not fail the write). */ @@ -112,9 +114,10 @@ export async function write(urlPath, content, options = {}) { // Belt-and-braces: when overwriting an existing file, writeFile // does NOT change the existing mode — apply chmod so a stale 0644 // file gets tightened to 0600 on subsequent writes. Logged but - // non-fatal: callers that care about strict permissions should - // also rely on filesystem-level protection (FDE / OS keyring / - // container user namespacing). + // non-fatal: chmod is attempted on every platform (incl. Windows, + // where NTFS approximates POSIX modes coarsely) but callers that + // care about strict permissions should also rely on filesystem- + // level protection (FDE / OS keyring / container user namespacing). if (typeof options.mode === 'number') { try { await fs.chmod(filePath, options.mode); diff --git a/test/keys-provision-integration.test.js b/test/keys-provision-integration.test.js index 3f75e79c..b567695c 100644 --- a/test/keys-provision-integration.test.js +++ b/test/keys-provision-integration.test.js @@ -248,4 +248,19 @@ describe('createPodStructure — provisionKeys option (direct call)', () => { ); assert.strictEqual(result.ownerKey, undefined); }); + + it('requires strict boolean true (a truthy non-boolean does not trigger)', async () => { + // Defensive: matches the HTTP-side check on the request body so a + // misconfigured caller passing 'true' / 1 / etc. through the + // direct API doesn't silently provision a plaintext secret. + const podUri = 'http://127.0.0.1:0/direct3/'; + const webId = `${podUri}profile/card.jsonld#me`; + const result = await createPodStructure( + 'direct3', webId, podUri, podUri.replace(/\/$/, '/'), 0, + { provisionKeys: 'true' } // intentionally string, not boolean + ); + assert.strictEqual(result.ownerKey, undefined, + 'string "true" must not activate provisioning at the direct entry'); + assert.strictEqual(await fs.pathExists('./data/direct3/private/privkey.jsonld'), false); + }); }); From 9109b3dc7652e806977d6d62570015ed74779f4a Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 14 May 2026 12:40:43 +0200 Subject: [PATCH 6/6] review: strict-boolean coercion on provisionKeysEnabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot pickup on #442. provisionKeysEnabled was derived as options.provisionKeys ?? false, which preserves truthy non-booleans. Combined with the strict === true check inside createPodStructure (named-pod path), this left the two pod shapes behaving differently for the same misconfigured input: options: { provisionKeys: 'true' } → root pod: if (provisionKeysEnabled) → truthy string → provisions → named pod: createPodStructure({ provisionKeys: 'true' }) → strict === true → does NOT provision Coerce to a strict boolean at the createServer boundary so both paths see the same value. A misconfigured `'true'` / `1` / etc. now falls through to false at the entry point and the operator gets the no-op behaviour on both pod shapes. 848/848 tests pass. --- src/server.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/server.js b/src/server.js index fdb0a427..eee613db 100644 --- a/src/server.js +++ b/src/server.js @@ -146,7 +146,14 @@ export function createServer(options = {}) { // Refuse the --provision-keys + --public combination at server-create // time so the operator hits the contradiction immediately rather than // by reading a leaked key from logs / the public web. See #442 review. - const provisionKeysEnabled = options.provisionKeys ?? false; + // + // Strict `=== true` (not `?? false`) coerces a misconfigured truthy + // non-boolean (e.g. JSON config / env coercion handing in `'true'` + // as a string) to false at the boundary. Without this, the root-pod + // branch's `if (provisionKeysEnabled)` would activate while the + // named-pod path's strict check downstream would not, leaving the + // two pod shapes behaving differently for the same input. + const provisionKeysEnabled = options.provisionKeys === true; assertProvisionKeysCompatible({ provisionKeys: provisionKeysEnabled, isPublic: !!options.public