Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 44 additions & 15 deletions src/handlers/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,20 @@ export async function createPodStructure(name, webId, podUri, issuer, defaultQuo
await storage.createContainer(`${podPath}settings/`);
await storage.createContainer(`${podPath}profile/`);

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

// Profile is written last (see the ACL/privkey block below). Skip
// the write here; we'll do it after privkey lands on disk.

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

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

return { podPath, podUri, ownerKey };
// Generate and write WebID profile at /profile/card.jsonld. When an
// owner key was provisioned, its VM lands in the profile so the
// existing LWS-CID verifier (src/auth/lws-cid.js) can authenticate
// JWTs signed with the matching secret. Profile is intentionally
// written last — see ordering rationale above.
const profile = generateProfile({ webId, name, podUri, issuer, ownerVm: ownerKey?.vm });
await storage.write(`${podPath}profile/card.jsonld`, serialize(profile));

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

/**
Expand Down
216 changes: 198 additions & 18 deletions src/keys/provision.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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)
};
}
Comment on lines +256 to 279

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor Author

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


Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

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

Loading