@oxy.so/core 1.9.1 → 1.11.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 (71) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/HttpService.js +56 -163
  4. package/dist/cjs/OxyServices.base.js +4 -2
  5. package/dist/cjs/boot/sessionColdBoot.js +3 -0
  6. package/dist/cjs/crypto/deviceBackup.js +75 -0
  7. package/dist/cjs/crypto/keyManager.js +285 -12
  8. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
  9. package/dist/cjs/mixins/OxyServices.user.js +7 -6
  10. package/dist/cjs/mixins/index.js +4 -0
  11. package/dist/cjs/server/cors.js +0 -1
  12. package/dist/cjs/server/securityHeaders.js +3 -0
  13. package/dist/cjs/session/refresh.js +32 -2
  14. package/dist/cjs/utils/errorUtils.js +1 -1
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/HttpService.js +56 -163
  17. package/dist/esm/OxyServices.base.js +4 -2
  18. package/dist/esm/boot/sessionColdBoot.js +3 -0
  19. package/dist/esm/crypto/deviceBackup.js +70 -0
  20. package/dist/esm/crypto/keyManager.js +252 -12
  21. package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
  22. package/dist/esm/mixins/OxyServices.user.js +7 -6
  23. package/dist/esm/mixins/index.js +4 -0
  24. package/dist/esm/server/cors.js +0 -1
  25. package/dist/esm/server/securityHeaders.js +3 -0
  26. package/dist/esm/session/refresh.js +32 -2
  27. package/dist/esm/utils/errorUtils.js +1 -1
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +31 -7
  30. package/dist/types/OxyServices.base.d.ts +3 -1
  31. package/dist/types/crypto/deviceBackup.d.ts +60 -0
  32. package/dist/types/crypto/keyManager.d.ts +79 -12
  33. package/dist/types/index.d.ts +1 -0
  34. package/dist/types/mixins/OxyServices.civic.d.ts +11 -1
  35. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
  36. package/dist/types/mixins/OxyServices.user.d.ts +8 -6
  37. package/dist/types/mixins/index.d.ts +2 -1
  38. package/dist/types/models/interfaces.d.ts +30 -0
  39. package/dist/types/server/securityHeaders.d.ts +5 -0
  40. package/dist/types/session/refresh.d.ts +7 -0
  41. package/dist/types/utils/errorUtils.d.ts +1 -1
  42. package/package.json +2 -2
  43. package/src/HttpService.ts +59 -187
  44. package/src/OxyServices.base.ts +4 -2
  45. package/src/__tests__/httpServiceFormEncoded.test.ts +2 -5
  46. package/src/__tests__/{httpServiceCsrf.test.ts → httpServiceWrites.test.ts} +49 -109
  47. package/src/__tests__/inSessionRefresh.test.ts +53 -0
  48. package/src/__tests__/linkedClient.test.ts +0 -7
  49. package/src/boot/__tests__/sessionColdBoot.identity.test.ts +1 -1
  50. package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +1 -1
  51. package/src/boot/__tests__/sessionColdBoot.test.ts +1 -1
  52. package/src/boot/sessionColdBoot.ts +3 -0
  53. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
  54. package/src/crypto/deviceBackup.ts +108 -0
  55. package/src/crypto/keyManager.ts +278 -13
  56. package/src/index.ts +1 -0
  57. package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
  58. package/src/mixins/OxyServices.user.ts +9 -7
  59. package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +5 -10
  60. package/src/mixins/__tests__/discoveryErrorHandling.test.ts +3 -4
  61. package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
  62. package/src/mixins/__tests__/topics.test.ts +2 -3
  63. package/src/mixins/index.ts +6 -0
  64. package/src/models/interfaces.ts +30 -1
  65. package/src/server/__tests__/securityHeaders.test.ts +7 -0
  66. package/src/server/cors.ts +0 -1
  67. package/src/server/securityHeaders.ts +8 -0
  68. package/src/session/__tests__/refresh.identityPin.test.ts +1 -1
  69. package/src/session/__tests__/refresh.test.ts +115 -2
  70. package/src/session/refresh.ts +38 -2
  71. package/src/utils/errorUtils.ts +1 -1
@@ -20,18 +20,12 @@ const requestUtils_1 = require("./utils/requestUtils");
20
20
  const asyncUtils_1 = require("./utils/asyncUtils");
21
21
  const errorUtils_1 = require("./utils/errorUtils");
22
22
  const jwt_decode_1 = require("jwt-decode");
23
- const platform_1 = require("./utils/platform");
24
23
  const protocol_1 = require("@oxy.so/protocol");
25
24
  const cacheKey_1 = require("./utils/cacheKey");
26
25
  const redactUrl_1 = require("./utils/redactUrl");
27
26
  const OxyServices_errors_1 = require("./OxyServices.errors");
28
27
  const edgeRegion_1 = require("./utils/edgeRegion");
29
28
  const activityId_1 = require("./utils/activityId");
30
- /**
31
- * Check if we're running in a native app environment (React Native, not web)
32
- * This is used to determine CSRF handling mode
33
- */
34
- const isNativeApp = (0, platform_1.isNative)();
35
29
  /**
36
30
  * Default per-request timeout (ms) when neither the call site nor
37
31
  * {@link OxyConfig.requestTimeout} overrides it. Kept tight so a stalled
@@ -39,24 +33,6 @@ const isNativeApp = (0, platform_1.isNative)();
39
33
  * request queue.
40
34
  */
41
35
  const DEFAULT_REQUEST_TIMEOUT_MS = 5000;
42
- /**
43
- * Timeout (ms) for the dedicated `GET /csrf-token` fetch. Independent of the
44
- * regular request timeout: this is a small, fast, unauthenticated call and
45
- * should never inherit a longer per-request budget.
46
- */
47
- const CSRF_FETCH_TIMEOUT_MS = 5000;
48
- /**
49
- * Number of attempts for fetching a CSRF token before giving up. The first
50
- * failure is usually a cold edge/cookie race; a single retry recovers it
51
- * without masking a genuinely broken `/csrf-token` route.
52
- */
53
- const CSRF_FETCH_MAX_ATTEMPTS = 2;
54
- /**
55
- * Backoff (ms) between CSRF-token fetch attempts. Short by design — a CSRF
56
- * fetch sits in the critical path of a state-changing request, so the retry
57
- * must add minimal latency.
58
- */
59
- const CSRF_FETCH_RETRY_DELAY_MS = 500;
60
36
  /**
61
37
  * Cooldown (ms) applied after a failed access-token refresh before another
62
38
  * refresh is attempted while the CURRENT token is still valid (a proactive,
@@ -159,8 +135,6 @@ const CACHE_SIZE_WARNING_THROTTLE_MS = 60000;
159
135
  class TokenStore {
160
136
  constructor() {
161
137
  this.accessToken = null;
162
- this.csrfToken = null;
163
- this.csrfTokenFetchPromise = null;
164
138
  }
165
139
  setTokens(accessToken) {
166
140
  this.accessToken = accessToken;
@@ -174,22 +148,6 @@ class TokenStore {
174
148
  hasAccessToken() {
175
149
  return !!this.accessToken;
176
150
  }
177
- setCsrfToken(token) {
178
- this.csrfToken = token;
179
- }
180
- getCsrfToken() {
181
- return this.csrfToken;
182
- }
183
- setCsrfTokenFetchPromise(promise) {
184
- this.csrfTokenFetchPromise = promise;
185
- }
186
- getCsrfTokenFetchPromise() {
187
- return this.csrfTokenFetchPromise;
188
- }
189
- clearCsrfToken() {
190
- this.csrfToken = null;
191
- this.csrfTokenFetchPromise = null;
192
- }
193
151
  }
194
152
  /**
195
153
  * Propagate `parent`'s abort to `child`, returning a disposer.
@@ -279,6 +237,21 @@ class HttpService {
279
237
  this.authRefreshHandler = null;
280
238
  this.accessTokenProvider = null;
281
239
  this.deviceSecretMintInFlight = null;
240
+ /**
241
+ * Bumped by every {@link endSession}. A re-mint captures it before its first
242
+ * await and plants nothing if it moved: a refresh that was already in flight
243
+ * when the user signed out would otherwise complete a moment later and put a
244
+ * bearer — and through the token listeners, the account — back.
245
+ */
246
+ this.sessionEpoch = 0;
247
+ /**
248
+ * Set by {@link endSession}, cleared by the next {@link setTokens}. Read by the
249
+ * native shared-keychain re-mint arm: after a sign-out a 401 is the expected
250
+ * answer, not a cue to sign back in with the identity key. The device-secret
251
+ * arm is NOT gated on it — minting from a credential the store still holds is
252
+ * how a signed-out tab joins a sign-in made in another tab.
253
+ */
254
+ this.sessionEnded = false;
282
255
  /**
283
256
  * Epoch (ms) before which a cache-size telemetry warning must not be
284
257
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
@@ -432,14 +405,9 @@ class HttpService {
432
405
  // it before enqueue means auth-blocked requests hold NO slot while the shared
433
406
  // mint runs. `skipAuth` requests (the body-authenticated mint) send NO bearer
434
407
  // and skip the near-expiry preflight — see RequestOptions.skipAuth. The
435
- // 401/CSRF retry re-enters request() with a fresh config, so it re-resolves
436
- // these here with the refreshed token / cleared CSRF.
437
- const isStateChangingMethod = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
408
+ // 401 retry re-enters request() with a fresh config, so it re-resolves
409
+ // this here with the refreshed token.
438
410
  const authHeader = config.skipAuth ? null : await this.getAuthHeader();
439
- // CSRF protects cookie-authenticated browser writes. Bearer-authenticated SDK
440
- // clients are not vulnerable to ambient-cookie CSRF, and linked app APIs
441
- // should not need to implement a duplicate `/csrf-token` route.
442
- const csrfToken = isStateChangingMethod && !authHeader ? await this.fetchCsrfToken() : null;
443
411
  const edgeRegionHeader = await (0, edgeRegion_1.getBrowserEdgeRegionHeader)();
444
412
  const activityIdHeader = (0, activityId_1.getBrowserActivityIdHeader)();
445
413
  // A request the caller has ALREADY abandoned takes no queue slot and makes
@@ -510,29 +478,6 @@ class HttpService {
510
478
  if (authHeader) {
511
479
  headers['Authorization'] = authHeader;
512
480
  }
513
- // Add CSRF token header for state-changing requests
514
- if (csrfToken) {
515
- headers['X-CSRF-Token'] = csrfToken;
516
- }
517
- // Add native app header for React Native (required for CSRF validation)
518
- // Native apps can't persist cookies like browsers, so the server uses
519
- // header-only CSRF validation when this header is present
520
- if (isNativeApp && isStateChangingMethod) {
521
- headers['X-Native-App'] = 'true';
522
- }
523
- // Debug logging for CSRF issues, routed through SimpleLogger so it only
524
- // fires when consumers opt in via `enableLogging`.
525
- if (isStateChangingMethod) {
526
- this.logger.debug('CSRF Debug:', {
527
- url,
528
- method,
529
- isNativeApp,
530
- platformOS: (0, platform_1.getPlatformOS)(),
531
- hasCsrfToken: !!csrfToken,
532
- csrfTokenLength: csrfToken?.length,
533
- hasNativeAppHeader: headers['X-Native-App'] === 'true',
534
- });
535
- }
536
481
  // Merge custom headers if provided
537
482
  if (config.headers) {
538
483
  Object.entries(config.headers).forEach(([key, value]) => {
@@ -584,34 +529,19 @@ class HttpService {
584
529
  if (response.status === 401 && !config._isAuthRetry && !config.skipAuth) {
585
530
  const refreshed = await this.refreshAccessToken('response-401');
586
531
  if (refreshed) {
587
- // `deduplicate: false` is REQUIRED on the retry (mirrors the 403
588
- // CSRF retry below). This re-issue runs while the ORIGINAL request
589
- // is still in-flight under its dedupe key; the refreshed token is
532
+ // `deduplicate: false` is REQUIRED on the retry. This re-issue runs
533
+ // while the ORIGINAL request is still in-flight under its dedupe
534
+ // key; the refreshed token is
590
535
  // for the SAME user, so the identity-scoped key is UNCHANGED — a
591
536
  // deduplicated retry would resolve to the still-pending original
592
537
  // and await itself (deadlock). Opting the retry out of dedupe makes
593
538
  // it a fresh request.
594
539
  return this.request({ ...config, _isAuthRetry: true, retry: false, deduplicate: false });
595
540
  }
596
- // Refresh failed or no token — clear tokens and stale CSRF
541
+ // Refresh failed or no token — clear tokens
597
542
  this.tokenStore.clearTokens();
598
- this.tokenStore.clearCsrfToken();
599
543
  this.notifyTokenChange();
600
544
  }
601
- // On 403 with CSRF error, clear cached token and retry once
602
- if (response.status === 403 && !config._isCsrfRetry) {
603
- try {
604
- const clonedResponse = response.clone();
605
- const errBody = await clonedResponse.json();
606
- if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
607
- this.tokenStore.clearCsrfToken();
608
- return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
609
- }
610
- }
611
- catch {
612
- // Failed to parse error body — not a CSRF error
613
- }
614
- }
615
545
  // Read the error body (may be absent, non-JSON, empty or malformed).
616
546
  // Anything unreadable leaves `errorBody` undefined and degrades to the
617
547
  // status-based message — an error path that throws its own error is
@@ -985,75 +915,6 @@ class HttpService {
985
915
  return false;
986
916
  }
987
917
  }
988
- /**
989
- * Fetch CSRF token from server (with deduplication)
990
- * Required for state-changing requests (POST, PUT, PATCH, DELETE)
991
- */
992
- async fetchCsrfToken() {
993
- // Return cached token if available
994
- const cachedToken = this.tokenStore.getCsrfToken();
995
- if (cachedToken) {
996
- this.logger.debug('Using cached CSRF token');
997
- return cachedToken;
998
- }
999
- // Deduplicate concurrent CSRF token fetches
1000
- const existingPromise = this.tokenStore.getCsrfTokenFetchPromise();
1001
- if (existingPromise) {
1002
- this.logger.debug('Waiting for existing CSRF fetch');
1003
- return existingPromise;
1004
- }
1005
- const fetchPromise = (async () => {
1006
- for (let attempt = 1; attempt <= CSRF_FETCH_MAX_ATTEMPTS; attempt++) {
1007
- try {
1008
- this.logger.debug('Fetching CSRF token from:', `${this.baseURL}/csrf-token`, `(attempt ${attempt})`);
1009
- // Use AbortController for timeout (more compatible than AbortSignal.timeout)
1010
- const controller = new AbortController();
1011
- const timeoutId = setTimeout(() => controller.abort(), CSRF_FETCH_TIMEOUT_MS);
1012
- const response = await fetch(`${this.baseURL}/csrf-token`, {
1013
- method: 'GET',
1014
- headers: { 'Accept': 'application/json' },
1015
- credentials: 'include', // Required to receive and send cookies
1016
- signal: controller.signal,
1017
- });
1018
- clearTimeout(timeoutId);
1019
- this.logger.debug('CSRF fetch response:', response.status, response.ok);
1020
- if (response.ok) {
1021
- const data = await response.json();
1022
- const token = data.csrfToken || null;
1023
- this.logger.debug('CSRF response data:', {
1024
- hasCsrfToken: typeof token === 'string' && token.length > 0,
1025
- csrfTokenLength: token?.length,
1026
- });
1027
- this.tokenStore.setCsrfToken(token);
1028
- this.logger.debug('CSRF token fetched');
1029
- return token;
1030
- }
1031
- // Also check response header for CSRF token
1032
- const headerToken = response.headers.get('X-CSRF-Token');
1033
- if (headerToken) {
1034
- this.tokenStore.setCsrfToken(headerToken);
1035
- this.logger.debug('CSRF token from header');
1036
- return headerToken;
1037
- }
1038
- this.logger.debug('CSRF fetch failed with status:', response.status);
1039
- this.logger.warn('Failed to fetch CSRF token:', response.status);
1040
- }
1041
- catch (error) {
1042
- this.logger.debug('CSRF fetch error:', error);
1043
- this.logger.warn('CSRF token fetch error:', error);
1044
- }
1045
- // Brief backoff before the next attempt.
1046
- if (attempt < CSRF_FETCH_MAX_ATTEMPTS) {
1047
- await new Promise(resolve => setTimeout(resolve, CSRF_FETCH_RETRY_DELAY_MS));
1048
- }
1049
- }
1050
- return null;
1051
- })().finally(() => {
1052
- this.tokenStore.setCsrfTokenFetchPromise(null);
1053
- });
1054
- this.tokenStore.setCsrfTokenFetchPromise(fetchPromise);
1055
- return fetchPromise;
1056
- }
1057
918
  /**
1058
919
  * Get auth header with automatic token refresh
1059
920
  */
@@ -1115,12 +976,21 @@ class HttpService {
1115
976
  // by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
1116
977
  // on the way out would discard the flag it just set.
1117
978
  this.lastRefreshWasRateLimited = false;
979
+ const epoch = this.sessionEpoch;
1118
980
  this.tokenRefreshPromise = this.authRefreshHandler(reason)
1119
981
  .then((newToken) => {
982
+ if (epoch !== this.sessionEpoch) {
983
+ // The session ended while this re-mint was in flight. Not a failure
984
+ // (no cooldown), and nothing to plant.
985
+ this.logger.debug('Discarded a token refresh that outlived its session');
986
+ return null;
987
+ }
1120
988
  if (!newToken) {
1121
989
  this.lastRefreshFailureAt = Date.now();
1122
990
  return null;
1123
991
  }
992
+ // A token is planted again, so there is a session again.
993
+ this.sessionEnded = false;
1124
994
  if (this.tokenStore.getAccessToken() !== newToken) {
1125
995
  this.tokenStore.setTokens(newToken);
1126
996
  this.notifyTokenChange();
@@ -1320,7 +1190,6 @@ class HttpService {
1320
1190
  return this.requestAuthenticatedResponseAttempt(config, `Bearer ${refreshed}`, true);
1321
1191
  }
1322
1192
  this.tokenStore.clearTokens();
1323
- this.tokenStore.clearCsrfToken();
1324
1193
  this.notifyTokenChange();
1325
1194
  }
1326
1195
  const duration = Date.now() - startTime;
@@ -1355,9 +1224,34 @@ class HttpService {
1355
1224
  }
1356
1225
  // Token management
1357
1226
  setTokens(accessToken) {
1227
+ this.sessionEnded = false;
1358
1228
  this.tokenStore.setTokens(accessToken);
1359
1229
  this.notifyTokenChange();
1360
1230
  }
1231
+ /**
1232
+ * End the local session: clear the bearer and abandon every re-mint already
1233
+ * in flight, so none of them can plant a token after the sign-out.
1234
+ *
1235
+ * {@link clearTokens} alone only drops the bearer, which is right for a
1236
+ * mirror (a linked client following its parent) but not for a sign-out: a
1237
+ * refresh started a moment earlier would finish and plant a new one.
1238
+ */
1239
+ endSession() {
1240
+ this.sessionEpoch += 1;
1241
+ this.sessionEnded = true;
1242
+ this.clearTokens();
1243
+ }
1244
+ /**
1245
+ * The current session epoch — see {@link endSession}. A re-mint lane that
1246
+ * plants tokens itself (the device-secret arm) compares it across its awaits.
1247
+ */
1248
+ getSessionEpoch() {
1249
+ return this.sessionEpoch;
1250
+ }
1251
+ /** Whether {@link endSession} ran and no token has been planted since. */
1252
+ hasSessionEnded() {
1253
+ return this.sessionEnded;
1254
+ }
1361
1255
  setAuthRefreshHandler(handler) {
1362
1256
  this.authRefreshHandler = handler;
1363
1257
  }
@@ -1366,7 +1260,6 @@ class HttpService {
1366
1260
  }
1367
1261
  clearTokens() {
1368
1262
  this.tokenStore.clearTokens();
1369
- this.tokenStore.clearCsrfToken();
1370
1263
  // Drop the response cache on logout. The cache is identity-scoped, so a
1371
1264
  // different user could never read these entries, but a logged-out client
1372
1265
  // must not keep the previous session's personalized data resident in
@@ -1482,8 +1375,8 @@ class HttpService {
1482
1375
  }
1483
1376
  // Test-only utility — clears tokens on this instance
1484
1377
  __resetTokensForTests() {
1378
+ this.sessionEnded = false;
1485
1379
  this.tokenStore.clearTokens();
1486
- this.tokenStore.clearCsrfToken();
1487
1380
  }
1488
1381
  }
1489
1382
  exports.HttpService = HttpService;
@@ -168,10 +168,12 @@ class OxyServicesBase {
168
168
  this.httpService.setTokens(accessToken);
169
169
  }
170
170
  /**
171
- * Clear stored authentication tokens
171
+ * Clear stored authentication tokens and end the local session: a re-mint
172
+ * already in flight plants nothing, and no new one runs until a token is set
173
+ * again (see `HttpService.endSession`).
172
174
  */
173
175
  clearTokens() {
174
- this.httpService.clearTokens();
176
+ this.httpService.endSession();
175
177
  this._cachedUserId = undefined;
176
178
  this._cachedAccessToken = null;
177
179
  }
@@ -205,6 +205,9 @@ async function runSessionColdBoot(opts) {
205
205
  // Network / 5xx: keep the secret; a later attempt can succeed.
206
206
  logger_1.logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' });
207
207
  return { kind: 'skip' };
208
+ case 'session-ended':
209
+ // Signed out while the boot was minting; the arm planted nothing.
210
+ return { kind: 'skip' };
208
211
  case 'no-secret':
209
212
  return { kind: 'skip' };
210
213
  }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ /**
3
+ * The device backup of the self-custody identity: a copy that lives OUTSIDE this
4
+ * app's own keystore, so a keystore wipe cannot take it along with the keys.
5
+ *
6
+ * Why it exists (OxyHQ/oxy#1388): on Android every Oxy app shares the Linux UID
7
+ * `so.oxy.shared`, and the Android Keystore is per UID. Clearing the storage of
8
+ * ANY sibling app (`pm clear`, or Settings › Apps › Storage › Clear storage)
9
+ * wipes the Keystore of the whole UID. Every copy the SDK keeps in
10
+ * expo-secure-store (primary, backup slot, phrase slot) and in the androidx
11
+ * encrypted prefs (the cross-app shared slot) is wrapped by a key in that
12
+ * Keystore, so they all die together, and until now the only way back was the
13
+ * recovery phrase.
14
+ *
15
+ * `@oxy.so/core` never imports a native module, so the store is injected by the
16
+ * app that owns the identity ({@link KeyManager.setDeviceBackupStore}). Commons
17
+ * registers Android Block Store; every other app registers nothing, which keeps
18
+ * every code path below a no-op for them. The design and the threat model are in
19
+ * `docs/identity/device-backup.md`.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.DEVICE_BACKUP_VERSION = void 0;
23
+ exports.serializeDeviceBackup = serializeDeviceBackup;
24
+ exports.parseDeviceBackup = parseDeviceBackup;
25
+ /** The record format. Bump {@link DEVICE_BACKUP_VERSION} on a breaking change. */
26
+ exports.DEVICE_BACKUP_VERSION = 1;
27
+ function serializeDeviceBackup(record) {
28
+ const out = {
29
+ version: exports.DEVICE_BACKUP_VERSION,
30
+ privateKey: record.privateKey,
31
+ publicKey: record.publicKey,
32
+ updatedAt: record.updatedAt,
33
+ };
34
+ if (record.mnemonic) {
35
+ out.mnemonic = record.mnemonic;
36
+ }
37
+ return JSON.stringify(out);
38
+ }
39
+ /**
40
+ * Parse a stored record, or `null` for anything that is not a well-formed
41
+ * version-1 record. Checks SHAPE only; the caller still proves the key pair is
42
+ * healthy (well-formed, public derives from private) before trusting it.
43
+ */
44
+ function parseDeviceBackup(raw) {
45
+ if (!raw) {
46
+ return null;
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ if (!parsed || typeof parsed !== 'object') {
56
+ return null;
57
+ }
58
+ const candidate = parsed;
59
+ if (candidate.version !== exports.DEVICE_BACKUP_VERSION ||
60
+ typeof candidate.privateKey !== 'string' ||
61
+ typeof candidate.publicKey !== 'string' ||
62
+ typeof candidate.updatedAt !== 'string') {
63
+ return null;
64
+ }
65
+ const record = {
66
+ version: exports.DEVICE_BACKUP_VERSION,
67
+ privateKey: candidate.privateKey,
68
+ publicKey: candidate.publicKey,
69
+ updatedAt: candidate.updatedAt,
70
+ };
71
+ if (typeof candidate.mnemonic === 'string' && candidate.mnemonic.length > 0) {
72
+ record.mnemonic = candidate.mnemonic;
73
+ }
74
+ return record;
75
+ }