@oxyhq/core 3.10.1 → 3.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 (82) 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 +103 -0
  8. package/dist/cjs/index.js +15 -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.identity.js +291 -0
  12. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  13. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  14. package/dist/cjs/mixins/index.js +3 -0
  15. package/dist/cjs/server/cors.js +20 -21
  16. package/dist/cjs/server/rateLimit.js +32 -8
  17. package/dist/cjs/utils/ssoReturn.js +1 -1
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/AuthManager.js +9 -2
  20. package/dist/esm/HttpService.js +27 -9
  21. package/dist/esm/OxyServices.base.js +3 -2
  22. package/dist/esm/crypto/canonicalJson.js +104 -0
  23. package/dist/esm/crypto/keyManager.js +67 -8
  24. package/dist/esm/crypto/signatureService.js +102 -0
  25. package/dist/esm/index.js +9 -1
  26. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  27. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  28. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  29. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  30. package/dist/esm/mixins/OxyServices.user.js +1 -0
  31. package/dist/esm/mixins/index.js +3 -0
  32. package/dist/esm/server/cors.js +20 -21
  33. package/dist/esm/server/rateLimit.js +32 -8
  34. package/dist/esm/utils/ssoReturn.js +1 -1
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/HttpService.d.ts +3 -0
  37. package/dist/types/OxyServices.d.ts +2 -2
  38. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  39. package/dist/types/crypto/keyManager.d.ts +7 -0
  40. package/dist/types/crypto/signatureService.d.ts +61 -0
  41. package/dist/types/index.d.ts +6 -2
  42. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  43. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  44. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  45. package/dist/types/mixins/index.d.ts +2 -1
  46. package/dist/types/models/interfaces.d.ts +3 -0
  47. package/dist/types/server/cors.d.ts +5 -5
  48. package/dist/types/utils/ssoReturn.d.ts +1 -1
  49. package/package.json +2 -2
  50. package/src/AuthManager.ts +8 -2
  51. package/src/HttpService.ts +36 -8
  52. package/src/OxyServices.base.ts +3 -2
  53. package/src/OxyServices.ts +1 -1
  54. package/src/__tests__/authManager.security.test.ts +31 -0
  55. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  56. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  57. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  58. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  59. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  60. package/src/crypto/canonicalJson.ts +120 -0
  61. package/src/crypto/keyManager.ts +62 -12
  62. package/src/crypto/signatureService.ts +126 -0
  63. package/src/index.ts +27 -2
  64. package/src/mixins/OxyServices.assets.ts +16 -1
  65. package/src/mixins/OxyServices.auth.ts +309 -1
  66. package/src/mixins/OxyServices.identity.ts +445 -0
  67. package/src/mixins/OxyServices.sso.ts +30 -1
  68. package/src/mixins/OxyServices.user.ts +1 -0
  69. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  70. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  71. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  72. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  73. package/src/mixins/__tests__/sso.test.ts +31 -0
  74. package/src/mixins/index.ts +4 -0
  75. package/src/models/interfaces.ts +3 -0
  76. package/src/server/__tests__/cors.test.ts +5 -1
  77. package/src/server/__tests__/rateLimit.test.ts +116 -0
  78. package/src/server/cors.ts +25 -20
  79. package/src/server/rateLimit.ts +39 -8
  80. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  81. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  82. package/src/utils/ssoReturn.ts +2 -2
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Signed-record envelope tests.
3
+ *
4
+ * Exercises the full client-side path: `SignatureService.signRecord` builds an
5
+ * envelope whose signature covers the canonical JSON of every field EXCEPT
6
+ * `publicKey`/`signature`, and `SignatureService.verifyRecord` round-trips it.
7
+ * We mock `KeyManager`'s key access with a REAL elliptic secp256k1 keypair, so
8
+ * the signing/verification is genuine cryptography (not a stub).
9
+ */
10
+
11
+ import { ec as EC } from 'elliptic';
12
+ import type { SignedRecordEnvelope } from '@oxyhq/contracts';
13
+ import { KeyManager } from '../keyManager';
14
+ import { SignatureService, signedRecordSigningInput } from '../signatureService';
15
+ import { canonicalize } from '../canonicalJson';
16
+
17
+ const ec = new EC('secp256k1');
18
+
19
+ describe('SignatureService.signRecord / verifyRecord', () => {
20
+ const keyPair = ec.genKeyPair();
21
+ const publicKey = keyPair.getPublic('hex');
22
+
23
+ beforeEach(() => {
24
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(publicKey);
25
+ jest.spyOn(KeyManager, 'getKeyPairObject').mockResolvedValue(keyPair);
26
+ });
27
+
28
+ afterEach(() => {
29
+ jest.restoreAllMocks();
30
+ });
31
+
32
+ it('builds a well-formed self-issued envelope', async () => {
33
+ const subject = 'did:web:oxy.so:u:abc123';
34
+ const record = { displayName: 'Nate', bio: 'builder' };
35
+
36
+ const envelope = await SignatureService.signRecord('profile', subject, record);
37
+
38
+ expect(envelope.version).toBe(1);
39
+ expect(envelope.type).toBe('profile');
40
+ expect(envelope.subject).toBe(subject);
41
+ expect(envelope.issuer).toBe(subject); // self-issued
42
+ expect(envelope.record).toEqual(record);
43
+ expect(envelope.publicKey).toBe(publicKey);
44
+ expect(envelope.alg).toBe('ES256K-DER-SHA256');
45
+ expect(typeof envelope.signature).toBe('string');
46
+ expect(envelope.signature.length).toBeGreaterThan(0);
47
+ expect(typeof envelope.issuedAt).toBe('number');
48
+ });
49
+
50
+ it('round-trips: a freshly signed record verifies', async () => {
51
+ const envelope = await SignatureService.signRecord('identity', 'did:web:oxy.so:u:u1', {
52
+ handle: '@nate',
53
+ nested: { a: 1, b: [2, 3] },
54
+ });
55
+ await expect(SignatureService.verifyRecord(envelope)).resolves.toBe(true);
56
+ });
57
+
58
+ it('signs the canonical JSON of every field except publicKey + signature', async () => {
59
+ const subject = 'did:web:oxy.so:u:u2';
60
+ const record = { z: 1, a: 2 };
61
+ const envelope = await SignatureService.signRecord('profile', subject, record);
62
+
63
+ const expectedInput = canonicalize({
64
+ version: envelope.version,
65
+ type: envelope.type,
66
+ subject: envelope.subject,
67
+ issuer: envelope.issuer,
68
+ record: envelope.record,
69
+ issuedAt: envelope.issuedAt,
70
+ });
71
+
72
+ // The helper reproduces exactly that input from the envelope.
73
+ expect(signedRecordSigningInput(envelope)).toBe(expectedInput);
74
+ // And it omits publicKey/signature.
75
+ expect(expectedInput).not.toContain(envelope.publicKey);
76
+ expect(expectedInput).not.toContain(envelope.signature);
77
+
78
+ // The signature verifies against that exact input + the embedded key.
79
+ await expect(
80
+ SignatureService.verify(expectedInput, envelope.signature, envelope.publicKey),
81
+ ).resolves.toBe(true);
82
+ });
83
+
84
+ describe('tamper detection', () => {
85
+ let envelope: SignedRecordEnvelope;
86
+
87
+ beforeEach(async () => {
88
+ envelope = await SignatureService.signRecord('profile', 'did:web:oxy.so:u:u3', {
89
+ displayName: 'Original',
90
+ score: 10,
91
+ });
92
+ });
93
+
94
+ it('rejects a mutated record', async () => {
95
+ const tampered: SignedRecordEnvelope = {
96
+ ...envelope,
97
+ record: { ...envelope.record, displayName: 'Tampered' },
98
+ };
99
+ await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
100
+ });
101
+
102
+ it('rejects a changed subject', async () => {
103
+ const tampered: SignedRecordEnvelope = { ...envelope, subject: 'did:web:oxy.so:u:evil' };
104
+ await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
105
+ });
106
+
107
+ it('rejects a changed issuedAt', async () => {
108
+ const tampered: SignedRecordEnvelope = { ...envelope, issuedAt: envelope.issuedAt + 1 };
109
+ await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
110
+ });
111
+
112
+ it('rejects verification against an unrelated public key', async () => {
113
+ const otherKey = ec.genKeyPair().getPublic('hex');
114
+ const tampered: SignedRecordEnvelope = { ...envelope, publicKey: otherKey };
115
+ await expect(SignatureService.verifyRecord(tampered)).resolves.toBe(false);
116
+ });
117
+ });
118
+
119
+ it('throws when no identity is stored', async () => {
120
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
121
+ await expect(
122
+ SignatureService.signRecord('identity', 'did:web:oxy.so:u:u4', {}),
123
+ ).rejects.toThrow(/No identity found/);
124
+ });
125
+ });
@@ -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,30 @@ 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
+ export type SignedRecordSigningFields = Pick<
26
+ SignedRecordEnvelope,
27
+ 'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
28
+ >;
29
+
30
+ /**
31
+ * Compute the canonical signing input for a signed-record envelope.
32
+ *
33
+ * This is the single definition of "what the signature covers": the canonical
34
+ * JSON of `{version, type, subject, issuer, record, issuedAt}`. `@oxyhq/core`
35
+ * (client signing) and `@oxyhq/api` (server verification) both call this, so a
36
+ * record signed by a client and verified by the server cannot drift.
37
+ */
38
+ export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
39
+ const { version, type, subject, issuer, record, issuedAt } = fields;
40
+ return canonicalize({ version, type, subject, issuer, record, issuedAt });
41
+ }
42
+
17
43
  /**
18
44
  * Compute SHA-256 hash of a string
19
45
  */
@@ -236,6 +262,39 @@ export class SignatureService {
236
262
  };
237
263
  }
238
264
 
265
+ /**
266
+ * Create a signed authentication challenge response using the SHARED identity
267
+ * key (the cross-app `group.so.oxy.shared` keychain key), not the primary
268
+ * device key.
269
+ *
270
+ * Mirrors {@link signChallenge} exactly — same message format
271
+ * (`auth:${publicKey}:${challenge}:${timestamp}`) so the server verification
272
+ * path is unchanged — but sources the shared public/private key from
273
+ * `KeyManager` and signs with `signWithKey`. Used by "Sign in with Oxy"
274
+ * same-device shared-keychain SSO (Mechanism A): a sibling native app proves
275
+ * control of the shared identity to mint its own session.
276
+ *
277
+ * Throws if no shared identity exists (native-only; the shared keychain is
278
+ * unavailable on web).
279
+ */
280
+ static async signChallengeWithSharedKey(challenge: string): Promise<AuthChallenge> {
281
+ const publicKey = await KeyManager.getSharedPublicKey();
282
+ const privateKey = await KeyManager.getSharedPrivateKey();
283
+ if (!publicKey || !privateKey) {
284
+ throw new Error('No shared identity found. Cannot sign with the shared key.');
285
+ }
286
+
287
+ const timestamp = Date.now();
288
+ const message = `auth:${publicKey}:${challenge}:${timestamp}`;
289
+ const signature = await SignatureService.signWithKey(message, privateKey);
290
+
291
+ return {
292
+ challenge: signature,
293
+ publicKey,
294
+ timestamp,
295
+ };
296
+ }
297
+
239
298
  /**
240
299
  * Verify a challenge response
241
300
  */
@@ -308,6 +367,73 @@ export class SignatureService {
308
367
  timestamp,
309
368
  };
310
369
  }
370
+
371
+ /**
372
+ * Build a signed-record envelope for a self-issued identity/profile record.
373
+ *
374
+ * The envelope is self-issued: `issuer` equals `subject` (the signer's DID).
375
+ * The signature covers the canonical JSON of every field EXCEPT `publicKey`
376
+ * and `signature` (see {@link signedRecordSigningInput}); `alg` is
377
+ * `ES256K-DER-SHA256` (secp256k1 over the SHA-256 of the canonical bytes,
378
+ * DER-encoded), the same scheme this service uses everywhere else.
379
+ *
380
+ * Requires a stored identity (native secure storage); throws if none exists.
381
+ *
382
+ * @param type - The record category (`'identity'` or `'profile'`).
383
+ * @param subject - The subject DID the record is about (also the issuer).
384
+ * @param record - The arbitrary record payload to attest to.
385
+ */
386
+ static async signRecord(
387
+ type: SignedRecordEnvelope['type'],
388
+ subject: string,
389
+ record: Record<string, unknown>,
390
+ ): Promise<SignedRecordEnvelope> {
391
+ const publicKey = await KeyManager.getPublicKey();
392
+ if (!publicKey) {
393
+ throw new Error('No identity found. Please create or import an identity first.');
394
+ }
395
+
396
+ const version = 1 as const;
397
+ const issuer = subject;
398
+ const issuedAt = Date.now();
399
+ const signingInput = signedRecordSigningInput({
400
+ version,
401
+ type,
402
+ subject,
403
+ issuer,
404
+ record,
405
+ issuedAt,
406
+ });
407
+ const signature = await SignatureService.sign(signingInput);
408
+
409
+ return {
410
+ version,
411
+ type,
412
+ subject,
413
+ issuer,
414
+ record,
415
+ issuedAt,
416
+ publicKey,
417
+ alg: 'ES256K-DER-SHA256',
418
+ signature,
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Verify a signed-record envelope: recompute the canonical signing input from
424
+ * the envelope's own fields and check the signature against the envelope's
425
+ * `publicKey`.
426
+ *
427
+ * Note: this confirms the signature is internally consistent with the
428
+ * embedded `publicKey`. It does NOT establish that `publicKey` is an
429
+ * authorized verification method for `subject` — that authorization check is
430
+ * the server's responsibility (it asserts the key is a current verification
431
+ * method on the subject's DID).
432
+ */
433
+ static async verifyRecord(envelope: SignedRecordEnvelope): Promise<boolean> {
434
+ const signingInput = signedRecordSigningInput(envelope);
435
+ return SignatureService.verify(signingInput, envelope.signature, envelope.publicKey);
436
+ }
311
437
  }
312
438
 
313
439
  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,
@@ -159,6 +166,23 @@ export type {
159
166
  ReverseReputationTransactionInput,
160
167
  } from './mixins/OxyServices.reputation';
161
168
 
169
+ // ---------------------------------------------------------------------------
170
+ // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping,
171
+ // verified domains). Wire shapes (DidDocument, SignedRecordEnvelope,
172
+ // AuthMethodsResponse, VerifiedDomain, DomainVerificationInstructions,
173
+ // ExportBundle) live in `@oxyhq/contracts` — import them directly from there.
174
+ // ---------------------------------------------------------------------------
175
+ export { buildUserDid } from './mixins/OxyServices.identity';
176
+ export type {
177
+ IdentityRecordType,
178
+ UnlinkableAuthMethodType,
179
+ LinkAuthMethodResult,
180
+ PublishRecordResult,
181
+ VerifyRecordResult,
182
+ VerifyDomainResult,
183
+ RemoveDomainResult,
184
+ } from './mixins/OxyServices.identity';
185
+
162
186
  // ---------------------------------------------------------------------------
163
187
  // Auth helpers (token refresh, error normalisation, retry policies)
164
188
  // ---------------------------------------------------------------------------
@@ -206,8 +230,9 @@ export {
206
230
  IdentityPersistError,
207
231
  } from './crypto/keyManager';
208
232
  export type { KeyPair } from './crypto/keyManager';
209
- export { SignatureService } from './crypto/signatureService';
210
- export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
233
+ export { SignatureService, signedRecordSigningInput } from './crypto/signatureService';
234
+ export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
235
+ export { canonicalize } from './crypto/canonicalJson';
211
236
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';
212
237
  export type { RecoveryPhraseResult } from './crypto/recoveryPhrase';
213
238
 
@@ -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
+ }