@le-space/orbitdb-identity-provider-webauthn-did 0.0.1 → 0.2.1
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/README.md +133 -340
- package/package.json +41 -9
- package/src/index.js +82 -453
- package/src/keystore/encryption.js +579 -0
- package/src/keystore/index.js +6 -0
- package/src/keystore/provider.js +555 -0
- package/src/keystore-encryption.js +6 -0
- package/src/varsig/assertion.js +205 -0
- package/src/varsig/credential.js +144 -0
- package/src/varsig/domain.js +11 -0
- package/src/varsig/identity.js +161 -0
- package/src/varsig/index.js +6 -0
- package/src/varsig/provider.js +78 -0
- package/src/varsig/storage.js +46 -0
- package/src/varsig/utils.js +43 -0
- package/src/verification.js +273 -0
- package/src/webauthn/provider.js +542 -0
- package/verification.js +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn DID Provider for OrbitDB
|
|
3
|
+
*
|
|
4
|
+
* Creates hardware-secured DIDs using WebAuthn authentication (Passkey, Yubikey, Ledger, etc.)
|
|
5
|
+
* Integrates with OrbitDB's identity system while keeping private keys in secure hardware
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { logger } from '@libp2p/logger';
|
|
9
|
+
import * as KeystoreEncryption from '../keystore/encryption.js';
|
|
10
|
+
|
|
11
|
+
const webauthnLog = logger('orbitdb-identity-provider-webauthn-did:webauthn');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* WebAuthn DID Provider Core Implementation
|
|
15
|
+
*/
|
|
16
|
+
export class WebAuthnDIDProvider {
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} credentialInfo - WebAuthn credential material.
|
|
19
|
+
* @param {string} credentialInfo.credentialId - Credential ID (base64url).
|
|
20
|
+
* @param {Object} credentialInfo.publicKey - P-256 public key details.
|
|
21
|
+
* @param {Uint8Array} credentialInfo.rawCredentialId - Raw credential ID bytes.
|
|
22
|
+
*/
|
|
23
|
+
constructor(credentialInfo) {
|
|
24
|
+
this.credentialId = credentialInfo.credentialId;
|
|
25
|
+
this.publicKey = credentialInfo.publicKey;
|
|
26
|
+
this.rawCredentialId = credentialInfo.rawCredentialId;
|
|
27
|
+
this.type = 'webauthn';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if WebAuthn is supported in current browser
|
|
32
|
+
* @returns {boolean} True if supported.
|
|
33
|
+
*/
|
|
34
|
+
static isSupported() {
|
|
35
|
+
return window.PublicKeyCredential &&
|
|
36
|
+
typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if platform authenticator (Face ID, Touch ID, Windows Hello) is available
|
|
41
|
+
* @returns {Promise<boolean>} True if available.
|
|
42
|
+
*/
|
|
43
|
+
static async isPlatformAuthenticatorAvailable() {
|
|
44
|
+
if (!this.isSupported()) return false;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn('Failed to check platform authenticator availability:', error);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a WebAuthn credential for OrbitDB identity
|
|
56
|
+
* This triggers biometric authentication (Face ID, Touch ID, Windows Hello, etc.)
|
|
57
|
+
* @param {Object} options - Credential options
|
|
58
|
+
* @param {string} options.userId - User ID
|
|
59
|
+
* @param {string} options.displayName - Display name
|
|
60
|
+
* @param {string} options.domain - Domain/RP ID
|
|
61
|
+
* @param {boolean} options.encryptKeystore - Enable keystore encryption
|
|
62
|
+
* @param {string} options.keystoreEncryptionMethod - 'prf' (default), 'hmac-secret', or 'largeBlob'
|
|
63
|
+
* @returns {Promise<Object>} Credential info with public key and metadata.
|
|
64
|
+
*/
|
|
65
|
+
static async createCredential(options = {}) {
|
|
66
|
+
const {
|
|
67
|
+
userId,
|
|
68
|
+
displayName,
|
|
69
|
+
domain,
|
|
70
|
+
encryptKeystore = false,
|
|
71
|
+
keystoreEncryptionMethod = 'prf'
|
|
72
|
+
} = {
|
|
73
|
+
userId: `orbitdb-user-${Date.now()}`,
|
|
74
|
+
displayName: 'Local-First Peer-to-Peer OrbitDB User',
|
|
75
|
+
domain: window.location.hostname,
|
|
76
|
+
...options
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
webauthnLog('createCredential() called with options: %o', { userId, displayName, domain });
|
|
80
|
+
|
|
81
|
+
if (!this.isSupported()) {
|
|
82
|
+
webauthnLog.error('WebAuthn is not supported in this browser');
|
|
83
|
+
throw new Error('WebAuthn is not supported in this browser');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Generate challenge for credential creation
|
|
87
|
+
const challenge = crypto.getRandomValues(new Uint8Array(32));
|
|
88
|
+
const userIdBytes = new TextEncoder().encode(userId);
|
|
89
|
+
|
|
90
|
+
webauthnLog('Calling navigator.credentials.create() for user: %s', userId);
|
|
91
|
+
|
|
92
|
+
// Prepare credential options
|
|
93
|
+
let credentialOptions = {
|
|
94
|
+
publicKey: {
|
|
95
|
+
challenge,
|
|
96
|
+
rp: {
|
|
97
|
+
name: 'OrbitDB Identity',
|
|
98
|
+
id: domain
|
|
99
|
+
},
|
|
100
|
+
user: {
|
|
101
|
+
id: userIdBytes,
|
|
102
|
+
name: userId,
|
|
103
|
+
displayName
|
|
104
|
+
},
|
|
105
|
+
pubKeyCredParams: [
|
|
106
|
+
{ alg: -7, type: 'public-key' }, // ES256 (P-256 curve)
|
|
107
|
+
{ alg: -257, type: 'public-key' } // RS256 fallback
|
|
108
|
+
],
|
|
109
|
+
authenticatorSelection: {
|
|
110
|
+
authenticatorAttachment: 'platform', // Prefer built-in authenticators
|
|
111
|
+
requireResidentKey: false,
|
|
112
|
+
residentKey: 'preferred',
|
|
113
|
+
userVerification: 'required' // Require biometric/PIN
|
|
114
|
+
},
|
|
115
|
+
timeout: 60000,
|
|
116
|
+
attestation: 'none' // Don't need attestation for DID creation
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Add encryption extension if requested
|
|
121
|
+
let prfInput = null;
|
|
122
|
+
if (encryptKeystore) {
|
|
123
|
+
webauthnLog('Adding encryption extension: %s', keystoreEncryptionMethod);
|
|
124
|
+
|
|
125
|
+
if (keystoreEncryptionMethod === 'prf') {
|
|
126
|
+
const prfConfig = KeystoreEncryption.addPRFToCredentialOptions(
|
|
127
|
+
credentialOptions.publicKey
|
|
128
|
+
);
|
|
129
|
+
credentialOptions.publicKey = prfConfig.credentialOptions;
|
|
130
|
+
prfInput = prfConfig.prfInput;
|
|
131
|
+
} else if (keystoreEncryptionMethod === 'hmac-secret') {
|
|
132
|
+
credentialOptions.publicKey = KeystoreEncryption.addHmacSecretToCredentialOptions(
|
|
133
|
+
credentialOptions.publicKey
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
// Note: largeBlob write happens after credential creation
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const credential = await navigator.credentials.create(credentialOptions);
|
|
141
|
+
|
|
142
|
+
if (!credential) {
|
|
143
|
+
webauthnLog.error('Failed to create WebAuthn credential - credential is null');
|
|
144
|
+
throw new Error('Failed to create WebAuthn credential');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
webauthnLog('Credential created successfully: %o', {
|
|
148
|
+
credentialId: this.arrayBufferToBase64url(credential.rawId).substring(0, 16) + '...',
|
|
149
|
+
type: credential.type
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
webauthnLog('Extracting public key from credential...');
|
|
153
|
+
|
|
154
|
+
// Extract public key from credential with timeout
|
|
155
|
+
const publicKey = await Promise.race([
|
|
156
|
+
this.extractPublicKey(credential),
|
|
157
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Public key extraction timeout')), 10000))
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
webauthnLog('Public key extracted successfully: %o', {
|
|
161
|
+
algorithm: publicKey.algorithm,
|
|
162
|
+
keyType: publicKey.keyType,
|
|
163
|
+
curve: publicKey.curve,
|
|
164
|
+
hasX: !!publicKey.x,
|
|
165
|
+
hasY: !!publicKey.y
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const result = {
|
|
169
|
+
credentialId: WebAuthnDIDProvider.arrayBufferToBase64url(credential.rawId),
|
|
170
|
+
rawCredentialId: new Uint8Array(credential.rawId),
|
|
171
|
+
publicKey,
|
|
172
|
+
userId,
|
|
173
|
+
displayName,
|
|
174
|
+
attestationObject: new Uint8Array(credential.response.attestationObject),
|
|
175
|
+
prfInput: prfInput || undefined
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
webauthnLog('Credential creation completed successfully');
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error('WebAuthn credential creation failed:', error);
|
|
184
|
+
|
|
185
|
+
// Provide user-friendly error messages
|
|
186
|
+
if (error.name === 'NotAllowedError') {
|
|
187
|
+
throw new Error('Biometric authentication was cancelled or failed');
|
|
188
|
+
} else if (error.name === 'InvalidStateError') {
|
|
189
|
+
throw new Error('A credential with this ID already exists');
|
|
190
|
+
} else if (error.name === 'NotSupportedError') {
|
|
191
|
+
throw new Error('WebAuthn is not supported on this device');
|
|
192
|
+
} else {
|
|
193
|
+
throw new Error(`WebAuthn error: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Extract P-256 public key from WebAuthn credential
|
|
200
|
+
* Parses the CBOR attestation object to get the real public key
|
|
201
|
+
*/
|
|
202
|
+
/**
|
|
203
|
+
* Extract and normalize WebAuthn public key data from a credential response.
|
|
204
|
+
* @param {PublicKeyCredential} credential - WebAuthn credential response.
|
|
205
|
+
* @returns {Promise<Object>} Parsed credential info with public key.
|
|
206
|
+
*/
|
|
207
|
+
static async extractPublicKey(credential) {
|
|
208
|
+
try {
|
|
209
|
+
// Import CBOR decoder for parsing attestation object
|
|
210
|
+
const cbor = await import('cbor-web');
|
|
211
|
+
const decode = cbor.decode || cbor.default?.decode || cbor.default;
|
|
212
|
+
|
|
213
|
+
if (typeof decode !== 'function') {
|
|
214
|
+
throw new Error('CBOR decoder not available from cbor-web');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const attestationObject = decode(new Uint8Array(credential.response.attestationObject));
|
|
218
|
+
const authData = attestationObject.authData;
|
|
219
|
+
|
|
220
|
+
// Parse authenticator data structure
|
|
221
|
+
// Skip: rpIdHash (32 bytes) + flags (1 byte) + signCount (4 bytes)
|
|
222
|
+
const credentialDataStart = 32 + 1 + 4 + 16 + 2; // +16 for AAGUID, +2 for credentialIdLength
|
|
223
|
+
const credentialIdLength = new DataView(authData.buffer, 32 + 1 + 4 + 16, 2).getUint16(0);
|
|
224
|
+
const publicKeyDataStart = credentialDataStart + credentialIdLength;
|
|
225
|
+
|
|
226
|
+
// Extract and decode the public key (CBOR format)
|
|
227
|
+
const publicKeyData = authData.slice(publicKeyDataStart);
|
|
228
|
+
const publicKeyObject = decode(publicKeyData);
|
|
229
|
+
|
|
230
|
+
// Extract P-256 coordinates (COSE key format)
|
|
231
|
+
return {
|
|
232
|
+
algorithm: publicKeyObject[3], // alg parameter
|
|
233
|
+
x: new Uint8Array(publicKeyObject[-2]), // x coordinate
|
|
234
|
+
y: new Uint8Array(publicKeyObject[-3]), // y coordinate
|
|
235
|
+
keyType: publicKeyObject[1], // kty parameter
|
|
236
|
+
curve: publicKeyObject[-1] // crv parameter
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.warn('Failed to extract real public key from WebAuthn credential, using fallback:', error);
|
|
241
|
+
|
|
242
|
+
// Fallback: Create deterministic public key from credential ID
|
|
243
|
+
// This ensures the SAME public key is generated every time for the same credential
|
|
244
|
+
const credentialId = new Uint8Array(credential.rawId);
|
|
245
|
+
|
|
246
|
+
const hash = await crypto.subtle.digest('SHA-256', credentialId);
|
|
247
|
+
const seed = new Uint8Array(hash);
|
|
248
|
+
|
|
249
|
+
// Create a second hash for the y coordinate to ensure uniqueness but determinism
|
|
250
|
+
const yData = new Uint8Array(credentialId.length + 4);
|
|
251
|
+
yData.set(credentialId, 0);
|
|
252
|
+
yData.set([0x59, 0x43, 0x4F, 0x4F], credentialId.length); // "YCOO" marker
|
|
253
|
+
const yHash = await crypto.subtle.digest('SHA-256', yData);
|
|
254
|
+
const ySeed = new Uint8Array(yHash);
|
|
255
|
+
|
|
256
|
+
const fallbackKey = {
|
|
257
|
+
algorithm: -7, // ES256
|
|
258
|
+
x: seed.slice(0, 32), // Use first 32 bytes as x coordinate
|
|
259
|
+
y: ySeed.slice(0, 32), // Deterministic y coordinate based on credential
|
|
260
|
+
keyType: 2, // EC2 key type
|
|
261
|
+
curve: 1 // P-256 curve
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
return fallbackKey;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generate DID from WebAuthn credential using did:key format for P-256 keys
|
|
271
|
+
* This ensures compatibility with ucanto and other DID:key implementations
|
|
272
|
+
*/
|
|
273
|
+
/**
|
|
274
|
+
* Create a did:key DID from a WebAuthn P-256 public key.
|
|
275
|
+
* @param {Object} credentialInfo - WebAuthn credential info.
|
|
276
|
+
* @returns {Promise<string>} DID string.
|
|
277
|
+
*/
|
|
278
|
+
static async createDID(credentialInfo) {
|
|
279
|
+
try {
|
|
280
|
+
// Import multiformats modules with correct exports
|
|
281
|
+
const multiformats = await import('multiformats');
|
|
282
|
+
const varint = multiformats.varint;
|
|
283
|
+
const { base58btc } = await import('multiformats/bases/base58');
|
|
284
|
+
|
|
285
|
+
// Extract public key coordinates
|
|
286
|
+
const { x, y } = credentialInfo.publicKey;
|
|
287
|
+
|
|
288
|
+
// Validate P-256 public key coordinates
|
|
289
|
+
if (!x || !y || x.length !== 32 || y.length !== 32) {
|
|
290
|
+
throw new Error('Invalid P-256 public key coordinates');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// P-256 multicodec prefix: 0x1200
|
|
294
|
+
// 0x12 = varint for 0x1200
|
|
295
|
+
// 0x00 = varint for 0x0000 (compression flag?)
|
|
296
|
+
const multicodec = 0x1200; // p256-pub multicodec
|
|
297
|
+
const codecLength = varint.encodingLength(multicodec);
|
|
298
|
+
const codecBytes = new Uint8Array(codecLength);
|
|
299
|
+
varint.encodeTo(multicodec, codecBytes, 0);
|
|
300
|
+
|
|
301
|
+
// Combine multicodec prefix + public key bytes (uncompressed format)
|
|
302
|
+
// P-256 uncompressed public key format: 0x04 || x || y
|
|
303
|
+
const publicKeyBytes = new Uint8Array(65);
|
|
304
|
+
publicKeyBytes[0] = 0x04; // Uncompressed point format
|
|
305
|
+
publicKeyBytes.set(x, 1);
|
|
306
|
+
publicKeyBytes.set(y, 33);
|
|
307
|
+
|
|
308
|
+
const multikey = new Uint8Array(codecBytes.length + publicKeyBytes.length);
|
|
309
|
+
multikey.set(codecBytes, 0);
|
|
310
|
+
multikey.set(publicKeyBytes, codecBytes.length);
|
|
311
|
+
|
|
312
|
+
// Encode as base58btc and create did:key
|
|
313
|
+
const multikeyEncoded = base58btc.encode(multikey);
|
|
314
|
+
return `did:key:${multikeyEncoded}`;
|
|
315
|
+
|
|
316
|
+
} catch (error) {
|
|
317
|
+
console.warn('Failed to create DID with multiformats, using fallback:', error);
|
|
318
|
+
|
|
319
|
+
// Fallback: Simple DID creation without multiformats dependency
|
|
320
|
+
const { x, y } = credentialInfo.publicKey;
|
|
321
|
+
|
|
322
|
+
// Create a hash-based approach for consistency
|
|
323
|
+
const combined = new Uint8Array(x.length + y.length);
|
|
324
|
+
combined.set(x, 0);
|
|
325
|
+
combined.set(y, x.length);
|
|
326
|
+
|
|
327
|
+
// Simple base58-like encoding for fallback
|
|
328
|
+
const base58Chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
329
|
+
let encoded = 'z'; // base58btc prefix
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < Math.min(combined.length, 32); i += 4) {
|
|
332
|
+
const chunk = combined.slice(i, i + 4);
|
|
333
|
+
let value = 0;
|
|
334
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
335
|
+
value = value * 256 + chunk[j];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (let k = 0; k < 6; k++) {
|
|
339
|
+
encoded += base58Chars[value % 58];
|
|
340
|
+
value = Math.floor(value / 58);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return `did:key:${encoded}`;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Sign data using WebAuthn (requires biometric authentication)
|
|
350
|
+
* Creates a persistent signature that can be verified multiple times
|
|
351
|
+
*/
|
|
352
|
+
/**
|
|
353
|
+
* Sign arbitrary data with WebAuthn.
|
|
354
|
+
* @param {string|Uint8Array} data - Data to sign.
|
|
355
|
+
* @returns {Promise<string>} Base64url-encoded signature envelope.
|
|
356
|
+
*/
|
|
357
|
+
async sign(data) {
|
|
358
|
+
if (!WebAuthnDIDProvider.isSupported()) {
|
|
359
|
+
webauthnLog.error('WebAuthn is not supported in this browser');
|
|
360
|
+
throw new Error('WebAuthn is not supported in this browser');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
webauthnLog('Signer context: %o', {
|
|
365
|
+
signer: 'webauthn',
|
|
366
|
+
credentialIdPrefix: this.credentialId?.slice?.(0, 12),
|
|
367
|
+
rawCredentialIdLength: this.rawCredentialId?.length
|
|
368
|
+
});
|
|
369
|
+
const dataBytes = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data);
|
|
370
|
+
const dataHash = await crypto.subtle.digest('SHA-256', dataBytes);
|
|
371
|
+
const dataHashStr = Array.from(new Uint8Array(dataHash)).map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 16);
|
|
372
|
+
|
|
373
|
+
webauthnLog('sign() called with data length: %d, hash: %s...', dataBytes.length, dataHashStr);
|
|
374
|
+
|
|
375
|
+
// Create a deterministic challenge based on the credential ID and data
|
|
376
|
+
const combined = new Uint8Array(this.rawCredentialId.length + dataBytes.length);
|
|
377
|
+
combined.set(this.rawCredentialId, 0);
|
|
378
|
+
combined.set(dataBytes, this.rawCredentialId.length);
|
|
379
|
+
const challenge = await crypto.subtle.digest('SHA-256', combined);
|
|
380
|
+
const challengeHashStr = Array.from(new Uint8Array(challenge)).map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 16);
|
|
381
|
+
|
|
382
|
+
webauthnLog('Challenge created: %s...', challengeHashStr);
|
|
383
|
+
webauthnLog('Calling navigator.credentials.get() - biometric prompt should appear');
|
|
384
|
+
|
|
385
|
+
// Use WebAuthn to authenticate (this proves the user is present and verified)
|
|
386
|
+
const assertion = await navigator.credentials.get({
|
|
387
|
+
publicKey: {
|
|
388
|
+
challenge,
|
|
389
|
+
allowCredentials: [{
|
|
390
|
+
id: this.rawCredentialId,
|
|
391
|
+
type: 'public-key'
|
|
392
|
+
}],
|
|
393
|
+
userVerification: 'required',
|
|
394
|
+
timeout: 60000
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (!assertion) {
|
|
399
|
+
webauthnLog.error('WebAuthn authentication failed - assertion is null');
|
|
400
|
+
throw new Error('WebAuthn authentication failed');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
webauthnLog('Assertion received from navigator.credentials.get(): %o', {
|
|
404
|
+
hasAuthenticatorData: !!assertion.response.authenticatorData,
|
|
405
|
+
hasSignature: !!assertion.response.signature,
|
|
406
|
+
signatureLength: assertion.response.signature?.byteLength || 0
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Create a signature that includes the original data and credential proof
|
|
410
|
+
// This allows verification without requiring WebAuthn again
|
|
411
|
+
webauthnLog('Creating proof object...');
|
|
412
|
+
const webauthnProof = {
|
|
413
|
+
credentialId: this.credentialId,
|
|
414
|
+
dataHash: WebAuthnDIDProvider.arrayBufferToBase64url(await crypto.subtle.digest('SHA-256', dataBytes)),
|
|
415
|
+
authenticatorData: WebAuthnDIDProvider.arrayBufferToBase64url(assertion.response.authenticatorData),
|
|
416
|
+
clientDataJSON: new TextDecoder().decode(assertion.response.clientDataJSON),
|
|
417
|
+
signature: WebAuthnDIDProvider.arrayBufferToBase64url(assertion.response.signature),
|
|
418
|
+
timestamp: Date.now()
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
webauthnLog('Proof created successfully: %o', {
|
|
422
|
+
credentialId: webauthnProof.credentialId.substring(0, 16) + '...',
|
|
423
|
+
dataHash: webauthnProof.dataHash.substring(0, 16) + '...',
|
|
424
|
+
timestamp: webauthnProof.timestamp
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Return the proof as a base64url encoded string for OrbitDB
|
|
428
|
+
const encodedProof = WebAuthnDIDProvider.arrayBufferToBase64url(new TextEncoder().encode(JSON.stringify(webauthnProof)));
|
|
429
|
+
webauthnLog('sign() completed successfully, proof length: %d', encodedProof.length);
|
|
430
|
+
return encodedProof;
|
|
431
|
+
|
|
432
|
+
} catch (error) {
|
|
433
|
+
webauthnLog.error('WebAuthn signing failed: %s', error.message);
|
|
434
|
+
|
|
435
|
+
if (error.name === 'NotAllowedError') {
|
|
436
|
+
throw new Error('Biometric authentication was cancelled');
|
|
437
|
+
} else {
|
|
438
|
+
throw new Error(`WebAuthn signing error: ${error.message}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Verify WebAuthn signature/proof for OrbitDB compatibility
|
|
445
|
+
*/
|
|
446
|
+
/**
|
|
447
|
+
* Verify a WebAuthn signature envelope.
|
|
448
|
+
* @param {string} signatureData - Base64url signature envelope.
|
|
449
|
+
* @returns {Promise<boolean>} True if verification succeeds.
|
|
450
|
+
*/
|
|
451
|
+
async verify(signatureData) {
|
|
452
|
+
webauthnLog('verify() called with signature length: %d', signatureData.length);
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
// Decode the WebAuthn proof object
|
|
456
|
+
const proofBytes = WebAuthnDIDProvider.base64urlToArrayBuffer(signatureData);
|
|
457
|
+
const proofText = new TextDecoder().decode(proofBytes);
|
|
458
|
+
const proof = JSON.parse(proofText);
|
|
459
|
+
|
|
460
|
+
// Verify the proof structure
|
|
461
|
+
if (!proof.credentialId || !proof.dataHash || !proof.signature) {
|
|
462
|
+
throw new Error('Invalid WebAuthn proof structure');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Check if credential ID matches
|
|
466
|
+
webauthnLog('Verification step: checking credential ID');
|
|
467
|
+
if (proof.credentialId !== this.credentialId) {
|
|
468
|
+
webauthnLog.error('Credential ID mismatch in WebAuthn proof verification');
|
|
469
|
+
throw new Error('Credential ID mismatch');
|
|
470
|
+
}
|
|
471
|
+
webauthnLog('Verification step: credential ID check PASSED');
|
|
472
|
+
|
|
473
|
+
// Verify client data JSON
|
|
474
|
+
webauthnLog('Verification step: checking client data');
|
|
475
|
+
if (proof.clientDataJSON) {
|
|
476
|
+
const clientData = JSON.parse(proof.clientDataJSON);
|
|
477
|
+
if (clientData.type !== 'webauthn.get') {
|
|
478
|
+
webauthnLog.error('Invalid WebAuthn proof type: %s', clientData.type);
|
|
479
|
+
throw new Error('Invalid WebAuthn proof type');
|
|
480
|
+
}
|
|
481
|
+
webauthnLog('Verification step: client data check PASSED');
|
|
482
|
+
} else {
|
|
483
|
+
webauthnLog.error('Invalid client data in WebAuthn proof');
|
|
484
|
+
throw new Error('Invalid client data');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Check if proof is recent (within 24 hours)
|
|
488
|
+
webauthnLog('Verification step: checking timestamp');
|
|
489
|
+
const proofAge = Date.now() - proof.timestamp;
|
|
490
|
+
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
|
491
|
+
if (proofAge > maxAge) {
|
|
492
|
+
webauthnLog.error('WebAuthn proof is too old: %d ms', proofAge);
|
|
493
|
+
throw new Error('WebAuthn proof has expired');
|
|
494
|
+
}
|
|
495
|
+
webauthnLog('Verification step: timestamp check PASSED (age: %d ms)', proofAge);
|
|
496
|
+
|
|
497
|
+
// Verify authenticator data exists
|
|
498
|
+
webauthnLog('Verification step: checking authenticator data');
|
|
499
|
+
if (!proof.authenticatorData) {
|
|
500
|
+
webauthnLog.error('Missing authenticator data in WebAuthn proof');
|
|
501
|
+
throw new Error('Missing authenticator data');
|
|
502
|
+
}
|
|
503
|
+
webauthnLog('Verification step: authenticator data check PASSED');
|
|
504
|
+
|
|
505
|
+
webauthnLog('Verification result: SUCCESS');
|
|
506
|
+
return true;
|
|
507
|
+
|
|
508
|
+
} catch (error) {
|
|
509
|
+
webauthnLog.error('WebAuthn proof verification failed: %s', error.message);
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Utility: Convert ArrayBuffer to base64url
|
|
516
|
+
*/
|
|
517
|
+
static arrayBufferToBase64url(buffer) {
|
|
518
|
+
const bytes = new Uint8Array(buffer);
|
|
519
|
+
let binary = '';
|
|
520
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
521
|
+
binary += String.fromCharCode(bytes[i]);
|
|
522
|
+
}
|
|
523
|
+
return btoa(binary)
|
|
524
|
+
.replace(/\+/g, '-')
|
|
525
|
+
.replace(/\//g, '_')
|
|
526
|
+
.replace(/=/g, '');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Utility: Convert base64url to ArrayBuffer
|
|
531
|
+
*/
|
|
532
|
+
static base64urlToArrayBuffer(base64url) {
|
|
533
|
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
|
534
|
+
const binary = atob(base64);
|
|
535
|
+
const buffer = new ArrayBuffer(binary.length);
|
|
536
|
+
const bytes = new Uint8Array(buffer);
|
|
537
|
+
for (let i = 0; i < binary.length; i++) {
|
|
538
|
+
bytes[i] = binary.charCodeAt(i);
|
|
539
|
+
}
|
|
540
|
+
return buffer;
|
|
541
|
+
}
|
|
542
|
+
}
|
package/verification.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './src/verification.js';
|