@prosopo/account 2.8.56 → 2.8.59
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/.turbo/turbo-build$colon$cjs.log +9 -9
- package/.turbo/turbo-build$colon$tsc.log +13 -13
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +48 -0
- package/dist/cjs/extension/ExtensionWeb2.cjs +11 -15
- package/dist/cjs/workers/CryptoWorkerManager.cjs +28 -58
- package/dist/cjs/workers/cryptoWorker.cjs +1 -1
- package/dist/extension/ExtensionWeb2.d.ts.map +1 -1
- package/dist/extension/ExtensionWeb2.js +11 -15
- package/dist/extension/ExtensionWeb2.js.map +1 -1
- package/dist/tests/ExtensionWeb2.unit.test.d.ts +2 -0
- package/dist/tests/ExtensionWeb2.unit.test.d.ts.map +1 -0
- package/dist/tests/ExtensionWeb2.unit.test.js +93 -0
- package/dist/tests/ExtensionWeb2.unit.test.js.map +1 -0
- package/dist/tests/cryptoWorker.unit.test.d.ts +2 -0
- package/dist/tests/cryptoWorker.unit.test.d.ts.map +1 -0
- package/dist/tests/cryptoWorker.unit.test.js +74 -0
- package/dist/tests/cryptoWorker.unit.test.js.map +1 -0
- package/dist/workers/CryptoWorkerManager.d.ts +8 -1
- package/dist/workers/CryptoWorkerManager.d.ts.map +1 -1
- package/dist/workers/CryptoWorkerManager.js +28 -58
- package/dist/workers/CryptoWorkerManager.js.map +1 -1
- package/dist/workers/cryptoWorker.js +1 -1
- package/dist/workers/cryptoWorker.js.map +1 -1
- package/package.json +5 -3
- package/src/extension/ExtensionWeb2.ts +26 -18
- package/src/tests/ExtensionWeb2.unit.test.ts +172 -0
- package/src/tests/cryptoWorker.unit.test.ts +146 -0
- package/src/workers/CryptoWorkerManager.ts +43 -76
- package/src/workers/cryptoWorker.ts +34 -15
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +28 -0
|
@@ -93,28 +93,36 @@ export class ExtensionWeb2 extends Extension {
|
|
|
93
93
|
|
|
94
94
|
const u8Entropy = stringToU8a(entropy);
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
const type: KeypairType = "sr25519";
|
|
97
|
+
const keyring = new Keyring({ type });
|
|
98
|
+
const workerManager = getCryptoWorkerManager();
|
|
99
|
+
|
|
100
|
+
// Two paths, in order of preference:
|
|
101
|
+
//
|
|
102
|
+
// 1. Worker path: entropy → mnemonic → sr25519 keypair in ONE fused
|
|
103
|
+
// worker round-trip. sr25519 derivation is a ~500ms scalar-mul
|
|
104
|
+
// on mid-tier hardware and was the biggest main-thread cost in
|
|
105
|
+
// the frictionless critical path pre-worker. Doing it in the
|
|
106
|
+
// worker lets it overlap with BotScoreWorker and main-thread
|
|
107
|
+
// detectors. We wrap the raw keypair bytes on main via
|
|
108
|
+
// `addFromPair` (cheap packaging — no ECC work).
|
|
109
|
+
//
|
|
110
|
+
// 2. Fallback path (worker refused to boot / CSP): synchronous
|
|
111
|
+
// derivation on main thread. Preserves behaviour for browsers
|
|
112
|
+
// that block workers.
|
|
99
113
|
try {
|
|
100
|
-
const
|
|
101
|
-
|
|
114
|
+
const { publicKey, secretKey } =
|
|
115
|
+
await workerManager.entropyToKeypair(u8Entropy);
|
|
116
|
+
const keypair = keyring.addFromPair({ publicKey, secretKey }, {}, type);
|
|
117
|
+
const address = keypair.address;
|
|
118
|
+
return { address, name: address, keypair };
|
|
102
119
|
} catch (workerError) {
|
|
103
120
|
const entropyToMnemonic = await EntropyToMnemonicLoader();
|
|
104
|
-
mnemonic = entropyToMnemonic(u8Entropy);
|
|
121
|
+
const mnemonic = entropyToMnemonic(u8Entropy);
|
|
122
|
+
const keypair = keyring.addFromMnemonic(mnemonic);
|
|
123
|
+
const address = keypair.address;
|
|
124
|
+
return { address, name: address, keypair };
|
|
105
125
|
}
|
|
106
|
-
|
|
107
|
-
const type: KeypairType = "sr25519";
|
|
108
|
-
const keyring = new Keyring({
|
|
109
|
-
type,
|
|
110
|
-
});
|
|
111
|
-
const keypair = keyring.addFromMnemonic(mnemonic);
|
|
112
|
-
const address = keypair.address;
|
|
113
|
-
return {
|
|
114
|
-
address,
|
|
115
|
-
name: address,
|
|
116
|
-
keypair,
|
|
117
|
-
};
|
|
118
126
|
}
|
|
119
127
|
}
|
|
120
128
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// Tests for ExtensionWeb2.createAccount. The interesting behaviour to lock
|
|
16
|
+
// down is the two-branch account-derivation path:
|
|
17
|
+
//
|
|
18
|
+
// 1. Worker branch (preferred): `workerManager.entropyToKeypair(...)` →
|
|
19
|
+
// `keyring.addFromPair({publicKey, secretKey})` — the sr25519 scalar-
|
|
20
|
+
// mul runs off the main thread, main thread just packages the bytes.
|
|
21
|
+
// 2. Fallback branch: worker throws (CSP block, task timeout, etc.) →
|
|
22
|
+
// dynamic-import `entropyToMnemonic` → `keyring.addFromMnemonic(...)`
|
|
23
|
+
// on the main thread.
|
|
24
|
+
//
|
|
25
|
+
// Both branches must produce the same account address for a given fingerprint
|
|
26
|
+
// or session identity breaks between the two paths — a repeat visitor whose
|
|
27
|
+
// worker fails would look like a different user to the provider.
|
|
28
|
+
|
|
29
|
+
import { stringToU8a } from "@polkadot/util";
|
|
30
|
+
import { getFingerprint } from "@prosopo/fingerprint";
|
|
31
|
+
import { Keyring } from "@prosopo/keyring";
|
|
32
|
+
import { entropyToMnemonic, hexHash } from "@prosopo/util-crypto";
|
|
33
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
34
|
+
|
|
35
|
+
// vi.hoisted() makes these accessible from within vi.mock factories, which
|
|
36
|
+
// run before the module's own imports at collection time.
|
|
37
|
+
const mocks = vi.hoisted(() => {
|
|
38
|
+
const entropyToKeypair = vi.fn();
|
|
39
|
+
return {
|
|
40
|
+
entropyToKeypair,
|
|
41
|
+
getCryptoWorkerManager: vi.fn(() => ({
|
|
42
|
+
prewarm: vi.fn(),
|
|
43
|
+
entropyToKeypair,
|
|
44
|
+
entropyToMnemonic: vi.fn(),
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
vi.mock("@prosopo/fingerprint", async (importOriginal) => {
|
|
50
|
+
const actual =
|
|
51
|
+
(await importOriginal()) as typeof import("@prosopo/fingerprint");
|
|
52
|
+
return {
|
|
53
|
+
...actual,
|
|
54
|
+
getFingerprint: vi.fn(),
|
|
55
|
+
prefetchFingerprint: vi.fn(),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
vi.mock("../workers/CryptoWorkerManager.js", () => ({
|
|
60
|
+
getCryptoWorkerManager: mocks.getCryptoWorkerManager,
|
|
61
|
+
// Types re-exported at runtime aren't needed for the tests but keep the
|
|
62
|
+
// mock shape aligned with the module's actual named exports.
|
|
63
|
+
CryptoWorkerManager: class {},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// Imported after mocks so the module picks up the mocked getFingerprint and
|
|
67
|
+
// worker manager during its top-level side effects (prefetchFingerprint,
|
|
68
|
+
// getCryptoWorkerManager().prewarm()).
|
|
69
|
+
const { default: ExtensionWeb2 } = await import(
|
|
70
|
+
"../extension/ExtensionWeb2.js"
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Fixed browser-fingerprint hex the mocked getFingerprint always returns.
|
|
74
|
+
// The real code hashes this to 128 bits, hex-encodes it, and passes the ASCII
|
|
75
|
+
// hex bytes as entropy to the mnemonic derivation — so both branches under
|
|
76
|
+
// test see the same input regardless of the underlying fingerprint.
|
|
77
|
+
const FIXED_FINGERPRINT_HEX = "0x1234567890abcdef1234567890abcdef";
|
|
78
|
+
|
|
79
|
+
// Config placeholder — ExtensionWeb2.createAccount ignores fields other than
|
|
80
|
+
// what the fingerprint hash + keyring type need.
|
|
81
|
+
const config = {
|
|
82
|
+
account: { address: "" },
|
|
83
|
+
web2: true,
|
|
84
|
+
defaultEnvironment: "test",
|
|
85
|
+
} as unknown as Parameters<InstanceType<typeof ExtensionWeb2>["getAccount"]>[0];
|
|
86
|
+
|
|
87
|
+
describe("ExtensionWeb2.createAccount", () => {
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
vi.mocked(getFingerprint).mockReset();
|
|
90
|
+
mocks.entropyToKeypair.mockReset();
|
|
91
|
+
vi.mocked(getFingerprint).mockResolvedValue(FIXED_FINGERPRINT_HEX);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("worker branch: derives account from worker-returned keypair bytes", async () => {
|
|
95
|
+
// Compute the expected keypair the same way ExtensionWeb2 would — we
|
|
96
|
+
// need to feed the mock the exact bytes the worker would produce for
|
|
97
|
+
// this fingerprint, otherwise the reconstructed address won't match.
|
|
98
|
+
const entropyHex = hexHash(FIXED_FINGERPRINT_HEX, 128).slice(2);
|
|
99
|
+
const u8Entropy = stringToU8a(entropyHex);
|
|
100
|
+
const mnemonic = entropyToMnemonic(u8Entropy);
|
|
101
|
+
const keyring = new Keyring({ type: "sr25519" });
|
|
102
|
+
const expectedPair = keyring.addFromMnemonic(mnemonic);
|
|
103
|
+
|
|
104
|
+
mocks.entropyToKeypair.mockResolvedValue({
|
|
105
|
+
publicKey: expectedPair.publicKey,
|
|
106
|
+
secretKey:
|
|
107
|
+
// Fish the secret out of the reference pair — this is what the
|
|
108
|
+
// real CryptoWorker returns from sr25519FromSeed(seed).
|
|
109
|
+
(expectedPair as unknown as { secretKey: Uint8Array }).secretKey ??
|
|
110
|
+
new Uint8Array(64),
|
|
111
|
+
mnemonic,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const ext = new ExtensionWeb2();
|
|
115
|
+
const { account } = await ext.getAccount(config);
|
|
116
|
+
|
|
117
|
+
expect(mocks.entropyToKeypair).toHaveBeenCalledTimes(1);
|
|
118
|
+
// The worker got called with the entropy ExtensionWeb2 hashed from the
|
|
119
|
+
// fingerprint — verifies the argument-shaping code isn't broken.
|
|
120
|
+
const [entropyArg] = mocks.entropyToKeypair.mock.calls[0] ?? [];
|
|
121
|
+
expect(entropyArg).toBeInstanceOf(Uint8Array);
|
|
122
|
+
expect(entropyArg).toEqual(u8Entropy);
|
|
123
|
+
// Account address matches the addFromMnemonic-derived one — proves the
|
|
124
|
+
// addFromPair wrapping preserves account identity.
|
|
125
|
+
expect(account.address).toBe(expectedPair.address);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("fallback branch: derives account on main thread when worker throws", async () => {
|
|
129
|
+
// Simulate a worker failure — CSP block, worker script parse failure,
|
|
130
|
+
// task timeout — anything that surfaces as a rejection from
|
|
131
|
+
// entropyToKeypair. Fallback path must reach the same account address.
|
|
132
|
+
mocks.entropyToKeypair.mockRejectedValue(new Error("Worker unavailable"));
|
|
133
|
+
|
|
134
|
+
const entropyHex = hexHash(FIXED_FINGERPRINT_HEX, 128).slice(2);
|
|
135
|
+
const u8Entropy = stringToU8a(entropyHex);
|
|
136
|
+
const mnemonic = entropyToMnemonic(u8Entropy);
|
|
137
|
+
const keyring = new Keyring({ type: "sr25519" });
|
|
138
|
+
const expectedPair = keyring.addFromMnemonic(mnemonic);
|
|
139
|
+
|
|
140
|
+
const ext = new ExtensionWeb2();
|
|
141
|
+
const { account } = await ext.getAccount(config);
|
|
142
|
+
|
|
143
|
+
expect(mocks.entropyToKeypair).toHaveBeenCalledTimes(1);
|
|
144
|
+
// Fallback still gives us a working keypair with the same address —
|
|
145
|
+
// this is the invariant that lets us safely swap in the worker path.
|
|
146
|
+
expect(account.address).toBe(expectedPair.address);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("worker and fallback branches derive the same account address", async () => {
|
|
150
|
+
// Direct A/B: one call goes through the worker mock, the next through
|
|
151
|
+
// the fallback. Assert the addresses match.
|
|
152
|
+
const entropyHex = hexHash(FIXED_FINGERPRINT_HEX, 128).slice(2);
|
|
153
|
+
const u8Entropy = stringToU8a(entropyHex);
|
|
154
|
+
const mnemonic = entropyToMnemonic(u8Entropy);
|
|
155
|
+
const refKeyring = new Keyring({ type: "sr25519" });
|
|
156
|
+
const refPair = refKeyring.addFromMnemonic(mnemonic);
|
|
157
|
+
|
|
158
|
+
mocks.entropyToKeypair.mockResolvedValueOnce({
|
|
159
|
+
publicKey: refPair.publicKey,
|
|
160
|
+
secretKey:
|
|
161
|
+
(refPair as unknown as { secretKey: Uint8Array }).secretKey ??
|
|
162
|
+
new Uint8Array(64),
|
|
163
|
+
mnemonic,
|
|
164
|
+
});
|
|
165
|
+
const workerAccount = await new ExtensionWeb2().getAccount(config);
|
|
166
|
+
|
|
167
|
+
mocks.entropyToKeypair.mockRejectedValueOnce(new Error("worker down"));
|
|
168
|
+
const fallbackAccount = await new ExtensionWeb2().getAccount(config);
|
|
169
|
+
|
|
170
|
+
expect(workerAccount.account.address).toBe(fallbackAccount.account.address);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// Unit tests for the CryptoWorker task implementations. The worker itself
|
|
16
|
+
// bootstraps under `?worker&inline` in the browser build and can't easily be
|
|
17
|
+
// spun up in a Node vitest env, so these tests exercise the same primitives
|
|
18
|
+
// the worker's message handler dispatches to (entropyToMnemonic,
|
|
19
|
+
// mnemonicToMiniSecret, sr25519FromSeed) and prove they produce a keypair
|
|
20
|
+
// equivalent to `keyring.addFromMnemonic` — the shipped-blob invariant that
|
|
21
|
+
// `ExtensionWeb2.createAccount`'s worker path relies on.
|
|
22
|
+
|
|
23
|
+
import { stringToU8a } from "@polkadot/util";
|
|
24
|
+
import { Keyring } from "@prosopo/keyring";
|
|
25
|
+
import {
|
|
26
|
+
entropyToMnemonic,
|
|
27
|
+
mnemonicToMiniSecret,
|
|
28
|
+
sr25519FromSeed,
|
|
29
|
+
} from "@prosopo/util-crypto";
|
|
30
|
+
import { describe, expect, it } from "vitest";
|
|
31
|
+
|
|
32
|
+
// Mirrors ExtensionWeb2.createAccount: browserEntropy → hexHash(., 128) →
|
|
33
|
+
// slice(2) → 32 ASCII-hex characters → stringToU8a → 32 bytes. Fixed value
|
|
34
|
+
// so tests are deterministic (real code path uses the browser fingerprint).
|
|
35
|
+
const FIXED_ENTROPY_HEX = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
|
|
36
|
+
|
|
37
|
+
const entropyBytes = (hex: string): Uint8Array => stringToU8a(hex);
|
|
38
|
+
|
|
39
|
+
describe("CryptoWorker task primitives", () => {
|
|
40
|
+
describe("entropyToKeypair pipeline", () => {
|
|
41
|
+
it("derives an sr25519 keypair from entropy deterministically", () => {
|
|
42
|
+
const entropy = entropyBytes(FIXED_ENTROPY_HEX);
|
|
43
|
+
|
|
44
|
+
const mnemonic = entropyToMnemonic(entropy);
|
|
45
|
+
const seed = mnemonicToMiniSecret(mnemonic);
|
|
46
|
+
const { publicKey, secretKey } = sr25519FromSeed(seed);
|
|
47
|
+
|
|
48
|
+
expect(publicKey).toBeInstanceOf(Uint8Array);
|
|
49
|
+
expect(secretKey).toBeInstanceOf(Uint8Array);
|
|
50
|
+
expect(publicKey.length).toBe(32);
|
|
51
|
+
expect(secretKey.length).toBe(64);
|
|
52
|
+
|
|
53
|
+
// Re-running with the same entropy must yield the same keys —
|
|
54
|
+
// otherwise the CryptoWorker cache would be poisoned by a
|
|
55
|
+
// non-deterministic derivation.
|
|
56
|
+
const again = sr25519FromSeed(
|
|
57
|
+
mnemonicToMiniSecret(entropyToMnemonic(entropy)),
|
|
58
|
+
);
|
|
59
|
+
expect(again.publicKey).toEqual(publicKey);
|
|
60
|
+
expect(again.secretKey).toEqual(secretKey);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("produces the same address as keyring.addFromMnemonic (worker/main-thread parity)", () => {
|
|
64
|
+
const entropy = entropyBytes(FIXED_ENTROPY_HEX);
|
|
65
|
+
const mnemonic = entropyToMnemonic(entropy);
|
|
66
|
+
|
|
67
|
+
// Worker path: entropy → mnemonic → miniSecret → sr25519 → addFromPair
|
|
68
|
+
const seed = mnemonicToMiniSecret(mnemonic);
|
|
69
|
+
const { publicKey, secretKey } = sr25519FromSeed(seed);
|
|
70
|
+
const workerKeyring = new Keyring({ type: "sr25519" });
|
|
71
|
+
const workerPair = workerKeyring.addFromPair(
|
|
72
|
+
{ publicKey, secretKey },
|
|
73
|
+
{},
|
|
74
|
+
"sr25519",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Legacy main-thread path: keyring.addFromMnemonic
|
|
78
|
+
const mainKeyring = new Keyring({ type: "sr25519" });
|
|
79
|
+
const mainPair = mainKeyring.addFromMnemonic(mnemonic);
|
|
80
|
+
|
|
81
|
+
// Addresses must match exactly — this is the invariant that guarantees
|
|
82
|
+
// swapping addFromMnemonic → addFromPair(entropyToKeypair(...)) is a
|
|
83
|
+
// behavioural no-op for account identity.
|
|
84
|
+
expect(workerPair.address).toBe(mainPair.address);
|
|
85
|
+
expect(workerPair.publicKey).toEqual(mainPair.publicKey);
|
|
86
|
+
expect(workerPair.type).toBe(mainPair.type);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("signatures produced by addFromPair verify against the addFromMnemonic public key", () => {
|
|
90
|
+
const entropy = entropyBytes(FIXED_ENTROPY_HEX);
|
|
91
|
+
const mnemonic = entropyToMnemonic(entropy);
|
|
92
|
+
const seed = mnemonicToMiniSecret(mnemonic);
|
|
93
|
+
const { publicKey, secretKey } = sr25519FromSeed(seed);
|
|
94
|
+
|
|
95
|
+
const workerKeyring = new Keyring({ type: "sr25519" });
|
|
96
|
+
const workerPair = workerKeyring.addFromPair(
|
|
97
|
+
{ publicKey, secretKey },
|
|
98
|
+
{},
|
|
99
|
+
"sr25519",
|
|
100
|
+
);
|
|
101
|
+
const mainKeyring = new Keyring({ type: "sr25519" });
|
|
102
|
+
const mainPair = mainKeyring.addFromMnemonic(mnemonic);
|
|
103
|
+
|
|
104
|
+
const message = stringToU8a("prosopo-test-message");
|
|
105
|
+
const workerSig = workerPair.sign(message);
|
|
106
|
+
const mainSig = mainPair.sign(message);
|
|
107
|
+
|
|
108
|
+
expect(workerSig.length).toBe(64);
|
|
109
|
+
expect(mainSig.length).toBe(64);
|
|
110
|
+
// sr25519 signatures are non-deterministic, so we can't byte-compare;
|
|
111
|
+
// but each signature must verify against BOTH pairs' public keys.
|
|
112
|
+
expect(mainPair.verify(message, workerSig, mainPair.publicKey)).toBe(
|
|
113
|
+
true,
|
|
114
|
+
);
|
|
115
|
+
expect(workerPair.verify(message, mainSig, workerPair.publicKey)).toBe(
|
|
116
|
+
true,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("entropyToMnemonic (existing worker task, unchanged)", () => {
|
|
122
|
+
it("produces a 24-word BIP39 mnemonic from 32 bytes of entropy", () => {
|
|
123
|
+
const entropy = entropyBytes(FIXED_ENTROPY_HEX);
|
|
124
|
+
const mnemonic = entropyToMnemonic(entropy);
|
|
125
|
+
const words = mnemonic.split(" ");
|
|
126
|
+
// 32 bytes of entropy = 256 bits → 24 BIP39 words.
|
|
127
|
+
expect(words.length).toBe(24);
|
|
128
|
+
for (const w of words) {
|
|
129
|
+
expect(w).toMatch(/^[a-z]+$/);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("is deterministic for a given entropy", () => {
|
|
134
|
+
const entropy = entropyBytes(FIXED_ENTROPY_HEX);
|
|
135
|
+
expect(entropyToMnemonic(entropy)).toBe(entropyToMnemonic(entropy));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("rejects entropy that isn't 16/20/24/28/32 bytes", () => {
|
|
139
|
+
// bip39 accepts only these sizes — CryptoWorker relies on the caller
|
|
140
|
+
// passing valid entropy (already-hashed to 32 bytes in ExtensionWeb2).
|
|
141
|
+
expect(() => entropyToMnemonic(new Uint8Array(15))).toThrow();
|
|
142
|
+
expect(() => entropyToMnemonic(new Uint8Array(17))).toThrow();
|
|
143
|
+
expect(() => entropyToMnemonic(new Uint8Array(33))).toThrow();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -20,9 +20,20 @@ interface CryptoWorkerMessage {
|
|
|
20
20
|
data: Record<string, string | Uint8Array>;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface KeypairBytes {
|
|
24
|
+
publicKey: Uint8Array;
|
|
25
|
+
secretKey: Uint8Array;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EntropyToKeypairResult extends KeypairBytes {
|
|
29
|
+
mnemonic: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type CryptoWorkerResult = string | KeypairBytes | EntropyToKeypairResult;
|
|
33
|
+
|
|
23
34
|
interface CryptoWorkerResponse {
|
|
24
35
|
taskId: string;
|
|
25
|
-
result?:
|
|
36
|
+
result?: CryptoWorkerResult;
|
|
26
37
|
error?: string;
|
|
27
38
|
}
|
|
28
39
|
|
|
@@ -34,9 +45,15 @@ export class CryptoWorkerManager {
|
|
|
34
45
|
private isInitializing = false;
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
|
-
* Initialize the worker
|
|
48
|
+
* Initialize the worker. No test round-trip — construction failure throws
|
|
49
|
+
* synchronously (caught below); post-construction failures surface via
|
|
50
|
+
* `worker.onerror` (cleans up so the next call retries); anything that
|
|
51
|
+
* gets past both surfaces via `runTask`'s timeout + reject path, which
|
|
52
|
+
* already falls back to main-thread crypto. The prior `testWorker()` ping
|
|
53
|
+
* was a Blob-URL-era defensive check that no longer earns its ~30–80ms
|
|
54
|
+
* critical-path cost under Vite's `?worker&inline` constructor.
|
|
38
55
|
*/
|
|
39
|
-
private
|
|
56
|
+
private initWorker(): void {
|
|
40
57
|
if (this.worker || this.isInitializing) {
|
|
41
58
|
return;
|
|
42
59
|
}
|
|
@@ -46,88 +63,24 @@ export class CryptoWorkerManager {
|
|
|
46
63
|
try {
|
|
47
64
|
this.worker = new CryptoWorkerConstructor({ type: "module" });
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Terminate the failed worker and mark for re-initialization
|
|
66
|
+
this.worker.onerror = () => {
|
|
67
|
+
// Terminate the failed worker; the next runTask will re-init.
|
|
52
68
|
this.cleanup();
|
|
53
|
-
// The specific runTask/testWorker promise will reject via their local handleError
|
|
54
69
|
};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await this.testWorker();
|
|
58
|
-
} catch (error) {
|
|
59
|
-
this.cleanup(); // Clean up if instantiation itself fails
|
|
60
|
-
throw error; // Re-throw to propagate failure
|
|
70
|
+
} catch {
|
|
71
|
+
this.cleanup();
|
|
61
72
|
} finally {
|
|
62
|
-
|
|
63
|
-
// but we can if the test fails or construction fails.
|
|
64
|
-
if (!this.worker) {
|
|
65
|
-
this.isInitializing = false;
|
|
66
|
-
}
|
|
73
|
+
this.isInitializing = false;
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
/**
|
|
71
|
-
* Test if the worker is functioning properly
|
|
72
|
-
*/
|
|
73
|
-
private async testWorker(): Promise<void> {
|
|
74
|
-
if (!this.worker) {
|
|
75
|
-
throw new Error("Worker not initialized");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const worker = this.worker;
|
|
79
|
-
|
|
80
|
-
return new Promise((resolve, reject) => {
|
|
81
|
-
const testTimeout = setTimeout(() => {
|
|
82
|
-
// Clean up event listeners on timeout
|
|
83
|
-
worker.removeEventListener("message", handleMessage);
|
|
84
|
-
worker.removeEventListener("error", handleError);
|
|
85
|
-
reject(new Error("Worker test timeout"));
|
|
86
|
-
}, 5000);
|
|
87
|
-
|
|
88
|
-
const handleMessage = (event: MessageEvent) => {
|
|
89
|
-
const { taskId, error, result } = event.data;
|
|
90
|
-
if (taskId === "test") {
|
|
91
|
-
clearTimeout(testTimeout);
|
|
92
|
-
worker.removeEventListener("message", handleMessage);
|
|
93
|
-
worker.removeEventListener("error", handleError); // Also remove error listener
|
|
94
|
-
|
|
95
|
-
if (error || result !== "ready") {
|
|
96
|
-
reject(
|
|
97
|
-
new Error(
|
|
98
|
-
`Worker test failed: ${error || 'Did not return "ready"'}`,
|
|
99
|
-
),
|
|
100
|
-
);
|
|
101
|
-
} else {
|
|
102
|
-
resolve();
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const handleError = (error: ErrorEvent) => {
|
|
108
|
-
clearTimeout(testTimeout);
|
|
109
|
-
worker.removeEventListener("message", handleMessage);
|
|
110
|
-
worker.removeEventListener("error", handleError);
|
|
111
|
-
reject(
|
|
112
|
-
new Error(`Worker test failed with error event: ${error.message}`),
|
|
113
|
-
);
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
worker.addEventListener("message", handleMessage);
|
|
117
|
-
worker.addEventListener("error", handleError); // Catch errors during test
|
|
118
|
-
|
|
119
|
-
// Send a test message
|
|
120
|
-
worker.postMessage({ taskId: "test", task: "test", data: {} });
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
77
|
/**
|
|
125
78
|
* Eagerly spawn the worker so the first runTask() doesn't pay the
|
|
126
|
-
* worker-spawn + module-parse cost.
|
|
127
|
-
*
|
|
79
|
+
* worker-spawn + module-parse cost. Synchronous now — construction is
|
|
80
|
+
* fire-and-forget with cleanup via `worker.onerror`.
|
|
128
81
|
*/
|
|
129
82
|
prewarm(): void {
|
|
130
|
-
this.initWorker()
|
|
83
|
+
this.initWorker();
|
|
131
84
|
}
|
|
132
85
|
|
|
133
86
|
/**
|
|
@@ -137,7 +90,7 @@ export class CryptoWorkerManager {
|
|
|
137
90
|
task: string,
|
|
138
91
|
data: Record<string, string | Uint8Array>,
|
|
139
92
|
): Promise<T> {
|
|
140
|
-
|
|
93
|
+
this.initWorker();
|
|
141
94
|
|
|
142
95
|
if (!this.worker) {
|
|
143
96
|
throw new Error("Failed to initialize worker");
|
|
@@ -192,6 +145,20 @@ export class CryptoWorkerManager {
|
|
|
192
145
|
return this.runTask<string>("entropyToMnemonic", { entropy });
|
|
193
146
|
}
|
|
194
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Fused: entropy → mnemonic → sr25519 keypair in a single worker
|
|
150
|
+
* round-trip. Callers that don't need the mnemonic separately should
|
|
151
|
+
* prefer this over two `entropyToMnemonic + keypairFromMnemonic` hops —
|
|
152
|
+
* saves one postMessage transit (~30–80ms on constrained hardware / under
|
|
153
|
+
* throttle) on the frictionless critical path. sr25519 derivation is a
|
|
154
|
+
* ~500ms main-thread cost otherwise.
|
|
155
|
+
*/
|
|
156
|
+
async entropyToKeypair(entropy: Uint8Array): Promise<EntropyToKeypairResult> {
|
|
157
|
+
return this.runTask<EntropyToKeypairResult>("entropyToKeypair", {
|
|
158
|
+
entropy,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
195
162
|
/**
|
|
196
163
|
* Clean up worker resources
|
|
197
164
|
*/
|
|
@@ -14,19 +14,29 @@
|
|
|
14
14
|
|
|
15
15
|
// cryptoWorker.ts
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
entropyToMnemonic,
|
|
19
|
+
mnemonicToMiniSecret,
|
|
20
|
+
sr25519FromSeed,
|
|
21
|
+
} from "@prosopo/util-crypto";
|
|
18
22
|
|
|
19
23
|
interface CryptoWorkerMessage {
|
|
20
24
|
taskId: string;
|
|
21
|
-
task: "entropyToMnemonic" | "test";
|
|
25
|
+
task: "entropyToMnemonic" | "entropyToKeypair" | "test";
|
|
22
26
|
data: {
|
|
23
27
|
entropy?: Uint8Array;
|
|
24
28
|
};
|
|
25
29
|
}
|
|
26
30
|
|
|
31
|
+
interface EntropyToKeypairResult {
|
|
32
|
+
mnemonic: string;
|
|
33
|
+
publicKey: Uint8Array;
|
|
34
|
+
secretKey: Uint8Array;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
interface CryptoWorkerResponse {
|
|
28
38
|
taskId: string;
|
|
29
|
-
result?: string;
|
|
39
|
+
result?: string | EntropyToKeypairResult;
|
|
30
40
|
error?: string;
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -37,7 +47,7 @@ self.addEventListener(
|
|
|
37
47
|
const { taskId, task, data } = event.data;
|
|
38
48
|
|
|
39
49
|
try {
|
|
40
|
-
let result: string;
|
|
50
|
+
let result: string | EntropyToKeypairResult;
|
|
41
51
|
|
|
42
52
|
switch (task) {
|
|
43
53
|
case "test":
|
|
@@ -48,7 +58,21 @@ self.addEventListener(
|
|
|
48
58
|
if (!data.entropy) {
|
|
49
59
|
throw new Error("Entropy data is required");
|
|
50
60
|
}
|
|
51
|
-
result =
|
|
61
|
+
result = entropyToMnemonic(data.entropy);
|
|
62
|
+
break;
|
|
63
|
+
case "entropyToKeypair":
|
|
64
|
+
// Fused: entropy → mnemonic → sr25519 keypair in one worker
|
|
65
|
+
// round-trip. Saves the postMessage transit on the frictionless
|
|
66
|
+
// critical path (~30–80ms per hop on constrained hardware).
|
|
67
|
+
// Called by ExtensionWeb2.createAccount when the caller doesn't
|
|
68
|
+
// need the mnemonic string for any other purpose than deriving
|
|
69
|
+
// the pair. sr25519 derivation is a scalar multiplication on
|
|
70
|
+
// Ristretto25519 — ~500ms mid-tier, ~800ms mobile — and was the
|
|
71
|
+
// single biggest main-thread cost pre-worker.
|
|
72
|
+
if (!data.entropy) {
|
|
73
|
+
throw new Error("Entropy data is required");
|
|
74
|
+
}
|
|
75
|
+
result = processEntropyToKeypair(data.entropy);
|
|
52
76
|
break;
|
|
53
77
|
default:
|
|
54
78
|
throw new Error(`Unknown task: ${task}`);
|
|
@@ -66,14 +90,9 @@ self.addEventListener(
|
|
|
66
90
|
},
|
|
67
91
|
);
|
|
68
92
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
} catch (error) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
`Failed to process entropy: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
93
|
+
function processEntropyToKeypair(entropy: Uint8Array): EntropyToKeypairResult {
|
|
94
|
+
const mnemonic = entropyToMnemonic(entropy);
|
|
95
|
+
const seed = mnemonicToMiniSecret(mnemonic);
|
|
96
|
+
const { publicKey, secretKey } = sr25519FromSeed(seed);
|
|
97
|
+
return { mnemonic, publicKey, secretKey };
|
|
79
98
|
}
|