Skip to content

Commit 66bd3bd

Browse files
feat: Phase 2 — wire owner key into WebID profile + did:nostr controller (JavaScriptSolidServer#443)
Closes the LWS-CID auth loop with the Phase 1 keypair. Three wires: 1. provisionOwnerKey() now also produces a verificationMethod entry (publicKeyMultibase for CID v1.0 conformance + publicKeyJwk for the LWS-CID verifier). createPodStructure / createRootPodStructure inject the VM into the seeded WebID profile so the existing verifier in src/auth/lws-cid.js can authenticate JWTs signed with the on-disk secret. The same VM `id` is referenced from `authentication` and `assertionMethod` so the key counts as an auth factor without an app having to PATCH those arrays. 2. The Multikey document at /private/privkey.jsonld now uses `did:nostr:<hex>` as its controller (was the WebID in Phase 1). The Phase 1 design log explicitly anticipated this swap once the resolver landed; jss has had `resolveDidNostrLocally` and `resolveDidNostrToWebId` for a while, so the controller is no longer a dangling reference. Backward-compat: callers can still pass an explicit `controller` if they want the WebID form. 3. generateOwnerKeypair now normalizes the secret so G*secret has even y (BIP-340 convention). Without normalization, ECDSA signatures made with the raw secret would verify against the *natural* y of the public point — even/odd ~50/50 — while the JWK we publish in the profile is derived from the even-y x-only Schnorr pubkey. Phase 2's LWS-CID round-trip would flake non- deterministically. Normalizing once at generation means a single secret-on-disk works under both Schnorr (Nostr) and ECDSA (LWS-CID JWT) without parity gymnastics in either path. Tests: - 6 new unit tests in test/keys-provision.test.js: did:nostr controller default, explicit override, VM shape (Multikey + JWK), legacy controllerWebId arg backward-compat, secret normalization stress (64 iterations). - 1 new integration test asserting the direct createPodStructure call returns the new ownerKey shape (vm + didNostr). - 1 new test file (test/keys-provision-lws-cid.test.js) with the end-to-end round-trip: provision a key, generate a profile with the VM, sign an LWS-CID JWT with the on-disk secret, verify via the existing verifyLwsCidAuth — returns the WebID. Plus a negative test: signing with a different secret rejects with a signature-verification error. No new dependencies. No changes to the LWS-CID verifier, the did:nostr resolver, or the profile generator's @context. Phase 2 is two wires plus a controller flip plus a parity-normalization fix to make the wires actually carry signal. Closes JavaScriptSolidServer#443. Phase 2 of JavaScriptSolidServer#437.
1 parent a0a2cd8 commit 66bd3bd

7 files changed

Lines changed: 520 additions & 59 deletions

File tree

src/handlers/container.js

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,24 @@ 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 });
193+
// Optional: provision a Schnorr secp256k1 owner key. The keypair is
194+
// generated up-front so the public side can be injected into the
195+
// WebID profile's verificationMethod array before the profile is
196+
// written. Phase 2 of #437 (#443) — see src/keys/provision.js for
197+
// the design notes. The on-disk secret file is written last so a
198+
// failure during ACL setup doesn't leave a key without protection.
199+
// Strict `=== true` (not just truthy) so a misconfigured caller
200+
// passing `'true'` / `1` / etc. doesn't silently activate; matches
201+
// handleCreatePod's HTTP-side check on the body field.
202+
const ownerKey = options.provisionKeys === true
203+
? provisionOwnerKey({ webId })
204+
: null;
205+
206+
// Generate and write WebID profile at /profile/card.jsonld. When
207+
// an owner key was provisioned, its VM lands in the profile so the
208+
// existing LWS-CID verifier (src/auth/lws-cid.js) can authenticate
209+
// JWTs signed with the matching secret.
210+
const profile = generateProfile({ webId, name, podUri, issuer, ownerVm: ownerKey?.vm });
195211
await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));
196212

197213
// Generate and write preferences
@@ -248,18 +264,14 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
248264
await initializeQuota(name, defaultQuota);
249265
}
250266

251-
// Optional: provision a Schnorr secp256k1 owner key in /private/.
252-
// Phase 1 of #437. See src/keys/provision.js for the design notes.
267+
// Owner-key file is written last (after the rest of the pod
268+
// structure exists) so a failure during ACL setup doesn't leave a
269+
// secret on disk without proper protection. The keypair itself was
270+
// generated up-front for profile injection; this step persists it.
253271
// Throw on write failure so the caller's cleanup path runs and the
254272
// pod isn't left with a phantom `ownerKey` in the response that
255273
// doesn't correspond to any on-disk file.
256-
//
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 });
274+
if (ownerKey) {
263275
const ok = await storage.write(
264276
`${podPath}private/privkey.jsonld`,
265277
JSON.stringify(ownerKey.document, null, 2),
@@ -272,7 +284,11 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
272284
}
273285
}
274286

275-
return { podPath, podUri, ownerKey };
287+
// Spread `ownerKey` only when set so the field is genuinely absent
288+
// (not `null`) on the no-provisioning path — matches the existing
289+
// test expectation that `result.ownerKey === undefined` when the
290+
// flag was omitted.
291+
return { podPath, podUri, ...(ownerKey && { ownerKey }) };
276292
}
277293

278294
/**

src/keys/provision.js

Lines changed: 176 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,170 @@ 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+
* @param {object} args
227+
* @param {string} args.webId - Pod owner's WebID. Used as the
228+
* `controller` of the VM and to derive the VM's `@id` fragment.
229+
* The Multikey document's `controller` is the `did:nostr:<hex>`
230+
* form by default (Phase 2 of #437 / #443).
231+
* @param {string} [args.controller] - Override the Multikey
232+
* document's controller. Defaults to `did:nostr:<publicHex>`.
233+
* @returns {{
234+
* document: object,
235+
* vm: object,
236+
* publicHex: string,
237+
* secretHex: string,
238+
* publicMultibase: string,
239+
* didNostr: string
240+
* }}
110241
*/
111-
export function provisionOwnerKey({ controllerWebId }) {
242+
export function provisionOwnerKey({ webId, controller, controllerWebId }) {
243+
// Backward-compat: callers from Phase 1 passed `controllerWebId` and
244+
// expected it to land in both the document's `controller` field and
245+
// (implicitly) the VM controller. Map it to the new shape.
246+
const effectiveWebId = webId ?? controllerWebId;
247+
if (typeof effectiveWebId !== 'string' || !effectiveWebId) {
248+
throw new Error('provisionOwnerKey: webId required');
249+
}
112250
const { publicHex, secretHex } = generateOwnerKeypair();
113-
const document = buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex });
251+
const document = buildOwnerKeyDocument({
252+
publicHex,
253+
secretHex,
254+
controller: controller ?? (controllerWebId ?? didNostrFromPublicHex(publicHex))
255+
});
256+
const vm = buildOwnerVerificationMethod({ webId: effectiveWebId, publicHex });
114257
return {
115258
document,
259+
vm,
116260
publicHex,
117261
secretHex,
118-
publicMultibase: document.publicKeyMultibase
262+
publicMultibase: document.publicKeyMultibase,
263+
didNostr: didNostrFromPublicHex(publicHex)
119264
};
120265
}
121266

@@ -155,3 +300,16 @@ function bytesToHex(bytes) {
155300
}
156301
return s;
157302
}
303+
304+
function hexToBase64Url(hex) {
305+
if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) {
306+
throw new Error('hexToBase64Url: expected even-length hex');
307+
}
308+
return Buffer.from(hex, 'hex').toString('base64url');
309+
}
310+
311+
function hexToBytes(hex) {
312+
const out = new Uint8Array(hex.length / 2);
313+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
314+
return out;
315+
}

src/server.js

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,8 +1045,18 @@ export function createServer(options = {}) {
10451045
await storage.createContainer('/settings/');
10461046
await storage.createContainer('/profile/');
10471047

1048-
// Generate profile
1049-
const profile = generateProfile({ webId, name: displayName, podUri, issuer });
1048+
// Generate the owner key up-front (when --provision-keys is set)
1049+
// so its public side can be injected into the WebID profile's
1050+
// verificationMethod array before the profile is written. Phase 2
1051+
// of #437 (#443). The on-disk secret file is written last, after
1052+
// the rest of the structure exists.
1053+
const ownerKey = provisionKeysEnabled
1054+
? provisionOwnerKey({ webId })
1055+
: null;
1056+
1057+
// Generate profile (with the owner key's VM landed in
1058+
// verificationMethod when --provision-keys is on).
1059+
const profile = generateProfile({ webId, name: displayName, podUri, issuer, ownerVm: ownerKey?.vm });
10501060
await storage.write('/profile/card.jsonld', serialize(profile));
10511061

10521062
// Preferences and type indexes
@@ -1090,13 +1100,11 @@ export function createServer(options = {}) {
10901100
const profileAcl = generatePublicFolderAcl('./', owner('profile/'));
10911101
await storage.write('/profile/.acl', serializeAcl(profileAcl));
10921102

1093-
// Optional: provision a Schnorr secp256k1 owner key in /private/.
1094-
// Phase 1 of #437. See src/keys/provision.js for the design notes.
1095-
// Throw on write failure so single-user startup fails loud rather
1096-
// than logging "Provisioned …" against a missing on-disk file.
1097-
let ownerKey;
1098-
if (provisionKeysEnabled) {
1099-
ownerKey = provisionOwnerKey({ controllerWebId: webId });
1103+
// Owner-key file is written last. The keypair itself was generated
1104+
// up-front for profile injection; this step persists it. Throw on
1105+
// write failure so single-user startup fails loud rather than
1106+
// logging "Provisioned …" against a missing on-disk file.
1107+
if (ownerKey) {
11001108
const ok = await storage.write(
11011109
'/private/privkey.jsonld',
11021110
JSON.stringify(ownerKey.document, null, 2),
@@ -1109,8 +1117,10 @@ export function createServer(options = {}) {
11091117
}
11101118
}
11111119

1112-
// Note: Quota not initialized for root-level pods (no user directory)
1113-
return { ownerKey };
1120+
// Note: Quota not initialized for root-level pods (no user directory).
1121+
// Spread `ownerKey` only when set so the field is genuinely absent
1122+
// (not `null`) on the no-provisioning path.
1123+
return { ...(ownerKey && { ownerKey }) };
11141124
}
11151125

11161126
// Start file watcher for live reload (watches filesystem for external changes)

0 commit comments

Comments
 (0)