@oxy.so/core 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +72 -9
  3. package/dist/cjs/crypto/identityMove.js +53 -34
  4. package/dist/cjs/crypto/identityProof.js +28 -0
  5. package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
  6. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  7. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  9. package/dist/cjs/i18n/locales/en-US.json +1 -0
  10. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  11. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  12. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  13. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  14. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  24. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  26. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  27. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  28. package/dist/cjs/index.js +22 -14
  29. package/dist/cjs/inference/OxyInferenceClient.js +62 -5
  30. package/dist/cjs/mixins/OxyServices.auth.js +129 -0
  31. package/dist/cjs/mixins/OxyServices.identity.js +11 -44
  32. package/dist/cjs/mixins/index.js +0 -2
  33. package/dist/cjs/server/index.js +10 -1
  34. package/dist/cjs/server/securityHeaders.js +9 -2
  35. package/dist/cjs/server/workloadIdentity.js +170 -0
  36. package/dist/cjs/session/refresh.js +12 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/HttpService.js +72 -9
  39. package/dist/esm/crypto/identityMove.js +51 -33
  40. package/dist/esm/crypto/identityProof.js +24 -0
  41. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  42. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  44. package/dist/esm/i18n/locales/de-DE.json +1 -0
  45. package/dist/esm/i18n/locales/en-US.json +1 -0
  46. package/dist/esm/i18n/locales/es-ES.json +1 -0
  47. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  48. package/dist/esm/i18n/locales/it-IT.json +1 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  54. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  62. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  64. package/dist/esm/index.js +5 -4
  65. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  66. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  67. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  68. package/dist/esm/mixins/index.js +0 -2
  69. package/dist/esm/server/index.js +7 -0
  70. package/dist/esm/server/securityHeaders.js +9 -2
  71. package/dist/esm/server/workloadIdentity.js +166 -0
  72. package/dist/esm/session/refresh.js +12 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/HttpService.d.ts +24 -0
  75. package/dist/types/crypto/identityMove.d.ts +48 -32
  76. package/dist/types/crypto/identityProof.d.ts +16 -0
  77. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  78. package/dist/types/index.d.ts +7 -6
  79. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  80. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  81. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  82. package/dist/types/mixins/index.d.ts +1 -2
  83. package/dist/types/server/index.d.ts +8 -0
  84. package/dist/types/server/securityHeaders.d.ts +8 -0
  85. package/dist/types/server/workloadIdentity.d.ts +32 -0
  86. package/package.json +1 -1
  87. package/src/HttpService.ts +74 -9
  88. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  89. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  90. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  91. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  92. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  93. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  94. package/src/crypto/identityMove.ts +77 -43
  95. package/src/crypto/identityProof.ts +36 -0
  96. package/src/crypto/webIdentityCarrier.ts +185 -117
  97. package/src/i18n/locales/ar-SA.json +1 -0
  98. package/src/i18n/locales/ca-ES.json +1 -0
  99. package/src/i18n/locales/de-DE.json +1 -0
  100. package/src/i18n/locales/en-US.json +1 -0
  101. package/src/i18n/locales/es-ES.json +1 -0
  102. package/src/i18n/locales/fr-FR.json +1 -0
  103. package/src/i18n/locales/it-IT.json +1 -0
  104. package/src/i18n/locales/ja-JP.json +1 -0
  105. package/src/i18n/locales/ko-KR.json +1 -0
  106. package/src/i18n/locales/pt-PT.json +1 -0
  107. package/src/i18n/locales/zh-CN.json +1 -0
  108. package/src/index.ts +26 -15
  109. package/src/inference/OxyInferenceClient.ts +84 -7
  110. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  111. package/src/mixins/OxyServices.auth.ts +100 -1
  112. package/src/mixins/OxyServices.identity.ts +17 -67
  113. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  114. package/src/mixins/index.ts +0 -3
  115. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  116. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  117. package/src/server/index.ts +9 -0
  118. package/src/server/securityHeaders.ts +19 -6
  119. package/src/server/workloadIdentity.ts +207 -0
  120. package/src/session/__tests__/refresh.test.ts +53 -3
  121. package/src/session/refresh.ts +12 -0
  122. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  123. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  124. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  125. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  126. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -1,18 +1,18 @@
1
1
  /**
2
- * Web identity carrier — the crypto of "one identity, two carriers".
2
+ * Web identity holder — the crypto of a browser holding a personal root (ADR 0024).
3
3
  *
4
- * An Oxy identity is a BIP-39 mnemonic; its seed's first 32 bytes are the
5
- * secp256k1 key (`recoveryPhrase.ts`). Commons keeps that key in the device
6
- * keychain. This module lets a browser carry the SAME identity without Oxy ever
7
- * being able to use it:
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
8
  *
9
- * entropy ──AEAD(DEK)──▶ sealedEntropy
9
+ secret ──AEAD(DEK)──▶ sealed secret (phrase entropy or raw key)
10
10
  * DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
11
11
  *
12
12
  * The PRF output is produced inside the user's authenticator, behind user
13
13
  * verification, and never leaves the page that asked for it. The envelope is
14
- * therefore safe to store anywhere — the identity origin's IndexedDB and the
15
- * server copy alike — and opens only with a registered passkey or the phrase.
14
+ * therefore safe to store anywhere — the holder host's IndexedDB and the server
15
+ * copy alike — and opens only with a registered passkey or the recovery material.
16
16
  *
17
17
  * PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
18
18
  * CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
@@ -27,11 +27,11 @@ import { entropyToMnemonic, generateMnemonic, mnemonicToEntropy, mnemonicToSeedS
27
27
  import { wordlist } from '@scure/bip39/wordlists/english';
28
28
  import { sha256 } from '@noble/hashes/sha256';
29
29
  import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
30
- import { signMessage } from '@oxy.so/protocol';
31
30
  import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
32
31
  import {
33
32
  WEB_IDENTITY_ENVELOPE_VERSION,
34
33
  type WebIdentityEnvelope,
34
+ type WebIdentitySecretKind,
35
35
  type WebIdentityWrap,
36
36
  } from '@oxy.so/contracts';
37
37
  import { AEAD_KEY_LENGTH, decryptAead, encryptAead } from './aead';
@@ -46,11 +46,11 @@ import { hkdfSha256 } from './kdf';
46
46
  */
47
47
  export const WEB_IDENTITY_PRF_INPUT: Uint8Array = sha256(utf8ToBytes('oxy-web-identity-carrier/prf/v1'));
48
48
 
49
- /** HKDF salt of the key-encryption-key schedule. Versioned with the envelope. */
49
+ /** HKDF salt of the key-encryption-key schedule. Shared by envelope versions 1 and 2. */
50
50
  const KEK_SALT = utf8ToBytes('oxy-web-identity-carrier/kek/v1');
51
51
 
52
- /** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
53
- const PRF_OUTPUT_LENGTH = 32;
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
54
 
55
55
  /** Why an envelope did not open. Every value is a normal outcome a UI can explain. */
56
56
  export type WebIdentityUnlockFailure =
@@ -59,7 +59,7 @@ export type WebIdentityUnlockFailure =
59
59
  /**
60
60
  * The passkey IS listed but its PRF output does not open its wrap — a synced
61
61
  * copy returning a different PRF value, or a provider that silently changed it.
62
- * Recoverable with another passkey or the phrase.
62
+ * Recoverable with another passkey or the recovery material.
63
63
  */
64
64
  | 'prf-mismatch'
65
65
  /** The envelope itself is malformed or tampered with. */
@@ -72,25 +72,45 @@ export class WebIdentityUnlockError extends Error {
72
72
  }
73
73
  }
74
74
 
75
- /** The identity in usable form. Hold it for one operation, then {@link wipeBytes}. */
76
- export interface OpenedWebIdentity {
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';
77
78
  mnemonic: string;
78
- /** 32-byte secp256k1 private key, lowercase hex. */
79
+ /** 32-byte secp256k1 private key, lowercase hex (`seed[0:32]`). */
79
80
  privateKey: string;
80
81
  /** Uncompressed SEC1 public key, lowercase hex (Oxy's canonical form). */
81
82
  publicKey: string;
82
83
  }
83
84
 
84
- function aadFor(parts: Record<string, string | number>): Uint8Array {
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 {
85
105
  return utf8ToBytes(JSON.stringify(parts));
86
106
  }
87
107
 
88
- function entropyAad(publicKey: string): Uint8Array {
89
- return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
108
+ function secretAad(publicKey: string, secretKind: WebIdentitySecretKind): Uint8Array {
109
+ return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'secret', secretKind, publicKey });
90
110
  }
91
111
 
92
- function wrapAad(publicKey: string, credentialId: string): Uint8Array {
93
- return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
112
+ function wrapAad(publicKey: string, credentialId: string, rpId: string): Uint8Array {
113
+ return aadFor({ v: WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId, rpId });
94
114
  }
95
115
 
96
116
  /** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
@@ -98,12 +118,25 @@ export function wipeBytes(bytes: Uint8Array): void {
98
118
  bytes.fill(0);
99
119
  }
100
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
+
101
132
  /**
102
133
  * Derive the identity from a mnemonic exactly as Commons does
103
- * (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
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.
104
137
  */
105
- export function deriveIdentityFromMnemonic(mnemonic: string): OpenedWebIdentity {
106
- const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
138
+ export function deriveIdentityFromMnemonic(mnemonic: string): OpenedMnemonicIdentity {
139
+ const normalized = normalizeMnemonic(mnemonic);
107
140
  if (!validateMnemonic(normalized, wordlist)) {
108
141
  throw new Error('Invalid recovery phrase');
109
142
  }
@@ -112,11 +145,45 @@ export function deriveIdentityFromMnemonic(mnemonic: string): OpenedWebIdentity
112
145
  wipeBytes(seed);
113
146
  const privateKey = bytesToHex(privateKeyBytes);
114
147
  wipeBytes(privateKeyBytes);
115
- return { mnemonic: normalized, privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
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);
116
183
  }
117
184
 
118
185
  /** A brand-new 12-word identity. Nothing is persisted. */
119
- export function generateWebIdentity(): OpenedWebIdentity {
186
+ export function generateWebIdentity(): OpenedMnemonicIdentity {
120
187
  return deriveIdentityFromMnemonic(generateMnemonic(wordlist, 128));
121
188
  }
122
189
 
@@ -127,36 +194,58 @@ export function generateDataKey(): Uint8Array {
127
194
  return key;
128
195
  }
129
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
+
130
202
  /** The key-encryption key a passkey's PRF output yields for its own wrap. */
131
203
  export function deriveKeyEncryptionKey(prfOutput: Uint8Array, credentialId: string): Uint8Array {
132
- if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
133
- throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
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}`);
134
206
  }
135
207
  return hkdfSha256(prfOutput, KEK_SALT, utf8ToBytes(credentialId), AEAD_KEY_LENGTH);
136
208
  }
137
209
 
138
- /** Wrap `dataKey` for one passkey. */
139
- export function wrapDataKey(
140
- dataKey: Uint8Array,
141
- prfOutput: Uint8Array,
142
- credentialId: string,
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(
143
221
  publicKey: string,
144
- now: Date = new Date(),
222
+ dataKey: Uint8Array,
223
+ input: WrapInput,
224
+ now: Date,
145
225
  ): WebIdentityWrap {
146
- const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
226
+ const kek = deriveKeyEncryptionKey(input.prfOutput, input.credentialId);
147
227
  try {
148
- const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, credentialId));
228
+ const { nonce, ciphertext } = encryptAead(kek, dataKey, wrapAad(publicKey, input.credentialId, input.rpId));
149
229
  return {
150
- credentialId,
230
+ credentialId: input.credentialId,
151
231
  nonce: bytesToHex(nonce),
152
232
  wrappedKey: bytesToHex(ciphertext),
153
233
  createdAt: now.toISOString(),
234
+ rpId: input.rpId,
235
+ ...(input.verifiedAt ? { verifiedAt: input.verifiedAt } : {}),
154
236
  };
155
237
  } finally {
156
238
  wipeBytes(kek);
157
239
  }
158
240
  }
159
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
+
160
249
  /**
161
250
  * Seal an identity into a new envelope that opens with the given passkey.
162
251
  *
@@ -164,29 +253,37 @@ export function wrapDataKey(
164
253
  * session; wipe it when done.
165
254
  */
166
255
  export function sealWebIdentity(
167
- identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>,
168
- firstWrap: { prfOutput: Uint8Array; credentialId: string },
256
+ identity: OpenedWebIdentity,
257
+ firstWrap: WrapInput,
169
258
  now: Date = new Date(),
170
259
  ): { envelope: WebIdentityEnvelope; dataKey: Uint8Array } {
171
- const derived = deriveIdentityFromMnemonic(identity.mnemonic);
172
- if (derived.publicKey !== identity.publicKey.toLowerCase()) {
173
- throw new Error('The recovery phrase does not belong to this identity');
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');
174
266
  }
267
+ const secret = secretOf(derived);
268
+ wipeOpenedIdentity(derived);
175
269
  const dataKey = generateDataKey();
176
- const entropy = mnemonicToEntropy(derived.mnemonic, wordlist);
177
270
  try {
178
- const { nonce, ciphertext } = encryptAead(dataKey, entropy, entropyAad(derived.publicKey));
271
+ const { nonce, ciphertext } = encryptAead(dataKey, secret.bytes, secretAad(publicKey, secret.kind));
179
272
  const envelope: WebIdentityEnvelope = {
180
273
  version: WEB_IDENTITY_ENVELOPE_VERSION,
181
274
  algorithm: 'xchacha20poly1305',
182
- publicKey: derived.publicKey,
183
- entropyNonce: bytesToHex(nonce),
184
- sealedEntropy: bytesToHex(ciphertext),
185
- wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
275
+ publicKey,
276
+ secretKind: secret.kind,
277
+ secretNonce: bytesToHex(nonce),
278
+ sealedSecret: bytesToHex(ciphertext),
279
+ wraps: [wrapFor(publicKey, dataKey, firstWrap, now)],
186
280
  };
187
281
  return { envelope, dataKey };
282
+ } catch (error) {
283
+ wipeBytes(dataKey);
284
+ throw error;
188
285
  } finally {
189
- wipeBytes(entropy);
286
+ wipeBytes(secret.bytes);
190
287
  }
191
288
  }
192
289
 
@@ -202,7 +299,7 @@ export function unwrapDataKey(
202
299
  }
203
300
  const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
204
301
  try {
205
- return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
302
+ return decryptAead(kek, hexToBytes(wrap.nonce), hexToBytes(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId, wrap.rpId));
206
303
  } catch {
207
304
  throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
208
305
  } finally {
@@ -213,29 +310,35 @@ export function unwrapDataKey(
213
310
  /**
214
311
  * Open an envelope with its data key.
215
312
  *
216
- * The derived public key is checked against the envelope's own: a sealed entropy
313
+ * The derived public key is checked against the envelope's own: a sealed secret
217
314
  * that decrypts into a different identity is treated as corruption, never used.
218
315
  */
219
316
  export function openWebIdentity(envelope: WebIdentityEnvelope, dataKey: Uint8Array): OpenedWebIdentity {
220
- let entropy: Uint8Array;
317
+ const publicKey = envelope.publicKey.toLowerCase();
318
+ const kind = envelope.secretKind;
319
+ let secret: Uint8Array;
221
320
  try {
222
- entropy = decryptAead(
223
- dataKey,
224
- hexToBytes(envelope.entropyNonce),
225
- hexToBytes(envelope.sealedEntropy),
226
- entropyAad(envelope.publicKey),
227
- );
321
+ secret = decryptAead(dataKey, hexToBytes(envelope.secretNonce), hexToBytes(envelope.sealedSecret), secretAad(envelope.publicKey, kind));
228
322
  } catch {
229
323
  throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
230
324
  }
231
325
  try {
232
- const identity = deriveIdentityFromMnemonic(entropyToMnemonic(entropy, wordlist));
233
- if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
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);
234
337
  throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
235
338
  }
236
339
  return identity;
237
340
  } finally {
238
- wipeBytes(entropy);
341
+ wipeBytes(secret);
239
342
  }
240
343
  }
241
344
 
@@ -254,19 +357,30 @@ export function unlockWebIdentity(
254
357
  }
255
358
 
256
359
  /** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
257
- export function addWrap(
258
- envelope: WebIdentityEnvelope,
259
- dataKey: Uint8Array,
260
- prfOutput: Uint8Array,
261
- credentialId: string,
262
- now: Date = new Date(),
263
- ): WebIdentityEnvelope {
360
+ export function addWrap(envelope: WebIdentityEnvelope, dataKey: Uint8Array, wrapInput: WrapInput, now: Date = new Date()): WebIdentityEnvelope {
264
361
  // Proves `dataKey` belongs to THIS envelope before anything new can open it.
265
- openWebIdentity(envelope, dataKey);
266
- const others = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
362
+ wipeOpenedIdentity(openWebIdentity(envelope, dataKey));
363
+ const others = envelope.wraps.filter((entry) => entry.credentialId !== wrapInput.credentialId);
267
364
  return {
268
365
  ...envelope,
269
- wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
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
+ ),
270
384
  };
271
385
  }
272
386
 
@@ -278,49 +392,3 @@ export function removeWrap(envelope: WebIdentityEnvelope, credentialId: string):
278
392
  }
279
393
  return { ...envelope, wraps: remaining };
280
394
  }
281
-
282
- /**
283
- * The message an identity key signs to authorize an account action
284
- * (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
285
- * timestamp })`, byte-identical to what the API reconstructs.
286
- */
287
- export function buildIdentityActionMessage(action: string, userId: string, timestamp: number): string {
288
- return JSON.stringify({ action, userId, timestamp });
289
- }
290
-
291
- /** Sign an identity-action message with an opened identity's key. */
292
- export async function signIdentityAction(
293
- identity: Pick<OpenedWebIdentity, 'privateKey'>,
294
- action: string,
295
- userId: string,
296
- timestamp: number = Date.now(),
297
- ): Promise<{ signature: string; timestamp: number }> {
298
- const signature = await signMessage(buildIdentityActionMessage(action, userId, timestamp), identity.privateKey);
299
- return { signature, timestamp };
300
- }
301
-
302
- /**
303
- * The 6-digit short authentication string both sides of an identity transfer show.
304
- *
305
- * Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
306
- * that substitutes either key produces a different code on each screen. The user
307
- * comparing the two codes is what makes the transfer safe against the relay itself.
308
- */
309
- export function deriveTransferSas(input: {
310
- pairingId: string;
311
- initiatorEphemeralPublicKey: string;
312
- responderEphemeralPublicKey: string;
313
- }): string {
314
- const digest = sha256(
315
- utf8ToBytes(
316
- JSON.stringify({
317
- v: 'oxy-identity-transfer-sas-v1',
318
- pairingId: input.pairingId.toLowerCase(),
319
- initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
320
- responder: input.responderEphemeralPublicKey.toLowerCase(),
321
- }),
322
- ),
323
- );
324
- const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
325
- return String(value % 1_000_000).padStart(6, '0');
326
- }
@@ -108,6 +108,7 @@
108
108
  "loading": "جارٍ تحميل الحسابات...",
109
109
  "qrHeadline": "سجّل الدخول بهوية Oxy الخاصة بك",
110
110
  "signInWithOxy": "تسجيل الدخول باستخدام Oxy",
111
+ "signUpWithOxy": "التسجيل باستخدام Oxy",
111
112
  "useIdentityOnDevice": "لا تملك Commons؟ استخدم مفتاح مرور على هذا الجهاز بدلاً من ذلك",
112
113
  "getCommons": "احصل على Commons",
113
114
  "commonsNotInstalled": "لا تملك Commons؟ احصل على التطبيق لتسجيل الدخول بمعرّف Oxy الخاص بك.",
@@ -108,6 +108,7 @@
108
108
  "loading": "S'estan carregant els comptes...",
109
109
  "qrHeadline": "Inicia sessió amb la teva identitat d'Oxy",
110
110
  "signInWithOxy": "Inicia sessió amb Oxy",
111
+ "signUpWithOxy": "Registra't amb Oxy",
111
112
  "useIdentityOnDevice": "No tens Commons? Fes servir una clau d'accés en aquest dispositiu",
112
113
  "getCommons": "Descarrega Commons",
113
114
  "commonsNotInstalled": "No tens Commons? Descarrega l'app per iniciar sessió amb el teu Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "Konten werden geladen...",
109
109
  "qrHeadline": "Melden Sie sich mit Ihrer Oxy-Identität an",
110
110
  "signInWithOxy": "Mit Oxy anmelden",
111
+ "signUpWithOxy": "Mit Oxy registrieren",
111
112
  "useIdentityOnDevice": "Kein Commons? Verwenden Sie stattdessen einen Passkey auf diesem Gerät",
112
113
  "getCommons": "Commons herunterladen",
113
114
  "commonsNotInstalled": "Kein Commons? Holen Sie sich die App, um sich mit Ihrer Oxy-ID anzumelden.",
@@ -416,6 +416,7 @@
416
416
  "scanQr": "Scan QR code",
417
417
  "qrHeadline": "Sign in with your Oxy identity",
418
418
  "signInWithOxy": "Sign in with Oxy",
419
+ "signUpWithOxy": "Sign up with Oxy",
419
420
  "useIdentityOnDevice": "No Commons? Use a passkey on this device instead",
420
421
  "getCommons": "Get Commons",
421
422
  "commonsNotInstalled": "Don't have Commons? Get the app to sign in with your Oxy ID.",
@@ -656,6 +656,7 @@
656
656
  "scanQr": "Escanear código QR",
657
657
  "qrHeadline": "Inicia sesión con tu identidad de Oxy",
658
658
  "signInWithOxy": "Inicia sesión con Oxy",
659
+ "signUpWithOxy": "Regístrate con Oxy",
659
660
  "useIdentityOnDevice": "¿No tienes Commons? Usa una clave de acceso en este dispositivo",
660
661
  "getCommons": "Descargar Commons",
661
662
  "commonsNotInstalled": "¿No tienes Commons? Descarga la app para iniciar sesión con tu Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "Chargement des comptes...",
109
109
  "qrHeadline": "Connectez-vous avec votre identité Oxy",
110
110
  "signInWithOxy": "Se connecter avec Oxy",
111
+ "signUpWithOxy": "S'inscrire avec Oxy",
111
112
  "useIdentityOnDevice": "Pas de Commons ? Utilisez une clé d'accès sur cet appareil",
112
113
  "getCommons": "Obtenir Commons",
113
114
  "commonsNotInstalled": "Pas de Commons ? Téléchargez l'app pour vous connecter avec votre Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "Caricamento account...",
109
109
  "qrHeadline": "Accedi con la tua identità Oxy",
110
110
  "signInWithOxy": "Accedi con Oxy",
111
+ "signUpWithOxy": "Registrati con Oxy",
111
112
  "useIdentityOnDevice": "Non hai Commons? Usa una passkey su questo dispositivo",
112
113
  "getCommons": "Ottieni Commons",
113
114
  "commonsNotInstalled": "Non hai Commons? Scarica l'app per accedere con il tuo Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "アカウントを読み込み中...",
109
109
  "qrHeadline": "Oxy IDでサインイン",
110
110
  "signInWithOxy": "Oxyでサインイン",
111
+ "signUpWithOxy": "Oxyで登録",
111
112
  "useIdentityOnDevice": "Commonsがありませんか?このデバイスのパスキーを使用",
112
113
  "getCommons": "Commonsを入手",
113
114
  "commonsNotInstalled": "Commonsがありませんか?アプリを入手してOxy IDでサインインしましょう。",
@@ -108,6 +108,7 @@
108
108
  "loading": "계정을 불러오는 중...",
109
109
  "qrHeadline": "Oxy ID로 로그인",
110
110
  "signInWithOxy": "Oxy로 로그인",
111
+ "signUpWithOxy": "Oxy로 가입하기",
111
112
  "useIdentityOnDevice": "Commons가 없으신가요? 이 기기의 패스키를 사용하세요",
112
113
  "getCommons": "Commons 다운로드",
113
114
  "commonsNotInstalled": "Commons가 없으신가요? 앱을 다운로드하여 Oxy ID로 로그인하세요.",
@@ -108,6 +108,7 @@
108
108
  "loading": "A carregar contas...",
109
109
  "qrHeadline": "Inicie sessão com a sua identidade Oxy",
110
110
  "signInWithOxy": "Iniciar sessão com Oxy",
111
+ "signUpWithOxy": "Registar-se com Oxy",
111
112
  "useIdentityOnDevice": "Não tem o Commons? Use uma chave de acesso neste dispositivo",
112
113
  "getCommons": "Obter o Commons",
113
114
  "commonsNotInstalled": "Não tem o Commons? Obtenha a app para iniciar sessão com o seu Oxy ID.",
@@ -108,6 +108,7 @@
108
108
  "loading": "正在加载账户...",
109
109
  "qrHeadline": "使用您的 Oxy 身份登录",
110
110
  "signInWithOxy": "使用 Oxy 登录",
111
+ "signUpWithOxy": "使用 Oxy 注册",
111
112
  "useIdentityOnDevice": "没有 Commons?改用此设备上的通行密钥",
112
113
  "getCommons": "获取 Commons",
113
114
  "commonsNotInstalled": "没有 Commons?获取应用以使用您的 Oxy ID 登录。",
package/src/index.ts CHANGED
@@ -86,10 +86,6 @@ export type {
86
86
  ContactDiscoveryMatch,
87
87
  ContactDiscoveryResponse,
88
88
  } from './mixins/OxyServices.contacts';
89
- export type {
90
- InitDeviceTransferResult,
91
- DeviceTransferOutcome,
92
- } from './mixins/OxyServices.deviceTransfer';
93
89
  export type {
94
90
  BulkFollowEntry,
95
91
  BulkFollowResult,
@@ -234,7 +230,6 @@ export {
234
230
  export { buildUserDid } from './mixins/OxyServices.identity';
235
231
  export type {
236
232
  IdentityRecordType,
237
- UnlinkableAuthMethodType,
238
233
  LinkAuthMethodResult,
239
234
  PublishRecordResult,
240
235
  VerifyRecordResult,
@@ -338,41 +333,55 @@ export {
338
333
  export type { AeadResult } from './crypto/aead';
339
334
  export { deriveSharedSecret } from './crypto/ecdh';
340
335
 
341
- // Web identity carrier — the same identity as Commons, sealed under a passkey's
342
- // PRF output (docs/superpowers/specs/2026-09-15-one-identity-two-carriers-design.md)
336
+ // Web identity holder — the same root as Commons, sealed under a passkey's PRF
337
+ // output; identity proofs; transfer to Commons (docs/adr/0024-one-oxy-account-root-holders.md)
343
338
  export {
344
339
  WEB_IDENTITY_PRF_INPUT,
345
340
  WebIdentityUnlockError,
341
+ WEB_IDENTITY_PRF_OUTPUT_LENGTH,
346
342
  addWrap,
347
- buildIdentityActionMessage,
348
343
  deriveIdentityFromMnemonic,
344
+ deriveIdentityFromPrivateKey,
345
+ deriveIdentityFromRecoveryMaterial,
349
346
  deriveKeyEncryptionKey,
350
- deriveTransferSas,
351
347
  generateDataKey,
352
348
  generateWebIdentity,
349
+ isUsablePrfOutput,
350
+ markWrapVerified,
351
+ normalizeMnemonic,
353
352
  openWebIdentity,
353
+ parseRecoveryMaterial,
354
354
  removeWrap,
355
355
  sealWebIdentity,
356
- signIdentityAction,
357
356
  unlockWebIdentity,
358
357
  unwrapDataKey,
359
358
  wipeBytes,
360
- wrapDataKey,
359
+ wipeOpenedIdentity,
360
+ } from './crypto/webIdentityCarrier';
361
+ export type {
362
+ OpenedMnemonicIdentity,
363
+ OpenedRawKeyIdentity,
364
+ OpenedWebIdentity,
365
+ WebIdentityRecoveryMaterial,
366
+ WebIdentityUnlockFailure,
367
+ WrapInput,
361
368
  } from './crypto/webIdentityCarrier';
362
- export type { OpenedWebIdentity, WebIdentityUnlockFailure } from './crypto/webIdentityCarrier';
369
+ export { digestIdentityPayload, signIdentityProof } from './crypto/identityProof';
363
370
  export {
364
- IDENTITY_MOVE_ACTIONS,
365
- buildMoveMessage,
366
371
  buildMoveQrPayload,
372
+ createMoveCommitment,
367
373
  deriveMoveKey,
368
374
  deriveMoveSas,
375
+ digestMoveCiphertext,
369
376
  generateMoveEphemeralKeyPair,
370
377
  openMovedIdentity,
371
378
  parseMoveQrPayload,
372
379
  sealIdentityForMove,
373
- signMoveAction,
380
+ signMoveReceipt,
381
+ verifyMoveCommitment,
374
382
  verifyMoveReceipt,
375
383
  } from './crypto/identityMove';
384
+ export type { MoveReceiptClaims } from './crypto/identityMove';
376
385
 
377
386
  // ---------------------------------------------------------------------------
378
387
  // Devices
@@ -909,6 +918,8 @@ export type {
909
918
  OxyInferenceResponse,
910
919
  OxyGenerationReceipt,
911
920
  OxyResponsesRequest,
921
+ OxySpeechRequest,
922
+ OxySpeechResponse,
912
923
  } from './inference/OxyInferenceClient';
913
924
 
914
925
  export { runSessionColdBoot } from './boot/sessionColdBoot';