feat: --provision-keys for owner key on pod creation (#437 Phase 1)#442
Conversation
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 <pod>/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)
There was a problem hiding this comment.
Pull request overview
Adds opt-in owner key provisioning during pod creation (Phase 1 of #437), generating a Schnorr secp256k1 keypair and persisting it as a W3C CID v1.0 Multikey JSON-LD document at <pod>/private/privkey.jsonld, with CLI/config/HTTP wiring and test coverage.
Changes:
- Introduces
--provision-keys/JSS_PROVISION_KEYS(single-user startup) andprovisionKeys: trueonPOST /.pods(multi-user) to generate + write the Multikey document. - Extends filesystem storage writes to optionally tighten file mode (intended for
0o600secrets) and surfaces only public key info in startup logs / pod-create HTTP responses. - Adds unit + integration tests and a new operator-facing doc page for threat model/backup guidance.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| test/keys-provision.test.js | Unit tests for key generation, multibase encoding, decoder round-trip, and document shape. |
| test/keys-provision-integration.test.js | Integration coverage for POST /.pods provisioning, WAC access, and POSIX file mode expectations. |
| src/storage/filesystem.js | Adds optional { mode } parameter to write() to support secret-file permission tightening. |
| src/server.js | Wires provisionKeys into single-user pod creation and logs public-only provisioning info + backup warning. |
| src/keys/provision.js | New key provisioning module: generate keypair, encode Multikey fields, build JSON-LD document. |
| src/handlers/container.js | Wires provisionKeys body flag into pod creation and returns a public-only summary in API responses. |
| src/config.js | Adds provisionKeys default + env var mapping + boolean coercion support. |
| docs/provision-keys.md | New documentation for usage, format, threat model, and backup guidance. |
| bin/jss.js | Adds CLI flags and passes provisionKeys through into server options. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 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); | ||
| } |
There was a problem hiding this comment.
Addressed in 2de873e: storage.write now passes mode to fs.writeFile at create time so the file is born with the requested permissions, closing the race window where another local process could read freshly created secret material. The follow-up chmod is kept as belt-and-braces for the overwrite case (Node only honours mode at create time, never on overwrite).
There was a problem hiding this comment.
Confirming this is already addressed (since commit 2de873e). The actual fix is at lines 108-113 in the current file: when options.mode is set, fs.writeFile is called with { mode: options.mode } so the file is created with the requested permissions — the race window between create-with-default-umask and follow-up chmod is closed. The chmod block this comment is anchored to is the belt-and-braces step that handles the overwrite case (where Node only applies mode at create time, not on overwrite). Reading the file top-to-bottom: writeFile-with-mode at the top, chmod-as-fallback at the bottom — both inside the same typeof options.mode === 'number' guard.
| * surface the public side in the startup banner. The secret is never | ||
| * returned (it's only on disk under /private/, mode 0600). |
There was a problem hiding this comment.
Addressed in 2de873e: tightened the docstring to match reality — the returned ownerKey does include secretHex/secretKeyMultibase (needed for tests and future one-shot signing before WAC reads the file back). Comment now says 'callers must not log the secret' explicitly, with a note that the only durable home for the secret is the on-disk file under owner-only WAC + 0o600.
| const keyPath = isRootPod | ||
| ? `${podUri}private/privkey.jsonld` | ||
| : `${podUri}private/privkey.jsonld`; |
There was a problem hiding this comment.
Addressed in 2de873e: collapsed the redundant ternary to a single expression. podUri already carries the trailing slash + name segment, so the same string covers root and named single-user pods.
| @@ -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 `<pod>/private/privkey.jsonld`. | |||
| # Or copy from disk if you have local access (preserves perms) | ||
| cp -p /path/to/data/private/privkey.jsonld pod-key-backup.jsonld |
There was a problem hiding this comment.
Addressed in 2de873e: added named-pod path variants for both the HTTP curl backup and the on-disk cp backup. The multi-user case (and single-user with --single-user-name=alice) puts the file under <DATA_ROOT>/alice/private/privkey.jsonld, distinct from the root-pod layout.
| /** | ||
| * Write resource content | ||
| * @param {string} urlPath | ||
| * @param {Buffer | string} content | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| export async function write(urlPath, content) { | ||
| export async function write(urlPath, content, options = {}) { | ||
| const filePath = urlToPath(urlPath); |
There was a problem hiding this comment.
Addressed in 2de873e: full JSDoc for write() now documents the options.mode arg with rationale (race-window closure, Windows no-op, secret-material use case).
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.
| // 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; |
There was a problem hiding this comment.
Addressed in 14c543d: refused the provisionKeys + --public combination at every entry point. New assertProvisionKeysCompatible() exported from src/keys/provision.js; createServer throws synchronously if both flags are set (server doesn't start), handleCreatePod returns 400 if a POST /.pods sends provisionKeys: true on a --public server, and docs/provision-keys.md gains an Incompatible-with-public section spelling out both refusal points. Tests cover the full truth table for the assertion plus an HTTP-level 400 integration test.
| // 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 0o600. | ||
| if (creation?.ownerKey) { | ||
| // `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}`); | ||
| 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.' | ||
| ); |
There was a problem hiding this comment.
Addressed in 14c543d: refused the provisionKeys + --public combination at every entry point. New assertProvisionKeysCompatible() exported from src/keys/provision.js; createServer throws synchronously if both flags are set (server doesn't start), handleCreatePod returns 400 if a POST /.pods sends provisionKeys: true on a --public server, and docs/provision-keys.md gains an Incompatible-with-public section spelling out both refusal points. Tests cover the full truth table for the assertion plus an HTTP-level 400 integration test.
| The file mode is set to `0o600` on POSIX. WAC restricts HTTP access to the pod owner via `<pod>/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 <owner-token>" \ | ||
| 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: |
There was a problem hiding this comment.
Addressed in 14c543d: refused the provisionKeys + --public combination at every entry point. New assertProvisionKeysCompatible() exported from src/keys/provision.js; createServer throws synchronously if both flags are set (server doesn't start), handleCreatePod returns 400 if a POST /.pods sends provisionKeys: true on a --public server, and docs/provision-keys.md gains an Incompatible-with-public section spelling out both refusal points. Tests cover the full truth table for the assertion plus an HTTP-level 400 integration test.
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.
| 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, assertProvisionKeysCompatible } from '../keys/provision.js'; |
There was a problem hiding this comment.
Addressed in eaa25d2: switched the call site to use assertProvisionKeysCompatible (the dead-import warning is gone) and converted the throw into a 400 response — single source of truth for the error message shared with createServer's startup-time check.
| await storage.write( | ||
| `${podPath}private/privkey.jsonld`, | ||
| JSON.stringify(ownerKey.document, null, 2), | ||
| { mode: 0o600 } | ||
| ); |
There was a problem hiding this comment.
Addressed in eaa25d2: storage.write's boolean return is now checked at both call sites. createPodStructure throws on !ok so the existing handleCreatePod cleanup path (storage.remove + 500) runs. createRootPodStructure also throws so single-user startup fails loud rather than logging 'Provisioned …' against a missing file.
| await storage.write( | ||
| '/private/privkey.jsonld', | ||
| JSON.stringify(ownerKey.document, null, 2), | ||
| { mode: 0o600 } | ||
| ); |
There was a problem hiding this comment.
Addressed in eaa25d2: storage.write's boolean return is now checked at both call sites. createPodStructure throws on !ok so the existing handleCreatePod cleanup path (storage.remove + 500) runs. createRootPodStructure also throws so single-user startup fails loud rather than logging 'Provisioned …' against a missing file.
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.
| // 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) { |
There was a problem hiding this comment.
Addressed in ac78dad: switched createPodStructure to strict === true on options.provisionKeys, matching handleCreatePod's HTTP-side check on the body field. New regression test asserts the direct-entry strict check (string 'true' doesn't trigger). 848/848 tests pass.
| * 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. |
There was a problem hiding this comment.
Addressed in ac78dad: updated the JSDoc and inline comment to reflect actual behaviour — chmod is attempted on every platform (NTFS approximates POSIX modes coarsely as a read-only flag based on owner perms), callers should not rely on it for cross-platform secrecy. No platform guard added; the warn-on-failure path catches anything weird.
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.
| // 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. | ||
| // | ||
| // 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 | ||
| }); |
There was a problem hiding this comment.
Addressed in 9109b3d: coerced provisionKeysEnabled to strict === true at the createServer boundary. Both root-pod and named-pod paths now see the same value for the same input — a misconfigured 'true' / 1 / etc. falls through to false at entry, no longer activating the root branch's truthy check while skipping the named branch's strict check. Same fix as the createPodStructure pickup from the previous round, applied at the matching boundary.
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.
| * `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). |
Summary
Adds a
--provision-keysCLI flag (andprovisionKeys: truebody field onPOST /.pods) that, on pod creation, generates a Schnorr secp256k1 keypair and writes it as a W3C CID v1.0 Multikey document at<pod>/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 in Phase 2. One CLI flag → pod-resident self-sovereign identity ready for both Solid and Nostr / agentic use cases.Phase 1 of #437. Closes that issue's Phase 1 acceptance criteria. The same issue body has the design resolutions for the six pre-implementation questions (path choice, controller in Phase 1, no
nostrblock, opt-in, multi-user via HTTP body, plaintext-with-mitigations).Usage
What gets written
{ "@context": "https://www.w3.org/ns/cid/v1", "type": "Multikey", "controller": "https://your.example/profile/card.jsonld#me", "publicKeyMultibase": "fe70102…", "secretKeyMultibase": "f81260a1…" }Encoding: f-form Multikey (multibase
f+ multicodec varint + payload, base16-lower) — matchessrc/auth/nostr-keys.js's existing decoder. Round-trips throughdecodeFFormSecp256k1. No new dependencies.File mode:
0o600on POSIX. WAC protects HTTP access (owner-only on/private/); the file mode blocks a separate exposure class (other unix users on the host, backups that don't preserve permissions). Defence-in-depth, not a replacement for FDE / OS keyring.Design decisions (logged in the issue body)
nostrextension block. 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. ThesecretKeyMultibaseIS the secret, in only one place.did:nostr:once the resolver lands. The document is self-consistent at every phase — strict CID consumers won't see unresolvable references./private/privkey.jsonld. Deviation from SolidOS's/settings/keys/convention./private/is the obvious "private namespace" and gets owner-only WAC by default in JSS-created pods. Documented to prevent future "fix" PRs.jss create-podsubcommand; the flag rides on the existingPOST /.podssurface so the two paths stay in lockstep.jss start --single-user. Visible choice required.Tests
840/840 pass (22 new):
test/keys-provision.test.js— 13 unit tests:publicKeyMultibase/secretKeyMultibasedecodeFFormSecp256k1decoder insrc/auth/nostr-keys.jsbuildOwnerKeyDocumentshape (W3C CID v1.0 conformant; WebID controller in Phase 1; nonostrblock)test/keys-provision-integration.test.js— 9 integration tests:POST /.podswithprovisionKeys: truewrites the file, response surfaces the public side, response never echoes the secret0o600on POSIX (skipped on Windows)provisionKeys: false, andprovisionKeys: "true"(string, not boolean) all result in no file writtencreatePodStructuredirect call returnsownerKeyonly when option is setTest plan
npm test— 840/840 passing locallyOut of scope (deferred to later phases per #437)
--key-passphrase/ scrypt+aesgcm wrap (Phase 3)Closes #437 Phase 1.