Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
88a8ee8
auth: serve /.well-known/did/nostr/<pubkey>.json (#407)
melvincarvalho May 10, 2026
7c21516
Address copilot pass 1 on #408
melvincarvalho May 10, 2026
3d89cd2
Address copilot pass 2 on #408
melvincarvalho May 10, 2026
df1c281
Address copilot pass 3 on #408
melvincarvalho May 10, 2026
94ca0cf
Address copilot pass 4 on #408
melvincarvalho May 10, 2026
59fe9fc
Address copilot pass 5 on #408
melvincarvalho May 10, 2026
95c6ece
Address copilot pass 6 on #408
melvincarvalho May 10, 2026
9adfdc5
Address copilot pass 7 on #408
melvincarvalho May 10, 2026
feec927
Address copilot pass 8 on #408 — SSRF via redirect chain
melvincarvalho May 10, 2026
e158df8
Address copilot pass 9 on #408
melvincarvalho May 10, 2026
d62db59
Address copilot pass 10 on #408
melvincarvalho May 10, 2026
3e5967b
Address copilot pass 11 on #408
melvincarvalho May 10, 2026
3460d90
Address copilot pass 12 on #408 — same-origin shortcut
melvincarvalho May 10, 2026
e0c2816
Address copilot pass 13 on #408
melvincarvalho May 10, 2026
670ff24
Address copilot pass 14 on #408
melvincarvalho May 10, 2026
24f4730
Address copilot pass 15 on #408
melvincarvalho May 10, 2026
02366be
Address copilot pass 16 on #408
melvincarvalho May 10, 2026
efb6c8a
Address copilot pass 17 on #408
melvincarvalho May 10, 2026
dfdc959
Address copilot pass 18 on #408 — drop unused test scaffolding
melvincarvalho May 10, 2026
7dbb302
Address copilot pass 19 on #408
melvincarvalho May 10, 2026
de7d27d
Address copilot pass 20 on #408
melvincarvalho May 10, 2026
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
490 changes: 417 additions & 73 deletions src/auth/did-nostr.js

Large diffs are not rendered by default.

112 changes: 112 additions & 0 deletions src/auth/nostr-keys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Shared Nostr-key encoding helpers.
*
* Lives in its own module so both the NIP-98 verifier
* (`src/auth/nostr.js`) and the well-known DID-doc publisher
* (`src/idp/well-known-did-nostr.js`) can use it without forming a
* circular import.
*/

import { secp256k1 } from '@noble/curves/secp256k1';

/** Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. */
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';

/**
* Validate a secp256k1 JWK as a Nostr key and return its x-only
* pubkey hex. Returns `null` if the JWK isn't a Nostr-shaped key
* or its `y` doesn't match the BIP-340 canonical (even-y) point
* for the declared `x`.
*
* Why y matters: every secp256k1 x has TWO valid points (positive
* and negative y). Nostr uses x-only pubkeys, which by BIP-340
* convention always pick the even-y point. A profile that declares
* a JWK with the right x but the wrong y is NOT the user's Nostr
* key — accepting it would let an attacker plant a JWK at someone
* else's WebID and have the indexer publish it as theirs.
*
* The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) does
* the same check. Keeping the indexer in sync prevents the
* "indexed but verifier rejects" inconsistency that would surface
* as a 401 on a key the well-known endpoint had advertised.
*/
export function pubkeyFromValidatedJwk(jwk) {
if (!jwk || typeof jwk !== 'object') return null;
if (jwk.kty !== 'EC') return null;
if (jwk.crv !== 'secp256k1' && jwk.crv !== 'P-256K') return null;
if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return null;
let xHex;
try {
xHex = Buffer.from(jwk.x.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
.toString('hex').toLowerCase();
} catch { return null; }
if (!/^[0-9a-f]{64}$/.test(xHex)) return null;
let canonicalY;
try {
// Compressed SEC1 encoding for the EVEN-y point at this x.
const point = secp256k1.ProjectivePoint.fromHex('02' + xHex);
canonicalY = point.toAffine().y.toString(16).padStart(64, '0');
} catch { return null; }
let jwkYHex;
try {
jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
.toString('hex').toLowerCase();
} catch { return null; }
if (jwkYHex !== canonicalY) return null;
return xHex;
}

/**
* Decode an f-form Multikey for secp256k1-pub back into the 32-byte
* x-only pubkey hex. Returns null if the input isn't this shape.
*
* The f-form recipe (per CCG community#254 / did:nostr): multibase
* `f` (base16-lower) + multicodec `e701` + parity byte (`02`/`03`)
* + 32-byte xonly pubkey.
*/
export function decodeFFormSecp256k1(mb) {
if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
const hex = mb.slice(1).toLowerCase();
if (!/^[0-9a-f]+$/.test(hex)) return null;
if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
// Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
if (rest.length !== 66) return null;
const parity = rest.slice(0, 2);
if (parity !== '02' && parity !== '03') return null;
return rest.slice(2);
}

/**
* Enumerate every Nostr pubkey declared in a profile's
* `verificationMethod` entries. Matches both encodings:
* - f-form Multikey (`publicKeyMultibase`)
* - JsonWebKey (`kty: EC, crv: secp256k1`) — derives x as the pubkey
*
* Returns `[ { pubkey, vm } ]` — the VM is returned alongside so
* callers can do further checks (`controller`, `authentication`
* membership, etc.) without re-parsing.
*/
export function extractNostrPubkeysFromProfile(profile) {
if (!profile || typeof profile !== 'object') return [];
const out = [];
const raw = profile.verificationMethod;
const vms = raw === undefined || raw === null ? []
: Array.isArray(raw) ? raw : [raw];
for (const vm of vms) {
if (!vm || typeof vm !== 'object') continue;
if (typeof vm.publicKeyMultibase === 'string') {
const xonly = decodeFFormSecp256k1(vm.publicKeyMultibase);
if (xonly) out.push({ pubkey: xonly, vm });
} else if (vm.publicKeyJwk && typeof vm.publicKeyJwk === 'object') {
// Require y to match the BIP-340 canonical point — the same
// check the NIP-98 verifier applies. Without this, the indexer
// could publish a JWK that the verifier will then reject,
// surfacing as a 401 on a key the well-known endpoint had
// advertised as authentic.
const xonly = pubkeyFromValidatedJwk(vm.publicKeyJwk);
if (xonly) out.push({ pubkey: xonly, vm });
}
}
return out;
}
65 changes: 37 additions & 28 deletions src/auth/nostr.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,36 @@
* Match by f-form Multikey or by JsonWebKey x/y coordinates. If
* found, authenticate as the WebID. (#399 — pairs with the
* LWS10-CID verifier.)
* 2. Resolve via the existing did:nostr DID-document path
* 2. (IdP-only) Look up the pubkey in the local in-process index
* built from `<DATA_ROOT>/.idp/accounts/_webid_index.json`.
* No HTTP, no SSRF surface — direct function call. Catches
* same-pod users without a third-party round-trip. (#407)
* 3. Resolve via the external did:nostr DID-document path
* (nostr.social `.well-known` + bidirectional alsoKnownAs).
* If found, authenticate as the WebID it points to.
* 3. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the
* Used for cross-pod identities; SSRF + redirect hardened.
* 4. Otherwise return `did:nostr:<64-char-hex-pubkey>` as the
* agent identity (the original behavior).
*/

import { verifyEvent, getEventHash } from '../nostr/event.js';
import { secp256k1 } from '@noble/curves/secp256k1';
import crypto from 'crypto';
import { resolveDidNostrToWebId } from './did-nostr.js';
// resolveDidNostrLocally is loaded lazily (inside the idpEnabled
// branch) so non-IdP deployments don't pay the IdP/accounts module
// startup cost (bcryptjs, oidc-provider helpers, etc.) just by
// importing the NIP-98 verifier.
import { fetchCidDocument } from './cid-doc-fetch.js';
import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper
import { decodeFFormSecp256k1, extractNostrPubkeysFromProfile } from './nostr-keys.js'; // re-exported for back-compat
export { extractNostrPubkeysFromProfile };

// NIP-98 event kind (references RFC 7235)
const HTTP_AUTH_KIND = 27235;

// Timestamp tolerance in seconds
const TIMESTAMP_TOLERANCE = 60;

// Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex.
// Used to decode f-form Multikey verificationMethod values back into
// the 32-byte x-only Nostr pubkey.
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';

// Profile-fetch body-size cap. Matches the LWS-CID verifier; both
// callers go through the shared fetchCidDocument helper.
const MAX_PROFILE_BYTES = 256 * 1024;
Expand Down Expand Up @@ -282,9 +287,30 @@ export async function verifyNostrAuth(request) {
return { webId: vmWebId, error: null };
}

// Second lookup: existing did:nostr DID-document resolver. Fetches
// an external DID doc (e.g. nostr.social/.well-known/...) and checks
// bidirectional alsoKnownAs ↔ WebID linking.
// Second lookup: in-process local DID resolution (#407). Fast path
// — direct function call into the local account index, no HTTP
// fetch, no SSRF surface from request-controlled headers. Catches
// any user who's published a Nostr Multikey VM into their profile
// on this same pod.
//
// Gated on idpEnabled because the index reads from
// <DATA_ROOT>/.idp/accounts which only exists when the IdP layer
// is in use. On non-IdP deployments the local resolver has nothing
// to find and would just spin disk on every request.
if (request.idpEnabled) {
// Dynamic import: only load the IdP-accounts stack when IdP is
// actually enabled. Cached after first load (ESM module caching).
const { resolveDidNostrLocally } = await import('../idp/well-known-did-nostr.js');
const localWebId = await resolveDidNostrLocally(event.pubkey);
if (localWebId) {
return { webId: localWebId, error: null };
}
}

// Third lookup: external did:nostr DID-document resolver. Fetches
// a DID doc from the configured external resolver (nostr.social) and
// checks bidirectional alsoKnownAs ↔ WebID linking. Used only for
// cross-pod identities (the local case is handled above).
Comment on lines +290 to +313
const resolvedWebId = await resolveDidNostrToWebId(event.pubkey);
if (resolvedWebId) {
return { webId: resolvedWebId, error: null };
Expand Down Expand Up @@ -599,23 +625,6 @@ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) {
return null;
}

/**
* Decode an f-form Multikey for secp256k1-pub back into the 32-byte
* x-only pubkey hex. Returns null if the input isn't this shape.
*/
function decodeFFormSecp256k1(mb) {
if (typeof mb !== 'string' || !mb.startsWith('f')) return null;
const hex = mb.slice(1).toLowerCase();
if (!/^[0-9a-f]+$/.test(hex)) return null;
if (!hex.startsWith(MULTICODEC_SECP256K1_PUB_HEX)) return null;
const rest = hex.slice(MULTICODEC_SECP256K1_PUB_HEX.length);
// Expect parity byte (02/03) + 32-byte xonly = 66 hex chars.
if (rest.length !== 66) return null;
const parity = rest.slice(0, 2);
if (parity !== '02' && parity !== '03') return null;
return rest.slice(2);
}

function hexToBase64url(hex) {
return Buffer.from(hex, 'hex').toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
Expand Down
Loading