Skip to content

Commit 14c543d

Browse files
review: refuse provisionKeys + --public combination
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.
1 parent 2de873e commit 14c543d

6 files changed

Lines changed: 161 additions & 2 deletions

File tree

‎docs/provision-keys.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ Same effect as the CLI flag; useful in containerised deployments.
6868

6969
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).
7070

71+
## Incompatible with `--public`
72+
73+
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.
74+
75+
The two flags are explicitly incompatible:
76+
77+
- **At server start**: `jss start --single-user --provision-keys --public` throws at server-create time with a clear error. The server doesn't start.
78+
- **At pod creation over HTTP**: `POST /.pods` with `provisionKeys: true` returns **400 Bad Request** when the server is in `--public` mode.
79+
80+
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.
81+
7182
## Threat model and protection
7283

7384
The web user (pod owner) reads the key over HTTP, authenticated against their WebID. WAC checks the request and serves the file:

‎src/handlers/container.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getAllHeaders } from '../ldp/headers.js';
44
import { isContainer, getEffectiveUrlPath, getPodName } from '../utils/url.js';
55
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
66
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, generatePublicFolderAcl, serializeAcl, relativizeOwnerWebId } from '../wac/parser.js';
7-
import { provisionOwnerKey } from '../keys/provision.js';
7+
import { provisionOwnerKey, assertProvisionKeysCompatible } from '../keys/provision.js';
88
import { createToken } from '../auth/token.js';
99
import { canAcceptInput, toJsonLd, RDF_TYPES } from '../rdf/conneg.js';
1010
import { emitChange } from '../notifications/events.js';
@@ -306,6 +306,19 @@ export async function handleCreatePod(request, reply) {
306306
return reply.code(400).send({ error: 'Invalid pod name. Use alphanumeric, dash, or underscore only.' });
307307
}
308308

309+
// Refuse provisionKeys + --public: WAC would be bypassed, exposing the
310+
// freshly written secret to anyone. Surfaces the contradiction at
311+
// request time rather than after a key leak.
312+
if (provisionKeys === true && request.config?.public) {
313+
return reply.code(400).send({
314+
error: 'provisionKeys cannot be used in --public mode',
315+
message:
316+
'--public bypasses WAC, which would make /private/privkey.jsonld ' +
317+
'publicly readable. Use provisionKeys with WAC enforcement (the ' +
318+
'default), or drop --public.'
319+
});
320+
}
321+
309322
const podPath = `/${name}/`;
310323

311324
// Check if pod already exists

‎src/keys/provision.js‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ export function provisionOwnerKey({ controllerWebId }) {
119119
};
120120
}
121121

122+
/**
123+
* Refuse to provision keys when WAC is being bypassed.
124+
*
125+
* `--public` (and `request.config.public`) tells JSS to skip WAC and
126+
* grant unauthenticated access to everything. Combined with
127+
* `--provision-keys`, it would mean a plaintext secret at
128+
* `/private/privkey.jsonld` is readable by anyone over HTTP — the
129+
* exact opposite of what the seeded owner-only ACL is supposed to do.
130+
*
131+
* Throws a clear error so the operator hits the contradiction at
132+
* startup (or pod-creation) rather than discovering it by reading
133+
* their own server logs after a key leak.
134+
*
135+
* @param {object} args
136+
* @param {boolean} args.provisionKeys
137+
* @param {boolean} args.isPublic - jss `--public` mode
138+
* @throws {Error} when both flags are true
139+
*/
140+
export function assertProvisionKeysCompatible({ provisionKeys, isPublic }) {
141+
if (provisionKeys && isPublic) {
142+
throw new Error(
143+
'--provision-keys cannot be combined with --public. --public bypasses ' +
144+
'WAC, which would make /private/privkey.jsonld readable by anyone over ' +
145+
'HTTP. Use --provision-keys with WAC enforcement (the default), or drop ' +
146+
'--public.'
147+
);
148+
}
149+
}
150+
122151
function bytesToHex(bytes) {
123152
let s = '';
124153
for (let i = 0; i < bytes.length; i++) {

‎src/server.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { tunnelPlugin } from './tunnel/index.js';
2929
import { terminalPlugin } from './terminal/index.js';
3030
import { registerErrorHandler } from './utils/error-handler.js';
3131
import { seedServerRoot } from './ui/server-root.js';
32+
import { assertProvisionKeysCompatible } from './keys/provision.js';
3233

3334
const __dirname = dirname(fileURLToPath(import.meta.url));
3435

@@ -141,7 +142,15 @@ export function createServer(options = {}) {
141142
// when a single-user pod is first created. Phase 1 of #437. Off by
142143
// default: keys-on-disk is a real security tradeoff, opt-in keeps
143144
// the choice visible to the operator.
145+
//
146+
// Refuse the --provision-keys + --public combination at server-create
147+
// time so the operator hits the contradiction immediately rather than
148+
// by reading a leaked key from logs / the public web. See #442 review.
144149
const provisionKeysEnabled = options.provisionKeys ?? false;
150+
assertProvisionKeysCompatible({
151+
provisionKeys: provisionKeysEnabled,
152+
isPublic: !!options.public
153+
});
145154
const mongoUrl = options.mongoUrl ?? 'mongodb://localhost:27017';
146155
const mongoDatabase = options.mongoDatabase ?? 'solid';
147156
// HTTP 402 paid /pay/ routes are OFF by default

‎test/keys-provision-integration.test.js‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,72 @@ describe('POST /.pods — provisionKeys: true (Phase 1 of #437)', () => {
152152
});
153153
});
154154

155+
describe('POST /.pods — provisionKeys + --public (footgun guard)', () => {
156+
// --public mode bypasses WAC: every resource becomes publicly
157+
// readable. Combined with provisionKeys, /private/privkey.jsonld
158+
// would be the world's worst secret store. Refuse the combination
159+
// at request time with 400 so the operator hits the contradiction
160+
// immediately. See #442 review.
161+
let server;
162+
let baseUrl;
163+
let savedDataRoot;
164+
const DATA_DIR = './test-data-provision-public';
165+
166+
before(async () => {
167+
savedDataRoot = process.env.DATA_ROOT;
168+
await fs.remove(DATA_DIR);
169+
await fs.ensureDir(DATA_DIR);
170+
const { createServer } = await import('../src/server.js');
171+
server = createServer({
172+
logger: false,
173+
forceCloseConnections: true,
174+
root: DATA_DIR,
175+
public: true
176+
});
177+
await server.listen({ port: 0, host: '127.0.0.1' });
178+
baseUrl = `http://127.0.0.1:${server.server.address().port}`;
179+
});
180+
after(async () => {
181+
await server.close();
182+
await fs.remove(DATA_DIR);
183+
if (savedDataRoot === undefined) delete process.env.DATA_ROOT;
184+
else process.env.DATA_ROOT = savedDataRoot;
185+
});
186+
187+
it('rejects POST /.pods with provisionKeys: true on a --public server (400)', async () => {
188+
const res = await fetch(`${baseUrl}/.pods`, {
189+
method: 'POST',
190+
headers: { 'Content-Type': 'application/json' },
191+
body: JSON.stringify({ name: 'shouldnotexist', provisionKeys: true })
192+
});
193+
assert.strictEqual(res.status, 400);
194+
const body = await res.json();
195+
assert.match(body.message || body.error || '', /public/i,
196+
'error must explain why provisionKeys is rejected');
197+
// Pod must not have been created.
198+
assert.strictEqual(await fs.pathExists(`${DATA_DIR}/shouldnotexist/`), false);
199+
});
200+
201+
it('still allows POST /.pods without provisionKeys on a --public server', async () => {
202+
const res = await fetch(`${baseUrl}/.pods`, {
203+
method: 'POST',
204+
headers: { 'Content-Type': 'application/json' },
205+
body: JSON.stringify({ name: 'publicpod' })
206+
});
207+
assert.strictEqual(res.status, 201);
208+
});
209+
});
210+
211+
describe('createServer — provisionKeys + --public refused at start', () => {
212+
it('throws synchronously when both options are set', async () => {
213+
const { createServer } = await import('../src/server.js');
214+
assert.throws(
215+
() => createServer({ logger: false, provisionKeys: true, public: true }),
216+
/cannot be combined with --public/
217+
);
218+
});
219+
});
220+
155221
describe('createPodStructure — provisionKeys option (direct call)', () => {
156222
before(async () => {
157223
await startTestServer();

‎test/keys-provision.test.js‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
publicKeyMultibase,
1111
secretKeyMultibase,
1212
buildOwnerKeyDocument,
13-
provisionOwnerKey
13+
provisionOwnerKey,
14+
assertProvisionKeysCompatible
1415
} from '../src/keys/provision.js';
1516
import { decodeFFormSecp256k1 } from '../src/auth/nostr-keys.js';
1617

@@ -131,6 +132,36 @@ describe('provisionOwnerKey', () => {
131132
});
132133
});
133134

135+
describe('assertProvisionKeysCompatible', () => {
136+
// Refuse the provisionKeys + --public combination — public mode
137+
// bypasses WAC, which would expose the freshly-written secret on
138+
// /private/privkey.jsonld to anyone over HTTP.
139+
it('throws when both provisionKeys and isPublic are true', () => {
140+
assert.throws(
141+
() => assertProvisionKeysCompatible({ provisionKeys: true, isPublic: true }),
142+
/cannot be combined with --public/
143+
);
144+
});
145+
146+
it('does NOT throw for provisionKeys alone (the supported case)', () => {
147+
assert.doesNotThrow(() =>
148+
assertProvisionKeysCompatible({ provisionKeys: true, isPublic: false })
149+
);
150+
});
151+
152+
it('does NOT throw for --public alone (no secrets being written)', () => {
153+
assert.doesNotThrow(() =>
154+
assertProvisionKeysCompatible({ provisionKeys: false, isPublic: true })
155+
);
156+
});
157+
158+
it('does NOT throw when neither flag is set', () => {
159+
assert.doesNotThrow(() =>
160+
assertProvisionKeysCompatible({ provisionKeys: false, isPublic: false })
161+
);
162+
});
163+
});
164+
134165
function hexToBytes(hex) {
135166
if (hex.length % 2 !== 0) throw new Error('hex length must be even');
136167
const out = new Uint8Array(hex.length / 2);

0 commit comments

Comments
 (0)