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
71 changes: 49 additions & 22 deletions src/auth/nostr-keys.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,52 @@ import { secp256k1 } from '@noble/curves/secp256k1';
/** Multicodec varint for secp256k1-pub: 0xe7 0x01 → "e701" hex. */
const MULTICODEC_SECP256K1_PUB_HEX = 'e701';

/**
* The two valid y-coordinates (64-char hex) for a secp256k1 x: the
* even-parity point and its odd-parity reflection. Returns `null` if
* `xHex` isn't a valid curve x.
*
* Both parities are in-spec for did:nostr: the spec
* (https://nostrcg.github.io/did-nostr/) states "Nostr applications
* may generate keys with either 0x02 or 0x03 prefixes" — 0x02 for an
* even y, 0x03 for an odd y. So a verification method that encodes the
* same x-only Nostr identity can legitimately carry either parity, and
* key matching accepts either while still requiring `(x, y)` to be a
* real on-curve point. See issue #571.
*/
export function nostrJwkYParities(xHex) {
if (typeof xHex !== 'string') return null;
const x = xHex.toLowerCase();
if (!/^[0-9a-f]{64}$/.test(x)) return null;
try {
const even = secp256k1.ProjectivePoint.fromHex('02' + x).toAffine().y;
const odd = secp256k1.ProjectivePoint.fromHex('03' + x).toAffine().y;
return [
even.toString(16).padStart(64, '0'),
odd.toString(16).padStart(64, '0'),
];
} catch {
return null;
}
}
Comment thread
Copilot marked this conversation as resolved.

/**
* 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`.
* or its `y` isn't an on-curve point (either parity) 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.
* Why y matters: every secp256k1 x has TWO valid points (even and
* odd y). Nostr identities are x-only, so the x coordinate IS the
* identity — but a profile that declares a JWK with the right x and a
* *fabricated* (off-curve) y is malformed and must be rejected, else
* an attacker could plant arbitrary key material at someone else's
* WebID and have the indexer publish it. We therefore require y to be
* one of the two genuine on-curve y's, accepting either parity per the
* did:nostr spec (see `nostrJwkYParities` / issue #571).
*
* The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) does
* the same check. Keeping the indexer in sync prevents the
* The verifier in src/auth/nostr.js (jwkMatchesNostrPubkey) applies
* the same rule. 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.
*/
Expand All @@ -41,18 +72,14 @@ export function pubkeyFromValidatedJwk(jwk) {
.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; }
const validY = nostrJwkYParities(xHex);
if (!validY) 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;
if (!validY.includes(jwkYHex)) return null;
return xHex;
}

Expand Down Expand Up @@ -99,11 +126,11 @@ export function extractNostrPubkeysFromProfile(profile) {
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.
// Require y to be a genuine on-curve point for x (either
// parity, per the did:nostr spec) — 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 });
}
Expand Down
34 changes: 13 additions & 21 deletions src/auth/nostr.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
*/

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
Expand All @@ -35,8 +34,8 @@ import { resolveDidNostrToWebId } from './did-nostr.js';
// 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 };
import { decodeFFormSecp256k1, extractNostrPubkeysFromProfile, nostrJwkYParities } from './nostr-keys.js';
export { extractNostrPubkeysFromProfile }; // re-exported for back-compat

// NIP-98 event kind (references RFC 7235)
const HTTP_AUTH_KIND = 27235;
Expand Down Expand Up @@ -659,37 +658,30 @@ function hexToBase64url(hex) {
*
* EC keys are (x, y) pairs — two distinct valid points share the same
* x with opposite y parities. Matching on x alone would let an
* attacker craft a JWK with the target x and a wrong y, which we'd
* then accept as the user's Nostr key. So we also derive the
* BIP-340-canonical y (even-parity) for the target x and require the
* JWK's y to match.
* attacker craft a JWK with the target x and a fabricated, off-curve
* y, which we'd then accept as the user's Nostr key. So we also
* require the JWK's y to be a genuine on-curve y for the target x —
* accepting either parity, since the did:nostr spec allows both 0x02
* (even) and 0x03 (odd) encodings of the same x-only identity (see
* `nostrJwkYParities` / issue #571).
*
* Returns false if the JWK's coordinates aren't on-curve, can't be
* decoded, or don't match the BIP-340 canonical point for `targetHex`.
* decoded, or don't match an on-curve point for `targetHex`.
*/
function jwkMatchesNostrPubkey(jwk, targetHex, targetB64u) {
if (typeof jwk.x !== 'string' || typeof jwk.y !== 'string') return false;
if (jwk.x !== targetB64u) return false;
// Decompress the BIP-340 even-y point for the target x. Then compare
// the JWK's declared y against this canonical y.
let canonicalY;
try {
// Compressed SEC1 point, even-y prefix (0x02) || x.
const compressed = '02' + targetHex;
const point = secp256k1.ProjectivePoint.fromHex(compressed);
const affine = point.toAffine();
canonicalY = affine.y.toString(16).padStart(64, '0');
} catch {
return false;
}
// The two genuine on-curve y's (even + odd parity) for the target x.
const validY = nostrJwkYParities(targetHex);
if (!validY) return false;
let jwkYHex;
try {
jwkYHex = Buffer.from(jwk.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
.toString('hex').toLowerCase();
} catch {
return false;
}
return jwkYHex === canonicalY;
return validY.includes(jwkYHex);
}

function isInProofPurpose(profile, predicate, vmId, baseUrl) {
Expand Down
96 changes: 96 additions & 0 deletions test/nostr-key-parity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Parity consistency across the two Nostr verification-method decoders
* (#571).
*
* The did:nostr spec (https://nostrcg.github.io/did-nostr/) allows BOTH
* parity prefixes for the same x-only identity: "Nostr applications may
* generate keys with either 0x02 or 0x03 prefixes." So:
*
* - the f-form Multikey decoder accepts 02 and 03 (parity discarded,
* x is the identity), and
* - the JWK path accepts an even-Y *or* odd-Y on-curve point,
*
* but both still reject a fabricated/off-curve y. These tests pin that
* the two paths agree.
*/

import { describe, it } from 'node:test';
import assert from 'node:assert';
import { secp256k1 } from '@noble/curves/secp256k1';
import {
decodeFFormSecp256k1,
pubkeyFromValidatedJwk,
nostrJwkYParities,
extractNostrPubkeysFromProfile,
} from '../src/auth/nostr-keys.js';

const b64u = (hex) => Buffer.from(hex, 'hex').toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

/** A real x-only Nostr pubkey (x with a valid even-Y point). */
const X = '124c0fa99407182ece5a24fad9b7f6674902fc422843d3128d38a0afbee0fdd2';

function jwkFor(xHex, parity) {
const point = secp256k1.ProjectivePoint.fromHex(parity + xHex).toAffine();
return {
kty: 'EC', crv: 'secp256k1', alg: 'ES256K',
x: b64u(point.x.toString(16).padStart(64, '0')),
y: b64u(point.y.toString(16).padStart(64, '0')),
};
}

const fform = (xHex, parity) => 'f' + 'e701' + parity + xHex.toLowerCase();

describe('Nostr key parity consistency (#571)', () => {
it('f-form Multikey decoder accepts both 02 and 03, returning the same x', () => {
assert.strictEqual(decodeFFormSecp256k1(fform(X, '02')), X);
assert.strictEqual(decodeFFormSecp256k1(fform(X, '03')), X);
});

it('f-form decoder rejects a non-parity prefix byte', () => {
assert.strictEqual(decodeFFormSecp256k1(fform(X, '04')), null);
});

it('JWK path accepts an even-Y point and returns x', () => {
assert.strictEqual(pubkeyFromValidatedJwk(jwkFor(X, '02')), X);
});

it('JWK path now also accepts an odd-Y point and returns the same x', () => {
assert.strictEqual(pubkeyFromValidatedJwk(jwkFor(X, '03')), X);
});

it('JWK path still rejects a fabricated off-curve y', () => {
const bad = jwkFor(X, '02');
// Flip the low bit of y → no longer the genuine even-Y coordinate
// (and not the odd-Y one either), so it must be rejected.
const yHex = Buffer.from(bad.y.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
.toString('hex');
const flipped = (BigInt('0x' + yHex) ^ 1n).toString(16).padStart(64, '0');
bad.y = b64u(flipped);
Comment thread
Copilot marked this conversation as resolved.
assert.strictEqual(pubkeyFromValidatedJwk(bad), null);
});

it('nostrJwkYParities returns both genuine y values and null for a bad x', () => {
const ys = nostrJwkYParities(X);
assert.ok(Array.isArray(ys) && ys.length === 2);
assert.notStrictEqual(ys[0], ys[1]);
assert.strictEqual(nostrJwkYParities('zz'), null);
assert.strictEqual(nostrJwkYParities(42), null);
});

it('nostrJwkYParities normalizes uppercase hex (matches the lowercase result)', () => {
assert.deepStrictEqual(nostrJwkYParities(X.toUpperCase()), nostrJwkYParities(X));
});

it('profile extraction maps a 03 Multikey and a 03 JWK to the same identity', () => {
const profile = {
verificationMethod: [
{ id: '#mk', type: 'Multikey', publicKeyMultibase: fform(X, '03') },
{ id: '#jwk', type: 'JsonWebKey', publicKeyJwk: jwkFor(X, '03') },
],
};
const found = extractNostrPubkeysFromProfile(profile);
assert.strictEqual(found.length, 2);
assert.ok(found.every((e) => e.pubkey === X));
});
Comment thread
Copilot marked this conversation as resolved.
});