@dfns/sdk-browser 0.8.31 → 0.8.34
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/index.d.ts +3 -1
- package/index.js +4 -7
- package/package.json +4 -3
- package/signers/ppk.d.ts +57 -0
- package/signers/ppk.js +196 -0
- package/signers/recovery.d.ts +21 -0
- package/signers/recovery.js +89 -0
- package/signers/session.d.ts +27 -0
- package/signers/session.js +103 -0
- package/signers/webauthn.d.ts +26 -6
- package/signers/webauthn.js +33 -38
- package/worker/dedicatedWorker.d.ts +1 -0
- package/worker/dedicatedWorker.js +7 -0
- package/worker/dispatcher.d.ts +3 -0
- package/worker/dispatcher.js +51 -0
- package/worker/handle.d.ts +40 -0
- package/worker/handle.js +114 -0
- package/worker/isolatedKey.d.ts +16 -0
- package/worker/isolatedKey.js +98 -0
- package/worker/protocol.d.ts +46 -0
- package/worker/protocol.js +7 -0
- package/worker/sharedWorker.d.ts +1 -0
- package/worker/sharedWorker.js +11 -0
- package/signers/key.d.ts +0 -10
- package/signers/key.js +0 -84
package/index.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export { BrowserKeySigner } from './signers/key';
|
|
2
1
|
export { WebAuthnSigner } from './signers/webauthn';
|
|
2
|
+
export { PasswordProtectedKeyAttestation, PasswordProtectedKeyRing, PasswordProtectedKeySigner, PromptCallback, PromptResult, } from './signers/ppk';
|
|
3
|
+
export { RecoveryKeyAssertion, RecoveryKeyAttestation, RecoveryKeySigner, randomRecoveryCode } from './signers/recovery';
|
|
4
|
+
export { SessionKeyAttestation, SessionKeySigner } from './signers/session';
|
package/index.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Object.defineProperty(exports, "BrowserKeySigner", { enumerable: true, get: function () { return key_1.BrowserKeySigner; } });
|
|
6
|
-
var webauthn_1 = require("./signers/webauthn");
|
|
7
|
-
Object.defineProperty(exports, "WebAuthnSigner", { enumerable: true, get: function () { return webauthn_1.WebAuthnSigner; } });
|
|
1
|
+
export { WebAuthnSigner } from './signers/webauthn';
|
|
2
|
+
export { PasswordProtectedKeyRing, PasswordProtectedKeySigner, } from './signers/ppk';
|
|
3
|
+
export { RecoveryKeySigner, randomRecoveryCode } from './signers/recovery';
|
|
4
|
+
export { SessionKeySigner } from './signers/session';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfns/sdk-browser",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.34",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/dfns/dfns-sdk-ts.git",
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
"cross-fetch": "^3.1.6"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
|
-
"@dfns/sdk": "0.8.
|
|
14
|
+
"@dfns/sdk": "0.8.34"
|
|
15
15
|
},
|
|
16
|
+
"module": "./index.js",
|
|
17
|
+
"type": "module",
|
|
16
18
|
"main": "./index.js",
|
|
17
|
-
"type": "commonjs",
|
|
18
19
|
"license": "MIT"
|
|
19
20
|
}
|
package/signers/ppk.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { CredentialSigner, KeyAssertion } from '@dfns/sdk';
|
|
2
|
+
import { CreateCredentialBody } from '@dfns/sdk/types/auth';
|
|
3
|
+
export type PasswordProtectedKeyAttestation = Omit<Extract<CreateCredentialBody, {
|
|
4
|
+
credentialKind: 'PasswordProtectedKey';
|
|
5
|
+
}>, 'challengeIdentifier' | 'credentialName'>;
|
|
6
|
+
export declare class PasswordProtectedKeySigner {
|
|
7
|
+
private readonly keyData;
|
|
8
|
+
private readonly ttlMs;
|
|
9
|
+
private handle;
|
|
10
|
+
private ttlTimer;
|
|
11
|
+
constructor(keyData: {
|
|
12
|
+
username: string;
|
|
13
|
+
credId: string;
|
|
14
|
+
wrappedKey: string;
|
|
15
|
+
}, ttlMs?: number);
|
|
16
|
+
static create(username: string, options: {
|
|
17
|
+
challenge: string;
|
|
18
|
+
}, password: string): Promise<PasswordProtectedKeyAttestation>;
|
|
19
|
+
isUnlocked(): boolean;
|
|
20
|
+
unlock(password: string): Promise<void>;
|
|
21
|
+
sign(options: {
|
|
22
|
+
challenge: string;
|
|
23
|
+
allowCredentials?: {
|
|
24
|
+
passwordProtectedKey?: {
|
|
25
|
+
id: string;
|
|
26
|
+
}[];
|
|
27
|
+
};
|
|
28
|
+
}): Promise<KeyAssertion>;
|
|
29
|
+
lock(): Promise<void>;
|
|
30
|
+
private resetTtl;
|
|
31
|
+
private clearTtl;
|
|
32
|
+
}
|
|
33
|
+
export type PromptResult = {
|
|
34
|
+
password: string;
|
|
35
|
+
};
|
|
36
|
+
export type PromptCallback = (opts: {
|
|
37
|
+
lastError?: Error;
|
|
38
|
+
}) => Promise<PromptResult>;
|
|
39
|
+
export declare class PasswordProtectedKeyRing implements CredentialSigner<KeyAssertion> {
|
|
40
|
+
private readonly username;
|
|
41
|
+
private readonly prompt;
|
|
42
|
+
private readonly ttlMs?;
|
|
43
|
+
private signers;
|
|
44
|
+
private signerIds;
|
|
45
|
+
private current;
|
|
46
|
+
constructor(username: string, prompt: PromptCallback, ttlMs?: number | undefined);
|
|
47
|
+
sign(options: {
|
|
48
|
+
challenge: string;
|
|
49
|
+
allowCredentials?: {
|
|
50
|
+
passwordProtectedKey?: {
|
|
51
|
+
id: string;
|
|
52
|
+
encryptedPrivateKey: string;
|
|
53
|
+
}[];
|
|
54
|
+
};
|
|
55
|
+
}): Promise<KeyAssertion>;
|
|
56
|
+
reset(): Promise<void>;
|
|
57
|
+
}
|
package/signers/ppk.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { fromBase64Url, toBase64Url } from '@dfns/sdk/utils';
|
|
3
|
+
import { IsolatedKeyHandle } from '../worker/handle';
|
|
4
|
+
import { namedError } from '../worker/protocol';
|
|
5
|
+
const DEFAULT_TTL_MS = 60000;
|
|
6
|
+
const deriveSaltFromUsername = async (username) => {
|
|
7
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(username.toLowerCase().trim()));
|
|
8
|
+
return new Uint8Array(digest);
|
|
9
|
+
};
|
|
10
|
+
const spawnWorker = () => {
|
|
11
|
+
const worker = new Worker(new URL('../worker/dedicatedWorker', import.meta.url), { type: 'module' });
|
|
12
|
+
return new IsolatedKeyHandle(worker);
|
|
13
|
+
};
|
|
14
|
+
export class PasswordProtectedKeySigner {
|
|
15
|
+
constructor(keyData, ttlMs = DEFAULT_TTL_MS) {
|
|
16
|
+
this.keyData = keyData;
|
|
17
|
+
this.ttlMs = ttlMs;
|
|
18
|
+
this.handle = null;
|
|
19
|
+
this.ttlTimer = null;
|
|
20
|
+
}
|
|
21
|
+
static async create(username, options, password) {
|
|
22
|
+
const handle = spawnWorker();
|
|
23
|
+
try {
|
|
24
|
+
const salt = await deriveSaltFromUsername(username);
|
|
25
|
+
const { publicKey, credId, wrappedKey } = await handle.create({ password, salt });
|
|
26
|
+
if (!wrappedKey) {
|
|
27
|
+
throw namedError('InvalidArgument', 'IsolatedKey.create did not return a wrappedKey despite a password being provided');
|
|
28
|
+
}
|
|
29
|
+
const clientData = JSON.stringify({ type: 'key.create', challenge: options.challenge });
|
|
30
|
+
const clientDataHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientData));
|
|
31
|
+
const fingerprint = JSON.stringify({
|
|
32
|
+
clientDataHash: Buffer.from(clientDataHash).toString('hex'),
|
|
33
|
+
publicKey,
|
|
34
|
+
});
|
|
35
|
+
const { signature: sigB64Url } = await handle.sign(fingerprint);
|
|
36
|
+
const signatureHex = fromBase64Url(sigB64Url).toString('hex');
|
|
37
|
+
const attestationData = JSON.stringify({
|
|
38
|
+
algorithm: 'SHA256',
|
|
39
|
+
publicKey,
|
|
40
|
+
signature: signatureHex,
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
credentialKind: 'PasswordProtectedKey',
|
|
44
|
+
credentialInfo: {
|
|
45
|
+
attestationData: toBase64Url(Buffer.from(attestationData)),
|
|
46
|
+
clientData: toBase64Url(Buffer.from(clientData)),
|
|
47
|
+
credId,
|
|
48
|
+
},
|
|
49
|
+
encryptedPrivateKey: wrappedKey,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
isUnlocked() {
|
|
57
|
+
return this.handle !== null;
|
|
58
|
+
}
|
|
59
|
+
async unlock(password) {
|
|
60
|
+
await this.lock();
|
|
61
|
+
const handle = spawnWorker();
|
|
62
|
+
try {
|
|
63
|
+
const salt = await deriveSaltFromUsername(this.keyData.username);
|
|
64
|
+
await handle.unlock({ credId: this.keyData.credId, wrappedKey: this.keyData.wrappedKey, password, salt });
|
|
65
|
+
if (this.handle) {
|
|
66
|
+
// Another unlock won the race during our handle.unlock() await. Terminate our worker
|
|
67
|
+
// rather than orphan it — overwriting this.handle would drop the winner's reference and
|
|
68
|
+
// leave a decrypted-key worker with no TTL and nothing holding it.
|
|
69
|
+
handle.terminate();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.handle = handle;
|
|
73
|
+
this.resetTtl();
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
handle.terminate();
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async sign(options) {
|
|
81
|
+
if (!this.handle) {
|
|
82
|
+
throw namedError('InvalidOperation', 'PasswordProtectedKey is locked; call unlock(password) first');
|
|
83
|
+
}
|
|
84
|
+
const { credId } = this.keyData;
|
|
85
|
+
const allowedIds = options.allowCredentials?.passwordProtectedKey?.map((c) => c.id) ?? [];
|
|
86
|
+
if (!allowedIds.includes(credId)) {
|
|
87
|
+
throw namedError('InvalidArgument', `${credId} does not match allowed password-protected credentials: ${allowedIds.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
const clientData = JSON.stringify({ challenge: options.challenge, type: 'key.get' });
|
|
90
|
+
this.resetTtl();
|
|
91
|
+
let signature;
|
|
92
|
+
try {
|
|
93
|
+
;
|
|
94
|
+
({ signature } = await this.handle.sign(clientData));
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof Error && (err.name === 'WorkerCrashed' || err.name === 'DispatchFailed')) {
|
|
98
|
+
this.clearTtl();
|
|
99
|
+
this.handle.terminate();
|
|
100
|
+
this.handle = null;
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
kind: 'Key',
|
|
106
|
+
credentialAssertion: {
|
|
107
|
+
credId,
|
|
108
|
+
clientData: toBase64Url(Buffer.from(clientData)),
|
|
109
|
+
signature,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async lock() {
|
|
114
|
+
this.clearTtl();
|
|
115
|
+
const h = this.handle;
|
|
116
|
+
this.handle = null;
|
|
117
|
+
if (h) {
|
|
118
|
+
await h.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
resetTtl() {
|
|
122
|
+
this.clearTtl();
|
|
123
|
+
this.ttlTimer = setTimeout(() => {
|
|
124
|
+
this.handle?.terminate();
|
|
125
|
+
this.handle = null;
|
|
126
|
+
this.ttlTimer = null;
|
|
127
|
+
}, this.ttlMs);
|
|
128
|
+
}
|
|
129
|
+
clearTtl() {
|
|
130
|
+
if (this.ttlTimer) {
|
|
131
|
+
clearTimeout(this.ttlTimer);
|
|
132
|
+
this.ttlTimer = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export class PasswordProtectedKeyRing {
|
|
137
|
+
constructor(username, prompt, ttlMs) {
|
|
138
|
+
this.username = username;
|
|
139
|
+
this.prompt = prompt;
|
|
140
|
+
this.ttlMs = ttlMs;
|
|
141
|
+
this.signers = [];
|
|
142
|
+
this.signerIds = [];
|
|
143
|
+
this.current = null;
|
|
144
|
+
}
|
|
145
|
+
async sign(options) {
|
|
146
|
+
const ppks = options.allowCredentials?.passwordProtectedKey ?? [];
|
|
147
|
+
if (ppks.length === 0) {
|
|
148
|
+
throw namedError('InvalidArgument', 'challenge has no password-protected credentials');
|
|
149
|
+
}
|
|
150
|
+
const ids = ppks.map((p) => p.id);
|
|
151
|
+
if (!sameIds(this.signerIds, ids)) {
|
|
152
|
+
await this.reset();
|
|
153
|
+
this.signers = ppks.map((p) => new PasswordProtectedKeySigner({ credId: p.id, username: this.username, wrappedKey: p.encryptedPrivateKey }, this.ttlMs));
|
|
154
|
+
this.signerIds = ids;
|
|
155
|
+
}
|
|
156
|
+
// The current signer's handle may have been terminated by its own TTL timer. Drop the stale
|
|
157
|
+
// reference so the prompt loop re-unlocks, rather than calling sign() on a locked handle.
|
|
158
|
+
if (this.current && !this.current.isUnlocked()) {
|
|
159
|
+
this.current = null;
|
|
160
|
+
}
|
|
161
|
+
let lastError;
|
|
162
|
+
while (!this.current) {
|
|
163
|
+
const { password } = await this.prompt({ lastError });
|
|
164
|
+
lastError = undefined;
|
|
165
|
+
for (const signer of this.signers) {
|
|
166
|
+
try {
|
|
167
|
+
await signer.unlock(password);
|
|
168
|
+
this.current = signer;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
if (err instanceof Error && err.name === 'UnlockFailed') {
|
|
173
|
+
lastError = err;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return this.current.sign(options);
|
|
181
|
+
}
|
|
182
|
+
async reset() {
|
|
183
|
+
const c = this.current;
|
|
184
|
+
this.current = null;
|
|
185
|
+
this.signers = [];
|
|
186
|
+
this.signerIds = [];
|
|
187
|
+
if (c)
|
|
188
|
+
await c.lock();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const sameIds = (a, b) => {
|
|
192
|
+
if (a.length !== b.length)
|
|
193
|
+
return false;
|
|
194
|
+
const seen = new Set(a);
|
|
195
|
+
return b.every((id) => seen.has(id));
|
|
196
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { CreateCredentialBody, RecoverBody } from '@dfns/sdk/types/auth';
|
|
2
|
+
export type RecoveryKeyAttestation = Omit<Extract<CreateCredentialBody, {
|
|
3
|
+
credentialKind: 'RecoveryKey';
|
|
4
|
+
}>, 'challengeIdentifier' | 'credentialName'>;
|
|
5
|
+
export type RecoveryKeyAssertion = RecoverBody['recovery'];
|
|
6
|
+
export declare const randomRecoveryCode: () => string;
|
|
7
|
+
export declare class RecoveryKeySigner {
|
|
8
|
+
private readonly keyData;
|
|
9
|
+
constructor(keyData: {
|
|
10
|
+
username: string;
|
|
11
|
+
credId: string;
|
|
12
|
+
wrappedKey: string;
|
|
13
|
+
});
|
|
14
|
+
static create(username: string, options: {
|
|
15
|
+
challenge: string;
|
|
16
|
+
}): Promise<{
|
|
17
|
+
attestation: RecoveryKeyAttestation;
|
|
18
|
+
recoveryCode: string;
|
|
19
|
+
}>;
|
|
20
|
+
sign(newCredentials: RecoverBody['newCredentials'], recoveryCode: string): Promise<RecoveryKeyAssertion>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { fromBase64Url, toBase64Url } from '@dfns/sdk/utils';
|
|
3
|
+
import { IsolatedKeyHandle } from '../worker/handle';
|
|
4
|
+
import { namedError } from '../worker/protocol';
|
|
5
|
+
const RECOVERY_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
6
|
+
export const randomRecoveryCode = () => {
|
|
7
|
+
const uuid1 = crypto.randomUUID().replace(/-/g, '');
|
|
8
|
+
const uuid2 = crypto.randomUUID().replace(/-/g, '');
|
|
9
|
+
let out = '';
|
|
10
|
+
for (let i = 0; i < uuid1.length; ++i) {
|
|
11
|
+
const idx = parseInt(uuid1[i], 16) + (parseInt(uuid2[i]) < 8 ? 0 : 16);
|
|
12
|
+
out += RECOVERY_CODE_ALPHABET[idx];
|
|
13
|
+
}
|
|
14
|
+
return `D1-${out.substring(0, 6)}-${out.substring(6, 11)}-${out.substring(11, 16)}-${out.substring(16, 21)}-${out.substring(21, 26)}-${out.substring(26)}`;
|
|
15
|
+
};
|
|
16
|
+
const deriveSaltFromUsername = async (username) => {
|
|
17
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(username.toLowerCase().trim()));
|
|
18
|
+
return new Uint8Array(digest);
|
|
19
|
+
};
|
|
20
|
+
const spawnWorker = () => {
|
|
21
|
+
const worker = new Worker(new URL('../worker/dedicatedWorker', import.meta.url), { type: 'module' });
|
|
22
|
+
return new IsolatedKeyHandle(worker);
|
|
23
|
+
};
|
|
24
|
+
export class RecoveryKeySigner {
|
|
25
|
+
constructor(keyData) {
|
|
26
|
+
this.keyData = keyData;
|
|
27
|
+
}
|
|
28
|
+
static async create(username, options) {
|
|
29
|
+
const handle = spawnWorker();
|
|
30
|
+
try {
|
|
31
|
+
const recoveryCode = randomRecoveryCode();
|
|
32
|
+
const salt = await deriveSaltFromUsername(username);
|
|
33
|
+
const { publicKey, credId, wrappedKey } = await handle.create({ password: recoveryCode, salt });
|
|
34
|
+
if (!wrappedKey) {
|
|
35
|
+
throw namedError('InvalidArgument', 'IsolatedKey.create did not return a wrappedKey despite a password being provided');
|
|
36
|
+
}
|
|
37
|
+
const clientData = JSON.stringify({ type: 'key.create', challenge: options.challenge });
|
|
38
|
+
const clientDataHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientData));
|
|
39
|
+
const fingerprint = JSON.stringify({
|
|
40
|
+
clientDataHash: Buffer.from(clientDataHash).toString('hex'),
|
|
41
|
+
publicKey,
|
|
42
|
+
});
|
|
43
|
+
const { signature: sigB64Url } = await handle.sign(fingerprint);
|
|
44
|
+
const signatureHex = fromBase64Url(sigB64Url).toString('hex');
|
|
45
|
+
const attestationData = JSON.stringify({
|
|
46
|
+
algorithm: 'SHA256',
|
|
47
|
+
publicKey,
|
|
48
|
+
signature: signatureHex,
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
attestation: {
|
|
52
|
+
credentialKind: 'RecoveryKey',
|
|
53
|
+
credentialInfo: {
|
|
54
|
+
credId,
|
|
55
|
+
clientData: toBase64Url(Buffer.from(clientData)),
|
|
56
|
+
attestationData: toBase64Url(Buffer.from(attestationData)),
|
|
57
|
+
},
|
|
58
|
+
encryptedPrivateKey: wrappedKey,
|
|
59
|
+
},
|
|
60
|
+
recoveryCode,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await handle.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async sign(newCredentials, recoveryCode) {
|
|
68
|
+
const { credId, username, wrappedKey } = this.keyData;
|
|
69
|
+
const challenge = toBase64Url(Buffer.from(JSON.stringify(newCredentials)));
|
|
70
|
+
const clientData = JSON.stringify({ challenge, type: 'key.get' });
|
|
71
|
+
const handle = spawnWorker();
|
|
72
|
+
try {
|
|
73
|
+
const salt = await deriveSaltFromUsername(username);
|
|
74
|
+
await handle.unlock({ credId, wrappedKey, password: recoveryCode, salt });
|
|
75
|
+
const { signature } = await handle.sign(clientData);
|
|
76
|
+
return {
|
|
77
|
+
kind: 'RecoveryKey',
|
|
78
|
+
credentialAssertion: {
|
|
79
|
+
credId,
|
|
80
|
+
clientData: toBase64Url(Buffer.from(clientData)),
|
|
81
|
+
signature,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await handle.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CredentialSigner, KeyAssertion } from '@dfns/sdk';
|
|
2
|
+
import { OidcLoginCredentialsBody } from '@dfns/sdk/types/auth';
|
|
3
|
+
export type SessionKeyAttestation = Omit<Extract<OidcLoginCredentialsBody, {
|
|
4
|
+
credentialKind: 'SessionKey';
|
|
5
|
+
}>, 'challengeIdentifier' | 'credentialName'>;
|
|
6
|
+
export declare class SessionKeySigner implements CredentialSigner<KeyAssertion> {
|
|
7
|
+
private readonly handle;
|
|
8
|
+
private readonly publicKey?;
|
|
9
|
+
private constructor();
|
|
10
|
+
static create(): Promise<{
|
|
11
|
+
signer: SessionKeySigner;
|
|
12
|
+
credId: string;
|
|
13
|
+
}>;
|
|
14
|
+
attest(options: {
|
|
15
|
+
challenge: string;
|
|
16
|
+
}): Promise<SessionKeyAttestation>;
|
|
17
|
+
sign(options: {
|
|
18
|
+
challenge: string;
|
|
19
|
+
allowCredentials?: {
|
|
20
|
+
key?: {
|
|
21
|
+
id: string;
|
|
22
|
+
}[];
|
|
23
|
+
};
|
|
24
|
+
}): Promise<KeyAssertion>;
|
|
25
|
+
static fromExistingWorker(): Promise<SessionKeySigner | null>;
|
|
26
|
+
static destroy(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { fromBase64Url, toBase64Url } from '@dfns/sdk/utils';
|
|
3
|
+
import { IsolatedKeyHandle } from '../worker/handle';
|
|
4
|
+
import { namedError } from '../worker/protocol';
|
|
5
|
+
let cachedHandle = null;
|
|
6
|
+
let keepAliveTimer = null;
|
|
7
|
+
// Chrome idle-terminates SharedWorkers heuristically even with a connected port. A periodic no-op
|
|
8
|
+
// (status) ping registers as activity and keeps the worker alive long enough to cover a typical
|
|
9
|
+
// IdP access-token TTL. Not a guarantee — memory pressure or device sleep can still kill it.
|
|
10
|
+
const KEEP_ALIVE_INTERVAL_MS = 30000;
|
|
11
|
+
const spawnSharedWorker = () => {
|
|
12
|
+
if (!cachedHandle) {
|
|
13
|
+
const worker = new SharedWorker(new URL('../worker/sharedWorker', import.meta.url), { type: 'module' });
|
|
14
|
+
const handle = new IsolatedKeyHandle(worker);
|
|
15
|
+
cachedHandle = handle;
|
|
16
|
+
const myTimer = setInterval(() => {
|
|
17
|
+
void handle.status().catch(() => {
|
|
18
|
+
// Worker is dead (crash, idle-kill, dispatch timeout). Always stop OUR timer so we don't
|
|
19
|
+
// leak intervals, but only null the module-global cache/timer if they still identify this
|
|
20
|
+
// pair — a concurrent destroy()+create() may have already swapped in a newer handle+timer.
|
|
21
|
+
clearInterval(myTimer);
|
|
22
|
+
if (cachedHandle === handle)
|
|
23
|
+
cachedHandle = null;
|
|
24
|
+
if (keepAliveTimer === myTimer)
|
|
25
|
+
keepAliveTimer = null;
|
|
26
|
+
});
|
|
27
|
+
}, KEEP_ALIVE_INTERVAL_MS);
|
|
28
|
+
keepAliveTimer = myTimer;
|
|
29
|
+
}
|
|
30
|
+
return cachedHandle;
|
|
31
|
+
};
|
|
32
|
+
export class SessionKeySigner {
|
|
33
|
+
constructor(handle, publicKey) {
|
|
34
|
+
this.handle = handle;
|
|
35
|
+
this.publicKey = publicKey;
|
|
36
|
+
}
|
|
37
|
+
static async create() {
|
|
38
|
+
const handle = spawnSharedWorker();
|
|
39
|
+
const { publicKey, credId } = await handle.create();
|
|
40
|
+
return { signer: new SessionKeySigner(handle, publicKey), credId };
|
|
41
|
+
}
|
|
42
|
+
async attest(options) {
|
|
43
|
+
if (!this.publicKey) {
|
|
44
|
+
throw namedError('InvalidOperation', 'SessionKeySigner.attest() requires a freshly created signer; rehydrated signers cannot attest.');
|
|
45
|
+
}
|
|
46
|
+
const clientData = JSON.stringify({ type: 'key.create', challenge: options.challenge });
|
|
47
|
+
const clientDataHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientData));
|
|
48
|
+
const fingerprint = JSON.stringify({
|
|
49
|
+
clientDataHash: Buffer.from(clientDataHash).toString('hex'),
|
|
50
|
+
publicKey: this.publicKey,
|
|
51
|
+
});
|
|
52
|
+
const { credId, signature: sigB64Url } = await this.handle.sign(fingerprint);
|
|
53
|
+
const attestationData = JSON.stringify({
|
|
54
|
+
algorithm: 'SHA256',
|
|
55
|
+
publicKey: this.publicKey,
|
|
56
|
+
signature: fromBase64Url(sigB64Url).toString('hex'),
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
credentialKind: 'SessionKey',
|
|
60
|
+
credentialInfo: {
|
|
61
|
+
credId,
|
|
62
|
+
clientData: toBase64Url(Buffer.from(clientData)),
|
|
63
|
+
attestationData: toBase64Url(Buffer.from(attestationData)),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async sign(options) {
|
|
68
|
+
const clientData = JSON.stringify({ challenge: options.challenge, type: 'key.get' });
|
|
69
|
+
const { credId, signature } = await this.handle.sign(clientData);
|
|
70
|
+
const allowedIds = options.allowCredentials?.key?.map((c) => c.id) ?? [];
|
|
71
|
+
if (!allowedIds.includes(credId)) {
|
|
72
|
+
throw namedError('InvalidArgument', `${credId} does not match allowed key credentials: ${allowedIds.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
kind: 'Key',
|
|
76
|
+
credentialAssertion: {
|
|
77
|
+
credId,
|
|
78
|
+
clientData: toBase64Url(Buffer.from(clientData)),
|
|
79
|
+
signature,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
static async fromExistingWorker() {
|
|
84
|
+
const handle = spawnSharedWorker();
|
|
85
|
+
const { hasKey } = await handle.status();
|
|
86
|
+
if (!hasKey)
|
|
87
|
+
return null;
|
|
88
|
+
return new SessionKeySigner(handle);
|
|
89
|
+
}
|
|
90
|
+
// SharedWorker persists across tabs and does not auto-terminate; on logout we need to flush
|
|
91
|
+
// its key material. Closes the worker and clears the module-level cache so a subsequent login
|
|
92
|
+
// spawns a fresh one.
|
|
93
|
+
static async destroy() {
|
|
94
|
+
if (keepAliveTimer) {
|
|
95
|
+
clearInterval(keepAliveTimer);
|
|
96
|
+
keepAliveTimer = null;
|
|
97
|
+
}
|
|
98
|
+
const h = cachedHandle;
|
|
99
|
+
cachedHandle = null;
|
|
100
|
+
if (h)
|
|
101
|
+
await h.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
package/signers/webauthn.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { CredentialSigner, CredentialStore, Fido2Assertion, Fido2Attestation
|
|
2
|
-
import { CreateCredentialChallengeResponse, CreateRegistrationChallengeResponse } from '@dfns/sdk/generated/auth';
|
|
1
|
+
import { CredentialSigner, CredentialStore, Fido2Assertion, Fido2Attestation } from '@dfns/sdk';
|
|
3
2
|
export declare const DEFAULT_WAIT_TIMEOUT = 60000;
|
|
4
3
|
interface WebAuthnSignerConf {
|
|
5
4
|
/**
|
|
@@ -23,9 +22,30 @@ interface WebAuthnSignerConf {
|
|
|
23
22
|
export declare class WebAuthnSigner implements CredentialSigner<Fido2Assertion>, CredentialStore<Fido2Attestation> {
|
|
24
23
|
private conf;
|
|
25
24
|
constructor(conf: WebAuthnSignerConf);
|
|
26
|
-
sign(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
sign(options: {
|
|
26
|
+
challenge: string;
|
|
27
|
+
allowCredentials?: {
|
|
28
|
+
webauthn?: {
|
|
29
|
+
id: string;
|
|
30
|
+
type: PublicKeyCredentialType;
|
|
31
|
+
}[];
|
|
32
|
+
};
|
|
33
|
+
userVerification?: UserVerificationRequirement;
|
|
34
|
+
}): Promise<Fido2Assertion>;
|
|
35
|
+
create(options: {
|
|
36
|
+
challenge: string;
|
|
37
|
+
pubKeyCredParams: PublicKeyCredentialParameters[];
|
|
38
|
+
user: {
|
|
39
|
+
displayName: string;
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
};
|
|
43
|
+
attestation: AttestationConveyancePreference;
|
|
44
|
+
excludeCredentials: {
|
|
45
|
+
id: string;
|
|
46
|
+
type: PublicKeyCredentialType;
|
|
47
|
+
}[];
|
|
48
|
+
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
|
49
|
+
}): Promise<Fido2Attestation>;
|
|
30
50
|
}
|
|
31
51
|
export {};
|
package/signers/webauthn.js
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
const buffer_1 = require("buffer");
|
|
7
|
-
exports.DEFAULT_WAIT_TIMEOUT = 60000;
|
|
8
|
-
class WebAuthnSigner {
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { DfnsError } from '@dfns/sdk';
|
|
3
|
+
import { fromBase64Url, toBase64Url } from '@dfns/sdk/utils';
|
|
4
|
+
export const DEFAULT_WAIT_TIMEOUT = 60000;
|
|
5
|
+
export class WebAuthnSigner {
|
|
9
6
|
constructor(conf) {
|
|
10
7
|
this.conf = conf;
|
|
11
8
|
if (!this.conf?.relyingParty?.id || !this.conf?.relyingParty?.name) {
|
|
12
|
-
throw new
|
|
9
|
+
throw new DfnsError(-1, `Relying party ID and name must be specified in the WebauthnSigner initializer`);
|
|
13
10
|
}
|
|
14
11
|
}
|
|
15
|
-
async sign(
|
|
12
|
+
async sign(options) {
|
|
16
13
|
const response = await navigator.credentials.get({
|
|
17
14
|
publicKey: {
|
|
18
|
-
challenge:
|
|
19
|
-
allowCredentials:
|
|
20
|
-
id:
|
|
15
|
+
challenge: Buffer.from(options.challenge),
|
|
16
|
+
allowCredentials: options.allowCredentials?.webauthn?.map(({ id, type }) => ({
|
|
17
|
+
id: fromBase64Url(id).buffer,
|
|
21
18
|
type,
|
|
22
19
|
})),
|
|
23
20
|
rpId: this.conf.relyingParty.id,
|
|
24
|
-
userVerification:
|
|
25
|
-
timeout: this.conf.timeout ??
|
|
21
|
+
userVerification: options.userVerification,
|
|
22
|
+
timeout: this.conf.timeout ?? DEFAULT_WAIT_TIMEOUT,
|
|
26
23
|
},
|
|
27
24
|
});
|
|
28
25
|
if (response === null) {
|
|
29
|
-
throw new
|
|
26
|
+
throw new DfnsError(-1, 'Failed to sign with WebAuthn credential');
|
|
30
27
|
}
|
|
31
28
|
const credential = response;
|
|
32
29
|
const assertion = credential.response;
|
|
@@ -34,36 +31,35 @@ class WebAuthnSigner {
|
|
|
34
31
|
kind: 'Fido2',
|
|
35
32
|
credentialAssertion: {
|
|
36
33
|
credId: credential.id,
|
|
37
|
-
clientData:
|
|
38
|
-
authenticatorData:
|
|
39
|
-
signature:
|
|
40
|
-
userHandle: assertion.userHandle ?
|
|
34
|
+
clientData: toBase64Url(Buffer.from(assertion.clientDataJSON)),
|
|
35
|
+
authenticatorData: toBase64Url(Buffer.from(assertion.authenticatorData)),
|
|
36
|
+
signature: toBase64Url(Buffer.from(assertion.signature)),
|
|
37
|
+
userHandle: assertion.userHandle ? toBase64Url(Buffer.from(assertion.userHandle)) : undefined,
|
|
41
38
|
},
|
|
42
39
|
};
|
|
43
40
|
}
|
|
44
|
-
async create(
|
|
45
|
-
const
|
|
41
|
+
async create(options) {
|
|
42
|
+
const response = await navigator.credentials.create({
|
|
46
43
|
publicKey: {
|
|
47
|
-
challenge:
|
|
48
|
-
pubKeyCredParams:
|
|
44
|
+
challenge: Buffer.from(options.challenge),
|
|
45
|
+
pubKeyCredParams: options.pubKeyCredParams,
|
|
49
46
|
rp: this.conf.relyingParty,
|
|
50
47
|
user: {
|
|
51
|
-
displayName:
|
|
52
|
-
id:
|
|
53
|
-
name:
|
|
48
|
+
displayName: options.user.displayName,
|
|
49
|
+
id: Buffer.from(options.user.id),
|
|
50
|
+
name: options.user.name,
|
|
54
51
|
},
|
|
55
|
-
attestation:
|
|
56
|
-
excludeCredentials:
|
|
57
|
-
id:
|
|
52
|
+
attestation: options.attestation,
|
|
53
|
+
excludeCredentials: options.excludeCredentials.map(({ id, type }) => ({
|
|
54
|
+
id: fromBase64Url(id).buffer,
|
|
58
55
|
type,
|
|
59
56
|
})),
|
|
60
|
-
authenticatorSelection:
|
|
61
|
-
timeout: this.conf.timeout ??
|
|
57
|
+
authenticatorSelection: options.authenticatorSelection,
|
|
58
|
+
timeout: this.conf.timeout ?? DEFAULT_WAIT_TIMEOUT,
|
|
62
59
|
},
|
|
63
|
-
};
|
|
64
|
-
const response = await navigator.credentials.create(options);
|
|
60
|
+
});
|
|
65
61
|
if (response === null) {
|
|
66
|
-
throw new
|
|
62
|
+
throw new DfnsError(-1, `Failed to create and sign with WebAuthn credential`);
|
|
67
63
|
}
|
|
68
64
|
const credential = response;
|
|
69
65
|
const attestation = credential.response;
|
|
@@ -71,10 +67,9 @@ class WebAuthnSigner {
|
|
|
71
67
|
credentialKind: 'Fido2',
|
|
72
68
|
credentialInfo: {
|
|
73
69
|
credId: credential.id,
|
|
74
|
-
attestationData:
|
|
75
|
-
clientData:
|
|
70
|
+
attestationData: toBase64Url(Buffer.from(attestation.attestationObject)),
|
|
71
|
+
clientData: toBase64Url(Buffer.from(attestation.clientDataJSON)),
|
|
76
72
|
},
|
|
77
73
|
};
|
|
78
74
|
}
|
|
79
75
|
}
|
|
80
|
-
exports.WebAuthnSigner = WebAuthnSigner;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { IsolatedKeyRequest, IsolatedKeyResponse } from './protocol';
|
|
2
|
+
export declare const createDispatcher: (onClose: () => void) => (req: IsolatedKeyRequest) => Promise<IsolatedKeyResponse>;
|
|
3
|
+
export declare const replyOverPort: (handle: (req: IsolatedKeyRequest) => Promise<IsolatedKeyResponse>, event: MessageEvent) => Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { toBase64Url } from '@dfns/sdk/utils';
|
|
3
|
+
import { IsolatedKey } from './isolatedKey';
|
|
4
|
+
import { namedError } from './protocol';
|
|
5
|
+
const toMessageError = (e) => e instanceof Error ? { name: e.name, message: e.message } : { message: String(e) };
|
|
6
|
+
export const createDispatcher = (onClose) => {
|
|
7
|
+
let isolated = null;
|
|
8
|
+
return async (req) => {
|
|
9
|
+
switch (req.kind) {
|
|
10
|
+
case 'create': {
|
|
11
|
+
const { isolated: made, publicKey, credId, wrappedKey, } = await IsolatedKey.create({
|
|
12
|
+
password: req.password,
|
|
13
|
+
salt: req.salt,
|
|
14
|
+
});
|
|
15
|
+
isolated = made;
|
|
16
|
+
return { kind: 'created', publicKey, credId, wrappedKey };
|
|
17
|
+
}
|
|
18
|
+
case 'unlock': {
|
|
19
|
+
isolated = await IsolatedKey.fromWrapped(req.credId, req.wrappedKey, req.password, req.salt);
|
|
20
|
+
return { kind: 'unlocked' };
|
|
21
|
+
}
|
|
22
|
+
case 'sign': {
|
|
23
|
+
if (!isolated)
|
|
24
|
+
throw namedError('InvalidOperation', 'IsolatedKey not initialized');
|
|
25
|
+
// Snapshot before awaiting: another message (SharedWorker: concurrent port) could reassign
|
|
26
|
+
// `isolated` while crypto.subtle.sign is in flight, letting us return a credId for one key
|
|
27
|
+
// and a signature from another.
|
|
28
|
+
const key = isolated;
|
|
29
|
+
const sig = await key.sign(req.message);
|
|
30
|
+
return { kind: 'signed', credId: key.credId, signature: toBase64Url(Buffer.from(sig)) };
|
|
31
|
+
}
|
|
32
|
+
case 'status': {
|
|
33
|
+
return isolated ? { kind: 'status', hasKey: true, credId: isolated.credId } : { kind: 'status', hasKey: false };
|
|
34
|
+
}
|
|
35
|
+
case 'close': {
|
|
36
|
+
isolated = null;
|
|
37
|
+
onClose();
|
|
38
|
+
return { kind: 'closed' };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export const replyOverPort = async (handle, event) => {
|
|
44
|
+
const reply = (envelope) => event.ports[0]?.postMessage(envelope);
|
|
45
|
+
try {
|
|
46
|
+
reply({ result: await handle(event.data) });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
reply({ error: toMessageError(error) });
|
|
50
|
+
}
|
|
51
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type WorkerLike = Worker | SharedWorker;
|
|
2
|
+
export declare class IsolatedKeyHandle {
|
|
3
|
+
private readonly worker;
|
|
4
|
+
private readonly timeoutMs;
|
|
5
|
+
private readonly inFlight;
|
|
6
|
+
private deadReason;
|
|
7
|
+
constructor(worker: WorkerLike, timeoutMs?: number);
|
|
8
|
+
create(args?: {
|
|
9
|
+
password?: string;
|
|
10
|
+
salt?: Uint8Array<ArrayBuffer>;
|
|
11
|
+
}): Promise<{
|
|
12
|
+
kind: "created";
|
|
13
|
+
publicKey: string;
|
|
14
|
+
credId: string;
|
|
15
|
+
wrappedKey?: string;
|
|
16
|
+
}>;
|
|
17
|
+
unlock(args: {
|
|
18
|
+
credId: string;
|
|
19
|
+
wrappedKey: string;
|
|
20
|
+
password: string;
|
|
21
|
+
salt: Uint8Array<ArrayBuffer>;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
kind: "unlocked";
|
|
24
|
+
}>;
|
|
25
|
+
sign(message: string): Promise<{
|
|
26
|
+
kind: "signed";
|
|
27
|
+
credId: string;
|
|
28
|
+
signature: string;
|
|
29
|
+
}>;
|
|
30
|
+
status(): Promise<{
|
|
31
|
+
kind: "status";
|
|
32
|
+
hasKey: boolean;
|
|
33
|
+
credId?: string;
|
|
34
|
+
}>;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
terminate(): void;
|
|
37
|
+
private failAll;
|
|
38
|
+
private postMessage;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
package/worker/handle.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { namedError } from './protocol';
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
3
|
+
const targetPort = (worker) => {
|
|
4
|
+
return 'port' in worker ? worker.port : worker;
|
|
5
|
+
};
|
|
6
|
+
export class IsolatedKeyHandle {
|
|
7
|
+
constructor(worker, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
8
|
+
this.worker = worker;
|
|
9
|
+
this.timeoutMs = timeoutMs;
|
|
10
|
+
// In-flight requests' reject functions. Populated per postMessage, drained by failAll on crash.
|
|
11
|
+
this.inFlight = new Set();
|
|
12
|
+
// Once a crash is observed, future requests short-circuit instead of queueing onto a dead port.
|
|
13
|
+
this.deadReason = null;
|
|
14
|
+
// Dedicated Workers fire .onerror reliably on uncaught exceptions. SharedWorker's .onerror is
|
|
15
|
+
// spottier across browsers but still worth attaching — on Chrome/Safari it fires on worker-side
|
|
16
|
+
// uncaught errors. The per-request timeout is the backstop for silent kills.
|
|
17
|
+
this.worker.onerror = (event) => {
|
|
18
|
+
const message = event.message || 'worker crashed';
|
|
19
|
+
this.failAll(namedError('WorkerCrashed', message));
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
create(args = {}) {
|
|
23
|
+
return this.postMessage({ kind: 'create', ...args });
|
|
24
|
+
}
|
|
25
|
+
unlock(args) {
|
|
26
|
+
return this.postMessage({ kind: 'unlock', ...args });
|
|
27
|
+
}
|
|
28
|
+
sign(message) {
|
|
29
|
+
return this.postMessage({ kind: 'sign', message });
|
|
30
|
+
}
|
|
31
|
+
status() {
|
|
32
|
+
return this.postMessage({ kind: 'status' });
|
|
33
|
+
}
|
|
34
|
+
async close() {
|
|
35
|
+
try {
|
|
36
|
+
await this.postMessage({ kind: 'close' });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// worker may already be gone or racing self.close() — close() is best-effort for the ack
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
// Dedicated workers hold the key material in a tab-local process; a timeout or ignored ack
|
|
43
|
+
// above would leave that process alive with the unlocked key. terminate() is a hard kill
|
|
44
|
+
// and safe to re-call.
|
|
45
|
+
if (!('port' in this.worker)) {
|
|
46
|
+
this.worker.terminate();
|
|
47
|
+
this.failAll(namedError('WorkerCrashed', 'worker terminated after close'));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
terminate() {
|
|
52
|
+
if ('port' in this.worker) {
|
|
53
|
+
throw namedError('InvalidOperation', 'terminate() is only supported for dedicated workers');
|
|
54
|
+
}
|
|
55
|
+
this.worker.terminate();
|
|
56
|
+
// A manual terminate kills everything in-flight too; drain them so callers see a WorkerCrashed
|
|
57
|
+
// rejection rather than a hung promise.
|
|
58
|
+
this.failAll(namedError('WorkerCrashed', 'worker terminated'));
|
|
59
|
+
}
|
|
60
|
+
failAll(err) {
|
|
61
|
+
// Preserve the ORIGINAL crash reason; a later cascading failure must not overwrite it.
|
|
62
|
+
if (!this.deadReason)
|
|
63
|
+
this.deadReason = err;
|
|
64
|
+
for (const cleanup of this.inFlight)
|
|
65
|
+
cleanup(err);
|
|
66
|
+
this.inFlight.clear();
|
|
67
|
+
}
|
|
68
|
+
postMessage(msg) {
|
|
69
|
+
if (this.deadReason)
|
|
70
|
+
return Promise.reject(this.deadReason);
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const channel = new MessageChannel();
|
|
73
|
+
let settled = false;
|
|
74
|
+
const settle = (fn) => {
|
|
75
|
+
if (settled)
|
|
76
|
+
return;
|
|
77
|
+
settled = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
this.inFlight.delete(crashCleanup);
|
|
80
|
+
channel.port1.close();
|
|
81
|
+
fn();
|
|
82
|
+
};
|
|
83
|
+
const crashCleanup = (err) => settle(() => reject(err));
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
settle(() => {
|
|
86
|
+
// Treat a dispatch timeout as evidence the worker is dead (silent kill, hung on crypto,
|
|
87
|
+
// etc). Poison the handle so future requests fail immediately instead of each waiting out
|
|
88
|
+
// another full timeout, and reject every other in-flight request on the same dead port.
|
|
89
|
+
const err = namedError('DispatchFailed', `worker did not respond within ${this.timeoutMs}ms`);
|
|
90
|
+
this.failAll(err);
|
|
91
|
+
reject(err);
|
|
92
|
+
});
|
|
93
|
+
}, this.timeoutMs);
|
|
94
|
+
this.inFlight.add(crashCleanup);
|
|
95
|
+
channel.port1.onmessage = ({ data }) => {
|
|
96
|
+
settle(() => {
|
|
97
|
+
if ('error' in data) {
|
|
98
|
+
const err = new Error(data.error.message);
|
|
99
|
+
if (data.error.name)
|
|
100
|
+
err.name = data.error.name;
|
|
101
|
+
reject(err);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
resolve(data.result);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
channel.port1.onmessageerror = () => {
|
|
109
|
+
settle(() => reject(namedError('DispatchFailed', 'worker response could not be deserialized')));
|
|
110
|
+
};
|
|
111
|
+
targetPort(this.worker).postMessage(msg, [channel.port2]);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare class IsolatedKey {
|
|
2
|
+
private readonly privateKey;
|
|
3
|
+
readonly credId: string;
|
|
4
|
+
private constructor();
|
|
5
|
+
static create(opts: {
|
|
6
|
+
password?: string;
|
|
7
|
+
salt?: Uint8Array<ArrayBuffer>;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
isolated: IsolatedKey;
|
|
10
|
+
publicKey: string;
|
|
11
|
+
credId: string;
|
|
12
|
+
wrappedKey?: string;
|
|
13
|
+
}>;
|
|
14
|
+
static fromWrapped(credId: string, wrappedKey: string, password: string, salt: Uint8Array<ArrayBuffer>): Promise<IsolatedKey>;
|
|
15
|
+
sign(message: string): Promise<Uint8Array>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Buffer } from 'buffer';
|
|
2
|
+
import { fromBase64, rawSignatureToAns1, toBase64, toBase64Url } from '@dfns/sdk/utils';
|
|
3
|
+
import { namedError } from './protocol';
|
|
4
|
+
const deriveWrappingKey = async (password, salt) => {
|
|
5
|
+
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), { name: 'PBKDF2' }, false, [
|
|
6
|
+
'deriveKey',
|
|
7
|
+
]);
|
|
8
|
+
return crypto.subtle.deriveKey({ hash: 'SHA-256', iterations: 100000, name: 'PBKDF2', salt }, material, { length: 256, name: 'AES-GCM' }, false, ['wrapKey', 'unwrapKey']);
|
|
9
|
+
};
|
|
10
|
+
const exportPublicKeySpki = (publicKey) => crypto.subtle.exportKey('spki', publicKey);
|
|
11
|
+
const exportPublicKeyAsPem = async (publicKey) => {
|
|
12
|
+
const buf = await exportPublicKeySpki(publicKey);
|
|
13
|
+
const b64 = toBase64(Buffer.from(buf));
|
|
14
|
+
return `-----BEGIN PUBLIC KEY-----\n${b64.match(/.{1,64}/g)?.join('\n')}\n-----END PUBLIC KEY-----`;
|
|
15
|
+
};
|
|
16
|
+
const computeCredId = async (publicKey) => {
|
|
17
|
+
const spki = await exportPublicKeySpki(publicKey);
|
|
18
|
+
const hash = await crypto.subtle.digest('SHA-256', spki);
|
|
19
|
+
return toBase64Url(Buffer.from(hash));
|
|
20
|
+
};
|
|
21
|
+
export class IsolatedKey {
|
|
22
|
+
constructor(privateKey, credId) {
|
|
23
|
+
this.privateKey = privateKey;
|
|
24
|
+
this.credId = credId;
|
|
25
|
+
}
|
|
26
|
+
static async create(opts) {
|
|
27
|
+
if (opts.password === undefined) {
|
|
28
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
|
|
29
|
+
const publicKey = await exportPublicKeyAsPem(pair.publicKey);
|
|
30
|
+
const credId = await computeCredId(pair.publicKey);
|
|
31
|
+
return { isolated: new IsolatedKey(pair.privateKey, credId), publicKey, credId };
|
|
32
|
+
}
|
|
33
|
+
if (!opts.salt) {
|
|
34
|
+
throw namedError('InvalidArgument', 'salt is required when password is provided');
|
|
35
|
+
}
|
|
36
|
+
const extractablePair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
|
37
|
+
'sign',
|
|
38
|
+
'verify',
|
|
39
|
+
]);
|
|
40
|
+
const publicKey = await exportPublicKeyAsPem(extractablePair.publicKey);
|
|
41
|
+
const credId = await computeCredId(extractablePair.publicKey);
|
|
42
|
+
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
43
|
+
const wrappingKey = await deriveWrappingKey(opts.password, opts.salt);
|
|
44
|
+
const wrappedBuf = await crypto.subtle.wrapKey('pkcs8', extractablePair.privateKey, wrappingKey, {
|
|
45
|
+
iv,
|
|
46
|
+
name: 'AES-GCM',
|
|
47
|
+
});
|
|
48
|
+
const nonExtractable = await crypto.subtle.unwrapKey('pkcs8', wrappedBuf, wrappingKey, { iv, name: 'AES-GCM' }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
49
|
+
const envelope = {
|
|
50
|
+
iv: toBase64(Buffer.from(iv)),
|
|
51
|
+
key: toBase64(Buffer.from(wrappedBuf)),
|
|
52
|
+
};
|
|
53
|
+
const wrappedKey = toBase64(JSON.stringify(envelope));
|
|
54
|
+
return { isolated: new IsolatedKey(nonExtractable, credId), publicKey, credId, wrappedKey };
|
|
55
|
+
}
|
|
56
|
+
static async fromWrapped(credId, wrappedKey, password, salt) {
|
|
57
|
+
let envelope;
|
|
58
|
+
try {
|
|
59
|
+
envelope = JSON.parse(fromBase64(wrappedKey).toString('utf8'));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw namedError('InvalidArgument', 'stored key envelope could not be parsed');
|
|
63
|
+
}
|
|
64
|
+
if (!envelope ||
|
|
65
|
+
typeof envelope !== 'object' ||
|
|
66
|
+
typeof envelope.iv !== 'string' ||
|
|
67
|
+
typeof envelope.key !== 'string') {
|
|
68
|
+
throw namedError('InvalidArgument', 'stored key envelope is missing iv or key');
|
|
69
|
+
}
|
|
70
|
+
let wrappedBuf;
|
|
71
|
+
let iv;
|
|
72
|
+
try {
|
|
73
|
+
wrappedBuf = new Uint8Array(fromBase64(envelope.key));
|
|
74
|
+
iv = new Uint8Array(fromBase64(envelope.iv));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw namedError('InvalidArgument', 'stored key envelope has invalid base64 fields');
|
|
78
|
+
}
|
|
79
|
+
const wrappingKey = await deriveWrappingKey(password, salt);
|
|
80
|
+
try {
|
|
81
|
+
const privateKey = await crypto.subtle.unwrapKey('pkcs8', wrappedBuf, wrappingKey, { iv, name: 'AES-GCM' }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
|
|
82
|
+
return new IsolatedKey(privateKey, credId);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// AES-GCM auth tag didn't verify. Overwhelmingly the common cause is a wrong
|
|
86
|
+
// password/recovery code; the rare alternative is tampered or truncated ciphertext.
|
|
87
|
+
// Both surface as OperationError from Web Crypto — we don't try to distinguish.
|
|
88
|
+
if (err instanceof Error && err.name === 'OperationError') {
|
|
89
|
+
throw namedError('UnlockFailed', 'key could not be unlocked; check the unlock code');
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async sign(message) {
|
|
95
|
+
const raw = await crypto.subtle.sign({ hash: { name: 'SHA-256' }, name: 'ECDSA' }, this.privateKey, new TextEncoder().encode(message));
|
|
96
|
+
return rawSignatureToAns1(new Uint8Array(raw));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type IsolatedKeyRequest = {
|
|
2
|
+
kind: 'create';
|
|
3
|
+
password?: string;
|
|
4
|
+
salt?: Uint8Array<ArrayBuffer>;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'unlock';
|
|
7
|
+
credId: string;
|
|
8
|
+
password: string;
|
|
9
|
+
salt: Uint8Array<ArrayBuffer>;
|
|
10
|
+
wrappedKey: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'sign';
|
|
13
|
+
message: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'close';
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'status';
|
|
18
|
+
};
|
|
19
|
+
export type IsolatedKeyResponse = {
|
|
20
|
+
kind: 'created';
|
|
21
|
+
publicKey: string;
|
|
22
|
+
credId: string;
|
|
23
|
+
wrappedKey?: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'unlocked';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'signed';
|
|
28
|
+
credId: string;
|
|
29
|
+
signature: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'closed';
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'status';
|
|
34
|
+
hasKey: boolean;
|
|
35
|
+
credId?: string;
|
|
36
|
+
};
|
|
37
|
+
export type MessageError = {
|
|
38
|
+
name?: string;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
export type MessageEnvelope<T> = {
|
|
42
|
+
result: T;
|
|
43
|
+
} | {
|
|
44
|
+
error: MessageError;
|
|
45
|
+
};
|
|
46
|
+
export declare const namedError: (name: string, message: string) => Error;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Builds an Error whose `.name` is the given discriminator. Callers match on `.name` to classify
|
|
2
|
+
// failure categories (`UnlockFailed`, `InvalidArgument`, `InvalidOperation`, `WorkerCrashed`, `DispatchFailed`).
|
|
3
|
+
export const namedError = (name, message) => {
|
|
4
|
+
const e = new Error(message);
|
|
5
|
+
e.name = name;
|
|
6
|
+
return e;
|
|
7
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { createDispatcher, replyOverPort } from './dispatcher';
|
|
3
|
+
const scope = self;
|
|
4
|
+
const dispatch = createDispatcher(() => scope.close());
|
|
5
|
+
scope.onconnect = (e) => {
|
|
6
|
+
const port = e.ports[0];
|
|
7
|
+
port.onmessage = (msg) => {
|
|
8
|
+
void replyOverPort(dispatch, msg);
|
|
9
|
+
};
|
|
10
|
+
port.start();
|
|
11
|
+
};
|
package/signers/key.d.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { CredentialSigner, CredentialStore, KeyAssertion, KeyAttestation, UserActionChallenge, UserRegistrationChallenge } from '@dfns/sdk';
|
|
2
|
-
export declare class BrowserKeySigner implements CredentialSigner<KeyAssertion>, CredentialStore<KeyAttestation> {
|
|
3
|
-
private options;
|
|
4
|
-
constructor(options: {
|
|
5
|
-
credId?: string;
|
|
6
|
-
keyPair: CryptoKeyPair;
|
|
7
|
-
});
|
|
8
|
-
create(challenge: UserRegistrationChallenge): Promise<KeyAttestation>;
|
|
9
|
-
sign(challenge: Pick<UserActionChallenge, 'challenge' | 'allowCredentials'>): Promise<KeyAssertion>;
|
|
10
|
-
}
|
package/signers/key.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BrowserKeySigner = void 0;
|
|
4
|
-
const sdk_1 = require("@dfns/sdk");
|
|
5
|
-
const utils_1 = require("@dfns/sdk/utils");
|
|
6
|
-
const buffer_1 = require("buffer");
|
|
7
|
-
class BrowserKeySigner {
|
|
8
|
-
constructor(options) {
|
|
9
|
-
this.options = options;
|
|
10
|
-
}
|
|
11
|
-
async create(challenge) {
|
|
12
|
-
let credId = this.options.credId;
|
|
13
|
-
if (credId === undefined || credId === '') {
|
|
14
|
-
credId = (0, utils_1.toBase64Url)(buffer_1.Buffer.from((0, utils_1.generateRandom)(32)));
|
|
15
|
-
this.options.credId = credId;
|
|
16
|
-
}
|
|
17
|
-
const publicKeyPem = await (0, utils_1.exportPublicKeyInPemFormatBrowser)(this.options.keyPair);
|
|
18
|
-
const clientData = JSON.stringify({
|
|
19
|
-
type: 'key.create',
|
|
20
|
-
challenge: challenge.challenge,
|
|
21
|
-
});
|
|
22
|
-
const clientDataHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(clientData));
|
|
23
|
-
const clientDataHashHex = (0, utils_1.toHex)(clientDataHash);
|
|
24
|
-
const credInfoFingerprint = JSON.stringify({
|
|
25
|
-
clientDataHash: clientDataHashHex,
|
|
26
|
-
publicKey: publicKeyPem,
|
|
27
|
-
});
|
|
28
|
-
let rawSignature;
|
|
29
|
-
const algorithm = this.options.keyPair.privateKey.algorithm.name;
|
|
30
|
-
if (algorithm == 'ECDSA') {
|
|
31
|
-
rawSignature = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, this.options.keyPair.privateKey, new TextEncoder().encode(credInfoFingerprint));
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
throw new sdk_1.DfnsError(-1, `${algorithm} is not supported`);
|
|
35
|
-
}
|
|
36
|
-
const signature = (0, utils_1.rawSignatureToAns1)(new Uint8Array(rawSignature));
|
|
37
|
-
const attestationData = JSON.stringify({
|
|
38
|
-
publicKey: publicKeyPem,
|
|
39
|
-
signature: (0, utils_1.toHex)(signature.buffer),
|
|
40
|
-
});
|
|
41
|
-
return {
|
|
42
|
-
credentialKind: 'Key',
|
|
43
|
-
credentialInfo: {
|
|
44
|
-
credId,
|
|
45
|
-
clientData: (0, utils_1.toBase64Url)(clientData),
|
|
46
|
-
attestationData: (0, utils_1.toBase64Url)(attestationData),
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
async sign(challenge) {
|
|
51
|
-
const credId = this.options.credId;
|
|
52
|
-
if (credId === undefined || credId === '') {
|
|
53
|
-
throw new sdk_1.DfnsError(-1, 'credId is needed to sign');
|
|
54
|
-
}
|
|
55
|
-
if (challenge.allowCredentials) {
|
|
56
|
-
const allowedCredId = challenge.allowCredentials.key.map((cred) => cred.id);
|
|
57
|
-
if (!allowedCredId.includes(credId)) {
|
|
58
|
-
throw new sdk_1.DfnsError(-1, `${credId} does not match allowed credentials: ${allowedCredId}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const clientData = JSON.stringify({
|
|
62
|
-
type: 'key.get',
|
|
63
|
-
challenge: challenge.challenge,
|
|
64
|
-
});
|
|
65
|
-
let rawSignature;
|
|
66
|
-
const algorithm = this.options.keyPair.privateKey.algorithm.name;
|
|
67
|
-
if (algorithm == 'ECDSA') {
|
|
68
|
-
rawSignature = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, this.options.keyPair.privateKey, new TextEncoder().encode(clientData));
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
throw new sdk_1.DfnsError(-1, `${algorithm} is not supported`);
|
|
72
|
-
}
|
|
73
|
-
const signature = (0, utils_1.rawSignatureToAns1)(new Uint8Array(rawSignature));
|
|
74
|
-
return {
|
|
75
|
-
kind: 'Key',
|
|
76
|
-
credentialAssertion: {
|
|
77
|
-
credId: this.options.credId ?? '',
|
|
78
|
-
clientData: (0, utils_1.toBase64Url)(clientData),
|
|
79
|
-
signature: (0, utils_1.toBase64Url)(buffer_1.Buffer.from(signature)),
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
exports.BrowserKeySigner = BrowserKeySigner;
|