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 */
5063export 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 - 9 a - 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 - 9 a - 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 - 9 a - 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 - 9 a - f A - 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