javascript-solid-server 0.0.73 → 0.0.75
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/.claude/settings.local.json +7 -1
- package/README.md +50 -3
- package/bin/jss.js +4 -0
- package/package.json +1 -1
- package/src/auth/solid-oidc.js +2 -2
- package/src/auth/token.js +45 -30
- package/src/auth/webid-tls.js +270 -0
- package/src/config.js +4 -0
- package/src/notifications/index.js +5 -2
- package/src/notifications/websocket.js +63 -3
- package/src/server.js +10 -0
- package/test/helpers.js +2 -0
- package/test/notifications.test.js +90 -0
- package/test/webid-tls.test.js +119 -0
|
@@ -228,7 +228,13 @@
|
|
|
228
228
|
"Bash(git pull:*)",
|
|
229
229
|
"Bash(gh pr:*)",
|
|
230
230
|
"Bash(node --test:*)",
|
|
231
|
-
"Bash(TOKEN_SECRET=test node --test:*)"
|
|
231
|
+
"Bash(TOKEN_SECRET=test node --test:*)",
|
|
232
|
+
"Bash(gh search issues:*)",
|
|
233
|
+
"Bash(timeout 60 bash:*)",
|
|
234
|
+
"Bash(timeout 90 bash -c:*)",
|
|
235
|
+
"Bash(git commit -m \"$\\(cat <<''EOF''\nsecurity: add ACL check on WebSocket subscription requests\n\nCheck WAC read permission before allowing subscription to prevent\ninformation leakage via notifications. Unauthorized subscriptions\nnow receive ''err <url> forbidden'' response.\n\nSecurity improvements:\n- Check ACL read access before allowing subscription\n- Validate URLs are on this server \\(prevents SSRF-like probing\\)\n- Add subscription limit and URL length validation\n\nFixes #62\nEOF\n\\)\")",
|
|
236
|
+
"Bash(gh repo fork:*)",
|
|
237
|
+
"Bash(timeout 180 npm test:*)"
|
|
232
238
|
]
|
|
233
239
|
}
|
|
234
240
|
}
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
### Implemented (v0.0.
|
|
9
|
+
### Implemented (v0.0.75)
|
|
10
10
|
|
|
11
11
|
- **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
|
|
12
12
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
@@ -26,6 +26,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
26
26
|
- **Solid-OIDC Resource Server** - Accept DPoP-bound access tokens from external IdPs
|
|
27
27
|
- **NSS-style Registration** - Username/password auth compatible with Solid apps
|
|
28
28
|
- **Nostr Authentication** - NIP-98 HTTP Auth with Schnorr signatures, did:nostr → WebID resolution
|
|
29
|
+
- **WebID-TLS** - Client certificate authentication for backend services and CLI tools
|
|
29
30
|
- **Simple Auth Tokens** - Built-in token authentication for development
|
|
30
31
|
- **Content Negotiation** - Turtle <-> JSON-LD conversion, including HTML data islands
|
|
31
32
|
- **CORS Support** - Full cross-origin resource sharing
|
|
@@ -126,6 +127,7 @@ jss --help # Show help
|
|
|
126
127
|
| `--nostr-path <path>` | Nostr relay WebSocket path | /relay |
|
|
127
128
|
| `--nostr-max-events <n>` | Max events in relay memory | 1000 |
|
|
128
129
|
| `--invite-only` | Require invite code for registration | false |
|
|
130
|
+
| `--webid-tls` | Enable WebID-TLS client certificate auth | false |
|
|
129
131
|
| `--default-quota <size>` | Default storage quota per pod (e.g., 50MB) | 50MB |
|
|
130
132
|
| `--activitypub` | Enable ActivityPub federation | false |
|
|
131
133
|
| `--ap-username <name>` | ActivityPub username | me |
|
|
@@ -148,6 +150,7 @@ export JSS_BASE_DOMAIN=example.com
|
|
|
148
150
|
export JSS_MASHLIB=true
|
|
149
151
|
export JSS_NOSTR=true
|
|
150
152
|
export JSS_INVITE_ONLY=true
|
|
153
|
+
export JSS_WEBID_TLS=true
|
|
151
154
|
export JSS_DEFAULT_QUOTA=100MB
|
|
152
155
|
export JSS_ACTIVITYPUB=true
|
|
153
156
|
export JSS_AP_USERNAME=alice
|
|
@@ -664,6 +667,49 @@ curl -H "Authorization: DPoP ACCESS_TOKEN" \
|
|
|
664
667
|
http://localhost:3000/alice/private/
|
|
665
668
|
```
|
|
666
669
|
|
|
670
|
+
### WebID-TLS (Client Certificates)
|
|
671
|
+
|
|
672
|
+
For backend services, CLI tools, and automated agents that need non-interactive authentication:
|
|
673
|
+
|
|
674
|
+
```bash
|
|
675
|
+
jss start --ssl-key key.pem --ssl-cert cert.pem --webid-tls
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
**How it works:**
|
|
679
|
+
1. Client presents X.509 certificate during TLS handshake
|
|
680
|
+
2. Certificate's `SubjectAlternativeName` contains a WebID URI
|
|
681
|
+
3. Server fetches the WebID profile
|
|
682
|
+
4. Server verifies the certificate's public key matches one in the profile
|
|
683
|
+
|
|
684
|
+
**Testing with curl:**
|
|
685
|
+
|
|
686
|
+
```bash
|
|
687
|
+
# Generate self-signed cert with WebID in SAN
|
|
688
|
+
openssl req -x509 -newkey rsa:2048 -keyout client-key.pem -out client-cert.pem -days 365 \
|
|
689
|
+
-subj "/CN=Test" -addext "subjectAltName=URI:https://example.com/alice/#me" -nodes
|
|
690
|
+
|
|
691
|
+
# Make authenticated request
|
|
692
|
+
curl --cert client-cert.pem --key client-key.pem https://localhost:8443/alice/private/
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
**Profile requirement:** Your WebID profile must contain the certificate's public key:
|
|
696
|
+
|
|
697
|
+
```turtle
|
|
698
|
+
@prefix cert: <http://www.w3.org/ns/auth/cert#> .
|
|
699
|
+
|
|
700
|
+
<#me> cert:key [
|
|
701
|
+
a cert:RSAPublicKey;
|
|
702
|
+
cert:modulus "abc123..."^^xsd:hexBinary;
|
|
703
|
+
cert:exponent 65537
|
|
704
|
+
] .
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
**Use cases:**
|
|
708
|
+
- Enterprise backend services with existing PKI
|
|
709
|
+
- Server-to-server communication
|
|
710
|
+
- CLI tools and scripts
|
|
711
|
+
- IoT devices with embedded certificates
|
|
712
|
+
|
|
667
713
|
## Pod Structure
|
|
668
714
|
|
|
669
715
|
```
|
|
@@ -810,7 +856,7 @@ npm run benchmark
|
|
|
810
856
|
npm test
|
|
811
857
|
```
|
|
812
858
|
|
|
813
|
-
Currently passing: **
|
|
859
|
+
Currently passing: **213 tests** (including 27 conformance tests)
|
|
814
860
|
|
|
815
861
|
### Conformance Test Harness (CTH)
|
|
816
862
|
|
|
@@ -861,7 +907,8 @@ src/
|
|
|
861
907
|
│ ├── token.js # Simple token auth
|
|
862
908
|
│ ├── solid-oidc.js # DPoP verification
|
|
863
909
|
│ ├── nostr.js # NIP-98 Nostr authentication
|
|
864
|
-
│
|
|
910
|
+
│ ├── did-nostr.js # did:nostr → WebID resolution
|
|
911
|
+
│ └── webid-tls.js # WebID-TLS client certificate auth
|
|
865
912
|
├── wac/
|
|
866
913
|
│ ├── parser.js # ACL parsing
|
|
867
914
|
│ └── checker.js # Permission checking
|
package/bin/jss.js
CHANGED
|
@@ -71,6 +71,8 @@ program
|
|
|
71
71
|
.option('--ap-nostr-pubkey <hex>', 'Nostr pubkey for identity linking')
|
|
72
72
|
.option('--invite-only', 'Require invite code for registration')
|
|
73
73
|
.option('--no-invite-only', 'Allow open registration')
|
|
74
|
+
.option('--webid-tls', 'Enable WebID-TLS client certificate authentication')
|
|
75
|
+
.option('--no-webid-tls', 'Disable WebID-TLS authentication')
|
|
74
76
|
.option('-q, --quiet', 'Suppress log output')
|
|
75
77
|
.option('--print-config', 'Print configuration and exit')
|
|
76
78
|
.action(async (options) => {
|
|
@@ -122,6 +124,7 @@ program
|
|
|
122
124
|
apSummary: config.apSummary,
|
|
123
125
|
apNostrPubkey: config.apNostrPubkey,
|
|
124
126
|
inviteOnly: config.inviteOnly,
|
|
127
|
+
webidTls: config.webidTls,
|
|
125
128
|
});
|
|
126
129
|
|
|
127
130
|
await server.listen({ port: config.port, host: config.host });
|
|
@@ -144,6 +147,7 @@ program
|
|
|
144
147
|
if (config.nostr) console.log(` Nostr: enabled (${config.nostrPath})`);
|
|
145
148
|
if (config.activitypub) console.log(` ActivityPub: enabled (@${config.apUsername || 'me'})`);
|
|
146
149
|
if (config.inviteOnly) console.log(' Registration: invite-only');
|
|
150
|
+
if (config.webidTls) console.log(' WebID-TLS: enabled (client certificate auth)');
|
|
147
151
|
console.log('\n Press Ctrl+C to stop\n');
|
|
148
152
|
}
|
|
149
153
|
|
package/package.json
CHANGED
package/src/auth/solid-oidc.js
CHANGED
|
@@ -56,8 +56,8 @@ function cleanupJtiCache() {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// Start periodic cleanup
|
|
60
|
-
setInterval(cleanupJtiCache, JTI_CACHE_CLEANUP_INTERVAL);
|
|
59
|
+
// Start periodic cleanup (unref so it doesn't keep process alive during tests)
|
|
60
|
+
setInterval(cleanupJtiCache, JTI_CACHE_CLEANUP_INTERVAL).unref();
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Check if a jti has been used (replay attack prevention)
|
package/src/auth/token.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import crypto from 'crypto';
|
|
11
11
|
import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
|
|
12
12
|
import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
|
|
13
|
+
import { webIdTlsAuth, hasClientCertificate } from './webid-tls.js';
|
|
13
14
|
|
|
14
15
|
// Secret for signing tokens
|
|
15
16
|
// SECURITY: In production, TOKEN_SECRET must be set via environment variable
|
|
@@ -214,41 +215,55 @@ export function getWebIdFromRequest(request) {
|
|
|
214
215
|
export async function getWebIdFromRequestAsync(request) {
|
|
215
216
|
const authHeader = request.headers.authorization;
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return verifySolidOidc(request);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Try Nostr NIP-98 (Schnorr signatures)
|
|
227
|
-
if (hasNostrAuth(request)) {
|
|
228
|
-
return verifyNostrAuth(request);
|
|
229
|
-
}
|
|
218
|
+
// Try Authorization header methods first
|
|
219
|
+
if (authHeader) {
|
|
220
|
+
// Try Solid-OIDC first (DPoP tokens)
|
|
221
|
+
if (hasSolidOidcAuth(request)) {
|
|
222
|
+
return verifySolidOidc(request);
|
|
223
|
+
}
|
|
230
224
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
225
|
+
// Try Nostr NIP-98 (Schnorr signatures)
|
|
226
|
+
if (hasNostrAuth(request)) {
|
|
227
|
+
return verifyNostrAuth(request);
|
|
228
|
+
}
|
|
236
229
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
230
|
+
// Fall back to Bearer tokens
|
|
231
|
+
const token = extractToken(authHeader);
|
|
232
|
+
if (token) {
|
|
233
|
+
// Try simple 2-part token first
|
|
234
|
+
const payload = verifyToken(token);
|
|
235
|
+
if (payload?.webId) {
|
|
236
|
+
return { webId: payload.webId, error: null };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// If 3-part JWT, verify against IdP's JWKS
|
|
240
|
+
const parts = token.split('.');
|
|
241
|
+
if (parts.length === 3) {
|
|
242
|
+
const jwtPayload = await verifyJwtFromIdp(token);
|
|
243
|
+
if (jwtPayload?.webId) {
|
|
244
|
+
return { webId: jwtPayload.webId, error: null };
|
|
245
|
+
}
|
|
246
|
+
return { webId: null, error: 'Invalid or unverifiable JWT token' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { webId: null, error: 'Invalid token' };
|
|
250
|
+
}
|
|
241
251
|
}
|
|
242
252
|
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
253
|
+
// Try WebID-TLS (client certificate authentication)
|
|
254
|
+
// This works even without Authorization header
|
|
255
|
+
if (hasClientCertificate(request)) {
|
|
256
|
+
try {
|
|
257
|
+
const webId = await webIdTlsAuth(request);
|
|
258
|
+
if (webId) {
|
|
259
|
+
return { webId, error: null };
|
|
260
|
+
}
|
|
261
|
+
// Certificate present but verification failed
|
|
262
|
+
return { webId: null, error: 'WebID-TLS certificate verification failed' };
|
|
263
|
+
} catch (err) {
|
|
264
|
+
return { webId: null, error: `WebID-TLS error: ${err.message}` };
|
|
249
265
|
}
|
|
250
|
-
return { webId: null, error: 'Invalid or unverifiable JWT token' };
|
|
251
266
|
}
|
|
252
267
|
|
|
253
|
-
return { webId: null, error:
|
|
268
|
+
return { webId: null, error: null };
|
|
254
269
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebID-TLS Authentication
|
|
3
|
+
*
|
|
4
|
+
* Authenticates clients via TLS client certificates.
|
|
5
|
+
* The certificate's SubjectAlternativeName (SAN) contains a WebID URI.
|
|
6
|
+
* The server fetches the WebID profile and verifies the certificate's
|
|
7
|
+
* public key matches one published in the profile.
|
|
8
|
+
*
|
|
9
|
+
* References:
|
|
10
|
+
* - https://dvcs.w3.org/hg/WebID/raw-file/tip/spec/tls-respec.html
|
|
11
|
+
* - https://www.w3.org/ns/auth/cert#
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { turtleToJsonLd } from '../rdf/turtle.js';
|
|
15
|
+
|
|
16
|
+
// cert: ontology namespace
|
|
17
|
+
const CERT_NS = 'http://www.w3.org/ns/auth/cert#';
|
|
18
|
+
|
|
19
|
+
// Cache for verified WebIDs (reduces profile fetches)
|
|
20
|
+
const cache = new Map();
|
|
21
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fetch with timeout
|
|
25
|
+
*/
|
|
26
|
+
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
31
|
+
clearTimeout(id);
|
|
32
|
+
return response;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
clearTimeout(id);
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract WebID URI from certificate's SubjectAlternativeName
|
|
41
|
+
* @param {string} subjectaltname - Certificate's SAN field
|
|
42
|
+
* @returns {string|null} WebID URI or null
|
|
43
|
+
*/
|
|
44
|
+
export function extractWebIdFromSAN(subjectaltname) {
|
|
45
|
+
if (!subjectaltname) return null;
|
|
46
|
+
|
|
47
|
+
// SAN format: "URI:https://alice.example/card#me, DNS:example.com"
|
|
48
|
+
const match = subjectaltname.match(/URI:([^,\s]+)/);
|
|
49
|
+
return match ? match[1] : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse certificate keys from WebID profile (JSON-LD format)
|
|
54
|
+
* Handles both inline objects and arrays
|
|
55
|
+
* @param {object|Array} jsonLd - Parsed JSON-LD profile
|
|
56
|
+
* @param {string} webId - The WebID to find keys for
|
|
57
|
+
* @returns {Array<{modulus: string, exponent: string}>} Array of keys
|
|
58
|
+
*/
|
|
59
|
+
function extractCertKeys(jsonLd, webId) {
|
|
60
|
+
const keys = [];
|
|
61
|
+
|
|
62
|
+
// Normalize to array
|
|
63
|
+
const nodes = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
|
|
64
|
+
|
|
65
|
+
for (const node of nodes) {
|
|
66
|
+
// Check if this node is the WebID subject
|
|
67
|
+
const nodeId = node['@id'];
|
|
68
|
+
if (nodeId && !nodeId.endsWith('#me') && nodeId !== webId) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Look for cert:key property (various forms)
|
|
73
|
+
const keyProps = [
|
|
74
|
+
node['cert:key'],
|
|
75
|
+
node[CERT_NS + 'key'],
|
|
76
|
+
node['http://www.w3.org/ns/auth/cert#key']
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const keyProp of keyProps) {
|
|
80
|
+
if (!keyProp) continue;
|
|
81
|
+
|
|
82
|
+
const keyValues = Array.isArray(keyProp) ? keyProp : [keyProp];
|
|
83
|
+
for (const keyValue of keyValues) {
|
|
84
|
+
const key = parseKeyObject(keyValue);
|
|
85
|
+
if (key) {
|
|
86
|
+
keys.push(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return keys;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a single key object from JSON-LD
|
|
97
|
+
* @param {object} keyObj - Key object (may be nested or have @id)
|
|
98
|
+
* @returns {{modulus: string, exponent: string}|null}
|
|
99
|
+
*/
|
|
100
|
+
function parseKeyObject(keyObj) {
|
|
101
|
+
if (!keyObj || typeof keyObj !== 'object') return null;
|
|
102
|
+
|
|
103
|
+
// Extract modulus (various forms)
|
|
104
|
+
let modulus = keyObj['cert:modulus'] ||
|
|
105
|
+
keyObj[CERT_NS + 'modulus'] ||
|
|
106
|
+
keyObj['http://www.w3.org/ns/auth/cert#modulus'];
|
|
107
|
+
|
|
108
|
+
// Extract exponent (various forms)
|
|
109
|
+
let exponent = keyObj['cert:exponent'] ||
|
|
110
|
+
keyObj[CERT_NS + 'exponent'] ||
|
|
111
|
+
keyObj['http://www.w3.org/ns/auth/cert#exponent'];
|
|
112
|
+
|
|
113
|
+
// Handle @value wrapper
|
|
114
|
+
if (modulus && typeof modulus === 'object' && modulus['@value']) {
|
|
115
|
+
modulus = modulus['@value'];
|
|
116
|
+
}
|
|
117
|
+
if (exponent && typeof exponent === 'object' && exponent['@value']) {
|
|
118
|
+
exponent = exponent['@value'];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Convert exponent to string if number
|
|
122
|
+
if (typeof exponent === 'number') {
|
|
123
|
+
exponent = exponent.toString();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!modulus || !exponent) return null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
modulus: String(modulus).toLowerCase().replace(/[\s:]/g, ''),
|
|
130
|
+
exponent: String(exponent)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetch and parse WebID profile
|
|
136
|
+
* @param {string} webId - WebID URI to fetch
|
|
137
|
+
* @returns {Promise<Array<{modulus: string, exponent: string}>>}
|
|
138
|
+
*/
|
|
139
|
+
async function fetchProfileKeys(webId) {
|
|
140
|
+
const response = await fetchWithTimeout(webId, {
|
|
141
|
+
headers: {
|
|
142
|
+
'Accept': 'application/ld+json, text/turtle, application/json'
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new Error(`Failed to fetch WebID profile: ${response.status}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const contentType = response.headers.get('content-type') || '';
|
|
151
|
+
const text = await response.text();
|
|
152
|
+
|
|
153
|
+
let jsonLd;
|
|
154
|
+
|
|
155
|
+
if (contentType.includes('text/turtle') || contentType.includes('text/n3')) {
|
|
156
|
+
// Parse Turtle to JSON-LD
|
|
157
|
+
jsonLd = await turtleToJsonLd(text, webId);
|
|
158
|
+
} else if (contentType.includes('text/html')) {
|
|
159
|
+
// Try to extract JSON-LD from HTML data island
|
|
160
|
+
const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
161
|
+
if (jsonLdMatch) {
|
|
162
|
+
jsonLd = JSON.parse(jsonLdMatch[1]);
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error('No JSON-LD found in HTML profile');
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// Assume JSON-LD
|
|
168
|
+
jsonLd = JSON.parse(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return extractCertKeys(jsonLd, webId);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Verify certificate against WebID profile
|
|
176
|
+
* @param {object} certificate - Node.js TLS certificate object
|
|
177
|
+
* @param {string} webId - WebID URI
|
|
178
|
+
* @returns {Promise<boolean>} True if certificate matches profile
|
|
179
|
+
*/
|
|
180
|
+
export async function verifyWebIdTls(certificate, webId) {
|
|
181
|
+
if (!certificate.modulus || !certificate.exponent) {
|
|
182
|
+
throw new Error('Certificate missing modulus or exponent');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Normalize certificate values
|
|
186
|
+
const certModulus = certificate.modulus.toLowerCase().replace(/[\s:]/g, '');
|
|
187
|
+
// Certificate exponent is hex, convert to decimal string
|
|
188
|
+
const certExponent = parseInt(certificate.exponent, 16).toString();
|
|
189
|
+
|
|
190
|
+
// Check cache
|
|
191
|
+
const cacheKey = `${webId}:${certModulus}`;
|
|
192
|
+
const cached = cache.get(cacheKey);
|
|
193
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
194
|
+
return cached.verified;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const profileKeys = await fetchProfileKeys(webId);
|
|
199
|
+
|
|
200
|
+
// Check if any key matches
|
|
201
|
+
const verified = profileKeys.some(key =>
|
|
202
|
+
key.modulus === certModulus && key.exponent === certExponent
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
cache.set(cacheKey, { verified, timestamp: Date.now() });
|
|
206
|
+
return verified;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error(`WebID-TLS verification error for ${webId}:`, err.message);
|
|
209
|
+
cache.set(cacheKey, { verified: false, timestamp: Date.now() });
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* WebID-TLS authentication middleware
|
|
216
|
+
* Extracts WebID from client certificate and verifies against profile
|
|
217
|
+
*
|
|
218
|
+
* @param {object} request - Fastify request object
|
|
219
|
+
* @returns {Promise<string|null>} WebID if verified, null otherwise
|
|
220
|
+
*/
|
|
221
|
+
export async function webIdTlsAuth(request) {
|
|
222
|
+
// Get socket from request
|
|
223
|
+
const socket = request.raw?.socket || request.socket;
|
|
224
|
+
|
|
225
|
+
if (!socket?.getPeerCertificate) {
|
|
226
|
+
return null; // Not a TLS connection or no cert support
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const cert = socket.getPeerCertificate();
|
|
230
|
+
|
|
231
|
+
// No certificate or empty certificate
|
|
232
|
+
if (!cert || Object.keys(cert).length === 0) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Extract WebID from SAN
|
|
237
|
+
const webId = extractWebIdFromSAN(cert.subjectaltname);
|
|
238
|
+
if (!webId) {
|
|
239
|
+
return null; // No WebID in certificate
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Only accept https:// WebIDs for now
|
|
243
|
+
if (!webId.startsWith('https://')) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Verify certificate against profile
|
|
248
|
+
const verified = await verifyWebIdTls(cert, webId);
|
|
249
|
+
return verified ? webId : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Check if request has a client certificate
|
|
254
|
+
* @param {object} request - Fastify request object
|
|
255
|
+
* @returns {boolean}
|
|
256
|
+
*/
|
|
257
|
+
export function hasClientCertificate(request) {
|
|
258
|
+
const socket = request.raw?.socket || request.socket;
|
|
259
|
+
if (!socket?.getPeerCertificate) return false;
|
|
260
|
+
|
|
261
|
+
const cert = socket.getPeerCertificate();
|
|
262
|
+
return cert && Object.keys(cert).length > 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Clear verification cache (for testing)
|
|
267
|
+
*/
|
|
268
|
+
export function clearCache() {
|
|
269
|
+
cache.clear();
|
|
270
|
+
}
|
package/src/config.js
CHANGED
|
@@ -60,6 +60,9 @@ export const defaults = {
|
|
|
60
60
|
// Invite-only registration
|
|
61
61
|
inviteOnly: false,
|
|
62
62
|
|
|
63
|
+
// WebID-TLS client certificate authentication
|
|
64
|
+
webidTls: false,
|
|
65
|
+
|
|
63
66
|
// Storage quota (bytes) - 50MB default
|
|
64
67
|
defaultQuota: 50 * 1024 * 1024,
|
|
65
68
|
|
|
@@ -102,6 +105,7 @@ const envMap = {
|
|
|
102
105
|
JSS_AP_SUMMARY: 'apSummary',
|
|
103
106
|
JSS_AP_NOSTR_PUBKEY: 'apNostrPubkey',
|
|
104
107
|
JSS_INVITE_ONLY: 'inviteOnly',
|
|
108
|
+
JSS_WEBID_TLS: 'webidTls',
|
|
105
109
|
JSS_DEFAULT_QUOTA: 'defaultQuota',
|
|
106
110
|
};
|
|
107
111
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import websocket from '@fastify/websocket';
|
|
20
20
|
import { handleWebSocket, getConnectionCount, getSubscriptionCount } from './websocket.js';
|
|
21
|
+
import { getWebIdFromRequestAsync } from '../auth/token.js';
|
|
21
22
|
export { emitChange } from './events.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -32,8 +33,10 @@ export async function notificationsPlugin(fastify, options) {
|
|
|
32
33
|
// WebSocket route for notifications (dedicated path to avoid route conflicts)
|
|
33
34
|
// Clients discover this via Updates-Via header
|
|
34
35
|
// In @fastify/websocket v8, handler receives (connection, request) where connection.socket is the raw WebSocket
|
|
35
|
-
fastify.get('/.notifications', { websocket: true }, (connection, request) => {
|
|
36
|
-
|
|
36
|
+
fastify.get('/.notifications', { websocket: true }, async (connection, request) => {
|
|
37
|
+
// Get WebID from auth token (if present) for ACL checking on subscriptions
|
|
38
|
+
const { webId } = await getWebIdFromRequestAsync(request);
|
|
39
|
+
handleWebSocket(connection.socket, request, webId);
|
|
37
40
|
});
|
|
38
41
|
|
|
39
42
|
// Optional: Status endpoint for monitoring
|
|
@@ -6,11 +6,19 @@
|
|
|
6
6
|
* Protocol:
|
|
7
7
|
* - Server sends: "protocol solid-0.1" on connect
|
|
8
8
|
* - Client sends: "sub <uri>" to subscribe
|
|
9
|
-
* - Server sends: "ack <uri>" to acknowledge
|
|
9
|
+
* - Server sends: "ack <uri>" to acknowledge (if authorized)
|
|
10
|
+
* - Server sends: "err <uri> forbidden" if not authorized
|
|
10
11
|
* - Server sends: "pub <uri>" when resource changes
|
|
12
|
+
*
|
|
13
|
+
* Security:
|
|
14
|
+
* - ACL is checked on every subscription request
|
|
15
|
+
* - Only subscribers with read access receive notifications
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { resourceEvents } from './events.js';
|
|
19
|
+
import { checkAccess } from '../wac/checker.js';
|
|
20
|
+
import { AccessMode } from '../wac/parser.js';
|
|
21
|
+
import * as storage from '../storage/filesystem.js';
|
|
14
22
|
|
|
15
23
|
// Security limits
|
|
16
24
|
const MAX_SUBSCRIPTIONS_PER_CONNECTION = 100;
|
|
@@ -26,8 +34,13 @@ const subscribers = new Map();
|
|
|
26
34
|
* Handle new WebSocket connection
|
|
27
35
|
* @param {WebSocket} socket - The WebSocket connection
|
|
28
36
|
* @param {Request} request - The HTTP request
|
|
37
|
+
* @param {string|null} webId - Authenticated WebID (null for anonymous)
|
|
29
38
|
*/
|
|
30
|
-
export function handleWebSocket(socket, request) {
|
|
39
|
+
export function handleWebSocket(socket, request, webId = null) {
|
|
40
|
+
// Store webId and server info on socket for ACL checks
|
|
41
|
+
socket.webId = webId;
|
|
42
|
+
socket.serverOrigin = `${request.protocol}://${request.hostname}`;
|
|
43
|
+
|
|
31
44
|
// Send protocol greeting
|
|
32
45
|
socket.send('protocol solid-0.1');
|
|
33
46
|
|
|
@@ -35,7 +48,7 @@ export function handleWebSocket(socket, request) {
|
|
|
35
48
|
subscriptions.set(socket, new Set());
|
|
36
49
|
|
|
37
50
|
// Handle incoming messages
|
|
38
|
-
socket.on('message', (message) => {
|
|
51
|
+
socket.on('message', async (message) => {
|
|
39
52
|
const msg = message.toString().trim();
|
|
40
53
|
|
|
41
54
|
// Handle subscription request
|
|
@@ -55,6 +68,13 @@ export function handleWebSocket(socket, request) {
|
|
|
55
68
|
return;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
// Security: check ACL read permission before allowing subscription
|
|
72
|
+
const canSubscribe = await checkSubscriptionAccess(url, socket);
|
|
73
|
+
if (!canSubscribe) {
|
|
74
|
+
socket.send(`err ${url} forbidden`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
subscribe(socket, url);
|
|
59
79
|
socket.send(`ack ${url}`);
|
|
60
80
|
}
|
|
@@ -80,6 +100,46 @@ export function handleWebSocket(socket, request) {
|
|
|
80
100
|
});
|
|
81
101
|
}
|
|
82
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Check if socket has read access to subscribe to a URL
|
|
105
|
+
* @param {string} url - The URL to subscribe to
|
|
106
|
+
* @param {WebSocket} socket - The WebSocket connection (with webId attached)
|
|
107
|
+
* @returns {Promise<boolean>} - true if subscription is allowed
|
|
108
|
+
*/
|
|
109
|
+
async function checkSubscriptionAccess(url, socket) {
|
|
110
|
+
try {
|
|
111
|
+
// Parse the subscription URL
|
|
112
|
+
const parsedUrl = new URL(url);
|
|
113
|
+
|
|
114
|
+
// Security: Only allow subscriptions to URLs on this server
|
|
115
|
+
// This prevents using the server as a proxy to probe other servers
|
|
116
|
+
if (parsedUrl.origin !== socket.serverOrigin) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resourcePath = decodeURIComponent(parsedUrl.pathname);
|
|
121
|
+
|
|
122
|
+
// Check if resource exists and if it's a container
|
|
123
|
+
const stats = await storage.stat(resourcePath);
|
|
124
|
+
const isContainer = stats?.isDirectory || resourcePath.endsWith('/');
|
|
125
|
+
|
|
126
|
+
// Check WAC read permission
|
|
127
|
+
const { allowed } = await checkAccess({
|
|
128
|
+
resourceUrl: url,
|
|
129
|
+
resourcePath,
|
|
130
|
+
isContainer,
|
|
131
|
+
agentWebId: socket.webId,
|
|
132
|
+
requiredMode: AccessMode.READ
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return allowed;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// On any error (invalid URL, storage error, etc.), deny subscription
|
|
138
|
+
// This prevents information leakage through error messages
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
83
143
|
/**
|
|
84
144
|
* Subscribe a socket to a resource URL
|
|
85
145
|
*/
|
package/src/server.js
CHANGED
|
@@ -37,6 +37,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
37
37
|
* @param {string} options.apDisplayName - ActivityPub display name
|
|
38
38
|
* @param {string} options.apSummary - ActivityPub bio/summary
|
|
39
39
|
* @param {string} options.apNostrPubkey - Nostr pubkey for identity linking
|
|
40
|
+
* @param {boolean} options.webidTls - Enable WebID-TLS client certificate auth (default false)
|
|
40
41
|
*/
|
|
41
42
|
export function createServer(options = {}) {
|
|
42
43
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
@@ -70,6 +71,8 @@ export function createServer(options = {}) {
|
|
|
70
71
|
const inviteOnly = options.inviteOnly ?? false;
|
|
71
72
|
// Default storage quota per pod (50MB default, 0 = unlimited)
|
|
72
73
|
const defaultQuota = options.defaultQuota ?? 50 * 1024 * 1024;
|
|
74
|
+
// WebID-TLS client certificate authentication is OFF by default
|
|
75
|
+
const webidTlsEnabled = options.webidTls ?? false;
|
|
73
76
|
|
|
74
77
|
// Set data root via environment variable if provided
|
|
75
78
|
if (options.root) {
|
|
@@ -90,6 +93,13 @@ export function createServer(options = {}) {
|
|
|
90
93
|
key: options.ssl.key,
|
|
91
94
|
cert: options.ssl.cert,
|
|
92
95
|
};
|
|
96
|
+
|
|
97
|
+
// Enable client certificate request for WebID-TLS
|
|
98
|
+
if (webidTlsEnabled) {
|
|
99
|
+
fastifyOptions.https.requestCert = true;
|
|
100
|
+
// Don't reject unauthorized - we verify via WebID profile, not CA chain
|
|
101
|
+
fastifyOptions.https.rejectUnauthorized = false;
|
|
102
|
+
}
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
const fastify = Fastify(fastifyOptions);
|
package/test/helpers.js
CHANGED
|
@@ -39,9 +39,11 @@ export async function startTestServer(options = {}) {
|
|
|
39
39
|
*/
|
|
40
40
|
export async function stopTestServer() {
|
|
41
41
|
if (server) {
|
|
42
|
+
// Force close all connections to avoid hanging
|
|
42
43
|
await server.close();
|
|
43
44
|
server = null;
|
|
44
45
|
}
|
|
46
|
+
baseUrl = null;
|
|
45
47
|
// Clean up test data
|
|
46
48
|
await fs.emptyDir(TEST_DATA_DIR);
|
|
47
49
|
// Clear tokens
|
|
@@ -328,6 +328,96 @@ describe('WebSocket Notifications (notifications enabled)', () => {
|
|
|
328
328
|
});
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
describe('WebSocket ACL Enforcement', () => {
|
|
332
|
+
let wsUrl;
|
|
333
|
+
|
|
334
|
+
before(async () => {
|
|
335
|
+
await startTestServer({ notifications: true });
|
|
336
|
+
await createTestPod('aclnotify');
|
|
337
|
+
const res = await request('/aclnotify/', { method: 'OPTIONS' });
|
|
338
|
+
wsUrl = res.headers.get('Updates-Via');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
after(async () => {
|
|
342
|
+
await stopTestServer();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should allow anonymous subscription to public resources', async () => {
|
|
346
|
+
const ws = new WebSocket(wsUrl);
|
|
347
|
+
const baseUrl = getBaseUrl();
|
|
348
|
+
const resourceUrl = `${baseUrl}/aclnotify/public/anon-allowed.json`;
|
|
349
|
+
|
|
350
|
+
const messages = [];
|
|
351
|
+
|
|
352
|
+
await new Promise((resolve, reject) => {
|
|
353
|
+
ws.on('open', () => {
|
|
354
|
+
ws.send(`sub ${resourceUrl}`);
|
|
355
|
+
});
|
|
356
|
+
ws.on('message', (data) => {
|
|
357
|
+
messages.push(data.toString());
|
|
358
|
+
if (messages.length >= 2) resolve();
|
|
359
|
+
});
|
|
360
|
+
ws.on('error', reject);
|
|
361
|
+
setTimeout(() => resolve(), 2000);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
|
|
365
|
+
assert.ok(messages.some(m => m === `ack ${resourceUrl}`), 'Should receive ack for public resource');
|
|
366
|
+
ws.close();
|
|
367
|
+
await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should deny anonymous subscription to private resources', async () => {
|
|
371
|
+
const ws = new WebSocket(wsUrl);
|
|
372
|
+
const baseUrl = getBaseUrl();
|
|
373
|
+
const resourceUrl = `${baseUrl}/aclnotify/private/secret.json`;
|
|
374
|
+
|
|
375
|
+
const messages = [];
|
|
376
|
+
|
|
377
|
+
await new Promise((resolve, reject) => {
|
|
378
|
+
ws.on('open', () => {
|
|
379
|
+
ws.send(`sub ${resourceUrl}`);
|
|
380
|
+
});
|
|
381
|
+
ws.on('message', (data) => {
|
|
382
|
+
messages.push(data.toString());
|
|
383
|
+
if (messages.some(m => m.startsWith('err '))) resolve();
|
|
384
|
+
});
|
|
385
|
+
ws.on('error', reject);
|
|
386
|
+
setTimeout(() => resolve(), 2000);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
assert.ok(messages.includes('protocol solid-0.1'), 'Should receive protocol greeting');
|
|
390
|
+
assert.ok(messages.some(m => m === `err ${resourceUrl} forbidden`),
|
|
391
|
+
`Should receive err forbidden for private resource. Got: ${messages.join(', ')}`);
|
|
392
|
+
ws.close();
|
|
393
|
+
await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should deny subscription to resources on other servers', async () => {
|
|
397
|
+
const ws = new WebSocket(wsUrl);
|
|
398
|
+
const externalUrl = 'https://evil.example.com/steal/data.json';
|
|
399
|
+
|
|
400
|
+
const messages = [];
|
|
401
|
+
|
|
402
|
+
await new Promise((resolve, reject) => {
|
|
403
|
+
ws.on('open', () => {
|
|
404
|
+
ws.send(`sub ${externalUrl}`);
|
|
405
|
+
});
|
|
406
|
+
ws.on('message', (data) => {
|
|
407
|
+
messages.push(data.toString());
|
|
408
|
+
if (messages.some(m => m.startsWith('err '))) resolve();
|
|
409
|
+
});
|
|
410
|
+
ws.on('error', reject);
|
|
411
|
+
setTimeout(() => resolve(), 2000);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
assert.ok(messages.some(m => m === `err ${externalUrl} forbidden`),
|
|
415
|
+
'Should deny subscription to external URLs');
|
|
416
|
+
ws.close();
|
|
417
|
+
await new Promise(r => setTimeout(r, 50)); // Allow WebSocket to fully close
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
331
421
|
describe('WebSocket Notifications (notifications disabled - default)', () => {
|
|
332
422
|
before(async () => {
|
|
333
423
|
// Start server with notifications DISABLED (default)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebID-TLS Authentication tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the WebID-TLS certificate parsing and verification logic.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert';
|
|
9
|
+
import {
|
|
10
|
+
extractWebIdFromSAN,
|
|
11
|
+
verifyWebIdTls,
|
|
12
|
+
clearCache
|
|
13
|
+
} from '../src/auth/webid-tls.js';
|
|
14
|
+
|
|
15
|
+
describe('WebID-TLS', () => {
|
|
16
|
+
describe('extractWebIdFromSAN', () => {
|
|
17
|
+
it('should extract WebID from simple SAN', () => {
|
|
18
|
+
const san = 'URI:https://alice.example/card#me';
|
|
19
|
+
const webId = extractWebIdFromSAN(san);
|
|
20
|
+
assert.strictEqual(webId, 'https://alice.example/card#me');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should extract WebID from multi-value SAN', () => {
|
|
24
|
+
const san = 'URI:https://bob.example/profile/card#me, DNS:example.com, IP:192.168.1.1';
|
|
25
|
+
const webId = extractWebIdFromSAN(san);
|
|
26
|
+
assert.strictEqual(webId, 'https://bob.example/profile/card#me');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return null for missing SAN', () => {
|
|
30
|
+
const webId = extractWebIdFromSAN(null);
|
|
31
|
+
assert.strictEqual(webId, null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return null for SAN without URI', () => {
|
|
35
|
+
const san = 'DNS:example.com, IP:192.168.1.1';
|
|
36
|
+
const webId = extractWebIdFromSAN(san);
|
|
37
|
+
assert.strictEqual(webId, null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should handle URI without spaces', () => {
|
|
41
|
+
const san = 'URI:https://user.example/me,DNS:example.com';
|
|
42
|
+
const webId = extractWebIdFromSAN(san);
|
|
43
|
+
assert.strictEqual(webId, 'https://user.example/me');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('verifyWebIdTls', () => {
|
|
48
|
+
// Clear cache before each test
|
|
49
|
+
it('should reject certificate without modulus', async () => {
|
|
50
|
+
clearCache();
|
|
51
|
+
const cert = { exponent: '10001' }; // Missing modulus
|
|
52
|
+
try {
|
|
53
|
+
await verifyWebIdTls(cert, 'https://example.com/card#me');
|
|
54
|
+
assert.fail('Should have thrown an error');
|
|
55
|
+
} catch (err) {
|
|
56
|
+
assert.ok(err.message.includes('modulus'));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should reject certificate without exponent', async () => {
|
|
61
|
+
clearCache();
|
|
62
|
+
const cert = { modulus: 'abc123' }; // Missing exponent
|
|
63
|
+
try {
|
|
64
|
+
await verifyWebIdTls(cert, 'https://example.com/card#me');
|
|
65
|
+
assert.fail('Should have thrown an error');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
assert.ok(err.message.includes('exponent'));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('Certificate key extraction', () => {
|
|
73
|
+
it('should extract keys from JSON-LD profile with cert:key', async () => {
|
|
74
|
+
// This tests the internal extractCertKeys function indirectly
|
|
75
|
+
// by checking that profiles are fetched and parsed correctly
|
|
76
|
+
clearCache();
|
|
77
|
+
|
|
78
|
+
// A minimal test - full integration would need a mock server
|
|
79
|
+
// For now we just ensure the functions are callable
|
|
80
|
+
const cert = {
|
|
81
|
+
modulus: 'abc123def456',
|
|
82
|
+
exponent: '10001' // 65537 in hex
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// This will fail because the profile URL doesn't exist
|
|
87
|
+
// but it tests that the function runs without syntax errors
|
|
88
|
+
const result = await verifyWebIdTls(cert, 'https://nonexistent.example/card#me');
|
|
89
|
+
assert.strictEqual(result, false);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Expected to fail on network error
|
|
92
|
+
assert.ok(true);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('SAN format variations', () => {
|
|
98
|
+
it('should handle lowercase uri prefix', () => {
|
|
99
|
+
// Some certs might have lowercase
|
|
100
|
+
const san = 'uri:https://alice.example/card#me';
|
|
101
|
+
// Our regex is case-sensitive, which matches the standard
|
|
102
|
+
const webId = extractWebIdFromSAN(san);
|
|
103
|
+
assert.strictEqual(webId, null); // Should not match lowercase
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should extract first URI when multiple are present', () => {
|
|
107
|
+
const san = 'URI:https://primary.example/me, URI:https://secondary.example/me';
|
|
108
|
+
const webId = extractWebIdFromSAN(san);
|
|
109
|
+
assert.strictEqual(webId, 'https://primary.example/me');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should handle spaces in SAN format', () => {
|
|
113
|
+
const san = 'URI: https://alice.example/card#me';
|
|
114
|
+
const webId = extractWebIdFromSAN(san);
|
|
115
|
+
// With space after colon, it captures from the space
|
|
116
|
+
assert.ok(webId === null || webId === ' https://alice.example/card#me');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|