@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
@@ -83,6 +83,33 @@ const TOKEN_REFRESH_COOLDOWN_MS = 15000;
83
83
  * ~15× faster without weakening the storm guard.
84
84
  */
85
85
  const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
86
+ /**
87
+ * Cooldown (ms) applied after the refresh endpoint answered 429 Too Many
88
+ * Requests. Overrides BOTH cooldowns above, because neither of them is about
89
+ * this failure.
90
+ *
91
+ * The other two ask "is the current token still usable?" and answer a server
92
+ * that is unreachable or erroring. A 429 is the opposite situation: the server
93
+ * is perfectly reachable and is telling this client, specifically, that it is
94
+ * asking too often. `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS` then becomes actively
95
+ * harmful — an expired token drives the request-time preflight and the 401
96
+ * retry lanes at one mint per second (60/min) against a mint budget of 30/min,
97
+ * so the client's own retries hold the limiter tripped and it can never fall
98
+ * back under the ceiling. The limit stops being self-clearing and becomes
99
+ * self-sustaining for as long as the app keeps issuing requests.
100
+ *
101
+ * Sized to the mint limiter's own window (60s, `packages/api/src/routes/sessionDevice.ts`)
102
+ * so ONE attempt lands per window: enough to discover the moment the budget has
103
+ * drained, never enough to consume it. The per-device lockout answers 429 for
104
+ * far longer (15 min), and that is fine — one probe a minute against it is
105
+ * negligible traffic and the session recovers the instant the lockout lifts.
106
+ *
107
+ * NOT routed through the scheduler's exponential backoff: that backoff is
108
+ * effect-local to `startTokenRefreshScheduler` and governs only the scheduled
109
+ * lane, while the lanes that actually produce the storm here (preflight, 401
110
+ * retry) are request-driven and are throttled solely by this cooldown.
111
+ */
112
+ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
86
113
  /**
87
114
  * Lead time (seconds) before access-token expiry at which a preflight refresh
88
115
  * is triggered. A token within this window of `exp` is treated as effectively
@@ -164,6 +191,17 @@ class HttpService {
164
191
  * fixed deadline that could not shrink once the token expired mid-cooldown.
165
192
  */
166
193
  this.lastRefreshFailureAt = 0;
194
+ /**
195
+ * Whether the last refresh failure was the server answering 429. Selects
196
+ * {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} over the two token-state cooldowns
197
+ * for the NEXT attempt, and is cleared by any success or any other failure.
198
+ *
199
+ * A boolean beside {@link lastRefreshFailureAt} rather than a stored deadline,
200
+ * for the same reason that field gives: the cooldown stays a function of the
201
+ * current state, so it can still shorten if the situation changes underneath
202
+ * it, and there is exactly one timestamp to reason about.
203
+ */
204
+ this.lastRefreshWasRateLimited = false;
167
205
  this.authRefreshHandler = null;
168
206
  this.accessTokenProvider = null;
169
207
  this.deviceSecretMintInFlight = null;
@@ -887,19 +925,28 @@ class HttpService {
887
925
  if (!this.authRefreshHandler) {
888
926
  return null;
889
927
  }
890
- // Post-failure cooldown. A genuinely EXPIRED current token uses a much
891
- // shorter cooldown than a still-valid (proactive, near-expiry) one: an
892
- // expired token is unusable, so re-mint as soon as the endpoint is reachable
893
- // again rather than waiting out the full window while requests carry a stale
894
- // bearer. Both cooldowns are measured from the last failure, so the moment a
895
- // still-valid token crosses `exp` mid-cooldown the shorter window applies.
896
- const cooldownMs = this.isAccessTokenExpired()
897
- ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
898
- : TOKEN_REFRESH_COOLDOWN_MS;
928
+ // Post-failure cooldown. A 429 names its own window regardless of token
929
+ // state — the server is reachable and is rationing this client, so retrying
930
+ // at the expired-token rate would spend the very budget being waited on.
931
+ // Otherwise a genuinely EXPIRED current token uses a much shorter cooldown
932
+ // than a still-valid (proactive, near-expiry) one: an expired token is
933
+ // unusable, so re-mint as soon as the endpoint is reachable again rather
934
+ // than waiting out the full window while requests carry a stale bearer. All
935
+ // three are measured from the last failure, so the moment a still-valid
936
+ // token crosses `exp` mid-cooldown the shorter window applies.
937
+ const cooldownMs = this.lastRefreshWasRateLimited
938
+ ? RATE_LIMITED_REFRESH_COOLDOWN_MS
939
+ : this.isAccessTokenExpired()
940
+ ? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
941
+ : TOKEN_REFRESH_COOLDOWN_MS;
899
942
  if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
900
943
  return null;
901
944
  }
902
945
  if (!this.tokenRefreshPromise) {
946
+ // Cleared before the attempt, never after it: the handler reports a 429
947
+ // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
948
+ // on the way out would discard the flag it just set.
949
+ this.lastRefreshWasRateLimited = false;
903
950
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
904
951
  .then((newToken) => {
905
952
  if (!newToken) {
@@ -913,6 +960,7 @@ class HttpService {
913
960
  // A success clears the failure timestamp so the next refresh is never
914
961
  // throttled by a stale cooldown.
915
962
  this.lastRefreshFailureAt = 0;
963
+ this.lastRefreshWasRateLimited = false;
916
964
  this.logger.debug('Token refreshed via the auth refresh handler');
917
965
  return newToken;
918
966
  })
@@ -927,6 +975,21 @@ class HttpService {
927
975
  }
928
976
  return this.tokenRefreshPromise;
929
977
  }
978
+ /**
979
+ * Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
980
+ * next attempt waits {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} instead of one of
981
+ * the token-state cooldowns.
982
+ *
983
+ * Called by the refresh handler (`refreshDeviceSecretArm`) from inside the
984
+ * `authRefreshHandler` call, because the handler's `Promise<string | null>`
985
+ * contract cannot carry WHY a refresh failed and 429 is the one failure whose
986
+ * correct retry interval is set by the server rather than by the token.
987
+ *
988
+ * Public because the handler lives in `session/refresh.ts`, not on this class.
989
+ */
990
+ noteRefreshRateLimited() {
991
+ this.lastRefreshWasRateLimited = true;
992
+ }
930
993
  /**
931
994
  * Whether the CURRENT stored access token is already past its `exp`. Drives
932
995
  * the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
@@ -1,29 +1,34 @@
1
1
  "use strict";
2
2
  /**
3
- * Identity move — the crypto of taking a web identity into Commons.
3
+ * Identity move — the crypto of giving a web root to Commons (ADR 0024 D6).
4
4
  *
5
- * Both carriers run this: `id.oxy.so` (the initiator, holding the identity) and
6
- * Commons (the responder, receiving it). The relay in between sees two ephemeral
7
- * public keys and ciphertext; the 6-digit SAS both screens show is what makes a
8
- * relay that swapped a key visible to the person.
5
+ * Both sides run this: the web holder (the initiator, holding the root) and
6
+ * Commons (the responder, receiving it). The relay in between sees a commitment,
7
+ * two ephemeral public keys and ciphertext. The initiator commits to its key
8
+ * before the responder chooses one and reveals it only afterwards, so the 6-digit
9
+ * code both screens show exposes a relay that substituted a key — it cannot grind
10
+ * one until the codes agree. The receipt binds the move, the root, both keys and
11
+ * the ciphertext actually relayed.
9
12
  *
10
13
  * PURE: no storage, no network. Contract: `@oxy.so/contracts` `identityMove`.
11
14
  */
12
15
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.IDENTITY_MOVE_ACTIONS = void 0;
14
16
  exports.generateMoveEphemeralKeyPair = generateMoveEphemeralKeyPair;
15
17
  exports.deriveMoveKey = deriveMoveKey;
18
+ exports.createMoveCommitment = createMoveCommitment;
19
+ exports.verifyMoveCommitment = verifyMoveCommitment;
16
20
  exports.deriveMoveSas = deriveMoveSas;
21
+ exports.digestMoveCiphertext = digestMoveCiphertext;
22
+ exports.signMoveReceipt = signMoveReceipt;
23
+ exports.verifyMoveReceipt = verifyMoveReceipt;
17
24
  exports.sealIdentityForMove = sealIdentityForMove;
18
25
  exports.openMovedIdentity = openMovedIdentity;
19
- exports.buildMoveMessage = buildMoveMessage;
20
- exports.signMoveAction = signMoveAction;
21
- exports.verifyMoveReceipt = verifyMoveReceipt;
22
26
  exports.buildMoveQrPayload = buildMoveQrPayload;
23
27
  exports.parseMoveQrPayload = parseMoveQrPayload;
24
28
  require("./polyfill");
25
29
  const bip39_1 = require("@scure/bip39");
26
30
  const english_1 = require("@scure/bip39/wordlists/english");
31
+ const sha256_1 = require("@noble/hashes/sha256");
27
32
  const utils_1 = require("@noble/hashes/utils");
28
33
  const protocol_1 = require("@oxy.so/protocol");
29
34
  const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
@@ -33,11 +38,9 @@ const ecdh_1 = require("./ecdh");
33
38
  const kdf_1 = require("./kdf");
34
39
  const webIdentityCarrier_1 = require("./webIdentityCarrier");
35
40
  const MOVE_KDF_INFO = (0, utils_1.utf8ToBytes)('oxy-identity-move-v1');
36
- /** Actions the identity key signs during a move. */
37
- exports.IDENTITY_MOVE_ACTIONS = {
38
- seal: 'identity_move_seal',
39
- received: 'identity_move_received',
40
- };
41
+ function sha256Hex(input) {
42
+ return (0, utils_1.bytesToHex)((0, sha256_1.sha256)((0, utils_1.utf8ToBytes)(input)));
43
+ }
41
44
  /** A fresh ephemeral key pair for one move. Keep the private key in memory only. */
42
45
  function generateMoveEphemeralKeyPair() {
43
46
  return (0, secp256k1_1.generateSecp256k1KeyPair)();
@@ -52,15 +55,47 @@ function deriveMoveKey(ownEphemeralPrivateKey, otherEphemeralPublicKey, moveId)
52
55
  (0, webIdentityCarrier_1.wipeBytes)(shared);
53
56
  }
54
57
  }
55
- /** The 6-digit code both screens show, bound to the move and both ephemeral keys in their roles. */
56
- function deriveMoveSas(moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey) {
57
- return (0, webIdentityCarrier_1.deriveTransferSas)({ pairingId: moveId, initiatorEphemeralPublicKey, responderEphemeralPublicKey });
58
+ /** Initiator: commit to the ephemeral key before anyone else's key is known. The nonce stays in memory until the reveal. */
59
+ function createMoveCommitment(initiatorEphemeralPublicKey) {
60
+ const nonceBytes = new Uint8Array(32);
61
+ globalThis.crypto.getRandomValues(nonceBytes);
62
+ const nonce = (0, utils_1.bytesToHex)(nonceBytes);
63
+ return { commitment: sha256Hex((0, contracts_1.buildMoveCommitmentInput)(initiatorEphemeralPublicKey, nonce)), nonce };
64
+ }
65
+ /** Responder: does the revealed key match the commitment read before joining? */
66
+ function verifyMoveCommitment(initiatorEphemeralPublicKey, nonce, commitment) {
67
+ return sha256Hex((0, contracts_1.buildMoveCommitmentInput)(initiatorEphemeralPublicKey, nonce)) === commitment.toLowerCase();
68
+ }
69
+ /** The 6-digit code, bound to the move, both keys in their roles, and the commitment. */
70
+ function deriveMoveSas(input) {
71
+ const digest = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)((0, contracts_1.buildMoveSasInput)(input)));
72
+ const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
73
+ return String(value % 1000000).padStart(6, '0');
74
+ }
75
+ /** The digest a receipt binds: what was actually relayed. */
76
+ function digestMoveCiphertext(sealed) {
77
+ return sha256Hex((0, contracts_1.buildMoveCiphertextDigestInput)(sealed));
78
+ }
79
+ /**
80
+ * Responder: sign the receipt with a signer that reads the root back from durable
81
+ * storage (Commons passes its keychain signer), so a receipt exists only for a
82
+ * root that was actually stored.
83
+ */
84
+ async function signMoveReceipt(sign, claims) {
85
+ return { signature: await sign((0, contracts_1.buildMoveReceiptMessage)(claims)) };
86
+ }
87
+ /** Initiator: is this receipt the root's, for exactly this move and ciphertext? */
88
+ function verifyMoveReceipt(claims, signature) {
89
+ return (0, protocol_1.verifySignature)((0, contracts_1.buildMoveReceiptMessage)(claims), signature, claims.rootPublicKey);
58
90
  }
59
91
  function moveAad(moveId, publicKey) {
60
92
  return (0, utils_1.utf8ToBytes)(JSON.stringify({ v: 1, purpose: 'identity-move', moveId: moveId.toLowerCase(), publicKey: publicKey.toLowerCase() }));
61
93
  }
62
- /** Initiator: seal the identity's entropy for the responder. */
94
+ /** Initiator: seal the phrase entropy (12–24 words) for the responder. */
63
95
  function sealIdentityForMove(identity, moveKey, moveId) {
96
+ if (typeof identity.mnemonic !== 'string' || !identity.mnemonic) {
97
+ throw new Error('Only an identity with a recovery phrase can be moved this way');
98
+ }
64
99
  const entropy = (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist);
65
100
  try {
66
101
  const { nonce, ciphertext } = (0, aead_1.encryptAead)(moveKey, entropy, moveAad(moveId, identity.publicKey));
@@ -93,22 +128,6 @@ function openMovedIdentity(sealed, moveKey, moveId, expectedPublicKey) {
93
128
  (0, webIdentityCarrier_1.wipeBytes)(entropy);
94
129
  }
95
130
  }
96
- /** The exact bytes a move proof or receipt signs. */
97
- function buildMoveMessage(action, moveId, timestamp) {
98
- return JSON.stringify({ action, moveId: moveId.toLowerCase(), timestamp });
99
- }
100
- /** Sign a move action with the identity key. */
101
- async function signMoveAction(identity, action, moveId, timestamp = Date.now()) {
102
- return { signature: await (0, protocol_1.signMessage)(buildMoveMessage(action, moveId, timestamp), identity.privateKey), timestamp };
103
- }
104
- /**
105
- * Initiator: is this receipt really from the identity? Checked locally with the
106
- * identity's public key, so a server that claims "completed" without Commons
107
- * having the key cannot make the web destroy its copy.
108
- */
109
- function verifyMoveReceipt(publicKey, moveId, receipt) {
110
- return (0, protocol_1.verifySignature)(buildMoveMessage(exports.IDENTITY_MOVE_ACTIONS.received, moveId, receipt.timestamp), receipt.signature, publicKey);
111
- }
112
131
  /** The QR payload for a move. */
113
132
  function buildMoveQrPayload(moveId) {
114
133
  return `${contracts_1.IDENTITY_MOVE_QR_PREFIX}${moveId.toLowerCase()}`;
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /**
3
+ * Signing the one identity-proof format (ADR 0024 D7).
4
+ *
5
+ * The bytes come from `@oxy.so/contracts` `buildIdentityProofMessage`, which the
6
+ * API verifies against; payload digests hash `canonicalJson`. Nothing here builds
7
+ * its own JSON.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.digestIdentityPayload = digestIdentityPayload;
11
+ exports.signIdentityProof = signIdentityProof;
12
+ require("./polyfill");
13
+ const sha256_1 = require("@noble/hashes/sha256");
14
+ const utils_1 = require("@noble/hashes/utils");
15
+ const protocol_1 = require("@oxy.so/protocol");
16
+ const contracts_1 = require("@oxy.so/contracts");
17
+ /** SHA-256 hex of the canonical JSON of `payload` — the `payloadDigest` claim. */
18
+ function digestIdentityPayload(payload) {
19
+ return (0, utils_1.bytesToHex)((0, sha256_1.sha256)((0, utils_1.utf8ToBytes)((0, contracts_1.canonicalJson)(payload))));
20
+ }
21
+ /** Sign `claims` with the root, producing the proof as it travels. */
22
+ async function signIdentityProof(identity, claims) {
23
+ if (claims.rootPublicKey !== identity.publicKey.toLowerCase()) {
24
+ throw new Error('identity proof: the claims name a different root');
25
+ }
26
+ const signature = await (0, protocol_1.signMessage)((0, contracts_1.buildIdentityProofMessage)(claims), identity.privateKey);
27
+ return { v: contracts_1.IDENTITY_PROOF_VERSION, challenge: claims.challenge, expiresAt: claims.expiresAt, signature };
28
+ }
@@ -1,19 +1,19 @@
1
1
  "use strict";
2
2
  /**
3
- * Web identity carrier — the crypto of "one identity, two carriers".
3
+ * Web identity holder — the crypto of a browser holding a personal root (ADR 0024).
4
4
  *
5
- * An Oxy identity is a BIP-39 mnemonic; its seed's first 32 bytes are the
6
- * secp256k1 key (`recoveryPhrase.ts`). Commons keeps that key in the device
7
- * keychain. This module lets a browser carry the SAME identity without Oxy ever
8
- * being able to use it:
5
+ * An Oxy root is usually a BIP-39 mnemonic whose seed's first 32 bytes are the
6
+ * secp256k1 key (`recoveryPhrase.ts`), and for a few imported identities a raw
7
+ * private key with no phrase. Commons keeps it in the device keychain. This
8
+ * module lets a browser hold the SAME root without Oxy ever being able to use it:
9
9
  *
10
- * entropy ──AEAD(DEK)──▶ sealedEntropy
10
+ secret ──AEAD(DEK)──▶ sealed secret (phrase entropy or raw key)
11
11
  * DEK ──AEAD(KEK_i)─▶ wraps[i] KEK_i = HKDF(PRF output of passkey i)
12
12
  *
13
13
  * The PRF output is produced inside the user's authenticator, behind user
14
14
  * verification, and never leaves the page that asked for it. The envelope is
15
- * therefore safe to store anywhere — the identity origin's IndexedDB and the
16
- * server copy alike — and opens only with a registered passkey or the phrase.
15
+ * therefore safe to store anywhere — the holder host's IndexedDB and the server
16
+ * copy alike — and opens only with a registered passkey or the recovery material.
17
17
  *
18
18
  * PURE: no storage, no network, no WebAuthn call, no platform globals beyond the
19
19
  * CSPRNG the AEAD polyfill guarantees. The caller runs the ceremony and hands the
@@ -23,28 +23,30 @@
23
23
  * Contract: `WebIdentityEnvelope` in `@oxy.so/contracts`.
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.WebIdentityUnlockError = exports.WEB_IDENTITY_PRF_INPUT = void 0;
26
+ exports.WebIdentityUnlockError = exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH = exports.WEB_IDENTITY_PRF_INPUT = void 0;
27
27
  exports.wipeBytes = wipeBytes;
28
+ exports.wipeOpenedIdentity = wipeOpenedIdentity;
29
+ exports.normalizeMnemonic = normalizeMnemonic;
28
30
  exports.deriveIdentityFromMnemonic = deriveIdentityFromMnemonic;
31
+ exports.deriveIdentityFromPrivateKey = deriveIdentityFromPrivateKey;
32
+ exports.parseRecoveryMaterial = parseRecoveryMaterial;
33
+ exports.deriveIdentityFromRecoveryMaterial = deriveIdentityFromRecoveryMaterial;
29
34
  exports.generateWebIdentity = generateWebIdentity;
30
35
  exports.generateDataKey = generateDataKey;
36
+ exports.isUsablePrfOutput = isUsablePrfOutput;
31
37
  exports.deriveKeyEncryptionKey = deriveKeyEncryptionKey;
32
- exports.wrapDataKey = wrapDataKey;
33
38
  exports.sealWebIdentity = sealWebIdentity;
34
39
  exports.unwrapDataKey = unwrapDataKey;
35
40
  exports.openWebIdentity = openWebIdentity;
36
41
  exports.unlockWebIdentity = unlockWebIdentity;
37
42
  exports.addWrap = addWrap;
43
+ exports.markWrapVerified = markWrapVerified;
38
44
  exports.removeWrap = removeWrap;
39
- exports.buildIdentityActionMessage = buildIdentityActionMessage;
40
- exports.signIdentityAction = signIdentityAction;
41
- exports.deriveTransferSas = deriveTransferSas;
42
45
  require("./polyfill");
43
46
  const bip39_1 = require("@scure/bip39");
44
47
  const english_1 = require("@scure/bip39/wordlists/english");
45
48
  const sha256_1 = require("@noble/hashes/sha256");
46
49
  const utils_1 = require("@noble/hashes/utils");
47
- const protocol_1 = require("@oxy.so/protocol");
48
50
  const secp256k1_1 = require("@oxy.so/protocol/secp256k1");
49
51
  const contracts_1 = require("@oxy.so/contracts");
50
52
  const aead_1 = require("./aead");
@@ -57,10 +59,10 @@ const kdf_1 = require("./kdf");
57
59
  * scheme change bumps the label — and with it every KEK — instead of mutating it.
58
60
  */
59
61
  exports.WEB_IDENTITY_PRF_INPUT = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)('oxy-web-identity-carrier/prf/v1'));
60
- /** HKDF salt of the key-encryption-key schedule. Versioned with the envelope. */
62
+ /** HKDF salt of the key-encryption-key schedule. Shared by envelope versions 1 and 2. */
61
63
  const KEK_SALT = (0, utils_1.utf8ToBytes)('oxy-web-identity-carrier/kek/v1');
62
- /** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). */
63
- const PRF_OUTPUT_LENGTH = 32;
64
+ /** A WebAuthn PRF output is 32 bytes (HMAC-SHA-256). Nothing shorter or longer is usable. */
65
+ exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH = 32;
64
66
  class WebIdentityUnlockError extends Error {
65
67
  constructor(failure, message) {
66
68
  super(message);
@@ -72,22 +74,34 @@ exports.WebIdentityUnlockError = WebIdentityUnlockError;
72
74
  function aadFor(parts) {
73
75
  return (0, utils_1.utf8ToBytes)(JSON.stringify(parts));
74
76
  }
75
- function entropyAad(publicKey) {
76
- return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'entropy', publicKey });
77
+ function secretAad(publicKey, secretKind) {
78
+ return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'secret', secretKind, publicKey });
77
79
  }
78
- function wrapAad(publicKey, credentialId) {
79
- return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId });
80
+ function wrapAad(publicKey, credentialId, rpId) {
81
+ return aadFor({ v: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION, purpose: 'wrap', publicKey, credentialId, rpId });
80
82
  }
81
83
  /** Overwrite a buffer holding secret material. Best effort — JS gives no stronger guarantee. */
82
84
  function wipeBytes(bytes) {
83
85
  bytes.fill(0);
84
86
  }
87
+ /** Best-effort removal of secret strings from an opened identity. */
88
+ function wipeOpenedIdentity(identity) {
89
+ identity.privateKey = '';
90
+ if (identity.kind === 'mnemonic')
91
+ identity.mnemonic = '';
92
+ }
93
+ /** Normalize a typed phrase: trimmed, lowercase, single spaces. */
94
+ function normalizeMnemonic(mnemonic) {
95
+ return mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
96
+ }
85
97
  /**
86
98
  * Derive the identity from a mnemonic exactly as Commons does
87
- * (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1.
99
+ * (`RecoveryPhraseService`): seed → first 32 bytes → secp256k1. Any standard
100
+ * BIP-39 length (12, 15, 18, 21, 24 words) is accepted; the derivation does not
101
+ * depend on the length.
88
102
  */
89
103
  function deriveIdentityFromMnemonic(mnemonic) {
90
- const normalized = mnemonic.trim().toLowerCase().split(/\s+/).join(' ');
104
+ const normalized = normalizeMnemonic(mnemonic);
91
105
  if (!(0, bip39_1.validateMnemonic)(normalized, english_1.wordlist)) {
92
106
  throw new Error('Invalid recovery phrase');
93
107
  }
@@ -96,7 +110,41 @@ function deriveIdentityFromMnemonic(mnemonic) {
96
110
  wipeBytes(seed);
97
111
  const privateKey = (0, utils_1.bytesToHex)(privateKeyBytes);
98
112
  wipeBytes(privateKeyBytes);
99
- return { mnemonic: normalized, privateKey, publicKey: (0, secp256k1_1.deriveSecp256k1PublicKey)(privateKey) };
113
+ return { kind: 'mnemonic', mnemonic: normalized, privateKey, publicKey: (0, secp256k1_1.deriveSecp256k1PublicKey)(privateKey) };
114
+ }
115
+ /** A raw 32-byte private key (hex, optional `0x`) as an identity. Never gains a phrase. */
116
+ function deriveIdentityFromPrivateKey(privateKey) {
117
+ const normalized = privateKey.trim().toLowerCase().replace(/^0x/, '');
118
+ if (!/^[0-9a-f]{64}$/.test(normalized)) {
119
+ throw new Error('Invalid private key');
120
+ }
121
+ let publicKey;
122
+ try {
123
+ publicKey = (0, secp256k1_1.deriveSecp256k1PublicKey)(normalized);
124
+ }
125
+ catch {
126
+ throw new Error('Invalid private key');
127
+ }
128
+ return { kind: 'raw-key', mnemonic: null, privateKey: normalized, publicKey };
129
+ }
130
+ /**
131
+ * Read what a person typed as recovery material: a BIP-39 phrase, or a 64-hex
132
+ * private key. Throws `Invalid recovery material` for anything else.
133
+ */
134
+ function parseRecoveryMaterial(input) {
135
+ const trimmed = input.trim();
136
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(trimmed))
137
+ return { kind: 'raw-key', privateKey: trimmed };
138
+ const words = normalizeMnemonic(trimmed);
139
+ if (words && (0, bip39_1.validateMnemonic)(words, english_1.wordlist))
140
+ return { kind: 'mnemonic', mnemonic: words };
141
+ throw new Error('Invalid recovery material');
142
+ }
143
+ /** Derive the identity recovery material names. */
144
+ function deriveIdentityFromRecoveryMaterial(material) {
145
+ return material.kind === 'mnemonic'
146
+ ? deriveIdentityFromMnemonic(material.mnemonic)
147
+ : deriveIdentityFromPrivateKey(material.privateKey);
100
148
  }
101
149
  /** A brand-new 12-word identity. Nothing is persisted. */
102
150
  function generateWebIdentity() {
@@ -108,29 +156,40 @@ function generateDataKey() {
108
156
  globalThis.crypto.getRandomValues(key);
109
157
  return key;
110
158
  }
159
+ /** Whether `bytes` is a usable PRF output: exactly 32 bytes. */
160
+ function isUsablePrfOutput(bytes) {
161
+ return bytes instanceof Uint8Array && bytes.byteLength === exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH;
162
+ }
111
163
  /** The key-encryption key a passkey's PRF output yields for its own wrap. */
112
164
  function deriveKeyEncryptionKey(prfOutput, credentialId) {
113
- if (prfOutput.length !== PRF_OUTPUT_LENGTH) {
114
- throw new Error(`PRF output must be ${PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
165
+ if (prfOutput.length !== exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH) {
166
+ throw new Error(`PRF output must be ${exports.WEB_IDENTITY_PRF_OUTPUT_LENGTH} bytes, got ${prfOutput.length}`);
115
167
  }
116
168
  return (0, kdf_1.hkdfSha256)(prfOutput, KEK_SALT, (0, utils_1.utf8ToBytes)(credentialId), aead_1.AEAD_KEY_LENGTH);
117
169
  }
118
- /** Wrap `dataKey` for one passkey. */
119
- function wrapDataKey(dataKey, prfOutput, credentialId, publicKey, now = new Date()) {
120
- const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
170
+ function wrapFor(publicKey, dataKey, input, now) {
171
+ const kek = deriveKeyEncryptionKey(input.prfOutput, input.credentialId);
121
172
  try {
122
- const { nonce, ciphertext } = (0, aead_1.encryptAead)(kek, dataKey, wrapAad(publicKey, credentialId));
173
+ const { nonce, ciphertext } = (0, aead_1.encryptAead)(kek, dataKey, wrapAad(publicKey, input.credentialId, input.rpId));
123
174
  return {
124
- credentialId,
175
+ credentialId: input.credentialId,
125
176
  nonce: (0, utils_1.bytesToHex)(nonce),
126
177
  wrappedKey: (0, utils_1.bytesToHex)(ciphertext),
127
178
  createdAt: now.toISOString(),
179
+ rpId: input.rpId,
180
+ ...(input.verifiedAt ? { verifiedAt: input.verifiedAt } : {}),
128
181
  };
129
182
  }
130
183
  finally {
131
184
  wipeBytes(kek);
132
185
  }
133
186
  }
187
+ function secretOf(identity) {
188
+ if (identity.kind === 'mnemonic') {
189
+ return { kind: 'mnemonic-entropy', bytes: (0, bip39_1.mnemonicToEntropy)(identity.mnemonic, english_1.wordlist) };
190
+ }
191
+ return { kind: 'raw-private-key', bytes: (0, utils_1.hexToBytes)(identity.privateKey) };
192
+ }
134
193
  /**
135
194
  * Seal an identity into a new envelope that opens with the given passkey.
136
195
  *
@@ -138,26 +197,34 @@ function wrapDataKey(dataKey, prfOutput, credentialId, publicKey, now = new Date
138
197
  * session; wipe it when done.
139
198
  */
140
199
  function sealWebIdentity(identity, firstWrap, now = new Date()) {
141
- const derived = deriveIdentityFromMnemonic(identity.mnemonic);
142
- if (derived.publicKey !== identity.publicKey.toLowerCase()) {
143
- throw new Error('The recovery phrase does not belong to this identity');
200
+ const derived = identity.kind === 'raw-key' ? deriveIdentityFromPrivateKey(identity.privateKey) : deriveIdentityFromMnemonic(identity.mnemonic);
201
+ const publicKey = identity.publicKey.toLowerCase();
202
+ if (derived.publicKey !== publicKey) {
203
+ wipeOpenedIdentity(derived);
204
+ throw new Error('The recovery material does not belong to this identity');
144
205
  }
206
+ const secret = secretOf(derived);
207
+ wipeOpenedIdentity(derived);
145
208
  const dataKey = generateDataKey();
146
- const entropy = (0, bip39_1.mnemonicToEntropy)(derived.mnemonic, english_1.wordlist);
147
209
  try {
148
- const { nonce, ciphertext } = (0, aead_1.encryptAead)(dataKey, entropy, entropyAad(derived.publicKey));
210
+ const { nonce, ciphertext } = (0, aead_1.encryptAead)(dataKey, secret.bytes, secretAad(publicKey, secret.kind));
149
211
  const envelope = {
150
212
  version: contracts_1.WEB_IDENTITY_ENVELOPE_VERSION,
151
213
  algorithm: 'xchacha20poly1305',
152
- publicKey: derived.publicKey,
153
- entropyNonce: (0, utils_1.bytesToHex)(nonce),
154
- sealedEntropy: (0, utils_1.bytesToHex)(ciphertext),
155
- wraps: [wrapDataKey(dataKey, firstWrap.prfOutput, firstWrap.credentialId, derived.publicKey, now)],
214
+ publicKey,
215
+ secretKind: secret.kind,
216
+ secretNonce: (0, utils_1.bytesToHex)(nonce),
217
+ sealedSecret: (0, utils_1.bytesToHex)(ciphertext),
218
+ wraps: [wrapFor(publicKey, dataKey, firstWrap, now)],
156
219
  };
157
220
  return { envelope, dataKey };
158
221
  }
222
+ catch (error) {
223
+ wipeBytes(dataKey);
224
+ throw error;
225
+ }
159
226
  finally {
160
- wipeBytes(entropy);
227
+ wipeBytes(secret.bytes);
161
228
  }
162
229
  }
163
230
  /** Recover the data key with one passkey's PRF output. */
@@ -168,7 +235,7 @@ function unwrapDataKey(envelope, prfOutput, credentialId) {
168
235
  }
169
236
  const kek = deriveKeyEncryptionKey(prfOutput, credentialId);
170
237
  try {
171
- return (0, aead_1.decryptAead)(kek, (0, utils_1.hexToBytes)(wrap.nonce), (0, utils_1.hexToBytes)(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId));
238
+ return (0, aead_1.decryptAead)(kek, (0, utils_1.hexToBytes)(wrap.nonce), (0, utils_1.hexToBytes)(wrap.wrappedKey), wrapAad(envelope.publicKey, credentialId, wrap.rpId));
172
239
  }
173
240
  catch {
174
241
  throw new WebIdentityUnlockError('prf-mismatch', 'This passkey returned a different secret than when it was registered');
@@ -180,26 +247,38 @@ function unwrapDataKey(envelope, prfOutput, credentialId) {
180
247
  /**
181
248
  * Open an envelope with its data key.
182
249
  *
183
- * The derived public key is checked against the envelope's own: a sealed entropy
250
+ * The derived public key is checked against the envelope's own: a sealed secret
184
251
  * that decrypts into a different identity is treated as corruption, never used.
185
252
  */
186
253
  function openWebIdentity(envelope, dataKey) {
187
- let entropy;
254
+ const publicKey = envelope.publicKey.toLowerCase();
255
+ const kind = envelope.secretKind;
256
+ let secret;
188
257
  try {
189
- entropy = (0, aead_1.decryptAead)(dataKey, (0, utils_1.hexToBytes)(envelope.entropyNonce), (0, utils_1.hexToBytes)(envelope.sealedEntropy), entropyAad(envelope.publicKey));
258
+ secret = (0, aead_1.decryptAead)(dataKey, (0, utils_1.hexToBytes)(envelope.secretNonce), (0, utils_1.hexToBytes)(envelope.sealedSecret), secretAad(envelope.publicKey, kind));
190
259
  }
191
260
  catch {
192
261
  throw new WebIdentityUnlockError('corrupt', 'The sealed identity could not be opened');
193
262
  }
194
263
  try {
195
- const identity = deriveIdentityFromMnemonic((0, bip39_1.entropyToMnemonic)(entropy, english_1.wordlist));
196
- if (identity.publicKey !== envelope.publicKey.toLowerCase()) {
264
+ let identity;
265
+ try {
266
+ identity =
267
+ kind === 'mnemonic-entropy'
268
+ ? deriveIdentityFromMnemonic((0, bip39_1.entropyToMnemonic)(secret, english_1.wordlist))
269
+ : deriveIdentityFromPrivateKey((0, utils_1.bytesToHex)(secret));
270
+ }
271
+ catch {
272
+ throw new WebIdentityUnlockError('corrupt', 'The sealed identity is malformed');
273
+ }
274
+ if (identity.publicKey !== publicKey) {
275
+ wipeOpenedIdentity(identity);
197
276
  throw new WebIdentityUnlockError('corrupt', 'The sealed identity does not match its public key');
198
277
  }
199
278
  return identity;
200
279
  }
201
280
  finally {
202
- wipeBytes(entropy);
281
+ wipeBytes(secret);
203
282
  }
204
283
  }
205
284
  /** Unwrap and open in one step. */
@@ -213,13 +292,27 @@ function unlockWebIdentity(envelope, prfOutput, credentialId) {
213
292
  }
214
293
  }
215
294
  /** Add (or replace) one passkey's wrap. Requires the data key of an unlocked envelope. */
216
- function addWrap(envelope, dataKey, prfOutput, credentialId, now = new Date()) {
295
+ function addWrap(envelope, dataKey, wrapInput, now = new Date()) {
217
296
  // Proves `dataKey` belongs to THIS envelope before anything new can open it.
218
- openWebIdentity(envelope, dataKey);
219
- const others = envelope.wraps.filter((entry) => entry.credentialId !== credentialId);
297
+ wipeOpenedIdentity(openWebIdentity(envelope, dataKey));
298
+ const others = envelope.wraps.filter((entry) => entry.credentialId !== wrapInput.credentialId);
299
+ return {
300
+ ...envelope,
301
+ wraps: [...others, wrapFor(envelope.publicKey, dataKey, wrapInput, now)],
302
+ };
303
+ }
304
+ /**
305
+ * Record that a wrap's passkey opened the envelope in a ceremony of its own. The
306
+ * AEAD does not cover `verifiedAt`; it is holder metadata the owner writes with a
307
+ * root proof, not a cryptographic claim.
308
+ */
309
+ function markWrapVerified(envelope, credentialId, now = new Date()) {
310
+ if (!envelope.wraps.some((entry) => entry.credentialId === credentialId)) {
311
+ throw new WebIdentityUnlockError('unknown-credential', 'This passkey cannot open this identity');
312
+ }
220
313
  return {
221
314
  ...envelope,
222
- wraps: [...others, wrapDataKey(dataKey, prfOutput, credentialId, envelope.publicKey, now)],
315
+ wraps: envelope.wraps.map((entry) => entry.credentialId === credentialId ? { ...entry, verifiedAt: now.toISOString() } : entry),
223
316
  };
224
317
  }
225
318
  /** Remove one passkey's wrap. The last wrap can never be removed — an envelope nobody can open is a loss. */
@@ -230,33 +323,3 @@ function removeWrap(envelope, credentialId) {
230
323
  }
231
324
  return { ...envelope, wraps: remaining };
232
325
  }
233
- /**
234
- * The message an identity key signs to authorize an account action
235
- * (`link_identity`, `web_envelope_delete`, …): `JSON.stringify({ action, userId,
236
- * timestamp })`, byte-identical to what the API reconstructs.
237
- */
238
- function buildIdentityActionMessage(action, userId, timestamp) {
239
- return JSON.stringify({ action, userId, timestamp });
240
- }
241
- /** Sign an identity-action message with an opened identity's key. */
242
- async function signIdentityAction(identity, action, userId, timestamp = Date.now()) {
243
- const signature = await (0, protocol_1.signMessage)(buildIdentityActionMessage(action, userId, timestamp), identity.privateKey);
244
- return { signature, timestamp };
245
- }
246
- /**
247
- * The 6-digit short authentication string both sides of an identity transfer show.
248
- *
249
- * Bound to the pairing and to BOTH ephemeral public keys in their roles, so a relay
250
- * that substitutes either key produces a different code on each screen. The user
251
- * comparing the two codes is what makes the transfer safe against the relay itself.
252
- */
253
- function deriveTransferSas(input) {
254
- const digest = (0, sha256_1.sha256)((0, utils_1.utf8ToBytes)(JSON.stringify({
255
- v: 'oxy-identity-transfer-sas-v1',
256
- pairingId: input.pairingId.toLowerCase(),
257
- initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
258
- responder: input.responderEphemeralPublicKey.toLowerCase(),
259
- })));
260
- const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
261
- return String(value % 1000000).padStart(6, '0');
262
- }