@evolu/web 1.0.1-preview.5 → 1.0.1-preview.7
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/src/Evolu/Db.worker.js +6 -8
- package/dist/src/Evolu/LocalAuth.d.ts +4 -0
- package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
- package/dist/src/Evolu/LocalAuth.js +193 -0
- package/dist/src/Evolu/Platform.d.ts +3 -0
- package/dist/src/Evolu/Platform.d.ts.map +1 -0
- package/dist/src/Evolu/Platform.js +6 -0
- package/dist/src/Evolu/index.d.ts +1 -0
- package/dist/src/Evolu/index.d.ts.map +1 -1
- package/dist/src/Evolu/index.js +12 -5
- package/dist/src/WasmSqliteDriver.d.ts.map +1 -1
- package/dist/src/WasmSqliteDriver.js +23 -5
- package/dist/test/SharedWebWorker.test.js +43 -37
- package/package.json +7 -5
- package/src/Evolu/Db.worker.ts +6 -12
- package/src/Evolu/LocalAuth.ts +329 -0
- package/src/Evolu/Platform.ts +9 -0
- package/src/Evolu/index.ts +20 -5
- package/src/WasmSqliteDriver.ts +27 -6
- package/dist/src/Evolu/AppState.d.ts +0 -3
- package/dist/src/Evolu/AppState.d.ts.map +0 -1
- package/dist/src/Evolu/AppState.js +0 -15
- package/src/Evolu/AppState.ts +0 -19
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { createConsole,
|
|
2
|
-
import { createDbWorkerForPlatform
|
|
1
|
+
import { createConsole, createRandom, createRandomBytes, createTime, createWebSocket, } from "@evolu/common";
|
|
2
|
+
import { createDbWorkerForPlatform } from "@evolu/common/evolu";
|
|
3
3
|
import { createWasmSqliteDriver } from "../WasmSqliteDriver.js";
|
|
4
4
|
import { wrapWebWorkerSelf } from "../WebWorker.js";
|
|
5
5
|
const dbWorker = createDbWorkerForPlatform({
|
|
6
|
-
createSqliteDriver: createWasmSqliteDriver,
|
|
7
|
-
createSync: createWebSocketSync,
|
|
8
6
|
console: createConsole(),
|
|
9
|
-
|
|
7
|
+
createSqliteDriver: createWasmSqliteDriver,
|
|
8
|
+
createWebSocket,
|
|
10
9
|
random: createRandom(),
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
createRandomBytes,
|
|
10
|
+
randomBytes: createRandomBytes(),
|
|
11
|
+
time: createTime(),
|
|
14
12
|
});
|
|
15
13
|
wrapWebWorkerSelf(dbWorker);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"LocalAuth.d.ts","sourceRoot":"","sources":["../../../src/Evolu/LocalAuth.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAIV,aAAa,EACb,cAAc,EACd,kBAAkB,EACnB,MAAM,eAAe,CAAC;AAGvB,oBAAoB;AACpB,eAAO,MAAM,mBAAmB,GAC9B,MAAM,cAAc,GAAG,kBAAkB,KACxC,aA4GD,CAAC"}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { set, get, del, keys, clear, createStore } from "idb-keyval";
|
|
2
|
+
import { createSlip21, utf8ToBytes, bytesToUtf8, base64UrlToUint8Array, uint8ArrayToBase64Url, EncryptionKey, Base64Url, } from "@evolu/common";
|
|
3
|
+
/** @experimental */
|
|
4
|
+
export const createWebAuthnStore = (deps) => ({
|
|
5
|
+
setItem: async (key, value, options) => {
|
|
6
|
+
if (options?.accessControl === "none") {
|
|
7
|
+
const metadata = createMetadata(false);
|
|
8
|
+
await set(key, { value, metadata }, getStore(options.service));
|
|
9
|
+
return { metadata };
|
|
10
|
+
}
|
|
11
|
+
const seed = generateSeed(deps)();
|
|
12
|
+
const authResult = JSON.parse(value);
|
|
13
|
+
const credential = await createCredential(deps)(options?.webAuthnUsername ?? "Evolu User", seed, options?.relyingPartyID, options?.relyingPartyName, options?.webAuthnUserVerification, options?.webAuthnAuthenticatorAttachment);
|
|
14
|
+
const encryptionKey = deriveEncryptionKey(seed);
|
|
15
|
+
const encryptedData = encryptAuthResult(deps)(authResult, encryptionKey);
|
|
16
|
+
const credentialId = uint8ArrayToBase64Url(new Uint8Array(credential.rawId));
|
|
17
|
+
const metadata = createMetadata();
|
|
18
|
+
await set(key, { credentialId, ...encryptedData, metadata }, getStore(options?.service));
|
|
19
|
+
return { metadata };
|
|
20
|
+
},
|
|
21
|
+
getItem: async (key, options) => {
|
|
22
|
+
if (options?.accessControl === "none") {
|
|
23
|
+
const data = await get(key, getStore(options.service));
|
|
24
|
+
return data
|
|
25
|
+
? {
|
|
26
|
+
key,
|
|
27
|
+
value: data.value,
|
|
28
|
+
service: options.service ?? "default",
|
|
29
|
+
metadata: data.metadata,
|
|
30
|
+
}
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
const data = await get(key, getStore(options?.service));
|
|
34
|
+
if (!data) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const credential = await getCredential(deps)(data.credentialId, options?.relyingPartyID, options?.webAuthnUserVerification);
|
|
39
|
+
const credentialSeed = extractSeedFromCredential(credential);
|
|
40
|
+
const encryptionKey = deriveEncryptionKey(credentialSeed);
|
|
41
|
+
const authResultVal = decryptAuthResult(deps)(data, encryptionKey);
|
|
42
|
+
if (!authResultVal) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
key,
|
|
47
|
+
service: options?.service ?? "default",
|
|
48
|
+
value: authResultVal,
|
|
49
|
+
metadata: data.metadata,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (_error) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
deleteItem: async (key, options) => {
|
|
57
|
+
await del(key, getStore(options?.service));
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
getAllItems: async (options) => {
|
|
61
|
+
const service = options?.service ?? "default";
|
|
62
|
+
const itemKeys = await keys(getStore(service));
|
|
63
|
+
const items = await Promise.all(itemKeys.map(async (key) => {
|
|
64
|
+
const data = await get(key, getStore(service));
|
|
65
|
+
return {
|
|
66
|
+
key,
|
|
67
|
+
service,
|
|
68
|
+
metadata: data?.metadata ?? createMetadata(),
|
|
69
|
+
...(options?.includeValues && data?.value
|
|
70
|
+
? { value: data.value }
|
|
71
|
+
: {}),
|
|
72
|
+
};
|
|
73
|
+
}));
|
|
74
|
+
return items;
|
|
75
|
+
},
|
|
76
|
+
clearService: async (options) => {
|
|
77
|
+
await clear(getStore(options?.service));
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
/**
|
|
81
|
+
* Create default metadata for backwards compatibility with items that don't
|
|
82
|
+
* have stored metadata.
|
|
83
|
+
*/
|
|
84
|
+
const createMetadata = (isSecure = true) => {
|
|
85
|
+
return {
|
|
86
|
+
backend: "keychain",
|
|
87
|
+
accessControl: isSecure ? "biometryCurrentSet" : "none",
|
|
88
|
+
securityLevel: isSecure ? "biometry" : "software",
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
/** Get storage key for owner ID. (supports namespaces via prefix) */
|
|
93
|
+
const getStore = (prefix = "default") => {
|
|
94
|
+
return createStore(prefix, "evolu-auth");
|
|
95
|
+
};
|
|
96
|
+
const createCredential = (deps) => async (username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment) => {
|
|
97
|
+
const options = createCredentialCreationOptions(deps)(username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment);
|
|
98
|
+
const credential = (await navigator.credentials.create(options));
|
|
99
|
+
if (!credential) {
|
|
100
|
+
throw new Error("Failed to create WebAuthn credential");
|
|
101
|
+
}
|
|
102
|
+
return credential;
|
|
103
|
+
};
|
|
104
|
+
const getCredential = (deps) => async (credentialId, relyingPartyID, userVerification) => {
|
|
105
|
+
const options = createCredentialRequestOptions(deps)(credentialId, relyingPartyID, userVerification);
|
|
106
|
+
const credential = (await navigator.credentials.get(options));
|
|
107
|
+
if (!credential?.response) {
|
|
108
|
+
throw new Error("Failed to get WebAuthn credential");
|
|
109
|
+
}
|
|
110
|
+
return credential;
|
|
111
|
+
};
|
|
112
|
+
const extractSeedFromCredential = (credential) => {
|
|
113
|
+
const response = credential.response;
|
|
114
|
+
if (!response.userHandle) {
|
|
115
|
+
throw new Error("No userHandle in credential response");
|
|
116
|
+
}
|
|
117
|
+
return new Uint8Array(response.userHandle);
|
|
118
|
+
};
|
|
119
|
+
const createCredentialCreationOptions = (deps) => (username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment) => {
|
|
120
|
+
return {
|
|
121
|
+
publicKey: {
|
|
122
|
+
challenge: generateSeed(deps)(),
|
|
123
|
+
rp: {
|
|
124
|
+
id: relyingPartyID ?? document.location.hostname,
|
|
125
|
+
name: relyingPartyName ?? "Evolu",
|
|
126
|
+
},
|
|
127
|
+
user: {
|
|
128
|
+
id: seed,
|
|
129
|
+
name: username,
|
|
130
|
+
displayName: username,
|
|
131
|
+
},
|
|
132
|
+
pubKeyCredParams: [
|
|
133
|
+
{ type: "public-key", alg: -8 }, // Ed25519
|
|
134
|
+
{ type: "public-key", alg: -7 }, // ES256
|
|
135
|
+
{ type: "public-key", alg: -257 }, // RS256
|
|
136
|
+
],
|
|
137
|
+
attestation: "none",
|
|
138
|
+
authenticatorSelection: {
|
|
139
|
+
// - "platform": Uses the platform's built-in authenticator.
|
|
140
|
+
// - "cross-platform": Uses a device specific authenticator (yubikey, fido2, etc.)
|
|
141
|
+
authenticatorAttachment: authenticatorAttachment ?? "platform",
|
|
142
|
+
// - "discouraged": Only User Presence is needed.
|
|
143
|
+
// - "preferred": User Verification is preferred but not required. Falls back to User Presence.
|
|
144
|
+
// - "required": User Verification MUST occur (biometrics/PIN). Clients may silently downgrade to User Presence only.
|
|
145
|
+
userVerification: userVerification ?? "required",
|
|
146
|
+
// - "discouraged": Server-side credential is preferable, but will accept client-side discoverable credential.
|
|
147
|
+
// - "preferred": Relying Party strongly prefers client-side discoverable credential but will accept server-side credential.
|
|
148
|
+
// - "required": Client-side discoverable credential MUST be created, error if it can't be created.
|
|
149
|
+
residentKey: "required",
|
|
150
|
+
// Included for backwards compatibility. Deprecated in favor of residentKey (true = "required")
|
|
151
|
+
requireResidentKey: true,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const createCredentialRequestOptions = (deps) => (credentialId, relyingPartyID, userVerification) => {
|
|
157
|
+
return {
|
|
158
|
+
publicKey: {
|
|
159
|
+
challenge: generateSeed(deps)(),
|
|
160
|
+
rpId: relyingPartyID ?? document.location.hostname,
|
|
161
|
+
userVerification: userVerification ?? "preferred",
|
|
162
|
+
allowCredentials: [
|
|
163
|
+
{
|
|
164
|
+
type: "public-key",
|
|
165
|
+
id: base64UrlToUint8Array(Base64Url.orThrow(credentialId)),
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
const deriveEncryptionKey = (seed) => {
|
|
172
|
+
const seed32 = seed.length === 32 ? seed : seed.slice(0, 32);
|
|
173
|
+
return EncryptionKey.orThrow(createSlip21(seed32, ["evolu", "auth"]));
|
|
174
|
+
};
|
|
175
|
+
const encryptAuthResult = (deps) => (authResult, encryptionKey) => {
|
|
176
|
+
const plaintext = utf8ToBytes(JSON.stringify(authResult));
|
|
177
|
+
const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(plaintext, encryptionKey);
|
|
178
|
+
return {
|
|
179
|
+
nonce: uint8ArrayToBase64Url(nonce),
|
|
180
|
+
ciphertext: uint8ArrayToBase64Url(ciphertext),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const decryptAuthResult = (deps) => (encryptedData, encryptionKey) => {
|
|
184
|
+
const nonce = base64UrlToUint8Array(encryptedData.nonce);
|
|
185
|
+
const ciphertext = base64UrlToUint8Array(encryptedData.ciphertext);
|
|
186
|
+
const result = deps.symmetricCrypto.decrypt(ciphertext, encryptionKey, nonce);
|
|
187
|
+
if (!result.ok)
|
|
188
|
+
return null;
|
|
189
|
+
return bytesToUtf8(result.value);
|
|
190
|
+
};
|
|
191
|
+
const generateSeed = (deps) => () => {
|
|
192
|
+
return deps.randomBytes.create(32);
|
|
193
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Platform.d.ts","sourceRoot":"","sources":["../../../src/Evolu/Platform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAEhD,eAAO,MAAM,SAAS,EAAE,SAMvB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/Evolu/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/Evolu/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAIL,SAAS,EACV,MAAM,qBAAqB,CAAC;AAiB7B,eAAO,MAAM,SAAS,mCAGpB,CAAC;AAEH,eAAO,MAAM,YAAY,EAAE,SAM1B,CAAC"}
|
package/dist/src/Evolu/index.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
import { createConsole,
|
|
1
|
+
import { createConsole, createLocalAuth, createRandomBytes, createSymmetricCrypto, createTime, } from "@evolu/common";
|
|
2
2
|
import { createSharedWebWorker } from "../SharedWebWorker.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createWebAuthnStore } from "./LocalAuth.js";
|
|
4
|
+
import { reloadApp } from "./Platform.js";
|
|
5
|
+
const randomBytes = createRandomBytes();
|
|
6
|
+
const symmetricCrypto = createSymmetricCrypto({ randomBytes });
|
|
4
7
|
const createDbWorker = (name) => createSharedWebWorker(name, () => new Worker(new URL("Db.worker.js", import.meta.url), {
|
|
5
8
|
type: "module",
|
|
6
9
|
}));
|
|
10
|
+
export const localAuth = createLocalAuth({
|
|
11
|
+
randomBytes,
|
|
12
|
+
secureStorage: createWebAuthnStore({ randomBytes, symmetricCrypto }),
|
|
13
|
+
});
|
|
7
14
|
export const evoluWebDeps = {
|
|
8
|
-
time: createTime(),
|
|
9
15
|
console: createConsole(),
|
|
10
|
-
nanoIdLib: createNanoIdLib(),
|
|
11
|
-
createAppState,
|
|
12
16
|
createDbWorker,
|
|
17
|
+
randomBytes: createRandomBytes(),
|
|
18
|
+
reloadApp,
|
|
19
|
+
time: createTime(),
|
|
13
20
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"WasmSqliteDriver.d.ts","sourceRoot":"","sources":["../../src/WasmSqliteDriver.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,kBAAkB,
|
|
1
|
+
{"version":3,"file":"WasmSqliteDriver.d.ts","sourceRoot":"","sources":["../../src/WasmSqliteDriver.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,kBAAkB,EAInB,MAAM,eAAe,CAAC;AAgBvB,eAAO,MAAM,sBAAsB,EAAE,kBAuFpC,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { constVoid, createPreparedStatementsCache, } from "@evolu/common";
|
|
2
|
-
import sqlite3InitModule from "@
|
|
1
|
+
import { constVoid, createPreparedStatementsCache, bytesToHex, } from "@evolu/common";
|
|
2
|
+
import sqlite3InitModule from "@evolu/sqlite-wasm";
|
|
3
3
|
// TODO: Do we still need that?
|
|
4
4
|
// https://github.com/sqlite/sqlite-wasm/issues/62
|
|
5
5
|
// @ts-expect-error Missing types.
|
|
@@ -10,9 +10,27 @@ globalThis.sqlite3ApiConfig = {
|
|
|
10
10
|
const sqlite3Promise = sqlite3InitModule();
|
|
11
11
|
export const createWasmSqliteDriver = async (name, options) => {
|
|
12
12
|
const sqlite3 = await sqlite3Promise;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
// This is used to make OPFS default vfs for multipleciphers
|
|
14
|
+
// @ts-expect-error Missing types (update @evolu/sqlite-wasm types)
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
16
|
+
sqlite3.capi.sqlite3mc_vfs_create("opfs", 1);
|
|
17
|
+
let db;
|
|
18
|
+
if (options?.memory) {
|
|
19
|
+
db = new sqlite3.oo1.DB(":memory:");
|
|
20
|
+
}
|
|
21
|
+
else if (options?.encryptionKey) {
|
|
22
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({ directory: `.${name}` });
|
|
23
|
+
db = new pool.OpfsSAHPoolDb("file:evolu1.db?vfs=multipleciphers-opfs-sahpool");
|
|
24
|
+
db.exec(`
|
|
25
|
+
PRAGMA cipher = 'sqlcipher';
|
|
26
|
+
PRAGMA legacy = 4;
|
|
27
|
+
PRAGMA key = "x'${bytesToHex(options.encryptionKey)}'";
|
|
28
|
+
`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({ name });
|
|
32
|
+
db = new pool.OpfsSAHPoolDb("file:evolu1.db");
|
|
33
|
+
}
|
|
16
34
|
let isDisposed = false;
|
|
17
35
|
const cache = createPreparedStatementsCache((sql) => db.prepare(sql), (statement) => {
|
|
18
36
|
statement.finalize();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
5
5
|
import { expect, test, vi, beforeEach, afterEach } from "vitest";
|
|
6
6
|
import { createSharedWebWorker } from "../src/SharedWebWorker.js";
|
|
7
|
-
import {
|
|
7
|
+
import { SimpleName, wait } from "@evolu/common";
|
|
8
8
|
// Mock BroadcastChannel
|
|
9
9
|
class MockBroadcastChannel {
|
|
10
10
|
name;
|
|
@@ -27,12 +27,12 @@ class MockWorker {
|
|
|
27
27
|
}
|
|
28
28
|
beforeEach(() => {
|
|
29
29
|
// Create a spy for BroadcastChannel constructor
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// Mock navigator.locks properly - remove the
|
|
35
|
-
Object.defineProperty(
|
|
30
|
+
globalThis.BroadcastChannel = vi.fn().mockImplementation(function (name) {
|
|
31
|
+
return new MockBroadcastChannel(name);
|
|
32
|
+
});
|
|
33
|
+
globalThis.document = {}; // Simulate browser environment
|
|
34
|
+
// Mock navigator.locks properly - remove the globalThis.navigator assignment
|
|
35
|
+
Object.defineProperty(globalThis.navigator, "locks", {
|
|
36
36
|
value: mockLocks,
|
|
37
37
|
writable: true,
|
|
38
38
|
configurable: true,
|
|
@@ -44,11 +44,11 @@ afterEach(() => {
|
|
|
44
44
|
});
|
|
45
45
|
test("createSharedWebWorker creates BroadcastChannel and requests lock", () => {
|
|
46
46
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
47
|
-
const sharedWorker = createSharedWebWorker(
|
|
47
|
+
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
48
48
|
// Should create BroadcastChannel with namespaced name
|
|
49
|
-
expect(
|
|
49
|
+
expect(globalThis.BroadcastChannel).toHaveBeenCalledWith("evolu-sharedwebworker-test-worker");
|
|
50
50
|
// Should request owner-ready immediately
|
|
51
|
-
const channelInstance = vi.mocked(
|
|
51
|
+
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
52
52
|
.value;
|
|
53
53
|
expect(channelInstance.postMessage).toHaveBeenCalledWith({
|
|
54
54
|
type: "request-owner-ready",
|
|
@@ -63,22 +63,22 @@ test("createSharedWebWorker creates BroadcastChannel and requests lock", () => {
|
|
|
63
63
|
});
|
|
64
64
|
test("createSharedWebWorker returns no-op on server", () => {
|
|
65
65
|
// Simulate server environment
|
|
66
|
-
delete
|
|
66
|
+
delete globalThis.document;
|
|
67
67
|
const mockCreateWorker = vi.fn();
|
|
68
|
-
const sharedWorker = createSharedWebWorker(
|
|
68
|
+
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
69
69
|
// Should not create BroadcastChannel or request locks
|
|
70
|
-
expect(
|
|
70
|
+
expect(globalThis.BroadcastChannel).not.toHaveBeenCalled();
|
|
71
71
|
expect(mockLocks.request).not.toHaveBeenCalled();
|
|
72
72
|
// Should return no-op worker
|
|
73
73
|
expect(sharedWorker.postMessage).toBeDefined();
|
|
74
74
|
expect(sharedWorker.onMessage).toBeDefined();
|
|
75
75
|
// Restore document for other tests
|
|
76
|
-
|
|
76
|
+
globalThis.document = {};
|
|
77
77
|
});
|
|
78
78
|
test("createSharedWebWorker queues messages when owner not ready", () => {
|
|
79
79
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
80
|
-
const sharedWorker = createSharedWebWorker(
|
|
81
|
-
const channelInstance = vi.mocked(
|
|
80
|
+
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
81
|
+
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
82
82
|
.value;
|
|
83
83
|
// Send message before owner is ready
|
|
84
84
|
sharedWorker.postMessage({ type: "test-message" });
|
|
@@ -97,8 +97,8 @@ test("createSharedWebWorker queues messages when owner not ready", () => {
|
|
|
97
97
|
});
|
|
98
98
|
test("createSharedWebWorker forwards messages when owner ready", () => {
|
|
99
99
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
100
|
-
const sharedWorker = createSharedWebWorker(
|
|
101
|
-
const channelInstance = vi.mocked(
|
|
100
|
+
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
101
|
+
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
102
102
|
.value;
|
|
103
103
|
// Simulate owner-ready message
|
|
104
104
|
const ownerReadyEvent = new MessageEvent("message", {
|
|
@@ -116,9 +116,9 @@ test("createSharedWebWorker forwards messages when owner ready", () => {
|
|
|
116
116
|
test("createSharedWebWorker handles onMessage callback", () => {
|
|
117
117
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
118
118
|
const onMessageCallback = vi.fn();
|
|
119
|
-
const sharedWorker = createSharedWebWorker(
|
|
119
|
+
const sharedWorker = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
120
120
|
sharedWorker.onMessage(onMessageCallback);
|
|
121
|
-
const channelInstance = vi.mocked(
|
|
121
|
+
const channelInstance = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
122
122
|
.value;
|
|
123
123
|
// Simulate message from worker
|
|
124
124
|
const workerMessage = new MessageEvent("message", {
|
|
@@ -143,13 +143,15 @@ test("createSharedWebWorker handles multiple tabs - first tab becomes owner", as
|
|
|
143
143
|
});
|
|
144
144
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
145
145
|
// Create first tab (will become owner)
|
|
146
|
-
createSharedWebWorker(
|
|
146
|
+
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
147
147
|
// Create second tab
|
|
148
|
-
createSharedWebWorker(
|
|
148
|
+
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
149
149
|
// Both tabs should create BroadcastChannels
|
|
150
|
-
expect(
|
|
151
|
-
const tab1Channel = vi.mocked(
|
|
152
|
-
|
|
150
|
+
expect(globalThis.BroadcastChannel).toHaveBeenCalledTimes(2);
|
|
151
|
+
const tab1Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
152
|
+
.value;
|
|
153
|
+
const tab2Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[1]
|
|
154
|
+
.value;
|
|
153
155
|
// Both tabs should request owner-ready
|
|
154
156
|
expect(tab1Channel.postMessage).toHaveBeenCalledWith({
|
|
155
157
|
type: "request-owner-ready",
|
|
@@ -158,7 +160,7 @@ test("createSharedWebWorker handles multiple tabs - first tab becomes owner", as
|
|
|
158
160
|
type: "request-owner-ready",
|
|
159
161
|
});
|
|
160
162
|
// Wait for lock acquisition
|
|
161
|
-
await wait(
|
|
163
|
+
await wait("10ms")();
|
|
162
164
|
// Only first tab should create worker (it became owner)
|
|
163
165
|
expect(mockCreateWorker).toHaveBeenCalledTimes(1);
|
|
164
166
|
// First tab should announce ownership
|
|
@@ -167,10 +169,12 @@ test("createSharedWebWorker handles multiple tabs - first tab becomes owner", as
|
|
|
167
169
|
test("createSharedWebWorker handles cross-tab message forwarding", () => {
|
|
168
170
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
169
171
|
// Create two tabs
|
|
170
|
-
const tab1 = createSharedWebWorker(
|
|
171
|
-
const tab2 = createSharedWebWorker(
|
|
172
|
-
const tab1Channel = vi.mocked(
|
|
173
|
-
|
|
172
|
+
const tab1 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
173
|
+
const tab2 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
174
|
+
const tab1Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
175
|
+
.value;
|
|
176
|
+
const tab2Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[1]
|
|
177
|
+
.value;
|
|
174
178
|
// Simulate tab1 receiving owner-ready (tab1 becomes aware of owner)
|
|
175
179
|
const ownerReadyEvent = new MessageEvent("message", {
|
|
176
180
|
data: { type: "owner-ready" },
|
|
@@ -196,13 +200,15 @@ test("createSharedWebWorker handles worker responses across tabs", () => {
|
|
|
196
200
|
const tab1Callback = vi.fn();
|
|
197
201
|
const tab2Callback = vi.fn();
|
|
198
202
|
// Create two tabs
|
|
199
|
-
const tab1 = createSharedWebWorker(
|
|
200
|
-
const tab2 = createSharedWebWorker(
|
|
203
|
+
const tab1 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
204
|
+
const tab2 = createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
201
205
|
// Set up message callbacks
|
|
202
206
|
tab1.onMessage(tab1Callback);
|
|
203
207
|
tab2.onMessage(tab2Callback);
|
|
204
|
-
const tab1Channel = vi.mocked(
|
|
205
|
-
|
|
208
|
+
const tab1Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[0]
|
|
209
|
+
.value;
|
|
210
|
+
const tab2Channel = vi.mocked(globalThis.BroadcastChannel).mock.results[1]
|
|
211
|
+
.value;
|
|
206
212
|
// Simulate worker response broadcast
|
|
207
213
|
const workerResponse = new MessageEvent("message", {
|
|
208
214
|
data: { type: "from-worker", message: { result: "shared-result" } },
|
|
@@ -218,7 +224,7 @@ test("createSharedWebWorker multi-tab scenario", () => {
|
|
|
218
224
|
const mockCreateWorker = vi.fn(() => new MockWorker());
|
|
219
225
|
const channelInstances = [];
|
|
220
226
|
// Track all BroadcastChannel instances
|
|
221
|
-
|
|
227
|
+
globalThis.BroadcastChannel = vi.fn().mockImplementation(function (name) {
|
|
222
228
|
const instance = new MockBroadcastChannel(name);
|
|
223
229
|
channelInstances.push(instance);
|
|
224
230
|
// Override postMessage to broadcast to all instances with same name
|
|
@@ -239,8 +245,8 @@ test("createSharedWebWorker multi-tab scenario", () => {
|
|
|
239
245
|
return Promise.resolve();
|
|
240
246
|
});
|
|
241
247
|
// Create two tabs
|
|
242
|
-
createSharedWebWorker(
|
|
243
|
-
createSharedWebWorker(
|
|
248
|
+
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
249
|
+
createSharedWebWorker(SimpleName.orThrow("test-worker"), mockCreateWorker);
|
|
244
250
|
// Only first tab should create worker (owner)
|
|
245
251
|
expect(mockCreateWorker).toHaveBeenCalledTimes(1);
|
|
246
252
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evolu/web",
|
|
3
|
-
"version": "1.0.1-preview.
|
|
3
|
+
"version": "1.0.1-preview.7",
|
|
4
4
|
"description": "Evolu for web",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"evolu",
|
|
@@ -28,18 +28,20 @@
|
|
|
28
28
|
"README.md"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@
|
|
31
|
+
"@evolu/sqlite-wasm": "2.2.4",
|
|
32
|
+
"idb-keyval": "^6.2.2"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/web-locks-api": "^0.0.5",
|
|
36
|
+
"shx": "^0.4.0",
|
|
35
37
|
"typescript": "^5.9.2",
|
|
36
38
|
"user-agent-data-types": "^0.4.2",
|
|
37
|
-
"vitest": "^
|
|
38
|
-
"@evolu/common": "6.0.1-preview.
|
|
39
|
+
"vitest": "^4.0.4",
|
|
40
|
+
"@evolu/common": "6.0.1-preview.23",
|
|
39
41
|
"@evolu/tsconfig": "0.0.2"
|
|
40
42
|
},
|
|
41
43
|
"peerDependencies": {
|
|
42
|
-
"@evolu/common": "^6.0.1-preview.
|
|
44
|
+
"@evolu/common": "^6.0.1-preview.23"
|
|
43
45
|
},
|
|
44
46
|
"publishConfig": {
|
|
45
47
|
"access": "public"
|
package/src/Evolu/Db.worker.ts
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createConsole,
|
|
3
|
-
createEnglishMnemonic,
|
|
4
|
-
createNanoIdLib,
|
|
5
3
|
createRandom,
|
|
6
4
|
createRandomBytes,
|
|
7
5
|
createTime,
|
|
6
|
+
createWebSocket,
|
|
8
7
|
} from "@evolu/common";
|
|
9
|
-
import {
|
|
10
|
-
createDbWorkerForPlatform,
|
|
11
|
-
createWebSocketSync,
|
|
12
|
-
} from "@evolu/common/evolu";
|
|
8
|
+
import { createDbWorkerForPlatform } from "@evolu/common/evolu";
|
|
13
9
|
import { createWasmSqliteDriver } from "../WasmSqliteDriver.js";
|
|
14
10
|
import { wrapWebWorkerSelf } from "../WebWorker.js";
|
|
15
11
|
|
|
16
12
|
const dbWorker = createDbWorkerForPlatform({
|
|
17
|
-
createSqliteDriver: createWasmSqliteDriver,
|
|
18
|
-
createSync: createWebSocketSync,
|
|
19
13
|
console: createConsole(),
|
|
20
|
-
|
|
14
|
+
createSqliteDriver: createWasmSqliteDriver,
|
|
15
|
+
createWebSocket,
|
|
21
16
|
random: createRandom(),
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
createRandomBytes,
|
|
17
|
+
randomBytes: createRandomBytes(),
|
|
18
|
+
time: createTime(),
|
|
25
19
|
});
|
|
26
20
|
|
|
27
21
|
wrapWebWorkerSelf(dbWorker);
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { set, get, del, keys, clear, createStore } from "idb-keyval";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createSlip21,
|
|
5
|
+
utf8ToBytes,
|
|
6
|
+
bytesToUtf8,
|
|
7
|
+
base64UrlToUint8Array,
|
|
8
|
+
uint8ArrayToBase64Url,
|
|
9
|
+
EncryptionKey,
|
|
10
|
+
Base64Url,
|
|
11
|
+
} from "@evolu/common";
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
AuthResult,
|
|
15
|
+
Entropy32,
|
|
16
|
+
SensitiveInfoItem,
|
|
17
|
+
SecureStorage,
|
|
18
|
+
RandomBytesDep,
|
|
19
|
+
SymmetricCryptoDep,
|
|
20
|
+
} from "@evolu/common";
|
|
21
|
+
import type { UseStore } from "idb-keyval";
|
|
22
|
+
|
|
23
|
+
/** @experimental */
|
|
24
|
+
export const createWebAuthnStore = (
|
|
25
|
+
deps: RandomBytesDep & SymmetricCryptoDep,
|
|
26
|
+
): SecureStorage => ({
|
|
27
|
+
setItem: async (key, value, options) => {
|
|
28
|
+
if (options?.accessControl === "none") {
|
|
29
|
+
const metadata = createMetadata(false);
|
|
30
|
+
await set(key, { value, metadata }, getStore(options.service));
|
|
31
|
+
return { metadata };
|
|
32
|
+
}
|
|
33
|
+
const seed = generateSeed(deps)();
|
|
34
|
+
const authResult = JSON.parse(value) as AuthResult;
|
|
35
|
+
const credential = await createCredential(deps)(
|
|
36
|
+
options?.webAuthnUsername ?? "Evolu User",
|
|
37
|
+
seed,
|
|
38
|
+
options?.relyingPartyID,
|
|
39
|
+
options?.relyingPartyName,
|
|
40
|
+
options?.webAuthnUserVerification,
|
|
41
|
+
options?.webAuthnAuthenticatorAttachment,
|
|
42
|
+
);
|
|
43
|
+
const encryptionKey = deriveEncryptionKey(seed);
|
|
44
|
+
const encryptedData = encryptAuthResult(deps)(authResult, encryptionKey);
|
|
45
|
+
const credentialId = uint8ArrayToBase64Url(
|
|
46
|
+
new Uint8Array(credential.rawId),
|
|
47
|
+
);
|
|
48
|
+
const metadata = createMetadata();
|
|
49
|
+
await set(
|
|
50
|
+
key,
|
|
51
|
+
{ credentialId, ...encryptedData, metadata },
|
|
52
|
+
getStore(options?.service),
|
|
53
|
+
);
|
|
54
|
+
return { metadata };
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
getItem: async (key, options) => {
|
|
58
|
+
if (options?.accessControl === "none") {
|
|
59
|
+
const data = await get<{
|
|
60
|
+
readonly value: string;
|
|
61
|
+
readonly metadata: SensitiveInfoItem["metadata"];
|
|
62
|
+
}>(key, getStore(options.service));
|
|
63
|
+
return data
|
|
64
|
+
? {
|
|
65
|
+
key,
|
|
66
|
+
value: data.value,
|
|
67
|
+
service: options.service ?? "default",
|
|
68
|
+
metadata: data.metadata,
|
|
69
|
+
}
|
|
70
|
+
: null;
|
|
71
|
+
}
|
|
72
|
+
const data = await get<{
|
|
73
|
+
readonly nonce: Base64Url;
|
|
74
|
+
readonly ciphertext: Base64Url;
|
|
75
|
+
readonly credentialId: string;
|
|
76
|
+
readonly metadata: SensitiveInfoItem["metadata"];
|
|
77
|
+
}>(key, getStore(options?.service));
|
|
78
|
+
if (!data) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const credential = await getCredential(deps)(
|
|
83
|
+
data.credentialId,
|
|
84
|
+
options?.relyingPartyID,
|
|
85
|
+
options?.webAuthnUserVerification,
|
|
86
|
+
);
|
|
87
|
+
const credentialSeed = extractSeedFromCredential(credential);
|
|
88
|
+
const encryptionKey = deriveEncryptionKey(credentialSeed);
|
|
89
|
+
const authResultVal = decryptAuthResult(deps)(data, encryptionKey);
|
|
90
|
+
if (!authResultVal) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
key,
|
|
95
|
+
service: options?.service ?? "default",
|
|
96
|
+
value: authResultVal,
|
|
97
|
+
metadata: data.metadata,
|
|
98
|
+
};
|
|
99
|
+
} catch (_error) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
deleteItem: async (key, options) => {
|
|
105
|
+
await del(key, getStore(options?.service));
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
getAllItems: async (options) => {
|
|
110
|
+
const service = options?.service ?? "default";
|
|
111
|
+
const itemKeys = await keys<string>(getStore(service));
|
|
112
|
+
const items = await Promise.all(
|
|
113
|
+
itemKeys.map(async (key) => {
|
|
114
|
+
const data = await get<{
|
|
115
|
+
readonly metadata?: SensitiveInfoItem["metadata"];
|
|
116
|
+
readonly value?: string;
|
|
117
|
+
}>(key, getStore(service));
|
|
118
|
+
return {
|
|
119
|
+
key,
|
|
120
|
+
service,
|
|
121
|
+
metadata: data?.metadata ?? createMetadata(),
|
|
122
|
+
...(options?.includeValues && data?.value
|
|
123
|
+
? { value: data.value }
|
|
124
|
+
: {}),
|
|
125
|
+
};
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
return items;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
clearService: async (options) => {
|
|
132
|
+
await clear(getStore(options?.service));
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create default metadata for backwards compatibility with items that don't
|
|
138
|
+
* have stored metadata.
|
|
139
|
+
*/
|
|
140
|
+
const createMetadata = (isSecure = true): SensitiveInfoItem["metadata"] => {
|
|
141
|
+
return {
|
|
142
|
+
backend: "keychain",
|
|
143
|
+
accessControl: isSecure ? "biometryCurrentSet" : "none",
|
|
144
|
+
securityLevel: isSecure ? "biometry" : "software",
|
|
145
|
+
timestamp: Date.now(),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Get storage key for owner ID. (supports namespaces via prefix) */
|
|
150
|
+
const getStore = (prefix = "default"): UseStore => {
|
|
151
|
+
return createStore(prefix, "evolu-auth");
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const createCredential =
|
|
155
|
+
(deps: RandomBytesDep) =>
|
|
156
|
+
async (
|
|
157
|
+
username: string,
|
|
158
|
+
seed: Uint8Array,
|
|
159
|
+
relyingPartyID?: string,
|
|
160
|
+
relyingPartyName?: string,
|
|
161
|
+
userVerification?: UserVerificationRequirement,
|
|
162
|
+
authenticatorAttachment?: AuthenticatorAttachment,
|
|
163
|
+
): Promise<PublicKeyCredential> => {
|
|
164
|
+
const options = createCredentialCreationOptions(deps)(
|
|
165
|
+
username,
|
|
166
|
+
seed,
|
|
167
|
+
relyingPartyID,
|
|
168
|
+
relyingPartyName,
|
|
169
|
+
userVerification,
|
|
170
|
+
authenticatorAttachment,
|
|
171
|
+
);
|
|
172
|
+
const credential = (await navigator.credentials.create(
|
|
173
|
+
options,
|
|
174
|
+
)) as PublicKeyCredential | null;
|
|
175
|
+
if (!credential) {
|
|
176
|
+
throw new Error("Failed to create WebAuthn credential");
|
|
177
|
+
}
|
|
178
|
+
return credential;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const getCredential =
|
|
182
|
+
(deps: RandomBytesDep) =>
|
|
183
|
+
async (
|
|
184
|
+
credentialId: string,
|
|
185
|
+
relyingPartyID?: string,
|
|
186
|
+
userVerification?: UserVerificationRequirement,
|
|
187
|
+
): Promise<PublicKeyCredential> => {
|
|
188
|
+
const options = createCredentialRequestOptions(deps)(
|
|
189
|
+
credentialId,
|
|
190
|
+
relyingPartyID,
|
|
191
|
+
userVerification,
|
|
192
|
+
);
|
|
193
|
+
const credential = (await navigator.credentials.get(
|
|
194
|
+
options,
|
|
195
|
+
)) as PublicKeyCredential | null;
|
|
196
|
+
if (!credential?.response) {
|
|
197
|
+
throw new Error("Failed to get WebAuthn credential");
|
|
198
|
+
}
|
|
199
|
+
return credential;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const extractSeedFromCredential = (
|
|
203
|
+
credential: PublicKeyCredential,
|
|
204
|
+
): Uint8Array => {
|
|
205
|
+
const response = credential.response as AuthenticatorAssertionResponse;
|
|
206
|
+
if (!response.userHandle) {
|
|
207
|
+
throw new Error("No userHandle in credential response");
|
|
208
|
+
}
|
|
209
|
+
return new Uint8Array(response.userHandle);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const createCredentialCreationOptions =
|
|
213
|
+
(deps: RandomBytesDep) =>
|
|
214
|
+
(
|
|
215
|
+
username: string,
|
|
216
|
+
seed: Uint8Array,
|
|
217
|
+
relyingPartyID?: string,
|
|
218
|
+
relyingPartyName?: string,
|
|
219
|
+
userVerification?: UserVerificationRequirement,
|
|
220
|
+
authenticatorAttachment?: AuthenticatorAttachment,
|
|
221
|
+
): CredentialCreationOptions => {
|
|
222
|
+
return {
|
|
223
|
+
publicKey: {
|
|
224
|
+
challenge: generateSeed(deps)() as BufferSource,
|
|
225
|
+
rp: {
|
|
226
|
+
id: relyingPartyID ?? document.location.hostname,
|
|
227
|
+
name: relyingPartyName ?? "Evolu",
|
|
228
|
+
},
|
|
229
|
+
user: {
|
|
230
|
+
id: seed as BufferSource,
|
|
231
|
+
name: username,
|
|
232
|
+
displayName: username,
|
|
233
|
+
},
|
|
234
|
+
pubKeyCredParams: [
|
|
235
|
+
{ type: "public-key", alg: -8 }, // Ed25519
|
|
236
|
+
{ type: "public-key", alg: -7 }, // ES256
|
|
237
|
+
{ type: "public-key", alg: -257 }, // RS256
|
|
238
|
+
],
|
|
239
|
+
attestation: "none",
|
|
240
|
+
authenticatorSelection: {
|
|
241
|
+
// - "platform": Uses the platform's built-in authenticator.
|
|
242
|
+
// - "cross-platform": Uses a device specific authenticator (yubikey, fido2, etc.)
|
|
243
|
+
authenticatorAttachment: authenticatorAttachment ?? "platform",
|
|
244
|
+
// - "discouraged": Only User Presence is needed.
|
|
245
|
+
// - "preferred": User Verification is preferred but not required. Falls back to User Presence.
|
|
246
|
+
// - "required": User Verification MUST occur (biometrics/PIN). Clients may silently downgrade to User Presence only.
|
|
247
|
+
userVerification: userVerification ?? "required",
|
|
248
|
+
// - "discouraged": Server-side credential is preferable, but will accept client-side discoverable credential.
|
|
249
|
+
// - "preferred": Relying Party strongly prefers client-side discoverable credential but will accept server-side credential.
|
|
250
|
+
// - "required": Client-side discoverable credential MUST be created, error if it can't be created.
|
|
251
|
+
residentKey: "required",
|
|
252
|
+
// Included for backwards compatibility. Deprecated in favor of residentKey (true = "required")
|
|
253
|
+
requireResidentKey: true,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const createCredentialRequestOptions =
|
|
260
|
+
(deps: RandomBytesDep) =>
|
|
261
|
+
(
|
|
262
|
+
credentialId: string,
|
|
263
|
+
relyingPartyID?: string,
|
|
264
|
+
userVerification?: UserVerificationRequirement,
|
|
265
|
+
): CredentialRequestOptions => {
|
|
266
|
+
return {
|
|
267
|
+
publicKey: {
|
|
268
|
+
challenge: generateSeed(deps)() as BufferSource,
|
|
269
|
+
rpId: relyingPartyID ?? document.location.hostname,
|
|
270
|
+
userVerification: userVerification ?? "preferred",
|
|
271
|
+
allowCredentials: [
|
|
272
|
+
{
|
|
273
|
+
type: "public-key",
|
|
274
|
+
id: base64UrlToUint8Array(
|
|
275
|
+
Base64Url.orThrow(credentialId),
|
|
276
|
+
) as BufferSource,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const deriveEncryptionKey = (seed: Uint8Array): EncryptionKey => {
|
|
284
|
+
const seed32 = seed.length === 32 ? seed : seed.slice(0, 32);
|
|
285
|
+
return EncryptionKey.orThrow(
|
|
286
|
+
createSlip21(seed32 as Entropy32, ["evolu", "auth"]),
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const encryptAuthResult =
|
|
291
|
+
(deps: SymmetricCryptoDep) =>
|
|
292
|
+
(
|
|
293
|
+
authResult: AuthResult,
|
|
294
|
+
encryptionKey: EncryptionKey,
|
|
295
|
+
): {
|
|
296
|
+
nonce: Base64Url;
|
|
297
|
+
ciphertext: Base64Url;
|
|
298
|
+
} => {
|
|
299
|
+
const plaintext = utf8ToBytes(JSON.stringify(authResult));
|
|
300
|
+
const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(
|
|
301
|
+
plaintext,
|
|
302
|
+
encryptionKey,
|
|
303
|
+
);
|
|
304
|
+
return {
|
|
305
|
+
nonce: uint8ArrayToBase64Url(nonce),
|
|
306
|
+
ciphertext: uint8ArrayToBase64Url(ciphertext),
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const decryptAuthResult =
|
|
311
|
+
(deps: SymmetricCryptoDep) =>
|
|
312
|
+
(
|
|
313
|
+
encryptedData: { nonce: Base64Url; ciphertext: Base64Url },
|
|
314
|
+
encryptionKey: EncryptionKey,
|
|
315
|
+
): string | null => {
|
|
316
|
+
const nonce = base64UrlToUint8Array(encryptedData.nonce);
|
|
317
|
+
const ciphertext = base64UrlToUint8Array(encryptedData.ciphertext);
|
|
318
|
+
const result = deps.symmetricCrypto.decrypt(
|
|
319
|
+
ciphertext,
|
|
320
|
+
encryptionKey,
|
|
321
|
+
nonce,
|
|
322
|
+
);
|
|
323
|
+
if (!result.ok) return null;
|
|
324
|
+
return bytesToUtf8(result.value);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const generateSeed = (deps: RandomBytesDep) => () => {
|
|
328
|
+
return deps.randomBytes.create(32);
|
|
329
|
+
};
|
package/src/Evolu/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createConsole,
|
|
3
|
+
createLocalAuth,
|
|
4
|
+
createRandomBytes,
|
|
5
|
+
createSymmetricCrypto,
|
|
6
|
+
createTime,
|
|
7
|
+
} from "@evolu/common";
|
|
2
8
|
import {
|
|
3
9
|
CreateDbWorker,
|
|
4
10
|
DbWorkerInput,
|
|
@@ -6,7 +12,11 @@ import {
|
|
|
6
12
|
EvoluDeps,
|
|
7
13
|
} from "@evolu/common/evolu";
|
|
8
14
|
import { createSharedWebWorker } from "../SharedWebWorker.js";
|
|
9
|
-
import {
|
|
15
|
+
import { createWebAuthnStore } from "./LocalAuth.js";
|
|
16
|
+
import { reloadApp } from "./Platform.js";
|
|
17
|
+
|
|
18
|
+
const randomBytes = createRandomBytes();
|
|
19
|
+
const symmetricCrypto = createSymmetricCrypto({ randomBytes });
|
|
10
20
|
|
|
11
21
|
const createDbWorker: CreateDbWorker = (name) =>
|
|
12
22
|
createSharedWebWorker<DbWorkerInput, DbWorkerOutput>(
|
|
@@ -17,10 +27,15 @@ const createDbWorker: CreateDbWorker = (name) =>
|
|
|
17
27
|
}),
|
|
18
28
|
);
|
|
19
29
|
|
|
30
|
+
export const localAuth = createLocalAuth({
|
|
31
|
+
randomBytes,
|
|
32
|
+
secureStorage: createWebAuthnStore({ randomBytes, symmetricCrypto }),
|
|
33
|
+
});
|
|
34
|
+
|
|
20
35
|
export const evoluWebDeps: EvoluDeps = {
|
|
21
|
-
time: createTime(),
|
|
22
36
|
console: createConsole(),
|
|
23
|
-
nanoIdLib: createNanoIdLib(),
|
|
24
|
-
createAppState,
|
|
25
37
|
createDbWorker,
|
|
38
|
+
randomBytes: createRandomBytes(),
|
|
39
|
+
reloadApp,
|
|
40
|
+
time: createTime(),
|
|
26
41
|
};
|
package/src/WasmSqliteDriver.ts
CHANGED
|
@@ -4,8 +4,12 @@ import {
|
|
|
4
4
|
CreateSqliteDriver,
|
|
5
5
|
SqliteDriver,
|
|
6
6
|
SqliteRow,
|
|
7
|
+
bytesToHex,
|
|
7
8
|
} from "@evolu/common";
|
|
8
|
-
import sqlite3InitModule, {
|
|
9
|
+
import sqlite3InitModule, {
|
|
10
|
+
PreparedStatement,
|
|
11
|
+
Database,
|
|
12
|
+
} from "@evolu/sqlite-wasm";
|
|
9
13
|
|
|
10
14
|
// TODO: Do we still need that?
|
|
11
15
|
// https://github.com/sqlite/sqlite-wasm/issues/62
|
|
@@ -22,12 +26,29 @@ export const createWasmSqliteDriver: CreateSqliteDriver = async (
|
|
|
22
26
|
options,
|
|
23
27
|
) => {
|
|
24
28
|
const sqlite3 = await sqlite3Promise;
|
|
29
|
+
// This is used to make OPFS default vfs for multipleciphers
|
|
30
|
+
// @ts-expect-error Missing types (update @evolu/sqlite-wasm types)
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
32
|
+
sqlite3.capi.sqlite3mc_vfs_create("opfs", 1);
|
|
33
|
+
|
|
34
|
+
let db: Database;
|
|
35
|
+
if (options?.memory) {
|
|
36
|
+
db = new sqlite3.oo1.DB(":memory:");
|
|
37
|
+
} else if (options?.encryptionKey) {
|
|
38
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({ directory: `.${name}` });
|
|
39
|
+
db = new pool.OpfsSAHPoolDb(
|
|
40
|
+
"file:evolu1.db?vfs=multipleciphers-opfs-sahpool",
|
|
41
|
+
);
|
|
42
|
+
db.exec(`
|
|
43
|
+
PRAGMA cipher = 'sqlcipher';
|
|
44
|
+
PRAGMA legacy = 4;
|
|
45
|
+
PRAGMA key = "x'${bytesToHex(options.encryptionKey)}'";
|
|
46
|
+
`);
|
|
47
|
+
} else {
|
|
48
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({ name });
|
|
49
|
+
db = new pool.OpfsSAHPoolDb("file:evolu1.db");
|
|
50
|
+
}
|
|
25
51
|
|
|
26
|
-
const db = options?.memory
|
|
27
|
-
? new sqlite3.oo1.DB(":memory:")
|
|
28
|
-
: new (await sqlite3.installOpfsSAHPoolVfs({ name })).OpfsSAHPoolDb(
|
|
29
|
-
"/evolu1.db",
|
|
30
|
-
);
|
|
31
52
|
let isDisposed = false;
|
|
32
53
|
|
|
33
54
|
const cache = createPreparedStatementsCache<PreparedStatement>(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"AppState.d.ts","sourceRoot":"","sources":["../../../src/Evolu/AppState.ts"],"names":[],"mappings":"AACA,OAAO,EAAY,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE/D,eAAO,MAAM,cAAc,EAAE,cAe5B,CAAC"}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { constVoid } from "@evolu/common";
|
|
2
|
-
export const createAppState = (config) => {
|
|
3
|
-
if (typeof document === "undefined") {
|
|
4
|
-
const appState = {
|
|
5
|
-
reset: constVoid,
|
|
6
|
-
};
|
|
7
|
-
return appState;
|
|
8
|
-
}
|
|
9
|
-
const appState = {
|
|
10
|
-
reset: () => {
|
|
11
|
-
location.replace(config.reloadUrl);
|
|
12
|
-
},
|
|
13
|
-
};
|
|
14
|
-
return appState;
|
|
15
|
-
};
|
package/src/Evolu/AppState.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { constVoid } from "@evolu/common";
|
|
2
|
-
import { AppState, CreateAppState } from "@evolu/common/evolu";
|
|
3
|
-
|
|
4
|
-
export const createAppState: CreateAppState = (config) => {
|
|
5
|
-
if (typeof document === "undefined") {
|
|
6
|
-
const appState: AppState = {
|
|
7
|
-
reset: constVoid,
|
|
8
|
-
};
|
|
9
|
-
return appState;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const appState: AppState = {
|
|
13
|
-
reset: () => {
|
|
14
|
-
location.replace(config.reloadUrl);
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
return appState;
|
|
19
|
-
};
|