@oxyhq/core 3.10.1 → 3.12.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 (92) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +189 -0
  8. package/dist/cjs/index.js +30 -4
  9. package/dist/cjs/mixins/OxyServices.assets.js +16 -1
  10. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  11. package/dist/cjs/mixins/OxyServices.civic.js +611 -0
  12. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  13. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  14. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  15. package/dist/cjs/mixins/index.js +6 -0
  16. package/dist/cjs/server/cors.js +20 -21
  17. package/dist/cjs/server/rateLimit.js +32 -8
  18. package/dist/cjs/utils/profileLinks.js +52 -0
  19. package/dist/cjs/utils/ssoReturn.js +1 -1
  20. package/dist/esm/.tsbuildinfo +1 -1
  21. package/dist/esm/AuthManager.js +9 -2
  22. package/dist/esm/HttpService.js +27 -9
  23. package/dist/esm/OxyServices.base.js +3 -2
  24. package/dist/esm/crypto/canonicalJson.js +104 -0
  25. package/dist/esm/crypto/keyManager.js +67 -8
  26. package/dist/esm/crypto/signatureService.js +187 -0
  27. package/dist/esm/index.js +19 -1
  28. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  29. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  30. package/dist/esm/mixins/OxyServices.civic.js +605 -0
  31. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  32. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  33. package/dist/esm/mixins/OxyServices.user.js +1 -0
  34. package/dist/esm/mixins/index.js +6 -0
  35. package/dist/esm/server/cors.js +20 -21
  36. package/dist/esm/server/rateLimit.js +32 -8
  37. package/dist/esm/utils/profileLinks.js +49 -0
  38. package/dist/esm/utils/ssoReturn.js +1 -1
  39. package/dist/types/.tsbuildinfo +1 -1
  40. package/dist/types/HttpService.d.ts +3 -0
  41. package/dist/types/OxyServices.d.ts +2 -2
  42. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  43. package/dist/types/crypto/keyManager.d.ts +7 -0
  44. package/dist/types/crypto/signatureService.d.ts +112 -0
  45. package/dist/types/index.d.ts +10 -2
  46. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  47. package/dist/types/mixins/OxyServices.civic.d.ts +512 -0
  48. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  49. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  50. package/dist/types/mixins/index.d.ts +3 -1
  51. package/dist/types/models/interfaces.d.ts +3 -0
  52. package/dist/types/server/cors.d.ts +5 -5
  53. package/dist/types/utils/profileLinks.d.ts +36 -0
  54. package/dist/types/utils/ssoReturn.d.ts +1 -1
  55. package/package.json +2 -2
  56. package/src/AuthManager.ts +8 -2
  57. package/src/HttpService.ts +36 -8
  58. package/src/OxyServices.base.ts +3 -2
  59. package/src/OxyServices.ts +1 -1
  60. package/src/__tests__/authManager.security.test.ts +31 -0
  61. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  62. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  63. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  64. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  65. package/src/crypto/__tests__/signedRecord.test.ts +345 -0
  66. package/src/crypto/canonicalJson.ts +120 -0
  67. package/src/crypto/keyManager.ts +62 -12
  68. package/src/crypto/signatureService.ts +225 -0
  69. package/src/index.ts +55 -2
  70. package/src/mixins/OxyServices.assets.ts +16 -1
  71. package/src/mixins/OxyServices.auth.ts +309 -1
  72. package/src/mixins/OxyServices.civic.ts +956 -0
  73. package/src/mixins/OxyServices.identity.ts +445 -0
  74. package/src/mixins/OxyServices.sso.ts +30 -1
  75. package/src/mixins/OxyServices.user.ts +1 -0
  76. package/src/mixins/__tests__/OxyServices.civic.test.ts +1097 -0
  77. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  78. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  79. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  80. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  81. package/src/mixins/__tests__/sso.test.ts +31 -0
  82. package/src/mixins/index.ts +8 -0
  83. package/src/models/interfaces.ts +3 -0
  84. package/src/server/__tests__/cors.test.ts +5 -1
  85. package/src/server/__tests__/rateLimit.test.ts +116 -0
  86. package/src/server/cors.ts +25 -20
  87. package/src/server/rateLimit.ts +39 -8
  88. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  89. package/src/utils/__tests__/profileLinks.test.ts +126 -0
  90. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  91. package/src/utils/profileLinks.ts +74 -0
  92. package/src/utils/ssoReturn.ts +2 -2
@@ -340,8 +340,15 @@ class AuthManager {
340
340
  * Get default storage based on environment.
341
341
  */
342
342
  getDefaultStorage() {
343
- if (typeof window !== 'undefined' && window.localStorage) {
344
- return new LocalStorageAdapter();
343
+ try {
344
+ if (typeof window !== 'undefined' && window.localStorage) {
345
+ return new LocalStorageAdapter();
346
+ }
347
+ }
348
+ catch {
349
+ // Accessing window.localStorage can throw in opaque-origin/sandboxed
350
+ // browser contexts or when storage is disabled. Fall back to memory so
351
+ // AuthManager construction remains safe during provider render.
345
352
  }
346
353
  return new MemoryStorage();
347
354
  }
@@ -351,13 +351,13 @@ class HttpService {
351
351
  // use fetch on every platform.
352
352
  const useXhrForUpload = isFormData && (0, platform_1.isReactNative)() && typeof XMLHttpRequest !== 'undefined';
353
353
  const response = useXhrForUpload
354
- ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout)
354
+ ? await this.uploadViaXHR(fullUrl, method, headers, bodyValue, controller.signal, timeout, this.shouldSendCredentials(fullUrl))
355
355
  : await fetch(fullUrl, {
356
356
  method,
357
357
  headers,
358
358
  body: bodyValue,
359
359
  signal: controller.signal,
360
- credentials: 'include', // Include cookies for cross-origin requests (CSRF, session)
360
+ credentials: this.getCredentialsMode(fullUrl),
361
361
  });
362
362
  if (timeoutId)
363
363
  clearTimeout(timeoutId);
@@ -383,7 +383,7 @@ class HttpService {
383
383
  const errBody = await clonedResponse.json();
384
384
  if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
385
385
  this.tokenStore.clearCsrfToken();
386
- return this.request({ ...config, _isCsrfRetry: true, retry: false });
386
+ return this.request({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
387
387
  }
388
388
  }
389
389
  catch {
@@ -417,7 +417,10 @@ class HttpService {
417
417
  // Handle different response types (optimized - read response once)
418
418
  const contentType = response.headers.get('content-type');
419
419
  let responseData;
420
- if (contentType && contentType.includes('application/json')) {
420
+ if (config.responseType === 'blob') {
421
+ responseData = await response.blob();
422
+ }
423
+ else if (contentType && contentType.includes('application/json')) {
421
424
  // Use response.json() directly for better performance
422
425
  try {
423
426
  responseData = await response.json();
@@ -528,13 +531,14 @@ class HttpService {
528
531
  * (status checks, 401/403 retries, JSON/blob/text parsing) is identical
529
532
  * to the fetch path.
530
533
  */
531
- uploadViaXHR(url, method, headers, body, abortSignal, timeout) {
534
+ uploadViaXHR(url, method, headers, body, abortSignal, timeout, withCredentials) {
532
535
  return new Promise((resolve, reject) => {
533
536
  const xhr = new XMLHttpRequest();
534
537
  xhr.open(method, url, true);
535
- // withCredentials mirrors fetch's `credentials: 'include'` so the
536
- // session cookie and CSRF cookie continue to flow.
537
- xhr.withCredentials = true;
538
+ // Only send ambient cookies to the configured API origin. Absolute
539
+ // caller-supplied URLs can target arbitrary origins, so they must not
540
+ // receive credential-bearing requests by default.
541
+ xhr.withCredentials = withCredentials;
538
542
  // Forward headers but skip Content-Type — XHR sets the multipart
539
543
  // boundary automatically and overriding it breaks the upload.
540
544
  for (const [key, value] of Object.entries(headers)) {
@@ -687,6 +691,17 @@ class HttpService {
687
691
  const queryString = searchParams.toString();
688
692
  return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
689
693
  }
694
+ getCredentialsMode(url) {
695
+ return this.shouldSendCredentials(url) ? 'include' : 'omit';
696
+ }
697
+ shouldSendCredentials(url) {
698
+ try {
699
+ return new URL(url).origin === new URL(this.baseURL).origin;
700
+ }
701
+ catch {
702
+ return false;
703
+ }
704
+ }
690
705
  /**
691
706
  * Fetch CSRF token from server (with deduplication)
692
707
  * Required for state-changing requests (POST, PUT, PATCH, DELETE)
@@ -721,8 +736,11 @@ class HttpService {
721
736
  this.logger.debug('CSRF fetch response:', response.status, response.ok);
722
737
  if (response.ok) {
723
738
  const data = await response.json();
724
- this.logger.debug('CSRF response data:', data);
725
739
  const token = data.csrfToken || null;
740
+ this.logger.debug('CSRF response data:', {
741
+ hasCsrfToken: typeof token === 'string' && token.length > 0,
742
+ csrfTokenLength: token?.length,
743
+ });
726
744
  this.tokenStore.setCsrfToken(token);
727
745
  this.logger.debug('CSRF token fetched');
728
746
  return token;
@@ -232,8 +232,9 @@ class OxyServicesBase {
232
232
  }
233
233
  try {
234
234
  const decoded = (0, jwt_decode_1.jwtDecode)(accessToken);
235
- this._cachedUserId = decoded.userId || decoded.id || null;
236
- return this._cachedUserId;
235
+ const userId = decoded.userId || decoded.id || null;
236
+ this._cachedUserId = userId;
237
+ return userId;
237
238
  }
238
239
  catch {
239
240
  this._cachedUserId = null;
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical JSON (RFC 8785 / JCS-style) serialization.
4
+ *
5
+ * `canonicalize(value)` produces a deterministic string for any JSON-compatible
6
+ * value so that a client which SIGNS a record and a server which VERIFIES it
7
+ * agree byte-for-byte on the signing input — regardless of the order in which
8
+ * object keys happen to be written, how the value was deserialized, or which
9
+ * runtime built it.
10
+ *
11
+ * This is the load-bearing primitive for the self-sovereign identity layer's
12
+ * signed records (`SignatureService.signRecord` + the API's record-verify path):
13
+ * both sides import THIS function from `@oxyhq/core`, so cross-implementation
14
+ * number/string formatting differences cannot cause a verify mismatch.
15
+ *
16
+ * Rules (the JSON Canonicalization Scheme subset we need):
17
+ * - Objects: keys are sorted (ascending, by UTF-16 code unit — the default
18
+ * `Array.prototype.sort` order) and serialized recursively. Properties whose
19
+ * value is `undefined`, a function, or a symbol are OMITTED (matching
20
+ * `JSON.stringify` object semantics).
21
+ * - Arrays: element order is PRESERVED; `undefined`/function/symbol elements
22
+ * serialize to `null` (matching `JSON.stringify` array semantics).
23
+ * - `null`, booleans, strings, and finite numbers serialize via the standard
24
+ * JSON representation.
25
+ * - Values exposing a `toJSON()` method (e.g. `Date`) are replaced by its
26
+ * result first, then serialized — so a `Date` and its ISO-string equivalent
27
+ * canonicalize identically (the wire always carries the string form).
28
+ * - Non-finite numbers (`NaN`, `Infinity`) and `bigint` are not part of the
29
+ * JSON data model and throw, rather than silently producing `null`.
30
+ *
31
+ * Platform-agnostic — zero dependencies, no `require()`, no react/react-native/
32
+ * expo. Safe in the dual CJS + ESM build.
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.canonicalize = canonicalize;
36
+ function hasToJSON(value) {
37
+ return typeof value.toJSON === 'function';
38
+ }
39
+ /**
40
+ * Serialize a single value into its canonical JSON fragment. Recursive; called
41
+ * on each nested member. Object keys are sorted at every level.
42
+ */
43
+ function serialize(value) {
44
+ if (value === null) {
45
+ return 'null';
46
+ }
47
+ const valueType = typeof value;
48
+ if (valueType === 'number') {
49
+ if (!Number.isFinite(value)) {
50
+ throw new Error('canonicalize: non-finite numbers cannot be serialized');
51
+ }
52
+ return JSON.stringify(value);
53
+ }
54
+ if (valueType === 'string' || valueType === 'boolean') {
55
+ return JSON.stringify(value);
56
+ }
57
+ if (valueType === 'bigint') {
58
+ throw new Error('canonicalize: bigint values cannot be serialized');
59
+ }
60
+ if (Array.isArray(value)) {
61
+ const items = value.map((item) => {
62
+ const itemType = typeof item;
63
+ // JSON array semantics: undefined / function / symbol become null so the
64
+ // element positions (and therefore the array length) are preserved.
65
+ if (item === undefined || itemType === 'function' || itemType === 'symbol') {
66
+ return 'null';
67
+ }
68
+ return serialize(item);
69
+ });
70
+ return `[${items.join(',')}]`;
71
+ }
72
+ if (valueType === 'object') {
73
+ const obj = value;
74
+ if (hasToJSON(obj)) {
75
+ return serialize(obj.toJSON());
76
+ }
77
+ const record = obj;
78
+ const parts = [];
79
+ for (const key of Object.keys(record).sort()) {
80
+ const member = record[key];
81
+ const memberType = typeof member;
82
+ // JSON object semantics: properties with undefined / function / symbol
83
+ // values are omitted entirely.
84
+ if (member === undefined || memberType === 'function' || memberType === 'symbol') {
85
+ continue;
86
+ }
87
+ parts.push(`${JSON.stringify(key)}:${serialize(member)}`);
88
+ }
89
+ return `{${parts.join(',')}}`;
90
+ }
91
+ // undefined / function / symbol at the top level have no JSON representation.
92
+ throw new Error(`canonicalize: cannot serialize a value of type ${valueType}`);
93
+ }
94
+ /**
95
+ * Produce the canonical JSON string for `value`.
96
+ *
97
+ * Deterministic: two structurally-equal values yield identical strings even if
98
+ * their object keys were written in different orders. Use this — never an
99
+ * ad-hoc `JSON.stringify` of a hand-sorted object — as the signing input for
100
+ * signed records, so client signing and server verification cannot drift.
101
+ *
102
+ * @throws if `value` (or any nested member used as the top-level/primitive)
103
+ * contains a non-finite number or a `bigint`, which have no JSON form.
104
+ */
105
+ function canonicalize(value) {
106
+ return serialize(value);
107
+ }
@@ -61,9 +61,9 @@ const STORAGE_KEYS = {
61
61
  /**
62
62
  * iOS Keychain Access Group for sharing identities across Oxy apps
63
63
  * All Oxy apps must have this access group enabled in their entitlements
64
- * Format: [Team ID].com.oxy.shared or group.com.oxy.shared
64
+ * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
65
65
  */
66
- const IOS_KEYCHAIN_GROUP = 'group.com.oxy.shared';
66
+ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
67
67
  /**
68
68
  * Android Account Manager type for shared authentication
69
69
  * Used with sharedUserId to share sessions across apps
@@ -695,11 +695,25 @@ class KeyManager {
695
695
  throw new IdentityPersistError('Stored identity failed crypto self-test', error);
696
696
  }
697
697
  // Step 3: The new primary is durable and functional. NOW it is safe to
698
- // refresh the backup to the new key. If this final backup write fails the
699
- // user still has a fully working primary, and the backup still holds the
700
- // PREVIOUS good identity so we log and continue rather than failing the
701
- // whole operation (failing here would be strictly worse: a working
702
- // primary would be reported as an error to the caller).
698
+ // refresh the backup to the new key. This is part of the successful write
699
+ // contract: returning success while the backup still belongs to the
700
+ // previous identity would allow a later restore with an absent primary to
701
+ // silently switch the device back to the previous account. Snapshot the
702
+ // backup first so a partial backup refresh can be rolled back along with
703
+ // the primary before surfacing the failure.
704
+ let priorBackupPrivate;
705
+ let priorBackupPublic;
706
+ let priorBackupTimestamp;
707
+ try {
708
+ priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
709
+ priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
710
+ priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
711
+ }
712
+ catch (error) {
713
+ loggerUtils_1.logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
714
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
715
+ throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
716
+ }
703
717
  try {
704
718
  await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
705
719
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
@@ -708,12 +722,57 @@ class KeyManager {
708
722
  await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
709
723
  }
710
724
  catch (error) {
711
- loggerUtils_1.logger.warn('Primary identity persisted successfully but refreshing the backup failed; primary is usable, backup may be stale', { component: 'KeyManager' }, error);
725
+ loggerUtils_1.logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
726
+ await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
727
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
728
+ throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
712
729
  }
713
730
  // Update cache only after we are certain the identity is durable.
714
731
  KeyManager.cachedPublicKey = canonicalPublic;
715
732
  KeyManager.cachedHasIdentity = true;
716
733
  }
734
+ /**
735
+ * Restore the backup slot to a previously-snapshotted state. Best-effort so
736
+ * the original persistence error remains the one surfaced to the caller.
737
+ *
738
+ * @internal
739
+ */
740
+ static async _rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp) {
741
+ try {
742
+ if (priorBackupPrivate) {
743
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
744
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
745
+ });
746
+ }
747
+ else {
748
+ try {
749
+ await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
750
+ }
751
+ catch { /* best effort */ }
752
+ }
753
+ if (priorBackupPublic) {
754
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
755
+ }
756
+ else {
757
+ try {
758
+ await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
759
+ }
760
+ catch { /* best effort */ }
761
+ }
762
+ if (priorBackupTimestamp) {
763
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
764
+ }
765
+ else {
766
+ try {
767
+ await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
768
+ }
769
+ catch { /* best effort */ }
770
+ }
771
+ }
772
+ catch (rollbackError) {
773
+ loggerUtils_1.logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
774
+ }
775
+ }
717
776
  /**
718
777
  * Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
719
778
  * or delete it entirely if there was no prior identity. Best-effort: every
@@ -7,13 +7,55 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.SignatureService = void 0;
10
+ exports.signedRecordSigningInput = signedRecordSigningInput;
11
+ exports.computeRecordId = computeRecordId;
10
12
  const elliptic_1 = require("elliptic");
11
13
  const keyManager_1 = require("./keyManager");
14
+ const canonicalJson_1 = require("./canonicalJson");
12
15
  const platform_1 = require("../utils/platform");
13
16
  const platformCrypto_1 = require("../utils/platformCrypto");
14
17
  const loggerUtils_1 = require("../utils/loggerUtils");
15
18
  const debugUtils_1 = require("../shared/utils/debugUtils");
16
19
  const ec = new elliptic_1.ec('secp256k1');
20
+ /**
21
+ * Compute the canonical signing input for a signed-record envelope.
22
+ *
23
+ * This is the single definition of "what the signature covers". `@oxyhq/core`
24
+ * (client signing) and `@oxyhq/api` (server verification) both call this, so a
25
+ * record signed by a client and verified by the server cannot drift.
26
+ *
27
+ * - **v1**: the canonical JSON of `{version, type, subject, issuer, record,
28
+ * issuedAt}` — BYTE-IDENTICAL to the original scheme, so every signature
29
+ * already in production keeps verifying.
30
+ * - **v2**: the canonical JSON additionally includes the hash-chain fields
31
+ * `{seq, prev, collection, rkey}`. Because {@link canonicalize} sorts keys,
32
+ * the on-the-wire field order is irrelevant; the resulting canonical key
33
+ * order is `collection, issuedAt, issuer, prev, record, rkey, seq, subject,
34
+ * type, version`. `prev` is `null` at genesis (serialized as `null`, not
35
+ * omitted), so it is always part of the signed bytes.
36
+ */
37
+ function signedRecordSigningInput(fields) {
38
+ const { version, type, subject, issuer, record, issuedAt } = fields;
39
+ if (version === 2) {
40
+ const { seq, prev, collection, rkey } = fields;
41
+ return (0, canonicalJson_1.canonicalize)({ version, type, subject, issuer, record, issuedAt, seq, prev, collection, rkey });
42
+ }
43
+ return (0, canonicalJson_1.canonicalize)({ version, type, subject, issuer, record, issuedAt });
44
+ }
45
+ /**
46
+ * Compute the `recordId` (content address) of a signed record: the SHA-256 hex
47
+ * digest of its canonical {@link signedRecordSigningInput}.
48
+ *
49
+ * Deterministic and stable across runtimes (it reuses the same canonicalization
50
+ * + SHA-256 the signature itself is built on). The recordId is what `prev`
51
+ * references in the per-subject hash chain, so `@oxyhq/core` (client) and
52
+ * `@oxyhq/api` (server) MUST compute it identically — both call this function.
53
+ * It is taken over the SIGNING input (excluding `publicKey`/`signature`), so it
54
+ * is a pure content address of the record's meaning, independent of who signed.
55
+ */
56
+ async function computeRecordId(fields) {
57
+ return sha256(signedRecordSigningInput(fields));
58
+ }
17
59
  /**
18
60
  * Compute SHA-256 hash of a string
19
61
  */
@@ -201,6 +243,36 @@ class SignatureService {
201
243
  timestamp,
202
244
  };
203
245
  }
246
+ /**
247
+ * Create a signed authentication challenge response using the SHARED identity
248
+ * key (the cross-app `group.so.oxy.shared` keychain key), not the primary
249
+ * device key.
250
+ *
251
+ * Mirrors {@link signChallenge} exactly — same message format
252
+ * (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
253
+ * path is unchanged — but sources the shared public/private key from
254
+ * `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
255
+ * same-device shared-keychain SSO (Mechanism A): a sibling native app proves
256
+ * control of the shared identity to mint its own session.
257
+ *
258
+ * Throws if no shared identity exists (native-only; the shared keychain is
259
+ * unavailable on web).
260
+ */
261
+ static async signChallengeWithSharedKey(challenge) {
262
+ const publicKey = await keyManager_1.KeyManager.getSharedPublicKey();
263
+ const privateKey = await keyManager_1.KeyManager.getSharedPrivateKey();
264
+ if (!publicKey || !privateKey) {
265
+ throw new Error('No shared identity found. Cannot sign with the shared key.');
266
+ }
267
+ const timestamp = Date.now();
268
+ const message = `auth:${publicKey}:${challenge}:${timestamp}`;
269
+ const signature = await SignatureService.signWithKey(message, privateKey);
270
+ return {
271
+ challenge: signature,
272
+ publicKey,
273
+ timestamp,
274
+ };
275
+ }
204
276
  /**
205
277
  * Verify a challenge response
206
278
  */
@@ -255,6 +327,123 @@ class SignatureService {
255
327
  timestamp,
256
328
  };
257
329
  }
330
+ /**
331
+ * Build a signed-record envelope for a self-issued identity/profile record.
332
+ *
333
+ * The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
334
+ * The signature covers the canonical JSON of every field EXCEPT `publicKey`
335
+ * and `signature` (see {@link signedRecordSigningInput}); `alg` is
336
+ * `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
337
+ * DER-encoded), the same scheme this service uses everywhere else.
338
+ *
339
+ * Requires a stored identity (native secure storage); throws if none exists.
340
+ *
341
+ * @param type - The record category (`'identity'` or `'profile'`).
342
+ * @param subject - The subject DID the record is about (also the issuer).
343
+ * @param record - The arbitrary record payload to attest to.
344
+ */
345
+ static async signRecord(type, subject, record) {
346
+ const publicKey = await keyManager_1.KeyManager.getPublicKey();
347
+ if (!publicKey) {
348
+ throw new Error('No identity found. Please create or import an identity first.');
349
+ }
350
+ const version = 1;
351
+ const issuer = subject;
352
+ const issuedAt = Date.now();
353
+ const signingInput = signedRecordSigningInput({
354
+ version,
355
+ type,
356
+ subject,
357
+ issuer,
358
+ record,
359
+ issuedAt,
360
+ });
361
+ const signature = await SignatureService.sign(signingInput);
362
+ return {
363
+ version,
364
+ type,
365
+ subject,
366
+ issuer,
367
+ record,
368
+ issuedAt,
369
+ publicKey,
370
+ alg: 'ES256K-DER-SHA256',
371
+ signature,
372
+ };
373
+ }
374
+ /**
375
+ * Build a signed-record envelope (v2) carrying the per-subject hash-chain
376
+ * fields.
377
+ *
378
+ * Identical to {@link signRecord} (self-issued: `issuer === subject`; same
379
+ * `ES256K-DER-SHA256` scheme over {@link signedRecordSigningInput}) but
380
+ * `version` is `2` and the signed bytes additionally cover the chain fields:
381
+ *
382
+ * @param type - The record category.
383
+ * @param subject - The subject DID the record is about (also the issuer).
384
+ * @param record - The arbitrary record payload to attest to.
385
+ * @param chain - The hash-chain coordinates:
386
+ * - `seq` — strictly-increasing sequence number for this subject's chain.
387
+ * - `prev` — the `recordId` of the previous record, or `null` at genesis.
388
+ * - `collection` + `rkey` — the AtProto-style record key.
389
+ *
390
+ * The caller is responsible for fetching the current chain head (so `seq` /
391
+ * `prev` are correct) before signing. Requires a stored identity; throws if
392
+ * none exists.
393
+ */
394
+ static async signRecordV2(type, subject, record, chain) {
395
+ const publicKey = await keyManager_1.KeyManager.getPublicKey();
396
+ if (!publicKey) {
397
+ throw new Error('No identity found. Please create or import an identity first.');
398
+ }
399
+ const version = 2;
400
+ const issuer = subject;
401
+ const issuedAt = Date.now();
402
+ const { seq, prev, collection, rkey } = chain;
403
+ const signingInput = signedRecordSigningInput({
404
+ version,
405
+ type,
406
+ subject,
407
+ issuer,
408
+ record,
409
+ issuedAt,
410
+ seq,
411
+ prev,
412
+ collection,
413
+ rkey,
414
+ });
415
+ const signature = await SignatureService.sign(signingInput);
416
+ return {
417
+ version,
418
+ type,
419
+ subject,
420
+ issuer,
421
+ record,
422
+ issuedAt,
423
+ seq,
424
+ prev,
425
+ collection,
426
+ rkey,
427
+ publicKey,
428
+ alg: 'ES256K-DER-SHA256',
429
+ signature,
430
+ };
431
+ }
432
+ /**
433
+ * Verify a signed-record envelope: recompute the canonical signing input from
434
+ * the envelope's own fields and check the signature against the envelope's
435
+ * `publicKey`.
436
+ *
437
+ * Note: this confirms the signature is internally consistent with the
438
+ * embedded `publicKey`. It does NOT establish that `publicKey` is an
439
+ * authorized verification method for `subject` — that authorization check is
440
+ * the server's responsibility (it asserts the key is a current verification
441
+ * method on the subject's DID).
442
+ */
443
+ static async verifyRecord(envelope) {
444
+ const signingInput = signedRecordSigningInput(envelope);
445
+ return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
446
+ }
258
447
  }
259
448
  exports.SignatureService = SignatureService;
260
449
  exports.default = SignatureService;
package/dist/cjs/index.js CHANGED
@@ -18,10 +18,10 @@
18
18
  * If a symbol does not appear here, it is NOT part of the public API.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
- exports.isRequiredNumber = exports.isRequiredString = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = void 0;
23
- exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = exports.ssoAttemptedKey = exports.ssoNoSessionKey = exports.ssoDestKey = exports.ssoGuardKey = exports.ssoStateKey = exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = exports.generateSsoState = exports.consumeSsoReturn = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_IDP_APEX = exports.CENTRAL_AUTH_URL = exports.registrableApex = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = void 0;
24
- exports.packageInfo = exports.runColdBoot = void 0;
21
+ exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.canonicalize = exports.computeRecordId = exports.signedRecordSigningInput = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
+ exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = void 0;
23
+ exports.ssoDestKey = exports.ssoGuardKey = exports.ssoStateKey = exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = exports.generateSsoState = exports.consumeSsoReturn = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_IDP_APEX = exports.CENTRAL_AUTH_URL = exports.registrableApex = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = void 0;
24
+ exports.packageInfo = exports.runColdBoot = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = exports.ssoAttemptedKey = exports.ssoNoSessionKey = void 0;
25
25
  // Ensure crypto polyfills are loaded before anything else
26
26
  require("./crypto/polyfill");
27
27
  // ---------------------------------------------------------------------------
@@ -57,6 +57,28 @@ Object.defineProperty(exports, "normalizeUserIdentityOrNull", { enumerable: true
57
57
  var userHandle_1 = require("./utils/userHandle");
58
58
  Object.defineProperty(exports, "getCanonicalUserHandle", { enumerable: true, get: function () { return userHandle_1.getCanonicalUserHandle; } });
59
59
  Object.defineProperty(exports, "getNormalizedUserHandle", { enumerable: true, get: function () { return userHandle_1.getNormalizedUserHandle; } });
60
+ var profileLinks_1 = require("./utils/profileLinks");
61
+ Object.defineProperty(exports, "normalizeProfileLinks", { enumerable: true, get: function () { return profileLinks_1.normalizeProfileLinks; } });
62
+ // ---------------------------------------------------------------------------
63
+ // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
64
+ // verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
65
+ // AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
66
+ // ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
67
+ // ---------------------------------------------------------------------------
68
+ var OxyServices_identity_1 = require("./mixins/OxyServices.identity");
69
+ Object.defineProperty(exports, "buildUserDid", { enumerable: true, get: function () { return OxyServices_identity_1.buildUserDid; } });
70
+ // ---------------------------------------------------------------------------
71
+ // Civic / Commons "Oxy ID" (public signed cards + Oxy ID QR payload) and Fase 2
72
+ // anti-gaming (real-life attestation QR + validator/jury). Wire shapes
73
+ // (PublicCard, SignedPublicCard, RealLifeAttestationResult,
74
+ // ValidationRequestSummary, ValidationVoteResult, ValidationVerdict, …) live in
75
+ // `@oxyhq/contracts` — import them from there. The SDK adds the client verdict
76
+ // wrapper, the QR payload parsers/builders, and the submit inputs/results.
77
+ // ---------------------------------------------------------------------------
78
+ var OxyServices_civic_1 = require("./mixins/OxyServices.civic");
79
+ Object.defineProperty(exports, "parseIdPayload", { enumerable: true, get: function () { return OxyServices_civic_1.parseIdPayload; } });
80
+ Object.defineProperty(exports, "parseAttestPayload", { enumerable: true, get: function () { return OxyServices_civic_1.parseAttestPayload; } });
81
+ Object.defineProperty(exports, "verifyPublicCardAttestation", { enumerable: true, get: function () { return OxyServices_civic_1.verifyPublicCardAttestation; } });
60
82
  // ---------------------------------------------------------------------------
61
83
  // Auth helpers (token refresh, error normalisation, retry policies)
62
84
  // ---------------------------------------------------------------------------
@@ -83,6 +105,10 @@ Object.defineProperty(exports, "IdentityAlreadyExistsError", { enumerable: true,
83
105
  Object.defineProperty(exports, "IdentityPersistError", { enumerable: true, get: function () { return keyManager_1.IdentityPersistError; } });
84
106
  var signatureService_1 = require("./crypto/signatureService");
85
107
  Object.defineProperty(exports, "SignatureService", { enumerable: true, get: function () { return signatureService_1.SignatureService; } });
108
+ Object.defineProperty(exports, "signedRecordSigningInput", { enumerable: true, get: function () { return signatureService_1.signedRecordSigningInput; } });
109
+ Object.defineProperty(exports, "computeRecordId", { enumerable: true, get: function () { return signatureService_1.computeRecordId; } });
110
+ var canonicalJson_1 = require("./crypto/canonicalJson");
111
+ Object.defineProperty(exports, "canonicalize", { enumerable: true, get: function () { return canonicalJson_1.canonicalize; } });
86
112
  var recoveryPhrase_1 = require("./crypto/recoveryPhrase");
87
113
  Object.defineProperty(exports, "RecoveryPhraseService", { enumerable: true, get: function () { return recoveryPhrase_1.RecoveryPhraseService; } });
88
114
  // ---------------------------------------------------------------------------
@@ -441,7 +441,9 @@ function OxyServicesAssetsMixin(Base) {
441
441
  return urlRes?.url || null;
442
442
  }
443
443
  async fetchAssetContent(url, type) {
444
- const response = await fetch(url, { credentials: 'include' });
444
+ const response = await fetch(url, {
445
+ credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
446
+ });
445
447
  if (!response?.ok) {
446
448
  throw new Error(`Failed to fetch asset content (status ${response?.status})`);
447
449
  }
@@ -449,3 +451,16 @@ function OxyServicesAssetsMixin(Base) {
449
451
  }
450
452
  };
451
453
  }
454
+ /**
455
+ * Only send ambient credentials (cookies) when the asset URL is same-origin with
456
+ * the configured API base. Caller-supplied cross-origin asset URLs must not leak
457
+ * the user's cookies to arbitrary hosts.
458
+ */
459
+ function shouldSendAssetCredentials(url, baseURL) {
460
+ try {
461
+ return new URL(url).origin === new URL(baseURL).origin;
462
+ }
463
+ catch {
464
+ return false;
465
+ }
466
+ }