@oxy.so/core 1.16.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +30 -1
- package/dist/cjs/crypto/identityLink.js +18 -0
- package/dist/cjs/i18n/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/cjs/i18n/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/zh-CN.json +78 -3
- package/dist/cjs/index.js +9 -42
- package/dist/cjs/mixins/OxyServices.auth.js +110 -17
- package/dist/cjs/mixins/OxyServices.identity.js +128 -8
- package/dist/cjs/mixins/OxyServices.user.js +42 -7
- package/dist/cjs/utils/authWebUrl.js +4 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +30 -1
- package/dist/esm/crypto/identityLink.js +15 -0
- package/dist/esm/i18n/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/esm/i18n/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/zh-CN.json +78 -3
- package/dist/esm/index.js +4 -4
- package/dist/esm/mixins/OxyServices.auth.js +110 -17
- package/dist/esm/mixins/OxyServices.identity.js +128 -8
- package/dist/esm/mixins/OxyServices.user.js +42 -7
- package/dist/esm/utils/authWebUrl.js +4 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/crypto/identityLink.d.ts +8 -0
- package/dist/types/index.d.ts +2 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +68 -25
- package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
- package/dist/types/mixins/OxyServices.user.d.ts +29 -6
- package/dist/types/models/interfaces.d.ts +67 -13
- package/dist/types/server/securityHeaders.d.ts +3 -3
- package/dist/types/utils/authWebUrl.d.ts +4 -5
- package/package.json +3 -3
- package/src/HttpService.ts +35 -1
- package/src/__tests__/serviceIdentityWhenAnonymous.test.ts +159 -0
- package/src/crypto/__tests__/identityLink.test.ts +18 -0
- package/src/crypto/__tests__/identityProof.test.ts +6 -6
- package/src/crypto/identityLink.ts +18 -0
- package/src/i18n/locales/ar-SA.json +78 -3
- package/src/i18n/locales/ca-ES.json +78 -3
- package/src/i18n/locales/de-DE.json +78 -3
- package/src/i18n/locales/en-US.json +75 -28
- package/src/i18n/locales/es-ES.json +75 -28
- package/src/i18n/locales/fr-FR.json +78 -3
- package/src/i18n/locales/it-IT.json +78 -3
- package/src/i18n/locales/ja-JP.json +78 -3
- package/src/i18n/locales/ko-KR.json +78 -3
- package/src/i18n/locales/pt-PT.json +78 -3
- package/src/i18n/locales/zh-CN.json +78 -3
- package/src/index.ts +6 -48
- package/src/mixins/OxyServices.auth.ts +127 -24
- package/src/mixins/OxyServices.identity.ts +126 -8
- package/src/mixins/OxyServices.user.ts +51 -10
- package/src/mixins/__tests__/identityLink.test.ts +89 -0
- package/src/mixins/__tests__/notifications.read.test.ts +82 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
- package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
- package/src/models/interfaces.ts +67 -12
- package/src/server/securityHeaders.ts +3 -3
- package/src/utils/authWebUrl.ts +4 -5
- package/dist/cjs/crypto/identityMove.js +0 -142
- package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
- package/dist/esm/crypto/identityMove.js +0 -128
- package/dist/esm/crypto/webIdentityCarrier.js +0 -303
- package/dist/types/crypto/identityMove.d.ts +0 -75
- package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
- package/src/crypto/__tests__/identityMove.test.ts +0 -146
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
- package/src/crypto/identityMove.ts +0 -167
- package/src/crypto/webIdentityCarrier.ts +0 -394
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Web identity holder — the crypto of a browser holding a personal root (ADR 0024).
|
|
3
|
-
*
|
|
4
|
-
* An Oxy root is usually a BIP-39 mnemonic whose seed's first 32 bytes are the
|
|
5
|
-
* secp256k1 key (`recoveryPhrase.ts`), and for a few imported identities a raw
|
|
6
|
-
* private key with no phrase. Commons keeps it in the device keychain. This
|
|
7
|
-
* module lets a browser hold the SAME root without Oxy ever being able to use it:
|
|
8
|
-
*
|
|
9
|
-
secret ──AEAD(DEK)──▶ sealed secret (phrase entropy or raw key)
|
|
10
|
-
* DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
|
|
11
|
-
*
|
|
12
|
-
* The PRF output is produced inside the user's authenticator, behind user
|
|
13
|
-
* verification, and never leaves the page that asked for it. The envelope is
|
|
14
|
-
* therefore safe to store anywhere — auth.oxy.so's IndexedDB and the server
|
|
15
|
-
* copy alike — and opens only with a registered passkey or the recovery material.
|
|
16
|
-
*
|
|
17
|
-
* PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
|
|
18
|
-
* CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
|
|
19
|
-
* PRF output in; everything here is deterministic given its inputs, so it is
|
|
20
|
-
* identical on web, Node and React Native, and fully unit-testable.
|
|
21
|
-
*
|
|
22
|
-
* Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import './polyfill';
|
|
26
|
-
import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
|
27
|
-
import { wordlist } from '@scure/bip39/wordlists/english';
|
|
28
|
-
import { sha256 } from '@noble/hashes/sha256';
|
|
29
|
-
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
|
|
30
|
-
import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
31
|
-
import {
|
|
32
|
-
WEB_IDENTITY_ENVELOPE_VERSION,
|
|
33
|
-
type WebIdentityEnvelope,
|
|
34
|
-
type WebIdentitySecretKind,
|
|
35
|
-
type WebIdentityWrap,
|
|
36
|
-
} from '@oxy.so/contracts';
|
|
37
|
-
import { AEAD_KEY_LENGTH, decryptAead, encryptAead } from './aead';
|
|
38
|
-
import { hkdfSha256 } from './kdf';
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The PRF input every identity-carrier ceremony evaluates (`prf.eval.first`).
|
|
42
|
-
*
|
|
43
|
-
* Fixed rather than per-wrap: discoverable sign-in has no credential list to key
|
|
44
|
-
* `evalByCredential` by, and each passkey's output is already distinct. A future
|
|
45
|
-
* scheme change bumps the label — and with it every KEK — instead of mutating it.
|
|
46
|
-
*/
|
|
47
|
-
export const WEB_IDENTITY_PRF_INPUT: Uint8Array = sha256(utf8ToBytes('oxy-web-identity-carrier/prf/v1'));
|
|
48
|
-
|
|
49
|
-
/** HKDF salt of the key-encryption-key schedule. Shared by envelope versions 1 and 2. */
|
|
50
|
-
const KEK_SALT = utf8ToBytes('oxy-web-identity-carrier/kek/v1');
|
|
51
|
-
|
|
52
|
-
/** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). Nothing shorter or longer is usable. */
|
|
53
|
-
export const WEB_IDENTITY_PRF_OUTPUT_LENGTH = 32;
|
|
54
|
-
|
|
55
|
-
/** Why an envelope did not open. Every value is a normal outcome a UI can explain. */
|
|
56
|
-
export type WebIdentityUnlockFailure =
|
|
57
|
-
/** The passkey used is not one of the envelope's wraps. */
|
|
58
|
-
| 'unknown-credential'
|
|
59
|
-
/**
|
|
60
|
-
* The passkey IS listed but its PRF output does not open its wrap — a synced
|
|
61
|
-
* copy returning a different PRF value, or a provider that silently changed it.
|
|
62
|
-
* Recoverable with another passkey or the recovery material.
|
|
63
|
-
*/
|
|
64
|
-
| 'prf-mismatch'
|
|
65
|
-
/** The envelope itself is malformed or tampered with. */
|
|
66
|
-
| 'corrupt';
|
|
67
|
-
|
|
68
|
-
export class WebIdentityUnlockError extends Error {
|
|
69
|
-
constructor(readonly failure: WebIdentityUnlockFailure, message: string) {
|
|
70
|
-
super(message);
|
|
71
|
-
this.name = 'WebIdentityUnlockError';
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** A root that came from a BIP-39 phrase (12–24 words). Hold it for one operation, then wipe. */
|
|
76
|
-
export interface OpenedMnemonicIdentity {
|
|
77
|
-
kind: 'mnemonic';
|
|
78
|
-
mnemonic: string;
|
|
79
|
-
/** 32-byte secp256k1 private key, lowercase hex (`seed[0:32]`). */
|
|
80
|
-
privateKey: string;
|
|
81
|
-
/** Uncompressed SEC1 public key, lowercase hex (Oxy's canonical form). */
|
|
82
|
-
publicKey: string;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* A root that was imported as a raw private key and never had a phrase. It has
|
|
87
|
-
* no mnemonic, and nothing may ever derive or display one for it (ADR 0024 D5).
|
|
88
|
-
*/
|
|
89
|
-
export interface OpenedRawKeyIdentity {
|
|
90
|
-
kind: 'raw-key';
|
|
91
|
-
mnemonic: null;
|
|
92
|
-
privateKey: string;
|
|
93
|
-
publicKey: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The identity in usable form. Hold it for one operation, then wipe it. */
|
|
97
|
-
export type OpenedWebIdentity = OpenedMnemonicIdentity | OpenedRawKeyIdentity;
|
|
98
|
-
|
|
99
|
-
/** Material a person can recover a root from. */
|
|
100
|
-
export type WebIdentityRecoveryMaterial =
|
|
101
|
-
| { kind: 'mnemonic'; mnemonic: string }
|
|
102
|
-
| { kind: 'raw-key'; privateKey: string };
|
|
103
|
-
|
|
104
|
-
function aadFor(parts: Record<string, string | number | null>): Uint8Array {
|
|
105
|
-
return utf8ToBytes(JSON.stringify(parts));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function secretAad(publicKey: string, secretKind: WebIdentitySecretKind): Uint8Array {
|
|
109
|
-
return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'secret', secretKind, publicKey });
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function wrapAad(publicKey: string, credentialId: string, rpId: string): Uint8Array {
|
|
113
|
-
return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId, rpId });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
|
|
117
|
-
export function wipeBytes(bytes: Uint8Array): void {
|
|
118
|
-
bytes.fill(0);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Best-effort removal of secret strings from an opened identity. */
|
|
122
|
-
export function wipeOpenedIdentity(identity: OpenedWebIdentity): void {
|
|
123
|
-
(identity as { privateKey: string }).privateKey = '';
|
|
124
|
-
if (identity.kind === 'mnemonic') (identity as { mnemonic: string }).mnemonic = '';
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Normalize a typed phrase: trimmed, lowercase, single spaces. */
|
|
128
|
-
export function normalizeMnemonic(mnemonic: string): string {
|
|
129
|
-
return mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Derive the identity from a mnemonic exactly as Commons does
|
|
134
|
-
* (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1. Any standard
|
|
135
|
-
* BIP-39 length (12, 15, 18, 21, 24 words) is accepted; the derivation does not
|
|
136
|
-
* depend on the length.
|
|
137
|
-
*/
|
|
138
|
-
export function deriveIdentityFromMnemonic(mnemonic: string): OpenedMnemonicIdentity {
|
|
139
|
-
const normalized = normalizeMnemonic(mnemonic);
|
|
140
|
-
if (!validateMnemonic(normalized, wordlist)) {
|
|
141
|
-
throw new Error('Invalid recovery phrase');
|
|
142
|
-
}
|
|
143
|
-
const seed = mnemonicToSeedSync(normalized);
|
|
144
|
-
const privateKeyBytes = seed.slice(0, 32);
|
|
145
|
-
wipeBytes(seed);
|
|
146
|
-
const privateKey = bytesToHex(privateKeyBytes);
|
|
147
|
-
wipeBytes(privateKeyBytes);
|
|
148
|
-
return { kind: 'mnemonic', mnemonic: normalized, privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** A raw 32-byte private key (hex, optional `0x`) as an identity. Never gains a phrase. */
|
|
152
|
-
export function deriveIdentityFromPrivateKey(privateKey: string): OpenedRawKeyIdentity {
|
|
153
|
-
const normalized = privateKey.trim().toLowerCase().replace(/^0x/, '');
|
|
154
|
-
if (!/^[0-9a-f]{64}$/.test(normalized)) {
|
|
155
|
-
throw new Error('Invalid private key');
|
|
156
|
-
}
|
|
157
|
-
let publicKey: string;
|
|
158
|
-
try {
|
|
159
|
-
publicKey = deriveSecp256k1PublicKey(normalized);
|
|
160
|
-
} catch {
|
|
161
|
-
throw new Error('Invalid private key');
|
|
162
|
-
}
|
|
163
|
-
return { kind: 'raw-key', mnemonic: null, privateKey: normalized, publicKey };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Read what a person typed as recovery material: a BIP-39 phrase, or a 64-hex
|
|
168
|
-
* private key. Throws `Invalid recovery material` for anything else.
|
|
169
|
-
*/
|
|
170
|
-
export function parseRecoveryMaterial(input: string): WebIdentityRecoveryMaterial {
|
|
171
|
-
const trimmed = input.trim();
|
|
172
|
-
if (/^(0x)?[0-9a-fA-F]{64}$/.test(trimmed)) return { kind: 'raw-key', privateKey: trimmed };
|
|
173
|
-
const words = normalizeMnemonic(trimmed);
|
|
174
|
-
if (words && validateMnemonic(words, wordlist)) return { kind: 'mnemonic', mnemonic: words };
|
|
175
|
-
throw new Error('Invalid recovery material');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Derive the identity recovery material names. */
|
|
179
|
-
export function deriveIdentityFromRecoveryMaterial(material: WebIdentityRecoveryMaterial): OpenedWebIdentity {
|
|
180
|
-
return material.kind === 'mnemonic'
|
|
181
|
-
? deriveIdentityFromMnemonic(material.mnemonic)
|
|
182
|
-
: deriveIdentityFromPrivateKey(material.privateKey);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** A brand-new 12-word identity. Nothing is persisted. */
|
|
186
|
-
export function generateWebIdentity(): OpenedMnemonicIdentity {
|
|
187
|
-
return deriveIdentityFromMnemonic(generateMnemonic(wordlist, 128));
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** A fresh random data key for a new envelope. */
|
|
191
|
-
export function generateDataKey(): Uint8Array {
|
|
192
|
-
const key = new Uint8Array(AEAD_KEY_LENGTH);
|
|
193
|
-
globalThis.crypto.getRandomValues(key);
|
|
194
|
-
return key;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Whether `bytes` is a usable PRF output: exactly 32 bytes. */
|
|
198
|
-
export function isUsablePrfOutput(bytes: Uint8Array | null | undefined): bytes is Uint8Array {
|
|
199
|
-
return bytes instanceof Uint8Array && bytes.byteLength === WEB_IDENTITY_PRF_OUTPUT_LENGTH;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** The key-encryption key a passkey's PRF output yields for its own wrap. */
|
|
203
|
-
export function deriveKeyEncryptionKey(prfOutput: Uint8Array, credentialId: string): Uint8Array {
|
|
204
|
-
if (prfOutput.length !== WEB_IDENTITY_PRF_OUTPUT_LENGTH) {
|
|
205
|
-
throw new Error(`PRF output must be ${WEB_IDENTITY_PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
|
|
206
|
-
}
|
|
207
|
-
return hkdfSha256(prfOutput, KEK_SALT, utf8ToBytes(credentialId), AEAD_KEY_LENGTH);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** One passkey's contribution to an envelope. */
|
|
211
|
-
export interface WrapInput {
|
|
212
|
-
prfOutput: Uint8Array;
|
|
213
|
-
credentialId: string;
|
|
214
|
-
/** The RP ID the passkey lives under; bound into the wrap. */
|
|
215
|
-
rpId: string;
|
|
216
|
-
/** Set when this PRF output came from a ceremony separate from the one that created the wrap. */
|
|
217
|
-
verifiedAt?: string;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function wrapFor(
|
|
221
|
-
publicKey: string,
|
|
222
|
-
dataKey: Uint8Array,
|
|
223
|
-
input: WrapInput,
|
|
224
|
-
now: Date,
|
|
225
|
-
): WebIdentityWrap {
|
|
226
|
-
const kek = deriveKeyEncryptionKey(input.prfOutput, input.credentialId);
|
|
227
|
-
try {
|
|
228
|
-
const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, input.credentialId, input.rpId));
|
|
229
|
-
return {
|
|
230
|
-
credentialId: input.credentialId,
|
|
231
|
-
nonce: bytesToHex(nonce),
|
|
232
|
-
wrappedKey: bytesToHex(ciphertext),
|
|
233
|
-
createdAt: now.toISOString(),
|
|
234
|
-
rpId: input.rpId,
|
|
235
|
-
...(input.verifiedAt ? { verifiedAt: input.verifiedAt } : {}),
|
|
236
|
-
};
|
|
237
|
-
} finally {
|
|
238
|
-
wipeBytes(kek);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function secretOf(identity: OpenedWebIdentity): { kind: WebIdentitySecretKind; bytes: Uint8Array } {
|
|
243
|
-
if (identity.kind === 'mnemonic') {
|
|
244
|
-
return { kind: 'mnemonic-entropy', bytes: mnemonicToEntropy(identity.mnemonic, wordlist) };
|
|
245
|
-
}
|
|
246
|
-
return { kind: 'raw-private-key', bytes: hexToBytes(identity.privateKey) };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Seal an identity into a new envelope that opens with the given passkey.
|
|
251
|
-
*
|
|
252
|
-
* Returns the data key too, so the caller can add further wraps in the same
|
|
253
|
-
* session; wipe it when done.
|
|
254
|
-
*/
|
|
255
|
-
export function sealWebIdentity(
|
|
256
|
-
identity: OpenedWebIdentity,
|
|
257
|
-
firstWrap: WrapInput,
|
|
258
|
-
now: Date = new Date(),
|
|
259
|
-
): { envelope: WebIdentityEnvelope; dataKey: Uint8Array } {
|
|
260
|
-
const derived: OpenedWebIdentity =
|
|
261
|
-
identity.kind === 'raw-key' ? deriveIdentityFromPrivateKey(identity.privateKey) : deriveIdentityFromMnemonic(identity.mnemonic);
|
|
262
|
-
const publicKey = identity.publicKey.toLowerCase();
|
|
263
|
-
if (derived.publicKey !== publicKey) {
|
|
264
|
-
wipeOpenedIdentity(derived);
|
|
265
|
-
throw new Error('The recovery material does not belong to this identity');
|
|
266
|
-
}
|
|
267
|
-
const secret = secretOf(derived);
|
|
268
|
-
wipeOpenedIdentity(derived);
|
|
269
|
-
const dataKey = generateDataKey();
|
|
270
|
-
try {
|
|
271
|
-
const { nonce, ciphertext } = encryptAead(dataKey, secret.bytes, secretAad(publicKey, secret.kind));
|
|
272
|
-
const envelope: WebIdentityEnvelope = {
|
|
273
|
-
version: WEB_IDENTITY_ENVELOPE_VERSION,
|
|
274
|
-
algorithm: 'xchacha20poly1305',
|
|
275
|
-
publicKey,
|
|
276
|
-
secretKind: secret.kind,
|
|
277
|
-
secretNonce: bytesToHex(nonce),
|
|
278
|
-
sealedSecret: bytesToHex(ciphertext),
|
|
279
|
-
wraps: [wrapFor(publicKey, dataKey, firstWrap, now)],
|
|
280
|
-
};
|
|
281
|
-
return { envelope, dataKey };
|
|
282
|
-
} catch (error) {
|
|
283
|
-
wipeBytes(dataKey);
|
|
284
|
-
throw error;
|
|
285
|
-
} finally {
|
|
286
|
-
wipeBytes(secret.bytes);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** Recover the data key with one passkey's PRF output. */
|
|
291
|
-
export function unwrapDataKey(
|
|
292
|
-
envelope: WebIdentityEnvelope,
|
|
293
|
-
prfOutput: Uint8Array,
|
|
294
|
-
credentialId: string,
|
|
295
|
-
): Uint8Array {
|
|
296
|
-
const wrap = envelope.wraps.find((entry) => entry.credentialId === credentialId);
|
|
297
|
-
if (!wrap) {
|
|
298
|
-
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
299
|
-
}
|
|
300
|
-
const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
|
|
301
|
-
try {
|
|
302
|
-
return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId, wrap.rpId));
|
|
303
|
-
} catch {
|
|
304
|
-
throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
|
|
305
|
-
} finally {
|
|
306
|
-
wipeBytes(kek);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Open an envelope with its data key.
|
|
312
|
-
*
|
|
313
|
-
* The derived public key is checked against the envelope's own: a sealed secret
|
|
314
|
-
* that decrypts into a different identity is treated as corruption, never used.
|
|
315
|
-
*/
|
|
316
|
-
export function openWebIdentity(envelope: WebIdentityEnvelope, dataKey: Uint8Array): OpenedWebIdentity {
|
|
317
|
-
const publicKey = envelope.publicKey.toLowerCase();
|
|
318
|
-
const kind = envelope.secretKind;
|
|
319
|
-
let secret: Uint8Array;
|
|
320
|
-
try {
|
|
321
|
-
secret = decryptAead(dataKey, hexToBytes(envelope.secretNonce), hexToBytes(envelope.sealedSecret), secretAad(envelope.publicKey, kind));
|
|
322
|
-
} catch {
|
|
323
|
-
throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
|
|
324
|
-
}
|
|
325
|
-
try {
|
|
326
|
-
let identity: OpenedWebIdentity;
|
|
327
|
-
try {
|
|
328
|
-
identity =
|
|
329
|
-
kind === 'mnemonic-entropy'
|
|
330
|
-
? deriveIdentityFromMnemonic(entropyToMnemonic(secret, wordlist))
|
|
331
|
-
: deriveIdentityFromPrivateKey(bytesToHex(secret));
|
|
332
|
-
} catch {
|
|
333
|
-
throw new WebIdentityUnlockError('corrupt', 'The sealed identity is malformed');
|
|
334
|
-
}
|
|
335
|
-
if (identity.publicKey !== publicKey) {
|
|
336
|
-
wipeOpenedIdentity(identity);
|
|
337
|
-
throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
|
|
338
|
-
}
|
|
339
|
-
return identity;
|
|
340
|
-
} finally {
|
|
341
|
-
wipeBytes(secret);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** Unwrap and open in one step. */
|
|
346
|
-
export function unlockWebIdentity(
|
|
347
|
-
envelope: WebIdentityEnvelope,
|
|
348
|
-
prfOutput: Uint8Array,
|
|
349
|
-
credentialId: string,
|
|
350
|
-
): OpenedWebIdentity {
|
|
351
|
-
const dataKey = unwrapDataKey(envelope, prfOutput, credentialId);
|
|
352
|
-
try {
|
|
353
|
-
return openWebIdentity(envelope, dataKey);
|
|
354
|
-
} finally {
|
|
355
|
-
wipeBytes(dataKey);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
|
|
360
|
-
export function addWrap(envelope: WebIdentityEnvelope, dataKey: Uint8Array, wrapInput: WrapInput, now: Date = new Date()): WebIdentityEnvelope {
|
|
361
|
-
// Proves `dataKey` belongs to THIS envelope before anything new can open it.
|
|
362
|
-
wipeOpenedIdentity(openWebIdentity(envelope, dataKey));
|
|
363
|
-
const others = envelope.wraps.filter((entry) => entry.credentialId !== wrapInput.credentialId);
|
|
364
|
-
return {
|
|
365
|
-
...envelope,
|
|
366
|
-
wraps: [...others, wrapFor(envelope.publicKey, dataKey, wrapInput, now)],
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* Record that a wrap's passkey opened the envelope in a ceremony of its own. The
|
|
372
|
-
* AEAD does not cover `verifiedAt`; it is holder metadata the owner writes with a
|
|
373
|
-
* root proof, not a cryptographic claim.
|
|
374
|
-
*/
|
|
375
|
-
export function markWrapVerified(envelope: WebIdentityEnvelope, credentialId: string, now: Date = new Date()): WebIdentityEnvelope {
|
|
376
|
-
if (!envelope.wraps.some((entry) => entry.credentialId === credentialId)) {
|
|
377
|
-
throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
|
|
378
|
-
}
|
|
379
|
-
return {
|
|
380
|
-
...envelope,
|
|
381
|
-
wraps: envelope.wraps.map((entry) =>
|
|
382
|
-
entry.credentialId === credentialId ? { ...entry, verifiedAt: now.toISOString() } : entry,
|
|
383
|
-
),
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
|
|
388
|
-
export function removeWrap(envelope: WebIdentityEnvelope, credentialId: string): WebIdentityEnvelope {
|
|
389
|
-
const remaining = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
|
|
390
|
-
if (remaining.length === 0) {
|
|
391
|
-
throw new Error('An identity envelope must keep at least one passkey');
|
|
392
|
-
}
|
|
393
|
-
return { ...envelope, wraps: remaining };
|
|
394
|
-
}
|