Skip to content

Commit 811fe6e

Browse files
Merge pull request JavaScriptSolidServer#444 from JavaScriptSolidServer/issue-443-phase-2-wire-key-into-profile
feat: Phase 2 — wire owner key into WebID profile + did:nostr controller (JavaScriptSolidServer#443)
2 parents a0a2cd8 + 001d832 commit 811fe6e

7 files changed

Lines changed: 614 additions & 64 deletions

File tree

src/handlers/container.js

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,20 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
190190
await storage.createContainer(`${podPath}settings/`);
191191
await storage.createContainer(`${podPath}profile/`);
192192

193-
// Generate and write WebID profile at /profile/card.jsonld
194-
const profile = generateProfile({ webId, name, podUri, issuer });
195-
await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));
193+
// Optional: provision a Schnorr secp256k1 owner key. The keypair is
194+
// generated in memory up-front so its VM can be injected into the
195+
// WebID profile that gets written last. The on-disk persistence of
196+
// the secret is deferred to *after* the ACL tree is in place — see
197+
// the ordering block further below. Strict `=== true` (not just
198+
// truthy) so a misconfigured caller passing `'true'` / `1` / etc.
199+
// doesn't silently activate; matches handleCreatePod's HTTP-side
200+
// check on the body field.
201+
const ownerKey = options.provisionKeys === true
202+
? provisionOwnerKey({ webId })
203+
: null;
204+
205+
// Profile is written last (see the ACL/privkey block below). Skip
206+
// the write here; we'll do it after privkey lands on disk.
196207

197208
// Generate and write preferences
198209
const prefs = generatePreferences({ webId, podUri });
@@ -248,18 +259,24 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
248259
await initializeQuota(name, defaultQuota);
249260
}
250261

251-
// Optional: provision a Schnorr secp256k1 owner key in /private/.
252-
// Phase 1 of #437. See src/keys/provision.js for the design notes.
253-
// Throw on write failure so the caller's cleanup path runs and the
254-
// pod isn't left with a phantom `ownerKey` in the response that
255-
// doesn't correspond to any on-disk file.
262+
// Owner-key persistence + profile write (when --provision-keys is on).
263+
// Order is load-bearing for two distinct concerns (#444 review):
264+
//
265+
// 1. WAC vacuum: write privkey *after* the ACL tree is in place so
266+
// the secret file is born under owner-only WAC. Without this,
267+
// there's a window where the file exists but no /private/.acl
268+
// protects it; jss's deny-by-default since #f43ecdf would
269+
// mitigate to 401, but defence-in-depth beats relying on a
270+
// security default holding.
256271
//
257-
// Strict `=== true` (not just truthy) so a misconfigured caller
258-
// passing `'true'` / `1` / etc. doesn't silently activate. Matches
259-
// handleCreatePod's HTTP-side check on the body field.
260-
let ownerKey;
261-
if (options.provisionKeys === true) {
262-
ownerKey = provisionOwnerKey({ controllerWebId: webId });
272+
// 2. Orphan-VM: write privkey *before* the profile so a crash
273+
// between the two leaves an orphan secret file (easy to delete)
274+
// rather than an orphan VM in a published WebID profile that
275+
// forever advertises an authentication method whose secret was
276+
// never persisted.
277+
//
278+
// Combined: ACLs (above) → privkey (here) → profile (next).
279+
if (ownerKey) {
263280
const ok = await storage.write(
264281
`${podPath}private/privkey.jsonld`,
265282
JSON.stringify(ownerKey.document, null, 2),
@@ -272,7 +289,19 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
272289
}
273290
}
274291

275-
return { podPath, podUri, ownerKey };
292+
// Generate and write WebID profile at /profile/card.jsonld. When an
293+
// owner key was provisioned, its VM lands in the profile so the
294+
// existing LWS-CID verifier (src/auth/lws-cid.js) can authenticate
295+
// JWTs signed with the matching secret. Profile is intentionally
296+
// written last — see ordering rationale above.
297+
const profile = generateProfile({ webId, name, podUri, issuer, ownerVm: ownerKey?.vm });
298+
await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));
299+
300+
// Spread `ownerKey` only when set so the field is genuinely absent
301+
// (not `null`) on the no-provisioning path — matches the existing
302+
// test expectation that `result.ownerKey === undefined` when the
303+
// flag was omitted.
304+
return { podPath, podUri, ...(ownerKey && { ownerKey }) };
276305
}
277306

278307
/**

src/keys/provision.js

Lines changed: 198 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* surface area or a new dependency.
2121
*/
2222

23-
import { schnorr } from '@noble/curves/secp256k1';
23+
import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
2424

2525
/**
2626
* Multicodec varints (lower-hex). The CCG / W3C CID v1.0 Multikey
@@ -43,12 +43,34 @@ const EVEN_Y_PARITY_HEX = '02';
4343
/**
4444
* Generate a fresh secp256k1 keypair suitable for Schnorr signing.
4545
*
46+
* The secret is normalized so that `G * secret` has *even* y (BIP-340
47+
* convention). Without normalization, ECDSA signatures made with the
48+
* raw secret would verify against the natural y of the public point —
49+
* even half the time, odd half the time — while the corresponding JWK
50+
* we publish in the WebID profile is always derived from the even-y
51+
* x-only Schnorr pubkey. The two would disagree on ~50% of generated
52+
* secrets, breaking Phase 2's LWS-CID round-trip non-deterministically.
53+
*
54+
* Normalizing once at generation time means a single secret-on-disk
55+
* works under both Schnorr (Nostr) and ECDSA (LWS-CID JWT) without
56+
* parity gymnastics in either signing path.
57+
*
4658
* @returns {{ secretHex: string, publicHex: string }}
47-
* `secretHex` — 32-byte secret scalar, lower-hex.
59+
* `secretHex` — 32-byte secret scalar, lower-hex, normalized so the
60+
* corresponding public point has even y.
4861
* `publicHex` — 32-byte x-only Schnorr pubkey, lower-hex (BIP-340).
4962
*/
5063
export function generateOwnerKeypair() {
51-
const secretBytes = schnorr.utils.randomPrivateKey();
64+
let secretBytes = schnorr.utils.randomPrivateKey();
65+
// Compressed SEC1 starts with 02 (even y) or 03 (odd y).
66+
const compressed = secp256k1.getPublicKey(secretBytes, /*compressed=*/true);
67+
if (compressed[0] === 0x03) {
68+
// Negate the secret (mod n) so the resulting point flips to even y.
69+
const n = secp256k1.CURVE.n;
70+
const original = BigInt('0x' + bytesToHex(secretBytes));
71+
const negated = (n - original) % n;
72+
secretBytes = hexToBytes(negated.toString(16).padStart(64, '0'));
73+
}
5274
const publicBytes = schnorr.getPublicKey(secretBytes);
5375
return {
5476
secretHex: bytesToHex(secretBytes),
@@ -75,47 +97,184 @@ export function secretKeyMultibase(secretHex) {
7597
return 'f' + MULTICODEC_SECP256K1_PRIV_HEX + secretHex;
7698
}
7799

100+
/**
101+
* Compute the canonical did:nostr identifier for a Schnorr secp256k1
102+
* pubkey. Lower-hex form (matches the rest of jss — see
103+
* src/idp/well-known-did-nostr.js's resolveDidNostrLocally(pubkeyHex)
104+
* and src/auth/did-nostr.js's `did:nostr:${pubkey.toLowerCase()}`).
105+
* Phase 2 of #437 (#443) flips the Multikey document's `controller`
106+
* field to this form now that jss's resolver round-trips it.
107+
*
108+
* @param {string} publicHex - 32-byte x-only Schnorr pubkey hex.
109+
* @returns {string} `did:nostr:<lower-hex pubkey>`.
110+
*/
111+
export function didNostrFromPublicHex(publicHex) {
112+
if (!/^[0-9a-f]{64}$/.test(publicHex)) {
113+
throw new Error('didNostrFromPublicHex: expected 64-char lower-hex pubkey');
114+
}
115+
return `did:nostr:${publicHex}`;
116+
}
117+
78118
/**
79119
* Build the W3C CID v1.0 Multikey JSON-LD document for a fresh pod
80-
* owner key. The `controller` is the pod owner's WebID — Phase 1
81-
* keeps the document self-consistent at every phase (Phase 2 will
82-
* swap in a `did:nostr:` controller once the resolver lands).
120+
* owner key.
121+
*
122+
* Phase 2 default controller: `did:nostr:<hex>`. The previous Phase 1
123+
* shape used the pod owner's WebID — both are valid CID v1.0
124+
* controllers, but did:nostr lets the keypair self-identify via jss's
125+
* existing resolver. Callers can still pass an explicit `controller`
126+
* (the legacy WebID form is what existing test fixtures use).
83127
*
84128
* @param {object} args
85-
* @param {string} args.controllerWebId - Absolute owner WebID URI.
86129
* @param {string} args.publicHex - 32-byte x-only Schnorr pubkey hex.
87130
* @param {string} args.secretHex - 32-byte secret scalar hex.
131+
* @param {string} [args.controller] - Override the controller field.
132+
* Defaults to `did:nostr:<publicHex>` (Phase 2 of #437 / #443).
88133
* @returns {object} JSON-LD Multikey document, ready for `JSON.stringify`.
89134
*/
90-
export function buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }) {
91-
if (typeof controllerWebId !== 'string' || !controllerWebId) {
92-
throw new Error('buildOwnerKeyDocument: controllerWebId required');
135+
export function buildOwnerKeyDocument({ publicHex, secretHex, controller }) {
136+
const ctrl = controller ?? didNostrFromPublicHex(publicHex);
137+
if (typeof ctrl !== 'string' || !ctrl) {
138+
throw new Error('buildOwnerKeyDocument: controller required');
93139
}
94140
return {
95141
'@context': 'https://www.w3.org/ns/cid/v1',
96142
type: 'Multikey',
97-
controller: controllerWebId,
143+
controller: ctrl,
98144
publicKeyMultibase: publicKeyMultibase(publicHex),
99145
secretKeyMultibase: secretKeyMultibase(secretHex)
100146
};
101147
}
102148

103149
/**
104-
* One-shot helper: generate a fresh keypair and produce both the
105-
* Multikey document and the raw key material (for log lines / CLI
106-
* output that wants to display the pubkey).
150+
* Compute the BIP-340 even-y JWK (kty=EC, crv=secp256k1) for an
151+
* x-only Schnorr pubkey hex. Phase 2 needs this for the VM that
152+
* lands in the WebID profile — the LWS-CID verifier currently reads
153+
* `publicKeyJwk` only (Multikey-only VMs aren't yet handled there
154+
* per `src/auth/lws-cid.js`'s docstring), so the VM ships both
155+
* `publicKeyMultibase` (CID v1.0 conformance) and `publicKeyJwk`
156+
* (LWS-CID compat).
157+
*
158+
* The y coordinate is computed against the canonical even-y point at
159+
* `x` — same convention `src/auth/nostr-keys.js`'s
160+
* `pubkeyFromValidatedJwk` checks against on the verifier side, so
161+
* the VM we mint round-trips through the existing extractor without
162+
* being rejected as "wrong y".
163+
*
164+
* @param {string} publicHex - 32-byte x-only Schnorr pubkey hex.
165+
* @returns {{ kty: 'EC', crv: 'secp256k1', x: string, y: string }}
166+
*/
167+
export function publicKeyJwkFromHex(publicHex) {
168+
if (!/^[0-9a-f]{64}$/.test(publicHex)) {
169+
throw new Error('publicKeyJwkFromHex: expected 64-char lower-hex pubkey');
170+
}
171+
// Compressed SEC1 with parity 02 = the canonical even-y point at x.
172+
const point = secp256k1.ProjectivePoint.fromHex('02' + publicHex);
173+
const yHex = point.toAffine().y.toString(16).padStart(64, '0');
174+
return {
175+
kty: 'EC',
176+
crv: 'secp256k1',
177+
x: hexToBase64Url(publicHex),
178+
y: hexToBase64Url(yHex)
179+
};
180+
}
181+
182+
/**
183+
* Build the verificationMethod entry for the seeded WebID profile.
184+
*
185+
* The VM wires the Phase 1 keypair into the existing LWS-CID auth
186+
* loop: an agent signs a JWT with the on-disk secret + `kid` set to
187+
* this VM's `id`, the verifier (already merged in `src/auth/lws-cid.js`)
188+
* fetches the WebID profile, finds this VM, decodes the JWK, verifies
189+
* the signature, and returns the WebID as the authenticated identity.
190+
*
191+
* Both forms are emitted: `publicKeyMultibase` for CID v1.0 readers
192+
* (and round-trip with `decodeFFormSecp256k1` in `src/auth/nostr-keys.js`),
193+
* `publicKeyJwk` for the LWS-CID verifier and any tool that prefers JOSE.
194+
*
195+
* @param {object} args
196+
* @param {string} args.webId - Pod owner's WebID; used to derive both
197+
* the `controller` and the document URL the VM `id` sits in.
198+
* @param {string} args.publicHex - 32-byte x-only Schnorr pubkey hex.
199+
* @param {string} [args.fragment='owner-key'] - Fragment id for the VM;
200+
* appended to the WebID document URL to form `vm.id`.
201+
* @returns {object} JSON-LD verificationMethod entry.
202+
*/
203+
export function buildOwnerVerificationMethod({ webId, publicHex, fragment = 'owner-key' }) {
204+
if (typeof webId !== 'string' || !webId) {
205+
throw new Error('buildOwnerVerificationMethod: webId required');
206+
}
207+
const docUrl = webId.split('#')[0];
208+
return {
209+
'@id': `${docUrl}#${fragment}`,
210+
'@type': 'Multikey',
211+
controller: webId,
212+
publicKeyMultibase: publicKeyMultibase(publicHex),
213+
publicKeyJwk: publicKeyJwkFromHex(publicHex)
214+
};
215+
}
216+
217+
/**
218+
* One-shot helper: generate a fresh keypair and produce the Multikey
219+
* document, the verificationMethod entry for the WebID profile, and
220+
* the raw key material (for log lines / CLI output that wants to
221+
* display the pubkey).
107222
*
108223
* The returned `secretHex` should be considered sensitive and not
109-
* logged; the public `multibase` IS safe to print.
224+
* logged; the public `publicMultibase` IS safe to print.
225+
*
226+
* Single canonical input shape — `webId` for VM controller + VM `@id`
227+
* derivation, `documentController` to override the Multikey document's
228+
* controller (defaults to `did:nostr:<publicHex>` per Phase 2 of #437).
229+
* The previous `controllerWebId` legacy alias was removed in #443
230+
* because it created surprising precedence interactions with the new
231+
* `documentController` and asymmetric document/VM controllers.
232+
*
233+
* **Two controllers, on purpose.** `vm.controller` lives inside the
234+
* WebID profile and always tracks `webId` — it identifies who in
235+
* WebID-land can present this VM (the pod owner). `document.controller`
236+
* lives in /private/privkey.jsonld and identifies the key in its own
237+
* right (default did:nostr, overridable). They're decoupled by design;
238+
* `documentController` does NOT propagate to the VM. If a caller wants
239+
* both to point at, say, a custom DID, they should construct the VM
240+
* separately via `buildOwnerVerificationMethod` and merge.
241+
*
242+
* @param {object} args
243+
* @param {string} args.webId - Pod owner's WebID. Used as the VM
244+
* controller and to derive the VM's `@id` fragment.
245+
* @param {string} [args.documentController] - Override the Multikey
246+
* document's controller. Defaults to `did:nostr:<publicHex>`.
247+
* @returns {{
248+
* document: object,
249+
* vm: object,
250+
* publicHex: string,
251+
* secretHex: string,
252+
* publicMultibase: string,
253+
* didNostr: string
254+
* }}
110255
*/
111-
export function provisionOwnerKey({ controllerWebId }) {
256+
export function provisionOwnerKey({ webId, documentController }) {
257+
if (typeof webId !== 'string' || !webId) {
258+
throw new Error('provisionOwnerKey: webId required');
259+
}
112260
const { publicHex, secretHex } = generateOwnerKeypair();
113-
const document = buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex });
261+
// Pass `documentController` through verbatim — buildOwnerKeyDocument
262+
// already defaults to `did:nostr:<publicHex>` when none is given, so
263+
// duplicating the fallback here would just create two sources of
264+
// truth for the default controller.
265+
const document = buildOwnerKeyDocument({
266+
publicHex,
267+
secretHex,
268+
controller: documentController
269+
});
270+
const vm = buildOwnerVerificationMethod({ webId, publicHex });
114271
return {
115272
document,
273+
vm,
116274
publicHex,
117275
secretHex,
118-
publicMultibase: document.publicKeyMultibase
276+
publicMultibase: document.publicKeyMultibase,
277+
didNostr: didNostrFromPublicHex(publicHex)
119278
};
120279
}
121280

@@ -155,3 +314,24 @@ function bytesToHex(bytes) {
155314
}
156315
return s;
157316
}
317+
318+
function hexToBase64Url(hex) {
319+
if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) {
320+
throw new Error('hexToBase64Url: expected even-length hex');
321+
}
322+
return Buffer.from(hex, 'hex').toString('base64url');
323+
}
324+
325+
function hexToBytes(hex) {
326+
// Validate up front — without this, `parseInt('zz', 16)` returns
327+
// NaN and silently writes 0 into the byte slot. Today the only
328+
// caller passes freshly-generated 32-byte hex, but the helper is
329+
// shared infrastructure: every sibling helper (`publicKeyJwkFromHex`,
330+
// `didNostrFromPublicHex`, `hexToBase64Url`) validates the same way.
331+
if (typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) {
332+
throw new Error('hexToBytes: expected even-length hex');
333+
}
334+
const out = new Uint8Array(hex.length / 2);
335+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
336+
return out;
337+
}

0 commit comments

Comments
 (0)