@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
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Canonical JSON (RFC 8785 / JCS-style) serialization.
3
+ *
4
+ * `canonicalize(value)` produces a deterministic string for any JSON-compatible
5
+ * value so that a client which SIGNS a record and a server which VERIFIES it
6
+ * agree byte-for-byte on the signing input — regardless of the order in which
7
+ * object keys happen to be written, how the value was deserialized, or which
8
+ * runtime built it.
9
+ *
10
+ * This is the load-bearing primitive for the self-sovereign identity layer's
11
+ * signed records (`SignatureService.signRecord` + the API's record-verify path):
12
+ * both sides import THIS function from `@oxyhq/core`, so cross-implementation
13
+ * number/string formatting differences cannot cause a verify mismatch.
14
+ *
15
+ * Rules (the JSON Canonicalization Scheme subset we need):
16
+ * - Objects: keys are sorted (ascending, by UTF-16 code unit — the default
17
+ * `Array.prototype.sort` order) and serialized recursively. Properties whose
18
+ * value is `undefined`, a function, or a symbol are OMITTED (matching
19
+ * `JSON.stringify` object semantics).
20
+ * - Arrays: element order is PRESERVED; `undefined`/function/symbol elements
21
+ * serialize to `null` (matching `JSON.stringify` array semantics).
22
+ * - `null`, booleans, strings, and finite numbers serialize via the standard
23
+ * JSON representation.
24
+ * - Values exposing a `toJSON()` method (e.g. `Date`) are replaced by its
25
+ * result first, then serialized — so a `Date` and its ISO-string equivalent
26
+ * canonicalize identically (the wire always carries the string form).
27
+ * - Non-finite numbers (`NaN`, `Infinity`) and `bigint` are not part of the
28
+ * JSON data model and throw, rather than silently producing `null`.
29
+ *
30
+ * Platform-agnostic — zero dependencies, no `require()`, no react/react-native/
31
+ * expo. Safe in the dual CJS + ESM build.
32
+ */
33
+
34
+ /** Object exposing a `toJSON()` serialization hook (e.g. `Date`). */
35
+ interface ToJsonable {
36
+ toJSON: () => unknown;
37
+ }
38
+
39
+ function hasToJSON(value: object): value is ToJsonable {
40
+ return typeof (value as { toJSON?: unknown }).toJSON === 'function';
41
+ }
42
+
43
+ /**
44
+ * Serialize a single value into its canonical JSON fragment. Recursive; called
45
+ * on each nested member. Object keys are sorted at every level.
46
+ */
47
+ function serialize(value: unknown): string {
48
+ if (value === null) {
49
+ return 'null';
50
+ }
51
+
52
+ const valueType = typeof value;
53
+
54
+ if (valueType === 'number') {
55
+ if (!Number.isFinite(value)) {
56
+ throw new Error('canonicalize: non-finite numbers cannot be serialized');
57
+ }
58
+ return JSON.stringify(value);
59
+ }
60
+
61
+ if (valueType === 'string' || valueType === 'boolean') {
62
+ return JSON.stringify(value);
63
+ }
64
+
65
+ if (valueType === 'bigint') {
66
+ throw new Error('canonicalize: bigint values cannot be serialized');
67
+ }
68
+
69
+ if (Array.isArray(value)) {
70
+ const items = value.map((item) => {
71
+ const itemType = typeof item;
72
+ // JSON array semantics: undefined / function / symbol become null so the
73
+ // element positions (and therefore the array length) are preserved.
74
+ if (item === undefined || itemType === 'function' || itemType === 'symbol') {
75
+ return 'null';
76
+ }
77
+ return serialize(item);
78
+ });
79
+ return `[${items.join(',')}]`;
80
+ }
81
+
82
+ if (valueType === 'object') {
83
+ const obj = value as object;
84
+ if (hasToJSON(obj)) {
85
+ return serialize(obj.toJSON());
86
+ }
87
+
88
+ const record = obj as Record<string, unknown>;
89
+ const parts: string[] = [];
90
+ for (const key of Object.keys(record).sort()) {
91
+ const member = record[key];
92
+ const memberType = typeof member;
93
+ // JSON object semantics: properties with undefined / function / symbol
94
+ // values are omitted entirely.
95
+ if (member === undefined || memberType === 'function' || memberType === 'symbol') {
96
+ continue;
97
+ }
98
+ parts.push(`${JSON.stringify(key)}:${serialize(member)}`);
99
+ }
100
+ return `{${parts.join(',')}}`;
101
+ }
102
+
103
+ // undefined / function / symbol at the top level have no JSON representation.
104
+ throw new Error(`canonicalize: cannot serialize a value of type ${valueType}`);
105
+ }
106
+
107
+ /**
108
+ * Produce the canonical JSON string for `value`.
109
+ *
110
+ * Deterministic: two structurally-equal values yield identical strings even if
111
+ * their object keys were written in different orders. Use this — never an
112
+ * ad-hoc `JSON.stringify` of a hand-sorted object — as the signing input for
113
+ * signed records, so client signing and server verification cannot drift.
114
+ *
115
+ * @throws if `value` (or any nested member used as the top-level/primitive)
116
+ * contains a non-finite number or a `bigint`, which have no JSON form.
117
+ */
118
+ export function canonicalize(value: unknown): string {
119
+ return serialize(value);
120
+ }
@@ -85,9 +85,9 @@ const STORAGE_KEYS = {
85
85
  /**
86
86
  * iOS Keychain Access Group for sharing identities across Oxy apps
87
87
  * All Oxy apps must have this access group enabled in their entitlements
88
- * Format: [Team ID].com.oxy.shared or group.com.oxy.shared
88
+ * Format: [Team ID].so.oxy.shared or group.so.oxy.shared
89
89
  */
90
- const IOS_KEYCHAIN_GROUP = 'group.com.oxy.shared';
90
+ const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
91
91
 
92
92
  /**
93
93
  * Android Account Manager type for shared authentication
@@ -795,11 +795,25 @@ export class KeyManager {
795
795
  }
796
796
 
797
797
  // Step 3: The new primary is durable and functional. NOW it is safe to
798
- // refresh the backup to the new key. If this final backup write fails the
799
- // user still has a fully working primary, and the backup still holds the
800
- // PREVIOUS good identity so we log and continue rather than failing the
801
- // whole operation (failing here would be strictly worse: a working
802
- // primary would be reported as an error to the caller).
798
+ // refresh the backup to the new key. This is part of the successful write
799
+ // contract: returning success while the backup still belongs to the
800
+ // previous identity would allow a later restore with an absent primary to
801
+ // silently switch the device back to the previous account. Snapshot the
802
+ // backup first so a partial backup refresh can be rolled back along with
803
+ // the primary before surfacing the failure.
804
+ let priorBackupPrivate: string | null;
805
+ let priorBackupPublic: string | null;
806
+ let priorBackupTimestamp: string | null;
807
+ try {
808
+ priorBackupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
809
+ priorBackupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
810
+ priorBackupTimestamp = await store.getItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP);
811
+ } catch (error) {
812
+ logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
813
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
814
+ throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
815
+ }
816
+
803
817
  try {
804
818
  await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, canonicalPrivate, {
805
819
  keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
@@ -807,11 +821,10 @@ export class KeyManager {
807
821
  await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, canonicalPublic);
808
822
  await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, Date.now().toString());
809
823
  } catch (error) {
810
- logger.warn(
811
- 'Primary identity persisted successfully but refreshing the backup failed; primary is usable, backup may be stale',
812
- { component: 'KeyManager' },
813
- error,
814
- );
824
+ logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
825
+ await KeyManager._rollbackBackup(store, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
826
+ await KeyManager._rollbackPrimary(store, priorPrivate, priorPublic);
827
+ throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
815
828
  }
816
829
 
817
830
  // Update cache only after we are certain the identity is durable.
@@ -819,6 +832,43 @@ export class KeyManager {
819
832
  KeyManager.cachedHasIdentity = true;
820
833
  }
821
834
 
835
+ /**
836
+ * Restore the backup slot to a previously-snapshotted state. Best-effort so
837
+ * the original persistence error remains the one surfaced to the caller.
838
+ *
839
+ * @internal
840
+ */
841
+ private static async _rollbackBackup(
842
+ store: Awaited<ReturnType<typeof initSecureStore>>,
843
+ priorBackupPrivate: string | null,
844
+ priorBackupPublic: string | null,
845
+ priorBackupTimestamp: string | null,
846
+ ): Promise<void> {
847
+ try {
848
+ if (priorBackupPrivate) {
849
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY, priorBackupPrivate, {
850
+ keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
851
+ });
852
+ } else {
853
+ try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY); } catch { /* best effort */ }
854
+ }
855
+
856
+ if (priorBackupPublic) {
857
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY, priorBackupPublic);
858
+ } else {
859
+ try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY); } catch { /* best effort */ }
860
+ }
861
+
862
+ if (priorBackupTimestamp) {
863
+ await store.setItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP, priorBackupTimestamp);
864
+ } else {
865
+ try { await store.deleteItemAsync(STORAGE_KEYS.BACKUP_TIMESTAMP); } catch { /* best effort */ }
866
+ }
867
+ } catch (rollbackError) {
868
+ logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
869
+ }
870
+ }
871
+
822
872
  /**
823
873
  * Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
824
874
  * or delete it entirely if there was no prior identity. Best-effort: every
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  import { ec as EC } from 'elliptic';
9
+ import type { SignedRecordEnvelope } from '@oxyhq/contracts';
9
10
  import { KeyManager } from './keyManager';
11
+ import { canonicalize } from './canonicalJson';
10
12
  import { isReactNative, isNodeJS } from '../utils/platform';
11
13
  import { loadExpoCrypto, loadNodeCrypto } from '../utils/platformCrypto';
12
14
  import { logger } from '../utils/loggerUtils';
@@ -14,6 +16,63 @@ import { isDev } from '../shared/utils/debugUtils';
14
16
 
15
17
  const ec = new EC('secp256k1');
16
18
 
19
+ /**
20
+ * The signing-input portion of a {@link SignedRecordEnvelope}: every field
21
+ * EXCEPT the `publicKey` and `signature`. Both the client (when signing) and
22
+ * the server (when verifying) canonicalize exactly these fields, so they agree
23
+ * on the bytes that the signature covers.
24
+ *
25
+ * The v2 chain fields (`seq`/`prev`/`collection`/`rkey`) are optional: a v1
26
+ * envelope omits them and is signed over only the base fields; a v2 envelope
27
+ * carries them and includes them in the signed bytes.
28
+ */
29
+ export type SignedRecordSigningFields = Pick<
30
+ SignedRecordEnvelope,
31
+ 'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
32
+ > &
33
+ Partial<Pick<SignedRecordEnvelope, 'seq' | 'prev' | 'collection' | 'rkey'>>;
34
+
35
+ /**
36
+ * Compute the canonical signing input for a signed-record envelope.
37
+ *
38
+ * This is the single definition of "what the signature covers". `@oxyhq/core`
39
+ * (client signing) and `@oxyhq/api` (server verification) both call this, so a
40
+ * record signed by a client and verified by the server cannot drift.
41
+ *
42
+ * - **v1**: the canonical JSON of `{version, type, subject, issuer, record,
43
+ * issuedAt}` — BYTE-IDENTICAL to the original scheme, so every signature
44
+ * already in production keeps verifying.
45
+ * - **v2**: the canonical JSON additionally includes the hash-chain fields
46
+ * `{seq, prev, collection, rkey}`. Because {@link canonicalize} sorts keys,
47
+ * the on-the-wire field order is irrelevant; the resulting canonical key
48
+ * order is `collection, issuedAt, issuer, prev, record, rkey, seq, subject,
49
+ * type, version`. `prev` is `null` at genesis (serialized as `null`, not
50
+ * omitted), so it is always part of the signed bytes.
51
+ */
52
+ export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
53
+ const { version, type, subject, issuer, record, issuedAt } = fields;
54
+ if (version === 2) {
55
+ const { seq, prev, collection, rkey } = fields;
56
+ return canonicalize({ version, type, subject, issuer, record, issuedAt, seq, prev, collection, rkey });
57
+ }
58
+ return canonicalize({ version, type, subject, issuer, record, issuedAt });
59
+ }
60
+
61
+ /**
62
+ * Compute the `recordId` (content address) of a signed record: the SHA-256 hex
63
+ * digest of its canonical {@link signedRecordSigningInput}.
64
+ *
65
+ * Deterministic and stable across runtimes (it reuses the same canonicalization
66
+ * + SHA-256 the signature itself is built on). The recordId is what `prev`
67
+ * references in the per-subject hash chain, so `@oxyhq/core` (client) and
68
+ * `@oxyhq/api` (server) MUST compute it identically — both call this function.
69
+ * It is taken over the SIGNING input (excluding `publicKey`/`signature`), so it
70
+ * is a pure content address of the record's meaning, independent of who signed.
71
+ */
72
+ export async function computeRecordId(fields: SignedRecordSigningFields): Promise<string> {
73
+ return sha256(signedRecordSigningInput(fields));
74
+ }
75
+
17
76
  /**
18
77
  * Compute SHA-256 hash of a string
19
78
  */
@@ -236,6 +295,39 @@ export class SignatureService {
236
295
  };
237
296
  }
238
297
 
298
+ /**
299
+ * Create a signed authentication challenge response using the SHARED identity
300
+ * key (the cross-app `group.so.oxy.shared` keychain key), not the primary
301
+ * device key.
302
+ *
303
+ * Mirrors {@link signChallenge} exactly — same message format
304
+ * (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
305
+ * path is unchanged — but sources the shared public/private key from
306
+ * `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
307
+ * same-device shared-keychain SSO (Mechanism A): a sibling native app proves
308
+ * control of the shared identity to mint its own session.
309
+ *
310
+ * Throws if no shared identity exists (native-only; the shared keychain is
311
+ * unavailable on web).
312
+ */
313
+ static async signChallengeWithSharedKey(challenge: string): Promise<AuthChallenge> {
314
+ const publicKey = await KeyManager.getSharedPublicKey();
315
+ const privateKey = await KeyManager.getSharedPrivateKey();
316
+ if (!publicKey || !privateKey) {
317
+ throw new Error('No shared identity found. Cannot sign with the shared key.');
318
+ }
319
+
320
+ const timestamp = Date.now();
321
+ const message = `auth:${publicKey}:${challenge}:${timestamp}`;
322
+ const signature = await SignatureService.signWithKey(message, privateKey);
323
+
324
+ return {
325
+ challenge: signature,
326
+ publicKey,
327
+ timestamp,
328
+ };
329
+ }
330
+
239
331
  /**
240
332
  * Verify a challenge response
241
333
  */
@@ -308,6 +400,139 @@ export class SignatureService {
308
400
  timestamp,
309
401
  };
310
402
  }
403
+
404
+ /**
405
+ * Build a signed-record envelope for a self-issued identity/profile record.
406
+ *
407
+ * The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
408
+ * The signature covers the canonical JSON of every field EXCEPT `publicKey`
409
+ * and `signature` (see {@link signedRecordSigningInput}); `alg` is
410
+ * `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
411
+ * DER-encoded), the same scheme this service uses everywhere else.
412
+ *
413
+ * Requires a stored identity (native secure storage); throws if none exists.
414
+ *
415
+ * @param type - The record category (`'identity'` or `'profile'`).
416
+ * @param subject - The subject DID the record is about (also the issuer).
417
+ * @param record - The arbitrary record payload to attest to.
418
+ */
419
+ static async signRecord(
420
+ type: SignedRecordEnvelope['type'],
421
+ subject: string,
422
+ record: Record<string, unknown>,
423
+ ): Promise<SignedRecordEnvelope> {
424
+ const publicKey = await KeyManager.getPublicKey();
425
+ if (!publicKey) {
426
+ throw new Error('No identity found. Please create or import an identity first.');
427
+ }
428
+
429
+ const version = 1 as const;
430
+ const issuer = subject;
431
+ const issuedAt = Date.now();
432
+ const signingInput = signedRecordSigningInput({
433
+ version,
434
+ type,
435
+ subject,
436
+ issuer,
437
+ record,
438
+ issuedAt,
439
+ });
440
+ const signature = await SignatureService.sign(signingInput);
441
+
442
+ return {
443
+ version,
444
+ type,
445
+ subject,
446
+ issuer,
447
+ record,
448
+ issuedAt,
449
+ publicKey,
450
+ alg: 'ES256K-DER-SHA256',
451
+ signature,
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Build a signed-record envelope (v2) carrying the per-subject hash-chain
457
+ * fields.
458
+ *
459
+ * Identical to {@link signRecord} (self-issued: `issuer === subject`; same
460
+ * `ES256K-DER-SHA256` scheme over {@link signedRecordSigningInput}) but
461
+ * `version` is `2` and the signed bytes additionally cover the chain fields:
462
+ *
463
+ * @param type - The record category.
464
+ * @param subject - The subject DID the record is about (also the issuer).
465
+ * @param record - The arbitrary record payload to attest to.
466
+ * @param chain - The hash-chain coordinates:
467
+ * - `seq` — strictly-increasing sequence number for this subject's chain.
468
+ * - `prev` — the `recordId` of the previous record, or `null` at genesis.
469
+ * - `collection` + `rkey` — the AtProto-style record key.
470
+ *
471
+ * The caller is responsible for fetching the current chain head (so `seq` /
472
+ * `prev` are correct) before signing. Requires a stored identity; throws if
473
+ * none exists.
474
+ */
475
+ static async signRecordV2(
476
+ type: SignedRecordEnvelope['type'],
477
+ subject: string,
478
+ record: Record<string, unknown>,
479
+ chain: { seq: number; prev: string | null; collection: string; rkey: string },
480
+ ): Promise<SignedRecordEnvelope> {
481
+ const publicKey = await KeyManager.getPublicKey();
482
+ if (!publicKey) {
483
+ throw new Error('No identity found. Please create or import an identity first.');
484
+ }
485
+
486
+ const version = 2 as const;
487
+ const issuer = subject;
488
+ const issuedAt = Date.now();
489
+ const { seq, prev, collection, rkey } = chain;
490
+ const signingInput = signedRecordSigningInput({
491
+ version,
492
+ type,
493
+ subject,
494
+ issuer,
495
+ record,
496
+ issuedAt,
497
+ seq,
498
+ prev,
499
+ collection,
500
+ rkey,
501
+ });
502
+ const signature = await SignatureService.sign(signingInput);
503
+
504
+ return {
505
+ version,
506
+ type,
507
+ subject,
508
+ issuer,
509
+ record,
510
+ issuedAt,
511
+ seq,
512
+ prev,
513
+ collection,
514
+ rkey,
515
+ publicKey,
516
+ alg: 'ES256K-DER-SHA256',
517
+ signature,
518
+ };
519
+ }
520
+
521
+ /**
522
+ * Verify a signed-record envelope: recompute the canonical signing input from
523
+ * the envelope's own fields and check the signature against the envelope's
524
+ * `publicKey`.
525
+ *
526
+ * Note: this confirms the signature is internally consistent with the
527
+ * embedded `publicKey`. It does NOT establish that `publicKey` is an
528
+ * authorized verification method for `subject` — that authorization check is
529
+ * the server's responsibility (it asserts the key is a current verification
530
+ * method on the subject's DID).
531
+ */
532
+ static async verifyRecord(envelope: SignedRecordEnvelope): Promise<boolean> {
533
+ const signingInput = signedRecordSigningInput(envelope);
534
+ return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
535
+ }
311
536
  }
312
537
 
313
538
  export default SignatureService;
package/src/index.ts CHANGED
@@ -51,6 +51,13 @@ export type { SilentAuthOptions } from './mixins/OxyServices.silent';
51
51
  export type { RedirectAuthOptions } from './mixins/OxyServices.redirect';
52
52
  export { ServiceCredentialMismatchError } from './mixins/OxyServices.auth';
53
53
  export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
54
+ // "Sign in with Oxy" — handoff (Workstream C)
55
+ export type {
56
+ CommonsSignInHandle,
57
+ CommonsSignInStatus,
58
+ CommonsApprovalInfo,
59
+ CommonsSignInActionResult,
60
+ } from './mixins/OxyServices.auth';
54
61
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
55
62
  export type {
56
63
  CreateManagedAccountInput,
@@ -82,6 +89,8 @@ export {
82
89
  getNormalizedUserHandle,
83
90
  } from './utils/userHandle';
84
91
  export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHandle';
92
+ export { normalizeProfileLinks } from './utils/profileLinks';
93
+ export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
85
94
 
86
95
  // ---------------------------------------------------------------------------
87
96
  // Applications (multi-user apps: membership, roles, credentials)
@@ -159,6 +168,49 @@ export type {
159
168
  ReverseReputationTransactionInput,
160
169
  } from './mixins/OxyServices.reputation';
161
170
 
171
+ // ---------------------------------------------------------------------------
172
+ // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
173
+ // verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
174
+ // AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
175
+ // ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
176
+ // ---------------------------------------------------------------------------
177
+ export { buildUserDid } from './mixins/OxyServices.identity';
178
+ export type {
179
+ IdentityRecordType,
180
+ UnlinkableAuthMethodType,
181
+ LinkAuthMethodResult,
182
+ PublishRecordResult,
183
+ VerifyRecordResult,
184
+ VerifyDomainResult,
185
+ RemoveDomainResult,
186
+ } from './mixins/OxyServices.identity';
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Civic / Commons "Oxy ID" (public signed cards + Oxy ID QR payload) and Fase 2
190
+ // anti-gaming (real-life attestation QR + validator/jury). Wire shapes
191
+ // (PublicCard, SignedPublicCard, RealLifeAttestationResult,
192
+ // ValidationRequestSummary, ValidationVoteResult, ValidationVerdict, …) live in
193
+ // `@oxyhq/contracts` — import them from there. The SDK adds the client verdict
194
+ // wrapper, the QR payload parsers/builders, and the submit inputs/results.
195
+ // ---------------------------------------------------------------------------
196
+ export {
197
+ parseIdPayload,
198
+ parseAttestPayload,
199
+ verifyPublicCardAttestation,
200
+ } from './mixins/OxyServices.civic';
201
+ export type {
202
+ CivicCardResult,
203
+ IdCardRef,
204
+ AttestQrPayload,
205
+ ParsedAttestPayload,
206
+ SubmitRealLifeAttestationInput,
207
+ DenyValidationResult,
208
+ VouchForPersonInput,
209
+ WithdrawVouchResult,
210
+ IssueCredentialInput,
211
+ RevokeCredentialResult,
212
+ } from './mixins/OxyServices.civic';
213
+
162
214
  // ---------------------------------------------------------------------------
163
215
  // Auth helpers (token refresh, error normalisation, retry policies)
164
216
  // ---------------------------------------------------------------------------
@@ -206,8 +258,9 @@ export {
206
258
  IdentityPersistError,
207
259
  } from './crypto/keyManager';
208
260
  export type { KeyPair } from './crypto/keyManager';
209
- export { SignatureService } from './crypto/signatureService';
210
- export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
261
+ export { SignatureService, signedRecordSigningInput, computeRecordId } from './crypto/signatureService';
262
+ export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
263
+ export { canonicalize } from './crypto/canonicalJson';
211
264
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
212
265
  export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
213
266
 
@@ -486,7 +486,9 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
486
486
  public async fetchAssetContent(url: string, type: 'text'): Promise<string>;
487
487
  public async fetchAssetContent(url: string, type: 'blob'): Promise<Blob>;
488
488
  public async fetchAssetContent(url: string, type: 'text' | 'blob') {
489
- const response = await fetch(url, { credentials: 'include' });
489
+ const response = await fetch(url, {
490
+ credentials: shouldSendAssetCredentials(url, this.getBaseURL()) ? 'include' : 'omit',
491
+ });
490
492
  if (!response?.ok) {
491
493
  throw new Error(`Failed to fetch asset content (status ${response?.status})`);
492
494
  }
@@ -494,3 +496,16 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
494
496
  }
495
497
  };
496
498
  }
499
+
500
+ /**
501
+ * Only send ambient credentials (cookies) when the asset URL is same-origin with
502
+ * the configured API base. Caller-supplied cross-origin asset URLs must not leak
503
+ * the user's cookies to arbitrary hosts.
504
+ */
505
+ function shouldSendAssetCredentials(url: string, baseURL: string): boolean {
506
+ try {
507
+ return new URL(url).origin === new URL(baseURL).origin;
508
+ } catch {
509
+ return false;
510
+ }
511
+ }