javascript-solid-server 0.0.178 → 0.0.179
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/lws.md +35 -24
- package/package.json +1 -1
- package/src/auth/nostr.js +63 -1
- package/src/idp/interactions.js +69 -7
- package/src/idp/views.js +11 -2
- package/test/nostr-cid-vm.test.js +54 -1
package/docs/lws.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# LWS / Controlled Identifiers (CID v1)
|
|
2
2
|
|
|
3
|
-
JSS
|
|
3
|
+
JSS is aligned end-to-end with the W3C [Linked Web Storage 1.0 Authentication Suite](https://www.w3.org/news/2026/first-public-working-drafts-for-the-linked-web-storage-lws-1-0-authentication-suite/) (FPWDs published 2026-04-23) and its substrate, [W3C Controlled Identifiers v1.0](https://www.w3.org/TR/cid-1.0/) — pod profiles are CID-shaped, users add keys via the [doctor](https://jss.live/doctor/), and the server accepts strict LWS10-CID JWTs as an HTTP auth method alongside the existing Solid-OIDC and NIP-98 paths.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Convergence tracker: [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386). FPWD-alignment audit: [#319](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/319).
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Compatibility, by level
|
|
8
8
|
|
|
9
9
|
| | What it means | Status |
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| **1. Profile shape** | A WebID profile that's structurally a W3C Controlled Identifier document — right `@context`, right vocabulary, parseable as a CID document by any LWS-aware tool | ✅ **Yes** (since v0.0.174, [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388)) |
|
|
12
|
-
| **2. Profile carries keys** | The CID document actually declares `verificationMethod` entries an LWS verifier can look up by `kid` |
|
|
13
|
-
| **3. Server accepts LWS-CID JWTs** | An incoming request with an LWS-CID self-signed JWT (`sub`/`iss`/`client_id` triple-equality, `kid` lookup against the WebID's `verificationMethod`, signature check) |
|
|
12
|
+
| **2. Profile carries keys** | The CID document actually declares `verificationMethod` entries an LWS verifier can look up by `kid` | ✅ Browser-side via the [doctor](https://jss.live/doctor/) — [B.2](https://github.com/JavaScriptSolidServer/doctor/pull/2) for Nostr/Multikey, [B.3](https://github.com/JavaScriptSolidServer/doctor/pull/4) for ES256K/JsonWebKey. The doctor authenticates as the WebID owner via Solid-OIDC and PATCHes the VM into the profile. |
|
|
13
|
+
| **3. Server accepts LWS-CID JWTs** | An incoming request with an LWS-CID self-signed JWT (`sub`/`iss`/`client_id` triple-equality, `kid` lookup against the WebID's `verificationMethod`, signature check) | ✅ Shipped in v0.0.177 ([#398](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/398)). Strict FPWD §4 — ES256K is the focus algorithm; ES256, ES384, EdDSA, RS256 also accepted. |
|
|
14
|
+
| **Bonus: NIP-98 → WebID** | A Schnorr-signed NIP-98 request authenticates as the WebID (not `did:nostr:`) when the pubkey is declared as a CID `verificationMethod` referenced from `authentication` | ✅ Shipped in v0.0.178 ([#400](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/400)). No client-side change — the doctor's B.2 output is enough to light it up. |
|
|
14
15
|
|
|
15
16
|
## What Phase A actually does
|
|
16
17
|
|
|
@@ -37,35 +38,42 @@ A freshly-created pod's `profile/card.jsonld` looks like this (excerpt — the e
|
|
|
37
38
|
}
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
##
|
|
41
|
+
## Adding keys (Phase B — via the doctor)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
The [doctor](https://jss.live/doctor/) is a separate browser-side tool that signs in to your pod via Solid-OIDC and writes verificationMethod entries to your profile. After the round-trip your profile has:
|
|
43
44
|
|
|
44
45
|
```jsonld
|
|
45
46
|
"verificationMethod": [
|
|
46
|
-
{ "id": "...#nostr-1", "type": "Multikey", "controller": "...#me",
|
|
47
|
-
"publicKeyMultibase": "fe70102
|
|
48
|
-
{ "id": "...#
|
|
49
|
-
"
|
|
50
|
-
{ "id": "...#passkey-1", "type": "JsonWebKey", "controller": "...#me",
|
|
51
|
-
"publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }
|
|
47
|
+
{ "id": "...#nostr-key-1", "type": "Multikey", "controller": "...#me",
|
|
48
|
+
"publicKeyMultibase": "fe70102…" },
|
|
49
|
+
{ "id": "...#lws-key-1", "type": "JsonWebKey", "controller": "...#me",
|
|
50
|
+
"publicKeyJwk": { "kty": "EC", "crv": "secp256k1", "alg": "ES256K", "x": "…", "y": "…" } }
|
|
52
51
|
],
|
|
53
|
-
"authentication": ["...#nostr-1", "...#
|
|
52
|
+
"authentication": ["...#nostr-key-1", "...#lws-key-1"]
|
|
54
53
|
```
|
|
55
54
|
|
|
55
|
+
The Multikey entry handles did:nostr binding + NIP-98 lookup; the JsonWebKey entry handles strict LWS10-CID JWT auth. Both can be the same secp256k1 key — different signature schemes (Schnorr vs ECDSA), same private key.
|
|
56
|
+
|
|
56
57
|
Because Phase A already declared the context terms, this is a pure data-layer PATCH — no `@context` rewrite needed.
|
|
57
58
|
|
|
58
|
-
##
|
|
59
|
+
## Server-side verifier (Phase 3 — `src/auth/lws-cid.js`)
|
|
60
|
+
|
|
61
|
+
When an incoming request carries an LWS-CID JWT (detected by an `Authorization: Bearer <jwt>` whose JWT-header `kid` is an http(s) URL with a fragment), JSS:
|
|
62
|
+
|
|
63
|
+
1. Confirms `sub === iss === client_id` (canonicalized via URL parsing) — that URI is the WebID being claimed
|
|
64
|
+
2. Validates `aud` includes the server origin, `exp` not past, `iat` recent, lifetime ≤ 1 hour
|
|
65
|
+
3. Fetches the WebID profile through the shared SSRF guard — manual redirects with same-origin enforcement, 256 KB body cap, bounded LRU cache
|
|
66
|
+
4. Confirms the profile's `@id` equals the JWT's `sub` (closes a profile-substitution attack)
|
|
67
|
+
5. Looks up `kid` in `verificationMethod`; the entry must be referenced from `authentication` and its `controller` must match the profile's outer `controller`
|
|
68
|
+
6. Verifies the JWT signature per RFC7515 §5.2. ES256K via `@noble/curves` (already in tree from NIP-98); ES256, ES384, EdDSA, RS256 via `jose`
|
|
69
|
+
|
|
70
|
+
The verifier joins the existing auth methods (Solid-OIDC, NIP-98, Bearer-JWT-from-IDP, WebID-TLS) — preference order is OIDC → LWS-CID → NIP-98 → Bearer fallback (per [#306](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/306)).
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
## NIP-98 → WebID upgrade (`src/auth/nostr.js`)
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
2. Dereference the WebID, parse it as a CID document
|
|
64
|
-
3. Look up `kid` in the document's `verificationMethod` array
|
|
65
|
-
4. Confirm the method is in `authentication`
|
|
66
|
-
5. Verify the JWT signature with that public key
|
|
74
|
+
Built on top of the LWS-CID infrastructure ([#400](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/400)): when a NIP-98 request's signing pubkey is declared as a CID `verificationMethod` (and the VM is in `authentication`) on the resource owner's WebID profile, the request authenticates as the WebID instead of `did:nostr:<pubkey>`. Match is by f-form Multikey or by JsonWebKey full-point (x AND y, BIP-340 even-y). Profile fetch uses the same SSRF guard / cache as the LWS-CID verifier. No client-side change — Nostr clients sign as today.
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
So: anyone who's used the doctor's B.2 to add a Nostr Multikey VM gets WebID-based NIP-98 sign-in for free.
|
|
69
77
|
|
|
70
78
|
## Spec references
|
|
71
79
|
|
|
@@ -76,9 +84,12 @@ The verifier joins the existing auth methods (OIDC, NIP-98, etc.) — preference
|
|
|
76
84
|
|
|
77
85
|
## Related
|
|
78
86
|
|
|
79
|
-
- [`docs/authentication.md`](authentication.md) —
|
|
87
|
+
- [`docs/authentication.md`](authentication.md) — full JSS auth surface (OIDC, NIP-98, LWS-CID, passkey, etc.)
|
|
80
88
|
- [`docs/nostr.md`](nostr.md) — Nostr relay + did:nostr resolution
|
|
89
|
+
- [doctor](https://github.com/JavaScriptSolidServer/doctor) — the browser-side diagnostic + add-keys app
|
|
81
90
|
- [#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386) — convergence tracker
|
|
82
|
-
- [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388) — Phase A
|
|
91
|
+
- [#388](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/388) — Phase A (profile shape)
|
|
92
|
+
- [#398](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/398) — Phase 3 (LWS-CID JWT verifier)
|
|
93
|
+
- [#400](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/400) — NIP-98 → WebID via VM lookup
|
|
83
94
|
- [#389](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/389) — `@context` array form support (turtle conneg)
|
|
84
95
|
- [#390](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/390) — `@type:'@json'` literal handling (turtle conneg)
|
package/package.json
CHANGED
package/src/auth/nostr.js
CHANGED
|
@@ -26,7 +26,7 @@ import { secp256k1 } from '@noble/curves/secp256k1';
|
|
|
26
26
|
import crypto from 'crypto';
|
|
27
27
|
import { resolveDidNostrToWebId } from './did-nostr.js';
|
|
28
28
|
import { fetchCidDocument } from './cid-doc-fetch.js';
|
|
29
|
-
import { normalizeControllers } from './lws-cid.js';
|
|
29
|
+
import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper
|
|
30
30
|
|
|
31
31
|
// NIP-98 event kind (references RFC 7235)
|
|
32
32
|
const HTTP_AUTH_KIND = 27235;
|
|
@@ -503,6 +503,68 @@ function firstHeaderValue(v) {
|
|
|
503
503
|
return first || null;
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Confirm that a verified Nostr pubkey is declared as a CID
|
|
508
|
+
* verificationMethod referenced from `authentication` in the given
|
|
509
|
+
* WebID's profile. Used by the Schnorr-login IdP path (#403): once
|
|
510
|
+
* the signature is verified and the user has typed their username,
|
|
511
|
+
* the IdP layer can derive the candidate WebID and ask this whether
|
|
512
|
+
* the verified pubkey actually belongs to that WebID.
|
|
513
|
+
*
|
|
514
|
+
* Mirrors the same controller-consistency / subject-identity /
|
|
515
|
+
* authentication-membership checks as the resource-side path
|
|
516
|
+
* (tryResolveViaCidVerificationMethod) so the two paths apply the
|
|
517
|
+
* same key-binding semantics.
|
|
518
|
+
*
|
|
519
|
+
* Returns true on match, false on any failure (fetch, VM not in
|
|
520
|
+
* authentication, subject mismatch, controller mismatch, bad input).
|
|
521
|
+
*
|
|
522
|
+
* @param {string} webId - canonical fragment-bearing WebID URI (e.g.
|
|
523
|
+
* `https://alice.example.com/profile/card.jsonld#me`). The profile's
|
|
524
|
+
* own `@id` must match this exactly after absolutization.
|
|
525
|
+
* @param {string} pubkeyHex - 32-byte x-only Nostr pubkey hex
|
|
526
|
+
* @returns {Promise<boolean>}
|
|
527
|
+
*/
|
|
528
|
+
export async function verifyNostrPubkeyAgainstWebId(webId, pubkeyHex) {
|
|
529
|
+
if (typeof webId !== 'string' || !webId) return false;
|
|
530
|
+
if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return false;
|
|
531
|
+
const docUrl = stripHash(webId);
|
|
532
|
+
let profile;
|
|
533
|
+
try {
|
|
534
|
+
profile = await fetchCidDocument(docUrl);
|
|
535
|
+
} catch {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return false;
|
|
539
|
+
|
|
540
|
+
// Confirm the profile actually identifies itself as the WebID we're
|
|
541
|
+
// asking about — otherwise a profile hosted at the WebID's URL could
|
|
542
|
+
// declare a different fragment as its subject and trick us. Both
|
|
543
|
+
// sides absolutized so a relative @id (e.g. "#me") resolves against
|
|
544
|
+
// docUrl and a webId without fragment (which the docstring no longer
|
|
545
|
+
// permits, but be defensive) doesn't accidentally match a fragment
|
|
546
|
+
// form.
|
|
547
|
+
const subject = absolutize(profile['@id'] || profile.id, docUrl);
|
|
548
|
+
const expectedSubject = absolutize(webId, docUrl);
|
|
549
|
+
if (!subject || subject !== expectedSubject) return false;
|
|
550
|
+
|
|
551
|
+
const vm = findNostrVmInProfile(profile, pubkeyHex.toLowerCase(), docUrl);
|
|
552
|
+
if (!vm) return false;
|
|
553
|
+
if (!isInProofPurpose(profile, 'authentication', vm.id, docUrl)) return false;
|
|
554
|
+
|
|
555
|
+
// Controller consistency: the VM's `controller` MUST be in the
|
|
556
|
+
// profile's expected controller set (declared `controller`, with
|
|
557
|
+
// @id fallback). Without this, a profile with a Nostr-keyed VM
|
|
558
|
+
// controlled by some unrelated identity would pass — a binding the
|
|
559
|
+
// actual subject never asserted. Mirrors the resource-path check.
|
|
560
|
+
const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, docUrl);
|
|
561
|
+
if (expectedCtrls.length === 0) return false;
|
|
562
|
+
const vmCtrls = normalizeControllers(vm.controller, docUrl);
|
|
563
|
+
if (!vmCtrls.some((c) => expectedCtrls.includes(c))) return false;
|
|
564
|
+
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
|
|
506
568
|
/**
|
|
507
569
|
* Find a verificationMethod whose key material matches the Nostr
|
|
508
570
|
* x-only pubkey hex. Two encodings supported:
|
package/src/idp/interactions.js
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Handles the user-facing parts of the authentication flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { authenticate, findById, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
|
|
6
|
+
import { authenticate, findById, findByUsername, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js';
|
|
7
7
|
import { loginPage, consentPage, errorPage, registerPage, passkeyPromptPage } from './views.js';
|
|
8
8
|
import * as storage from '../storage/filesystem.js';
|
|
9
9
|
import { createPodStructure } from '../handlers/container.js';
|
|
10
10
|
import { validateInvite } from './invites.js';
|
|
11
|
-
import { verifyNostrAuth } from '../auth/nostr.js';
|
|
11
|
+
import { verifyNostrAuth, getNostrPubkey, verifyNostrPubkeyAgainstWebId } from '../auth/nostr.js';
|
|
12
12
|
|
|
13
13
|
// Security: Maximum body size for IdP form submissions (1MB)
|
|
14
14
|
const MAX_BODY_SIZE = 1024 * 1024;
|
|
@@ -658,6 +658,41 @@ export async function handlePasskeySkip(request, reply, provider) {
|
|
|
658
658
|
}
|
|
659
659
|
}
|
|
660
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Pull the optional `username` field out of a schnorr-login POST.
|
|
663
|
+
*
|
|
664
|
+
* JSS registers a wildcard parseAs:'buffer' content-type parser
|
|
665
|
+
* (src/server.js), so request.body for application/x-www-form-urlencoded
|
|
666
|
+
* arrives as a Buffer that needs string-decode + URLSearchParams. JSON
|
|
667
|
+
* and already-parsed object bodies are also accepted for flexibility.
|
|
668
|
+
*
|
|
669
|
+
* Returns either:
|
|
670
|
+
* - { tooLarge: true } if the body exceeds MAX_BODY_SIZE (matching
|
|
671
|
+
* handleLogin / handleRegisterPost — caller emits 413).
|
|
672
|
+
* - { username: string } otherwise, possibly empty.
|
|
673
|
+
*/
|
|
674
|
+
function parseUsernameField(request) {
|
|
675
|
+
const body = request.body;
|
|
676
|
+
if (!body) return { username: '' };
|
|
677
|
+
const ct = (request.headers?.['content-type'] || '').toLowerCase();
|
|
678
|
+
if (Buffer.isBuffer(body) && body.length > MAX_BODY_SIZE) return { tooLarge: true };
|
|
679
|
+
if (typeof body === 'string' && body.length > MAX_BODY_SIZE) return { tooLarge: true };
|
|
680
|
+
|
|
681
|
+
let bag = {};
|
|
682
|
+
if (Buffer.isBuffer(body) || typeof body === 'string') {
|
|
683
|
+
const s = Buffer.isBuffer(body) ? body.toString() : body;
|
|
684
|
+
if (ct.includes('application/json')) {
|
|
685
|
+
try { bag = JSON.parse(s); } catch { bag = {}; }
|
|
686
|
+
} else {
|
|
687
|
+
try { bag = Object.fromEntries(new URLSearchParams(s).entries()); }
|
|
688
|
+
catch { bag = {}; }
|
|
689
|
+
}
|
|
690
|
+
} else if (typeof body === 'object') {
|
|
691
|
+
bag = body;
|
|
692
|
+
}
|
|
693
|
+
return { username: (bag.username || '').toString().trim() };
|
|
694
|
+
}
|
|
695
|
+
|
|
661
696
|
/**
|
|
662
697
|
* Handle POST /idp/interaction/:uid/schnorr-login
|
|
663
698
|
* Authenticates user via Schnorr signature (NIP-98)
|
|
@@ -689,16 +724,43 @@ export async function handleSchnorrLogin(request, reply, provider) {
|
|
|
689
724
|
const identity = authResult.webId;
|
|
690
725
|
request.log.info({ identity, uid }, 'Schnorr auth verified');
|
|
691
726
|
|
|
692
|
-
// Try to find an existing account linked to this identity
|
|
727
|
+
// Try to find an existing account linked to this identity. The
|
|
728
|
+
// primary path: identity is already a WebID (e.g. resolved via the
|
|
729
|
+
// existing did:nostr DID-doc resolver) and an account exists for it.
|
|
693
730
|
let account = await findByWebId(identity);
|
|
694
731
|
|
|
695
732
|
if (!account) {
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
733
|
+
// Fallback: if the user typed a username on the login form, check
|
|
734
|
+
// whether the verified Nostr pubkey is declared as a CID
|
|
735
|
+
// verificationMethod referenced from `authentication` in that
|
|
736
|
+
// user's WebID profile (#400's IdP-side parallel — #403). The
|
|
737
|
+
// signature has already been verified above, so this is just
|
|
738
|
+
// "does this verified pubkey belong to the typed user".
|
|
739
|
+
const parsed = parseUsernameField(request);
|
|
740
|
+
if (parsed.tooLarge) {
|
|
741
|
+
return reply.code(413).type('application/json').send({
|
|
742
|
+
success: false,
|
|
743
|
+
error: 'Request body exceeds maximum size.',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const typedUsername = parsed.username;
|
|
747
|
+
if (typedUsername) {
|
|
748
|
+
const candidate = await findByUsername(typedUsername);
|
|
749
|
+
if (candidate?.webId) {
|
|
750
|
+
const pubkey = await getNostrPubkey(request);
|
|
751
|
+
if (pubkey && await verifyNostrPubkeyAgainstWebId(candidate.webId, pubkey)) {
|
|
752
|
+
account = candidate;
|
|
753
|
+
request.log.info({ accountId: account.id, webId: candidate.webId, uid },
|
|
754
|
+
'Schnorr login resolved via typed username + profile VM');
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if (!account) {
|
|
699
761
|
return reply.code(403).type('application/json').send({
|
|
700
762
|
success: false,
|
|
701
|
-
error: 'No account linked to this identity.
|
|
763
|
+
error: 'No account linked to this identity. Type your username and add a Schnorr verificationMethod to your WebID profile (or link via did:nostr DID document).'
|
|
702
764
|
});
|
|
703
765
|
}
|
|
704
766
|
|
package/src/idp/views.js
CHANGED
|
@@ -381,12 +381,21 @@ export function loginPage(uid, clientId, error = null, passkeyEnabled = true, sc
|
|
|
381
381
|
// Sign with NIP-07 extension
|
|
382
382
|
const signedEvent = await window.nostr.signEvent(event);
|
|
383
383
|
|
|
384
|
+
// Read the typed username so the server can resolve which
|
|
385
|
+
// account this Nostr key belongs to, in case the existing
|
|
386
|
+
// did:nostr DID-doc resolver doesn't have a binding yet.
|
|
387
|
+
// The signature is verified BEFORE the username is consulted —
|
|
388
|
+
// typing someone else's username doesn't grant access.
|
|
389
|
+
const typedUsername = (document.getElementById('username')?.value || '').trim();
|
|
390
|
+
|
|
384
391
|
// Send to server
|
|
385
392
|
const response = await fetch(authUrl, {
|
|
386
393
|
method: 'POST',
|
|
387
394
|
headers: {
|
|
388
|
-
'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent))
|
|
389
|
-
|
|
395
|
+
'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent)),
|
|
396
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
397
|
+
},
|
|
398
|
+
body: typedUsername ? 'username=' + encodeURIComponent(typedUsername) : ''
|
|
390
399
|
});
|
|
391
400
|
|
|
392
401
|
const result = await response.json();
|
|
@@ -16,7 +16,7 @@ import { describe, it, before, beforeEach, after } from 'node:test';
|
|
|
16
16
|
import assert from 'node:assert';
|
|
17
17
|
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
18
18
|
import { generateSecretKey, getPublicKey, finalizeEvent } from '../src/nostr/event.js';
|
|
19
|
-
import { verifyNostrAuth } from '../src/auth/nostr.js';
|
|
19
|
+
import { verifyNostrAuth, verifyNostrPubkeyAgainstWebId } from '../src/auth/nostr.js';
|
|
20
20
|
import { _clearProfileCacheForTests } from '../src/auth/cid-doc-fetch.js';
|
|
21
21
|
|
|
22
22
|
/** Compute the BIP-340 even-y JWK coordinates for an x-only Nostr pubkey. */
|
|
@@ -492,6 +492,59 @@ describe('NIP-98 + CID verificationMethod lookup (#399)', () => {
|
|
|
492
492
|
assert.strictEqual(r.webId, WEBID);
|
|
493
493
|
});
|
|
494
494
|
|
|
495
|
+
// --- IdP Schnorr-login helper (#403) ---------------------------------
|
|
496
|
+
|
|
497
|
+
describe('verifyNostrPubkeyAgainstWebId', () => {
|
|
498
|
+
it('returns true when the pubkey is a Multikey VM in authentication', async () => {
|
|
499
|
+
_clearProfileCacheForTests();
|
|
500
|
+
nextProfile = buildProfile({ pubkey: pk });
|
|
501
|
+
const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
|
|
502
|
+
assert.strictEqual(ok, true);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('returns false when the pubkey is in verificationMethod but NOT in authentication', async () => {
|
|
506
|
+
_clearProfileCacheForTests();
|
|
507
|
+
nextProfile = buildProfile({ pubkey: pk, withAuth: false });
|
|
508
|
+
const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
|
|
509
|
+
assert.strictEqual(ok, false);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('returns false when the profile has no matching VM', async () => {
|
|
513
|
+
_clearProfileCacheForTests();
|
|
514
|
+
const otherPk = getPublicKey(generateSecretKey());
|
|
515
|
+
nextProfile = buildProfile({ pubkey: otherPk });
|
|
516
|
+
const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
|
|
517
|
+
assert.strictEqual(ok, false);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("returns false when the profile's @id differs from the asked WebID", async () => {
|
|
521
|
+
_clearProfileCacheForTests();
|
|
522
|
+
nextProfile = { ...buildProfile({ pubkey: pk }), '@id': `${DOC_URL}#bob` };
|
|
523
|
+
const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
|
|
524
|
+
assert.strictEqual(ok, false);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('returns false on bad input', async () => {
|
|
528
|
+
assert.strictEqual(await verifyNostrPubkeyAgainstWebId('', pk), false);
|
|
529
|
+
assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, 'not-hex'), false);
|
|
530
|
+
assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, ''), false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('returns false when VM controller is unrelated to profile controller', async () => {
|
|
534
|
+
_clearProfileCacheForTests();
|
|
535
|
+
// VM with right Multikey but its controller points at a different
|
|
536
|
+
// identity — the profile's outer controller is the WebID, but the
|
|
537
|
+
// VM claims to be controlled by `https://attacker.example/#me`.
|
|
538
|
+
// This is the "key bound by an unrelated controller" attack the
|
|
539
|
+
// controller-consistency check defends against.
|
|
540
|
+
const profile = buildProfile({ pubkey: pk });
|
|
541
|
+
profile.verificationMethod[0].controller = 'https://attacker.example/profile/card.jsonld#me';
|
|
542
|
+
nextProfile = profile;
|
|
543
|
+
const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk);
|
|
544
|
+
assert.strictEqual(ok, false);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
495
548
|
it('still rejects an invalid signature regardless of the profile', async () => {
|
|
496
549
|
const url = `https://${POD_HOST}/private/data.ttl`;
|
|
497
550
|
const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });
|