@oxy.so/core 1.5.0 → 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 (131) 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/OxyServices.d.ts +3 -0
  76. package/dist/types/crypto/identityMove.d.ts +48 -32
  77. package/dist/types/crypto/identityProof.d.ts +16 -0
  78. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  79. package/dist/types/index.d.ts +7 -6
  80. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  81. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  82. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  83. package/dist/types/mixins/OxyServices.utility.d.ts +2 -0
  84. package/dist/types/mixins/index.d.ts +1 -2
  85. package/dist/types/server/index.d.ts +8 -0
  86. package/dist/types/server/securityHeaders.d.ts +8 -0
  87. package/dist/types/server/workloadIdentity.d.ts +32 -0
  88. package/package.json +1 -1
  89. package/src/HttpService.ts +74 -9
  90. package/src/OxyServices.ts +9 -0
  91. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  92. package/src/__tests__/publicInterfaceParity.test.ts +75 -0
  93. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  94. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  95. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  96. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  97. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  98. package/src/crypto/identityMove.ts +77 -43
  99. package/src/crypto/identityProof.ts +36 -0
  100. package/src/crypto/webIdentityCarrier.ts +185 -117
  101. package/src/i18n/locales/ar-SA.json +1 -0
  102. package/src/i18n/locales/ca-ES.json +1 -0
  103. package/src/i18n/locales/de-DE.json +1 -0
  104. package/src/i18n/locales/en-US.json +1 -0
  105. package/src/i18n/locales/es-ES.json +1 -0
  106. package/src/i18n/locales/fr-FR.json +1 -0
  107. package/src/i18n/locales/it-IT.json +1 -0
  108. package/src/i18n/locales/ja-JP.json +1 -0
  109. package/src/i18n/locales/ko-KR.json +1 -0
  110. package/src/i18n/locales/pt-PT.json +1 -0
  111. package/src/i18n/locales/zh-CN.json +1 -0
  112. package/src/index.ts +26 -15
  113. package/src/inference/OxyInferenceClient.ts +84 -7
  114. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  115. package/src/mixins/OxyServices.auth.ts +100 -1
  116. package/src/mixins/OxyServices.identity.ts +17 -67
  117. package/src/mixins/OxyServices.utility.ts +2 -0
  118. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  119. package/src/mixins/index.ts +0 -3
  120. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  121. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  122. package/src/server/index.ts +9 -0
  123. package/src/server/securityHeaders.ts +19 -6
  124. package/src/server/workloadIdentity.ts +207 -0
  125. package/src/session/__tests__/refresh.test.ts +53 -3
  126. package/src/session/refresh.ts +12 -0
  127. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  128. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  129. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  130. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  131. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Identity move — the crypto of taking a web identity into Commons.
2
+ * Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
3
3
  *
4
- * Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
5
- * Commons (the responder, receiving it). The relay in between sees two ephemeral
6
- * public keys and ciphertext; the 6-digit SAS both screens show is what makes a
7
- * relay that swapped a key visible to the person.
4
+ * Both sides run this: the web holder (the initiator, holding the root) and
5
+ * Commons (the responder, receiving it). The relay in between sees a commitment,
6
+ * two ephemeral public keys and ciphertext. The initiator commits to its key
7
+ * before the responder chooses one and reveals it only afterwards, so the 6-digit
8
+ * code both screens show exposes a relay that substituted a key — it cannot grind
9
+ * one until the codes agree. The receipt binds the move, the root, both keys and
10
+ * the ciphertext actually relayed.
8
11
  *
9
12
  * PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
10
13
  */
@@ -12,22 +15,27 @@
12
15
  import './polyfill';
13
16
  import { entropyToMnemonic, mnemonicToEntropy } from '@scure/bip39';
14
17
  import { wordlist } from '@scure/bip39/wordlists/english';
18
+ import { sha256 } from '@noble/hashes/sha256';
15
19
  import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
16
- import { signMessage, verifySignature } from '@oxy.so/protocol';
20
+ import { verifySignature } from '@oxy.so/protocol';
17
21
  import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
18
- import { IDENTITY_MOVE_QR_PREFIX } from '@oxy.so/contracts';
22
+ import {
23
+ IDENTITY_MOVE_QR_PREFIX,
24
+ buildMoveCiphertextDigestInput,
25
+ buildMoveCommitmentInput,
26
+ buildMoveReceiptMessage,
27
+ buildMoveSasInput,
28
+ } from '@oxy.so/contracts';
19
29
  import { decryptAead, encryptAead, AEAD_KEY_LENGTH } from './aead';
20
30
  import { deriveSharedSecret } from './ecdh';
21
31
  import { hkdfSha256 } from './kdf';
22
- import { deriveIdentityFromMnemonic, deriveTransferSas, wipeBytes, type OpenedWebIdentity } from './webIdentityCarrier';
32
+ import { deriveIdentityFromMnemonic, wipeBytes, type OpenedMnemonicIdentity } from './webIdentityCarrier';
23
33
 
24
34
  const MOVE_KDF_INFO = utf8ToBytes('oxy-identity-move-v1');
25
35
 
26
- /** Actions the identity key signs during a move. */
27
- export const IDENTITY_MOVE_ACTIONS = {
28
- seal: 'identity_move_seal',
29
- received: 'identity_move_received',
30
- } as const;
36
+ function sha256Hex(input: string): string {
37
+ return bytesToHex(sha256(utf8ToBytes(input)));
38
+ }
31
39
 
32
40
  /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
33
41
  export function generateMoveEphemeralKeyPair(): { privateKey: string; publicKey: string } {
@@ -44,21 +52,71 @@ export function deriveMoveKey(ownEphemeralPrivateKey: string, otherEphemeralPubl
44
52
  }
45
53
  }
46
54
 
47
- /** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
48
- export function deriveMoveSas(moveId: string, initiatorEphemeralPublicKey: string, responderEphemeralPublicKey: string): string {
49
- return deriveTransferSas({ pairingId: moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey });
55
+ /** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
56
+ export function createMoveCommitment(initiatorEphemeralPublicKey: string): { commitment: string; nonce: string } {
57
+ const nonceBytes = new Uint8Array(32);
58
+ globalThis.crypto.getRandomValues(nonceBytes);
59
+ const nonce = bytesToHex(nonceBytes);
60
+ return { commitment: sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)), nonce };
61
+ }
62
+
63
+ /** Responder: does the revealed key match the commitment read before joining? */
64
+ export function verifyMoveCommitment(initiatorEphemeralPublicKey: string, nonce: string, commitment: string): boolean {
65
+ return sha256Hex(buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
66
+ }
67
+
68
+ /** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
69
+ export function deriveMoveSas(input: {
70
+ moveId: string;
71
+ initiatorEphemeralPublicKey: string;
72
+ responderEphemeralPublicKey: string;
73
+ initiatorCommitment: string;
74
+ }): string {
75
+ const digest = sha256(utf8ToBytes(buildMoveSasInput(input)));
76
+ const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
77
+ return String(value % 1_000_000).padStart(6, '0');
78
+ }
79
+
80
+ /** The digest a receipt binds: what was actually relayed. */
81
+ export function digestMoveCiphertext(sealed: { nonce: string; ciphertext: string }): string {
82
+ return sha256Hex(buildMoveCiphertextDigestInput(sealed));
83
+ }
84
+
85
+ export interface MoveReceiptClaims {
86
+ moveId: string;
87
+ rootPublicKey: string;
88
+ initiatorEphemeralPublicKey: string;
89
+ responderEphemeralPublicKey: string;
90
+ ciphertextDigest: string;
91
+ }
92
+
93
+ /**
94
+ * Responder: sign the receipt with a signer that reads the root back from durable
95
+ * storage (Commons passes its keychain signer), so a receipt exists only for a
96
+ * root that was actually stored.
97
+ */
98
+ export async function signMoveReceipt(sign: (message: string) => Promise<string>, claims: MoveReceiptClaims): Promise<{ signature: string }> {
99
+ return { signature: await sign(buildMoveReceiptMessage(claims)) };
100
+ }
101
+
102
+ /** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
103
+ export function verifyMoveReceipt(claims: MoveReceiptClaims, signature: string): Promise<boolean> {
104
+ return verifySignature(buildMoveReceiptMessage(claims), signature, claims.rootPublicKey);
50
105
  }
51
106
 
52
107
  function moveAad(moveId: string, publicKey: string): Uint8Array {
53
108
  return utf8ToBytes(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
54
109
  }
55
110
 
56
- /** Initiator: seal the identity's entropy for the responder. */
111
+ /** Initiator: seal the phrase entropy (12–24 words) for the responder. */
57
112
  export function sealIdentityForMove(
58
- identity: Pick<OpenedWebIdentity, 'mnemonic' | 'publicKey'>,
113
+ identity: Pick<OpenedMnemonicIdentity, 'mnemonic' | 'publicKey'>,
59
114
  moveKey: Uint8Array,
60
115
  moveId: string,
61
116
  ): { nonce: string; ciphertext: string } {
117
+ if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
118
+ throw new Error('Only an identity with a recovery phrase can be moved this way');
119
+ }
62
120
  const entropy = mnemonicToEntropy(identity.mnemonic, wordlist);
63
121
  try {
64
122
  const { nonce, ciphertext } = encryptAead(moveKey, entropy, moveAad(moveId, identity.publicKey));
@@ -77,7 +135,7 @@ export function openMovedIdentity(
77
135
  moveKey: Uint8Array,
78
136
  moveId: string,
79
137
  expectedPublicKey: string,
80
- ): OpenedWebIdentity {
138
+ ): OpenedMnemonicIdentity {
81
139
  let entropy: Uint8Array;
82
140
  try {
83
141
  entropy = decryptAead(moveKey, hexToBytes(sealed.nonce), hexToBytes(sealed.ciphertext), moveAad(moveId, expectedPublicKey));
@@ -95,30 +153,6 @@ export function openMovedIdentity(
95
153
  }
96
154
  }
97
155
 
98
- /** The exact bytes a move proof or receipt signs. */
99
- export function buildMoveMessage(action: string, moveId: string, timestamp: number): string {
100
- return JSON.stringify({ action, moveId: moveId.toLowerCase(), timestamp });
101
- }
102
-
103
- /** Sign a move action with the identity key. */
104
- export async function signMoveAction(
105
- identity: Pick<OpenedWebIdentity, 'privateKey'>,
106
- action: string,
107
- moveId: string,
108
- timestamp: number = Date.now(),
109
- ): Promise<{ signature: string; timestamp: number }> {
110
- return { signature: await signMessage(buildMoveMessage(action, moveId, timestamp), identity.privateKey), timestamp };
111
- }
112
-
113
- /**
114
- * Initiator: is this receipt really from the identity? Checked locally with the
115
- * identity's public key, so a server that claims "completed" without Commons
116
- * having the key cannot make the web destroy its copy.
117
- */
118
- export function verifyMoveReceipt(publicKey: string, moveId: string, receipt: { signature: string; timestamp: number }): Promise<boolean> {
119
- return verifySignature(buildMoveMessage(IDENTITY_MOVE_ACTIONS.received, moveId, receipt.timestamp), receipt.signature, publicKey);
120
- }
121
-
122
156
  /** The QR payload for a move. */
123
157
  export function buildMoveQrPayload(moveId: string): string {
124
158
  return `${IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Signing the one identity-proof format (ADR 0024 D7).
3
+ *
4
+ * The bytes come from `@oxy.so/contracts` `buildIdentityProofMessage`, which the
5
+ * API verifies against; payload digests hash `canonicalJson`. Nothing here builds
6
+ * its own JSON.
7
+ */
8
+
9
+ import './polyfill';
10
+ import { sha256 } from '@noble/hashes/sha256';
11
+ import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils';
12
+ import { signMessage } from '@oxy.so/protocol';
13
+ import {
14
+ IDENTITY_PROOF_VERSION,
15
+ buildIdentityProofMessage,
16
+ canonicalJson,
17
+ type IdentityProof,
18
+ type IdentityProofClaims,
19
+ } from '@oxy.so/contracts';
20
+
21
+ /** SHA-256 hex of the canonical JSON of `payload` — the `payloadDigest` claim. */
22
+ export function digestIdentityPayload(payload: unknown): string {
23
+ return bytesToHex(sha256(utf8ToBytes(canonicalJson(payload))));
24
+ }
25
+
26
+ /** Sign `claims` with the root, producing the proof as it travels. */
27
+ export async function signIdentityProof(
28
+ identity: { privateKey: string; publicKey: string },
29
+ claims: IdentityProofClaims,
30
+ ): Promise<IdentityProof> {
31
+ if (claims.rootPublicKey !== identity.publicKey.toLowerCase()) {
32
+ throw new Error('identity proof: the claims name a different root');
33
+ }
34
+ const signature = await signMessage(buildIdentityProofMessage(claims), identity.privateKey);
35
+ return { v: IDENTITY_PROOF_VERSION, challenge: claims.challenge, expiresAt: claims.expiresAt, signature };
36
+ }
@@ -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
- }