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.
@@ -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.61)
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: **191 tests** (including 27 conformance tests)
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
- └── did-nostr.js # did:nostr → WebID resolution
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.73",
3
+ "version": "0.0.75",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
- if (!authHeader) {
218
- return { webId: null, error: null };
219
- }
220
-
221
- // Try Solid-OIDC first (DPoP tokens)
222
- if (hasSolidOidcAuth(request)) {
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
- // Fall back to Bearer tokens
232
- const token = extractToken(authHeader);
233
- if (!token) {
234
- return { webId: null, error: null };
235
- }
225
+ // Try Nostr NIP-98 (Schnorr signatures)
226
+ if (hasNostrAuth(request)) {
227
+ return verifyNostrAuth(request);
228
+ }
236
229
 
237
- // Try simple 2-part token first
238
- const payload = verifyToken(token);
239
- if (payload?.webId) {
240
- return { webId: payload.webId, error: null };
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
- // If 3-part JWT, verify against IdP's JWKS
244
- const parts = token.split('.');
245
- if (parts.length === 3) {
246
- const jwtPayload = await verifyJwtFromIdp(token);
247
- if (jwtPayload?.webId) {
248
- return { webId: jwtPayload.webId, error: null };
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: 'Invalid token' };
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
- handleWebSocket(connection.socket, request);
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
+ });