@abraca/dabra 0.1.0
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/dist/hocuspocus-provider.cjs +3237 -0
- package/dist/hocuspocus-provider.cjs.map +1 -0
- package/dist/hocuspocus-provider.esm.js +3199 -0
- package/dist/hocuspocus-provider.esm.js.map +1 -0
- package/dist/index.d.ts +784 -0
- package/package.json +42 -0
- package/src/AbracadabraProvider.ts +381 -0
- package/src/CryptoIdentityKeystore.ts +294 -0
- package/src/EventEmitter.ts +44 -0
- package/src/HocuspocusProvider.ts +603 -0
- package/src/HocuspocusProviderWebsocket.ts +533 -0
- package/src/IncomingMessage.ts +63 -0
- package/src/MessageReceiver.ts +139 -0
- package/src/MessageSender.ts +22 -0
- package/src/OfflineStore.ts +185 -0
- package/src/OutgoingMessage.ts +25 -0
- package/src/OutgoingMessages/AuthenticationMessage.ts +25 -0
- package/src/OutgoingMessages/AwarenessMessage.ts +41 -0
- package/src/OutgoingMessages/CloseMessage.ts +17 -0
- package/src/OutgoingMessages/QueryAwarenessMessage.ts +17 -0
- package/src/OutgoingMessages/StatelessMessage.ts +18 -0
- package/src/OutgoingMessages/SubdocMessage.ts +35 -0
- package/src/OutgoingMessages/SyncStepOneMessage.ts +25 -0
- package/src/OutgoingMessages/SyncStepTwoMessage.ts +25 -0
- package/src/OutgoingMessages/UpdateMessage.ts +20 -0
- package/src/index.ts +7 -0
- package/src/types.ts +144 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CryptoIdentityKeystore
|
|
3
|
+
*
|
|
4
|
+
* Per-device Ed25519 keypair, private key protected by WebAuthn PRF + AES-256-GCM.
|
|
5
|
+
* Stored in IndexedDB under "abracadabra:identity" / "identity" / key "current".
|
|
6
|
+
*
|
|
7
|
+
* No private key is ever shared between devices. Each device generates its own
|
|
8
|
+
* keypair, encrypts the private key with the PRF output from its own WebAuthn
|
|
9
|
+
* credential, and stores the ciphertext in IndexedDB.
|
|
10
|
+
*
|
|
11
|
+
* Dependencies: @noble/ed25519, @noble/hashes (for HKDF)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as ed from "@noble/ed25519";
|
|
15
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
16
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
17
|
+
|
|
18
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface StoredIdentity {
|
|
21
|
+
username: string;
|
|
22
|
+
/** base64url-encoded Ed25519 public key (32 bytes) */
|
|
23
|
+
publicKey: string;
|
|
24
|
+
/** AES-GCM ciphertext of the 32-byte private key */
|
|
25
|
+
encryptedPrivateKey: ArrayBuffer;
|
|
26
|
+
/** 12-byte AES-GCM nonce */
|
|
27
|
+
iv: Uint8Array;
|
|
28
|
+
/** 32-byte constant PRF input salt (per-enrollment) */
|
|
29
|
+
salt: Uint8Array;
|
|
30
|
+
/** WebAuthn credential ID */
|
|
31
|
+
credentialId: ArrayBuffer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function toBase64url(bytes: Uint8Array): string {
|
|
37
|
+
return btoa(String.fromCharCode(...bytes))
|
|
38
|
+
.replace(/\+/g, "-")
|
|
39
|
+
.replace(/\//g, "_")
|
|
40
|
+
.replace(/=/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fromBase64url(b64: string): Uint8Array {
|
|
44
|
+
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
45
|
+
const padLen = (4 - (padded.length % 4)) % 4;
|
|
46
|
+
const padded2 = padded + "=".repeat(padLen);
|
|
47
|
+
return Uint8Array.from(atob(padded2), (c) => c.charCodeAt(0));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DB_NAME = "abracadabra:identity";
|
|
51
|
+
const STORE_NAME = "identity";
|
|
52
|
+
const RECORD_KEY = "current";
|
|
53
|
+
const HKDF_INFO = new TextEncoder().encode("abracadabra-identity-v1");
|
|
54
|
+
|
|
55
|
+
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function openDb(): Promise<IDBDatabase> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
60
|
+
req.onupgradeneeded = () => {
|
|
61
|
+
req.result.createObjectStore(STORE_NAME);
|
|
62
|
+
};
|
|
63
|
+
req.onsuccess = () => resolve(req.result);
|
|
64
|
+
req.onerror = () => reject(req.error);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function dbGet(db: IDBDatabase): Promise<StoredIdentity | undefined> {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const tx = db.transaction(STORE_NAME, "readonly");
|
|
71
|
+
const req = tx.objectStore(STORE_NAME).get(RECORD_KEY);
|
|
72
|
+
req.onsuccess = () => resolve(req.result as StoredIdentity | undefined);
|
|
73
|
+
req.onerror = () => reject(req.error);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function dbPut(db: IDBDatabase, value: StoredIdentity): Promise<void> {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
80
|
+
const req = tx.objectStore(STORE_NAME).put(value, RECORD_KEY);
|
|
81
|
+
req.onsuccess = () => resolve();
|
|
82
|
+
req.onerror = () => reject(req.error);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function dbDelete(db: IDBDatabase): Promise<void> {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
89
|
+
const req = tx.objectStore(STORE_NAME).delete(RECORD_KEY);
|
|
90
|
+
req.onsuccess = () => resolve();
|
|
91
|
+
req.onerror = () => reject(req.error);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Key derivation ────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
async function deriveAesKey(prfOutput: ArrayBuffer, salt: Uint8Array): Promise<CryptoKey> {
|
|
98
|
+
const ikm = new Uint8Array(prfOutput);
|
|
99
|
+
const keyBytes = hkdf(sha256, ikm, salt, HKDF_INFO, 32);
|
|
100
|
+
return crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, [
|
|
101
|
+
"encrypt",
|
|
102
|
+
"decrypt",
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Main class ───────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export class CryptoIdentityKeystore {
|
|
109
|
+
/**
|
|
110
|
+
* One-time setup for a device: generates an Ed25519 keypair, creates a
|
|
111
|
+
* WebAuthn credential with PRF extension, encrypts the private key, and
|
|
112
|
+
* stores everything in IndexedDB.
|
|
113
|
+
*
|
|
114
|
+
* Returns the base64url-encoded public key. The caller must register this
|
|
115
|
+
* key with the server via POST /auth/register (first device) or
|
|
116
|
+
* POST /auth/keys (additional device).
|
|
117
|
+
*
|
|
118
|
+
* @param username - The user's account name.
|
|
119
|
+
* @param rpId - WebAuthn relying party ID (e.g. "example.com").
|
|
120
|
+
* @param rpName - Human-readable relying party name.
|
|
121
|
+
*/
|
|
122
|
+
async register(username: string, rpId: string, rpName: string): Promise<string> {
|
|
123
|
+
// 1. Generate Ed25519 keypair
|
|
124
|
+
const privateKey = ed.utils.randomPrivateKey();
|
|
125
|
+
const publicKey = await ed.getPublicKeyAsync(privateKey);
|
|
126
|
+
|
|
127
|
+
// 2. Generate per-enrollment salt
|
|
128
|
+
const salt = crypto.getRandomValues(new Uint8Array(32));
|
|
129
|
+
|
|
130
|
+
// 3. Create WebAuthn credential with PRF extension
|
|
131
|
+
const credential = await navigator.credentials.create({
|
|
132
|
+
publicKey: {
|
|
133
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
134
|
+
rp: { id: rpId, name: rpName },
|
|
135
|
+
user: {
|
|
136
|
+
id: new TextEncoder().encode(username),
|
|
137
|
+
name: username,
|
|
138
|
+
displayName: username,
|
|
139
|
+
},
|
|
140
|
+
pubKeyCredParams: [
|
|
141
|
+
{ alg: -7, type: "public-key" }, // ES256
|
|
142
|
+
{ alg: -257, type: "public-key" }, // RS256
|
|
143
|
+
],
|
|
144
|
+
authenticatorSelection: {
|
|
145
|
+
userVerification: "required",
|
|
146
|
+
},
|
|
147
|
+
extensions: {
|
|
148
|
+
prf: { eval: { first: salt.buffer } },
|
|
149
|
+
} as AuthenticationExtensionsClientInputs,
|
|
150
|
+
},
|
|
151
|
+
}) as PublicKeyCredential | null;
|
|
152
|
+
|
|
153
|
+
if (!credential) {
|
|
154
|
+
throw new Error("WebAuthn credential creation failed");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const extResults = credential.getClientExtensionResults() as {
|
|
158
|
+
prf?: { results?: { first?: ArrayBuffer } };
|
|
159
|
+
};
|
|
160
|
+
const prfOutput = extResults?.prf?.results?.first;
|
|
161
|
+
if (!prfOutput) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
"WebAuthn PRF extension not available on this authenticator. " +
|
|
164
|
+
"A PRF-capable authenticator (e.g. platform authenticator with PRF support) is required.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 4. Derive AES key from PRF output via HKDF
|
|
169
|
+
const aesKey = await deriveAesKey(prfOutput, salt);
|
|
170
|
+
|
|
171
|
+
// 5. Encrypt private key
|
|
172
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
173
|
+
const encryptedPrivateKey = await crypto.subtle.encrypt(
|
|
174
|
+
{ name: "AES-GCM", iv },
|
|
175
|
+
aesKey,
|
|
176
|
+
privateKey,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// 6. Store in IndexedDB
|
|
180
|
+
const db = await openDb();
|
|
181
|
+
await dbPut(db, {
|
|
182
|
+
username,
|
|
183
|
+
publicKey: toBase64url(publicKey),
|
|
184
|
+
encryptedPrivateKey,
|
|
185
|
+
iv,
|
|
186
|
+
salt,
|
|
187
|
+
credentialId: credential.rawId,
|
|
188
|
+
});
|
|
189
|
+
db.close();
|
|
190
|
+
|
|
191
|
+
return toBase64url(publicKey);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Sign a base64url-encoded challenge using the stored Ed25519 private key.
|
|
196
|
+
*
|
|
197
|
+
* This triggers a WebAuthn assertion (biometric / PIN prompt) to unlock the
|
|
198
|
+
* private key via PRF → HKDF → AES-GCM decryption. The private key is
|
|
199
|
+
* wiped from memory after signing.
|
|
200
|
+
*
|
|
201
|
+
* @param challengeB64 - base64url-encoded challenge bytes from the server.
|
|
202
|
+
* @returns base64url-encoded Ed25519 signature (64 bytes).
|
|
203
|
+
*/
|
|
204
|
+
async sign(challengeB64: string): Promise<string> {
|
|
205
|
+
const db = await openDb();
|
|
206
|
+
const stored = await dbGet(db);
|
|
207
|
+
db.close();
|
|
208
|
+
|
|
209
|
+
if (!stored) {
|
|
210
|
+
throw new Error("No identity stored. Call register() first.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// WebAuthn assertion with PRF
|
|
214
|
+
const assertion = await navigator.credentials.get({
|
|
215
|
+
publicKey: {
|
|
216
|
+
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
217
|
+
allowCredentials: [
|
|
218
|
+
{
|
|
219
|
+
id: stored.credentialId,
|
|
220
|
+
type: "public-key",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
userVerification: "required",
|
|
224
|
+
extensions: {
|
|
225
|
+
prf: { eval: { first: stored.salt.buffer } },
|
|
226
|
+
} as AuthenticationExtensionsClientInputs,
|
|
227
|
+
},
|
|
228
|
+
}) as PublicKeyCredential | null;
|
|
229
|
+
|
|
230
|
+
if (!assertion) {
|
|
231
|
+
throw new Error("WebAuthn assertion failed");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const extResults = assertion.getClientExtensionResults() as {
|
|
235
|
+
prf?: { results?: { first?: ArrayBuffer } };
|
|
236
|
+
};
|
|
237
|
+
const prfOutput = extResults?.prf?.results?.first;
|
|
238
|
+
if (!prfOutput) {
|
|
239
|
+
throw new Error("PRF output not available from authenticator");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Derive the same AES key
|
|
243
|
+
const aesKey = await deriveAesKey(prfOutput, stored.salt);
|
|
244
|
+
|
|
245
|
+
// Decrypt private key
|
|
246
|
+
const privateKeyBytes = await crypto.subtle.decrypt(
|
|
247
|
+
{ name: "AES-GCM", iv: stored.iv },
|
|
248
|
+
aesKey,
|
|
249
|
+
stored.encryptedPrivateKey,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const privateKey = new Uint8Array(privateKeyBytes);
|
|
253
|
+
|
|
254
|
+
// Sign the challenge
|
|
255
|
+
const challengeBytes = fromBase64url(challengeB64);
|
|
256
|
+
const signature = await ed.signAsync(challengeBytes, privateKey);
|
|
257
|
+
|
|
258
|
+
// Wipe private key from memory
|
|
259
|
+
privateKey.fill(0);
|
|
260
|
+
|
|
261
|
+
return toBase64url(signature);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Returns the stored base64url public key, or null if no identity exists. */
|
|
265
|
+
async getPublicKey(): Promise<string | null> {
|
|
266
|
+
const db = await openDb();
|
|
267
|
+
const stored = await dbGet(db);
|
|
268
|
+
db.close();
|
|
269
|
+
return stored?.publicKey ?? null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Returns the stored username, or null if no identity exists. */
|
|
273
|
+
async getUsername(): Promise<string | null> {
|
|
274
|
+
const db = await openDb();
|
|
275
|
+
const stored = await dbGet(db);
|
|
276
|
+
db.close();
|
|
277
|
+
return stored?.username ?? null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Returns true if an identity is stored in IndexedDB. */
|
|
281
|
+
async hasIdentity(): Promise<boolean> {
|
|
282
|
+
const db = await openDb();
|
|
283
|
+
const stored = await dbGet(db);
|
|
284
|
+
db.close();
|
|
285
|
+
return stored !== undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Remove the stored identity from IndexedDB. */
|
|
289
|
+
async clear(): Promise<void> {
|
|
290
|
+
const db = await openDb();
|
|
291
|
+
await dbDelete(db);
|
|
292
|
+
db.close();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export default class EventEmitter {
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
3
|
+
public callbacks: { [key: string]: Function[] } = {};
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
6
|
+
public on(event: string, fn: Function): this {
|
|
7
|
+
if (!this.callbacks[event]) {
|
|
8
|
+
this.callbacks[event] = [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
this.callbacks[event].push(fn);
|
|
12
|
+
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
protected emit(event: string, ...args: any): this {
|
|
17
|
+
const callbacks = this.callbacks[event];
|
|
18
|
+
|
|
19
|
+
if (callbacks) {
|
|
20
|
+
callbacks.forEach((callback) => callback.apply(this, args));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
27
|
+
public off(event: string, fn?: Function): this {
|
|
28
|
+
const callbacks = this.callbacks[event];
|
|
29
|
+
|
|
30
|
+
if (callbacks) {
|
|
31
|
+
if (fn) {
|
|
32
|
+
this.callbacks[event] = callbacks.filter((callback) => callback !== fn);
|
|
33
|
+
} else {
|
|
34
|
+
delete this.callbacks[event];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
removeAllListeners(): void {
|
|
42
|
+
this.callbacks = {};
|
|
43
|
+
}
|
|
44
|
+
}
|