forked from JavaScriptSolidServer/JavaScriptSolidServer
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwebid-tls.js
More file actions
270 lines (228 loc) · 7.65 KB
/
webid-tls.js
File metadata and controls
270 lines (228 loc) · 7.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
/**
* WebID-TLS Authentication
*
* Authenticates clients via TLS client certificates.
* The certificate's SubjectAlternativeName (SAN) contains a WebID URI.
* The server fetches the WebID profile and verifies the certificate's
* public key matches one published in the profile.
*
* References:
* - https://dvcs.w3.org/hg/WebID/raw-file/tip/spec/tls-respec.html
* - https://www.w3.org/ns/auth/cert#
*/
import { turtleToJsonLd } from '../rdf/turtle.js';
// cert: ontology namespace
const CERT_NS = 'http://www.w3.org/ns/auth/cert#';
// Cache for verified WebIDs (reduces profile fetches)
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Fetch with timeout
*/
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(id);
return response;
} catch (err) {
clearTimeout(id);
throw err;
}
}
/**
* Extract WebID URI from certificate's SubjectAlternativeName
* @param {string} subjectaltname - Certificate's SAN field
* @returns {string|null} WebID URI or null
*/
export function extractWebIdFromSAN(subjectaltname) {
if (!subjectaltname) return null;
// SAN format: "URI:https://alice.example/card#me, DNS:example.com"
const match = subjectaltname.match(/URI:([^,\s]+)/);
return match ? match[1] : null;
}
/**
* Parse certificate keys from WebID profile (JSON-LD format)
* Handles both inline objects and arrays
* @param {object|Array} jsonLd - Parsed JSON-LD profile
* @param {string} webId - The WebID to find keys for
* @returns {Array<{modulus: string, exponent: string}>} Array of keys
*/
function extractCertKeys(jsonLd, webId) {
const keys = [];
// Normalize to array
const nodes = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
for (const node of nodes) {
// Check if this node is the WebID subject
const nodeId = node['@id'];
if (nodeId && !nodeId.endsWith('#me') && nodeId !== webId) {
continue;
}
// Look for cert:key property (various forms)
const keyProps = [
node['cert:key'],
node[CERT_NS + 'key'],
node['http://www.w3.org/ns/auth/cert#key']
];
for (const keyProp of keyProps) {
if (!keyProp) continue;
const keyValues = Array.isArray(keyProp) ? keyProp : [keyProp];
for (const keyValue of keyValues) {
const key = parseKeyObject(keyValue);
if (key) {
keys.push(key);
}
}
}
}
return keys;
}
/**
* Parse a single key object from JSON-LD
* @param {object} keyObj - Key object (may be nested or have @id)
* @returns {{modulus: string, exponent: string}|null}
*/
function parseKeyObject(keyObj) {
if (!keyObj || typeof keyObj !== 'object') return null;
// Extract modulus (various forms)
let modulus = keyObj['cert:modulus'] ||
keyObj[CERT_NS + 'modulus'] ||
keyObj['http://www.w3.org/ns/auth/cert#modulus'];
// Extract exponent (various forms)
let exponent = keyObj['cert:exponent'] ||
keyObj[CERT_NS + 'exponent'] ||
keyObj['http://www.w3.org/ns/auth/cert#exponent'];
// Handle @value wrapper
if (modulus && typeof modulus === 'object' && modulus['@value']) {
modulus = modulus['@value'];
}
if (exponent && typeof exponent === 'object' && exponent['@value']) {
exponent = exponent['@value'];
}
// Convert exponent to string if number
if (typeof exponent === 'number') {
exponent = exponent.toString();
}
if (!modulus || !exponent) return null;
return {
modulus: String(modulus).toLowerCase().replace(/[\s:]/g, ''),
exponent: String(exponent)
};
}
/**
* Fetch and parse WebID profile
* @param {string} webId - WebID URI to fetch
* @returns {Promise<Array<{modulus: string, exponent: string}>>}
*/
async function fetchProfileKeys(webId) {
const response = await fetchWithTimeout(webId, {
headers: {
'Accept': 'application/ld+json, text/turtle, application/json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch WebID profile: ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
const text = await response.text();
let jsonLd;
if (contentType.includes('text/turtle') || contentType.includes('text/n3')) {
// Parse Turtle to JSON-LD
jsonLd = await turtleToJsonLd(text, webId);
} else if (contentType.includes('text/html')) {
// Try to extract JSON-LD from HTML data island
const jsonLdMatch = text.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
if (jsonLdMatch) {
jsonLd = JSON.parse(jsonLdMatch[1]);
} else {
throw new Error('No JSON-LD found in HTML profile');
}
} else {
// Assume JSON-LD
jsonLd = JSON.parse(text);
}
return extractCertKeys(jsonLd, webId);
}
/**
* Verify certificate against WebID profile
* @param {object} certificate - Node.js TLS certificate object
* @param {string} webId - WebID URI
* @returns {Promise<boolean>} True if certificate matches profile
*/
export async function verifyWebIdTls(certificate, webId) {
if (!certificate.modulus || !certificate.exponent) {
throw new Error('Certificate missing modulus or exponent');
}
// Normalize certificate values
const certModulus = certificate.modulus.toLowerCase().replace(/[\s:]/g, '');
// Certificate exponent is hex, convert to decimal string
const certExponent = parseInt(certificate.exponent, 16).toString();
// Check cache
const cacheKey = `${webId}:${certModulus}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.verified;
}
try {
const profileKeys = await fetchProfileKeys(webId);
// Check if any key matches
const verified = profileKeys.some(key =>
key.modulus === certModulus && key.exponent === certExponent
);
cache.set(cacheKey, { verified, timestamp: Date.now() });
return verified;
} catch (err) {
console.error(`WebID-TLS verification error for ${webId}:`, err.message);
cache.set(cacheKey, { verified: false, timestamp: Date.now() });
return false;
}
}
/**
* WebID-TLS authentication middleware
* Extracts WebID from client certificate and verifies against profile
*
* @param {object} request - Fastify request object
* @returns {Promise<string|null>} WebID if verified, null otherwise
*/
export async function webIdTlsAuth(request) {
// Get socket from request
const socket = request.raw?.socket || request.socket;
if (!socket?.getPeerCertificate) {
return null; // Not a TLS connection or no cert support
}
const cert = socket.getPeerCertificate();
// No certificate or empty certificate
if (!cert || Object.keys(cert).length === 0) {
return null;
}
// Extract WebID from SAN
const webId = extractWebIdFromSAN(cert.subjectaltname);
if (!webId) {
return null; // No WebID in certificate
}
// Only accept https:// WebIDs for now
if (!webId.startsWith('https://')) {
return null;
}
// Verify certificate against profile
const verified = await verifyWebIdTls(cert, webId);
return verified ? webId : null;
}
/**
* Check if request has a client certificate
* @param {object} request - Fastify request object
* @returns {boolean}
*/
export function hasClientCertificate(request) {
const socket = request.raw?.socket || request.socket;
if (!socket?.getPeerCertificate) return false;
const cert = socket.getPeerCertificate();
return cert && Object.keys(cert).length > 0;
}
/**
* Clear verification cache (for testing)
*/
export function clearCache() {
cache.clear();
}