-
Notifications
You must be signed in to change notification settings - Fork 9
feat: Phase 2 — wire owner key into WebID profile + did:nostr controller (#443) #444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
66bd3bd
2285d05
d375f18
c16e1d0
e8a268b
001d832
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,7 @@ | |
| * surface area or a new dependency. | ||
| */ | ||
|
|
||
| import { schnorr } from '@noble/curves/secp256k1'; | ||
| import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; | ||
|
|
||
| /** | ||
| * Multicodec varints (lower-hex). The CCG / W3C CID v1.0 Multikey | ||
|
|
@@ -43,12 +43,34 @@ const EVEN_Y_PARITY_HEX = '02'; | |
| /** | ||
| * Generate a fresh secp256k1 keypair suitable for Schnorr signing. | ||
| * | ||
| * The secret is normalized so that `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 half the time, odd half the time — while the corresponding JWK | ||
| * we publish in the WebID profile is always derived from the even-y | ||
| * x-only Schnorr pubkey. The two would disagree on ~50% of generated | ||
| * secrets, breaking Phase 2's LWS-CID round-trip non-deterministically. | ||
| * | ||
| * Normalizing once at generation time means a single secret-on-disk | ||
| * works under both Schnorr (Nostr) and ECDSA (LWS-CID JWT) without | ||
| * parity gymnastics in either signing path. | ||
| * | ||
| * @returns {{ secretHex: string, publicHex: string }} | ||
| * `secretHex` — 32-byte secret scalar, lower-hex. | ||
| * `secretHex` — 32-byte secret scalar, lower-hex, normalized so the | ||
| * corresponding public point has even y. | ||
| * `publicHex` — 32-byte x-only Schnorr pubkey, lower-hex (BIP-340). | ||
| */ | ||
| export function generateOwnerKeypair() { | ||
| const secretBytes = schnorr.utils.randomPrivateKey(); | ||
| let secretBytes = schnorr.utils.randomPrivateKey(); | ||
| // Compressed SEC1 starts with 02 (even y) or 03 (odd y). | ||
| const compressed = secp256k1.getPublicKey(secretBytes, /*compressed=*/true); | ||
| if (compressed[0] === 0x03) { | ||
| // Negate the secret (mod n) so the resulting point flips to even y. | ||
| const n = secp256k1.CURVE.n; | ||
| const original = BigInt('0x' + bytesToHex(secretBytes)); | ||
| const negated = (n - original) % n; | ||
| secretBytes = hexToBytes(negated.toString(16).padStart(64, '0')); | ||
| } | ||
| const publicBytes = schnorr.getPublicKey(secretBytes); | ||
| return { | ||
| secretHex: bytesToHex(secretBytes), | ||
|
|
@@ -75,47 +97,184 @@ export function secretKeyMultibase(secretHex) { | |
| return 'f' + MULTICODEC_SECP256K1_PRIV_HEX + secretHex; | ||
| } | ||
|
|
||
| /** | ||
| * Compute the canonical did:nostr identifier for a Schnorr secp256k1 | ||
| * pubkey. Lower-hex form (matches the rest of jss — see | ||
| * src/idp/well-known-did-nostr.js's resolveDidNostrLocally(pubkeyHex) | ||
| * and src/auth/did-nostr.js's `did:nostr:${pubkey.toLowerCase()}`). | ||
| * Phase 2 of #437 (#443) flips the Multikey document's `controller` | ||
| * field to this form now that jss's resolver round-trips it. | ||
| * | ||
| * @param {string} publicHex - 32-byte x-only Schnorr pubkey hex. | ||
| * @returns {string} `did:nostr:<lower-hex pubkey>`. | ||
| */ | ||
| export function didNostrFromPublicHex(publicHex) { | ||
| if (!/^[0-9a-f]{64}$/.test(publicHex)) { | ||
| throw new Error('didNostrFromPublicHex: expected 64-char lower-hex pubkey'); | ||
| } | ||
| return `did:nostr:${publicHex}`; | ||
| } | ||
|
|
||
| /** | ||
| * Build the W3C CID v1.0 Multikey JSON-LD document for a fresh pod | ||
| * owner key. The `controller` is the pod owner's WebID — Phase 1 | ||
| * keeps the document self-consistent at every phase (Phase 2 will | ||
| * swap in a `did:nostr:` controller once the resolver lands). | ||
| * owner key. | ||
| * | ||
| * Phase 2 default controller: `did:nostr:<hex>`. The previous Phase 1 | ||
| * shape used the pod owner's WebID — both are valid CID v1.0 | ||
| * controllers, but did:nostr lets the keypair self-identify via jss's | ||
| * existing resolver. Callers can still pass an explicit `controller` | ||
| * (the legacy WebID form is what existing test fixtures use). | ||
| * | ||
| * @param {object} args | ||
| * @param {string} args.controllerWebId - Absolute owner WebID URI. | ||
| * @param {string} args.publicHex - 32-byte x-only Schnorr pubkey hex. | ||
| * @param {string} args.secretHex - 32-byte secret scalar hex. | ||
| * @param {string} [args.controller] - Override the controller field. | ||
| * Defaults to `did:nostr:<publicHex>` (Phase 2 of #437 / #443). | ||
| * @returns {object} JSON-LD Multikey document, ready for `JSON.stringify`. | ||
| */ | ||
| export function buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }) { | ||
| if (typeof controllerWebId !== 'string' || !controllerWebId) { | ||
| throw new Error('buildOwnerKeyDocument: controllerWebId required'); | ||
| export function buildOwnerKeyDocument({ publicHex, secretHex, controller }) { | ||
| const ctrl = controller ?? didNostrFromPublicHex(publicHex); | ||
| if (typeof ctrl !== 'string' || !ctrl) { | ||
| throw new Error('buildOwnerKeyDocument: controller required'); | ||
| } | ||
| return { | ||
| '@context': 'https://www.w3.org/ns/cid/v1', | ||
| type: 'Multikey', | ||
| controller: controllerWebId, | ||
| controller: ctrl, | ||
| publicKeyMultibase: publicKeyMultibase(publicHex), | ||
| secretKeyMultibase: secretKeyMultibase(secretHex) | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * One-shot helper: generate a fresh keypair and produce both the | ||
| * Multikey document and the raw key material (for log lines / CLI | ||
| * output that wants to display the pubkey). | ||
| * Compute the BIP-340 even-y JWK (kty=EC, crv=secp256k1) for an | ||
| * x-only Schnorr pubkey hex. Phase 2 needs this for the VM that | ||
| * lands in the WebID profile — the LWS-CID verifier currently reads | ||
| * `publicKeyJwk` only (Multikey-only VMs aren't yet handled there | ||
| * per `src/auth/lws-cid.js`'s docstring), so the VM ships both | ||
| * `publicKeyMultibase` (CID v1.0 conformance) and `publicKeyJwk` | ||
| * (LWS-CID compat). | ||
| * | ||
| * The y coordinate is computed against the canonical even-y point at | ||
| * `x` — same convention `src/auth/nostr-keys.js`'s | ||
| * `pubkeyFromValidatedJwk` checks against on the verifier side, so | ||
| * the VM we mint round-trips through the existing extractor without | ||
| * being rejected as "wrong y". | ||
| * | ||
| * @param {string} publicHex - 32-byte x-only Schnorr pubkey hex. | ||
| * @returns {{ kty: 'EC', crv: 'secp256k1', x: string, y: string }} | ||
| */ | ||
| export function publicKeyJwkFromHex(publicHex) { | ||
| if (!/^[0-9a-f]{64}$/.test(publicHex)) { | ||
| throw new Error('publicKeyJwkFromHex: expected 64-char lower-hex pubkey'); | ||
| } | ||
| // Compressed SEC1 with parity 02 = the canonical even-y point at x. | ||
| const point = secp256k1.ProjectivePoint.fromHex('02' + publicHex); | ||
| const yHex = point.toAffine().y.toString(16).padStart(64, '0'); | ||
| return { | ||
| kty: 'EC', | ||
| crv: 'secp256k1', | ||
| x: hexToBase64Url(publicHex), | ||
| y: hexToBase64Url(yHex) | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Build the verificationMethod entry for the seeded WebID profile. | ||
| * | ||
| * The VM wires the Phase 1 keypair into the existing LWS-CID auth | ||
| * loop: an agent signs a JWT with the on-disk secret + `kid` set to | ||
| * this VM's `id`, the verifier (already merged in `src/auth/lws-cid.js`) | ||
| * fetches the WebID profile, finds this VM, decodes the JWK, verifies | ||
| * the signature, and returns the WebID as the authenticated identity. | ||
| * | ||
| * Both forms are emitted: `publicKeyMultibase` for CID v1.0 readers | ||
| * (and round-trip with `decodeFFormSecp256k1` in `src/auth/nostr-keys.js`), | ||
| * `publicKeyJwk` for the LWS-CID verifier and any tool that prefers JOSE. | ||
| * | ||
| * @param {object} args | ||
| * @param {string} args.webId - Pod owner's WebID; used to derive both | ||
| * the `controller` and the document URL the VM `id` sits in. | ||
| * @param {string} args.publicHex - 32-byte x-only Schnorr pubkey hex. | ||
| * @param {string} [args.fragment='owner-key'] - Fragment id for the VM; | ||
| * appended to the WebID document URL to form `vm.id`. | ||
| * @returns {object} JSON-LD verificationMethod entry. | ||
| */ | ||
| export function buildOwnerVerificationMethod({ webId, publicHex, fragment = 'owner-key' }) { | ||
| if (typeof webId !== 'string' || !webId) { | ||
| throw new Error('buildOwnerVerificationMethod: webId required'); | ||
| } | ||
| const docUrl = webId.split('#')[0]; | ||
| return { | ||
| '@id': `${docUrl}#${fragment}`, | ||
| '@type': 'Multikey', | ||
| controller: webId, | ||
| publicKeyMultibase: publicKeyMultibase(publicHex), | ||
| publicKeyJwk: publicKeyJwkFromHex(publicHex) | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * One-shot helper: generate a fresh keypair and produce the Multikey | ||
| * document, the verificationMethod entry for the WebID profile, and | ||
| * the raw key material (for log lines / CLI output that wants to | ||
| * display the pubkey). | ||
| * | ||
| * The returned `secretHex` should be considered sensitive and not | ||
| * logged; the public `multibase` IS safe to print. | ||
| * logged; the public `publicMultibase` IS safe to print. | ||
| * | ||
| * Single canonical input shape — `webId` for VM controller + VM `@id` | ||
| * derivation, `documentController` to override the Multikey document's | ||
| * controller (defaults to `did:nostr:<publicHex>` per Phase 2 of #437). | ||
| * The previous `controllerWebId` legacy alias was removed in #443 | ||
| * because it created surprising precedence interactions with the new | ||
| * `documentController` and asymmetric document/VM controllers. | ||
| * | ||
| * **Two controllers, on purpose.** `vm.controller` lives inside the | ||
| * WebID profile and always tracks `webId` — it identifies who in | ||
| * WebID-land can present this VM (the pod owner). `document.controller` | ||
| * lives in /private/privkey.jsonld and identifies the key in its own | ||
| * right (default did:nostr, overridable). They're decoupled by design; | ||
| * `documentController` does NOT propagate to the VM. If a caller wants | ||
| * both to point at, say, a custom DID, they should construct the VM | ||
| * separately via `buildOwnerVerificationMethod` and merge. | ||
| * | ||
| * @param {object} args | ||
| * @param {string} args.webId - Pod owner's WebID. Used as the VM | ||
| * controller and to derive the VM's `@id` fragment. | ||
| * @param {string} [args.documentController] - Override the Multikey | ||
| * document's controller. Defaults to `did:nostr:<publicHex>`. | ||
| * @returns {{ | ||
| * document: object, | ||
| * vm: object, | ||
| * publicHex: string, | ||
| * secretHex: string, | ||
| * publicMultibase: string, | ||
| * didNostr: string | ||
| * }} | ||
| */ | ||
| export function provisionOwnerKey({ controllerWebId }) { | ||
| export function provisionOwnerKey({ webId, documentController }) { | ||
| if (typeof webId !== 'string' || !webId) { | ||
| throw new Error('provisionOwnerKey: webId required'); | ||
| } | ||
| const { publicHex, secretHex } = generateOwnerKeypair(); | ||
| const document = buildOwnerKeyDocument({ controllerWebId, publicHex, secretHex }); | ||
| // Pass `documentController` through verbatim — buildOwnerKeyDocument | ||
| // already defaults to `did:nostr:<publicHex>` when none is given, so | ||
| // duplicating the fallback here would just create two sources of | ||
| // truth for the default controller. | ||
| const document = buildOwnerKeyDocument({ | ||
| publicHex, | ||
| secretHex, | ||
| controller: documentController | ||
| }); | ||
| const vm = buildOwnerVerificationMethod({ webId, publicHex }); | ||
| return { | ||
| document, | ||
| vm, | ||
| publicHex, | ||
| secretHex, | ||
| publicMultibase: document.publicKeyMultibase | ||
| publicMultibase: document.publicKeyMultibase, | ||
| didNostr: didNostrFromPublicHex(publicHex) | ||
| }; | ||
| } | ||
|
|
||
|
|
@@ -155,3 +314,24 @@ function bytesToHex(bytes) { | |
| } | ||
| return s; | ||
| } | ||
|
|
||
| function hexToBase64Url(hex) { | ||
| if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) { | ||
| throw new Error('hexToBase64Url: expected even-length hex'); | ||
| } | ||
| return Buffer.from(hex, 'hex').toString('base64url'); | ||
| } | ||
|
|
||
| function hexToBytes(hex) { | ||
| // Validate up front — without this, `parseInt('zz', 16)` returns | ||
| // NaN and silently writes 0 into the byte slot. Today the only | ||
| // caller passes freshly-generated 32-byte hex, but the helper is | ||
| // shared infrastructure: every sibling helper (`publicKeyJwkFromHex`, | ||
| // `didNostrFromPublicHex`, `hexToBase64Url`) validates the same way. | ||
| if (typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) { | ||
| throw new Error('hexToBytes: expected even-length hex'); | ||
| } | ||
| const out = new Uint8Array(hex.length / 2); | ||
| for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); | ||
| return out; | ||
| } | ||
|
Comment on lines
+325
to
+337
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed: hexToBytes now validates with the same /^[0-9a-fA-F]+$/ + even-length check the sibling helpers use. The current caller is safe (always passes freshly-generated hex), but a shared helper should never silently corrupt — caught for any future caller.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already addressed in earlier commits — Copilot's re-anchoring to new line numbers but the substantive fixes are in place (asymmetry documented in provisionOwnerKey JSDoc + new test in e8a268b, hexToBytes validates in e8a268b, generateProfileJsonLd fresh-pod note merged into a single JSDoc block in the next push). |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed: documented the asymmetry explicitly in provisionOwnerKey's JSDoc as a 'two controllers, on purpose' design — vm.controller identifies who in WebID-land presents the VM (always the pod owner), document.controller identifies the key in its own right (default did:nostr, overridable). Added a unit test pinning the asymmetric behaviour as a documented expectation. Sync isn't appropriate here because the two fields are semantically distinct in CID v1; the JSDoc points callers wanting symmetric controllers at buildOwnerVerificationMethod for a separate construction.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already addressed in earlier commits — Copilot's re-anchoring to new line numbers but the substantive fixes are in place (asymmetry documented in provisionOwnerKey JSDoc + new test in e8a268b, hexToBytes validates in e8a268b, generateProfileJsonLd fresh-pod note merged into a single JSDoc block in the next push).