Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bin/jss.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pod>/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 <url>', 'IdP issuer URL (defaults to server URL)')
.option('--subdomains', 'Enable subdomain-based pods (XSS protection)')
.option('--no-subdomains', 'Disable subdomain-based pods')
Expand Down Expand Up @@ -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,
Expand Down
157 changes: 157 additions & 0 deletions docs/provision-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Owner-key provisioning (`--provision-keys`)

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 `<pod>/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

`<pod>/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 `<pod>/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:

```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:
Comment on lines +69 to +91

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


- `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).
# For a single-user / root pod the key is at <pod>/private/privkey.jsonld;
# for a named pod (multi-user) it's at <pod>/<name>/private/privkey.jsonld.
curl -H "Authorization: Bearer <owner-token>" \
http://your.example/private/privkey.jsonld \
-o pod-key-backup.jsonld
# named-pod variant:
curl -H "Authorization: Bearer <owner-token>" \
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).
# Root pod (single-user, default since #348):
cp -p <DATA_ROOT>/private/privkey.jsonld pod-key-backup.jsonld
# Named pod (multi-user, or single-user with --single-user-name=alice):
cp -p <DATA_ROOT>/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.

## 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)
8 changes: 8 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pod>/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,

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -228,6 +235,7 @@ const BOOLEAN_KEYS = new Set([
'inviteOnly',
'multiuser',
'singleUser',
'provisionKeys',
'webidTls',
'public',
'readOnly',
Expand Down
84 changes: 78 additions & 6 deletions src/handlers/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, assertProvisionKeysCompatible } from '../keys/provision.js';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

import { createToken } from '../auth/token.js';
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
import { emitChange } from '../notifications/events.js';
Expand Down Expand Up @@ -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 `<pod>/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
Expand Down Expand Up @@ -236,7 +248,31 @@ 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.
// 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 === true) {
ownerKey = provisionOwnerKey({ controllerWebId: webId });
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 };
}

/**
Expand All @@ -260,7 +296,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') {
Expand All @@ -282,6 +318,25 @@ 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. 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}/`;

// Check if pod already exists
Expand Down Expand Up @@ -310,9 +365,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
Expand All @@ -326,6 +386,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;
Comment on lines +389 to +397

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.


// If IdP is enabled, create account and return token + login URL
if (idpEnabled) {
try {
Expand All @@ -340,6 +410,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);
Expand All @@ -356,6 +427,7 @@ export async function handleCreatePod(request, reply) {
name,
webId,
podUri,
token
token,
...(keyInfo && { ownerKey: keyInfo })
});
}
Loading