@oxyhq/core 3.10.0 → 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 (105) 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/i18n/locales/en-US.json +9 -0
  9. package/dist/cjs/i18n/locales/es-ES.json +9 -0
  10. package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
  11. package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
  12. package/dist/cjs/index.js +15 -5
  13. package/dist/cjs/mixins/OxyServices.assets.js +45 -7
  14. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  15. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  16. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  17. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  18. package/dist/cjs/mixins/OxyServices.utility.js +52 -23
  19. package/dist/cjs/mixins/index.js +3 -0
  20. package/dist/cjs/server/cors.js +20 -21
  21. package/dist/cjs/server/rateLimit.js +32 -8
  22. package/dist/cjs/utils/fapiAutoDetect.js +12 -42
  23. package/dist/cjs/utils/ssoReturn.js +1 -1
  24. package/dist/esm/.tsbuildinfo +1 -1
  25. package/dist/esm/AuthManager.js +9 -2
  26. package/dist/esm/HttpService.js +27 -9
  27. package/dist/esm/OxyServices.base.js +3 -2
  28. package/dist/esm/crypto/canonicalJson.js +104 -0
  29. package/dist/esm/crypto/keyManager.js +67 -8
  30. package/dist/esm/crypto/signatureService.js +102 -0
  31. package/dist/esm/i18n/locales/en-US.json +9 -0
  32. package/dist/esm/i18n/locales/es-ES.json +9 -0
  33. package/dist/esm/i18n/locales/locales/en-US.json +9 -0
  34. package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
  35. package/dist/esm/index.js +10 -2
  36. package/dist/esm/mixins/OxyServices.assets.js +45 -7
  37. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  38. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  39. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  40. package/dist/esm/mixins/OxyServices.user.js +1 -0
  41. package/dist/esm/mixins/OxyServices.utility.js +52 -23
  42. package/dist/esm/mixins/index.js +3 -0
  43. package/dist/esm/server/cors.js +20 -21
  44. package/dist/esm/server/rateLimit.js +32 -8
  45. package/dist/esm/utils/fapiAutoDetect.js +12 -41
  46. package/dist/esm/utils/ssoReturn.js +1 -1
  47. package/dist/types/.tsbuildinfo +1 -1
  48. package/dist/types/HttpService.d.ts +3 -0
  49. package/dist/types/OxyServices.d.ts +2 -2
  50. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  51. package/dist/types/crypto/keyManager.d.ts +7 -0
  52. package/dist/types/crypto/signatureService.d.ts +61 -0
  53. package/dist/types/index.d.ts +7 -3
  54. package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
  55. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  56. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  57. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  58. package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
  59. package/dist/types/mixins/index.d.ts +2 -1
  60. package/dist/types/models/interfaces.d.ts +3 -0
  61. package/dist/types/server/cors.d.ts +5 -5
  62. package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
  63. package/dist/types/utils/ssoReturn.d.ts +1 -1
  64. package/package.json +3 -2
  65. package/src/AuthManager.ts +8 -2
  66. package/src/HttpService.ts +36 -8
  67. package/src/OxyServices.base.ts +3 -2
  68. package/src/OxyServices.ts +1 -1
  69. package/src/__tests__/authManager.security.test.ts +31 -0
  70. package/src/__tests__/authSocket.test.ts +96 -0
  71. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  72. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  73. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  74. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  75. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  76. package/src/crypto/canonicalJson.ts +120 -0
  77. package/src/crypto/keyManager.ts +62 -12
  78. package/src/crypto/signatureService.ts +126 -0
  79. package/src/i18n/locales/en-US.json +9 -0
  80. package/src/i18n/locales/es-ES.json +9 -0
  81. package/src/index.ts +28 -3
  82. package/src/mixins/OxyServices.assets.ts +56 -7
  83. package/src/mixins/OxyServices.auth.ts +309 -1
  84. package/src/mixins/OxyServices.identity.ts +445 -0
  85. package/src/mixins/OxyServices.sso.ts +30 -1
  86. package/src/mixins/OxyServices.user.ts +1 -0
  87. package/src/mixins/OxyServices.utility.ts +57 -23
  88. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  89. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  90. package/src/mixins/__tests__/assetUpload.test.ts +191 -0
  91. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  92. package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
  93. package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
  94. package/src/mixins/__tests__/sso.test.ts +31 -0
  95. package/src/mixins/index.ts +4 -0
  96. package/src/models/interfaces.ts +3 -0
  97. package/src/server/__tests__/cors.test.ts +5 -1
  98. package/src/server/__tests__/rateLimit.test.ts +116 -0
  99. package/src/server/cors.ts +25 -20
  100. package/src/server/rateLimit.ts +39 -8
  101. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  102. package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
  103. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  104. package/src/utils/fapiAutoDetect.ts +12 -39
  105. package/src/utils/ssoReturn.ts +2 -2
@@ -21,7 +21,7 @@ import { setPlatformOS } from '../../utils/platform';
21
21
  // Fault-injectable in-memory secure store. `failPlan` lets a test make a
22
22
  // specific (op, key) pair throw to simulate a keychain that fails mid-write or
23
23
  // is transiently locked.
24
- const failPlan: { failKey?: string; failOp?: 'set' | 'get' } = {};
24
+ const failPlan: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number } = {};
25
25
 
26
26
  jest.mock(
27
27
  'expo-secure-store',
@@ -29,6 +29,14 @@ jest.mock(
29
29
  const store = new Map<string, string>();
30
30
  const maybeFail = (op: 'set' | 'get', key: string) => {
31
31
  if (failPlan.failOp === op && failPlan.failKey === key) {
32
+ if (failPlan.failTimes !== undefined) {
33
+ failPlan.failTimes -= 1;
34
+ if (failPlan.failTimes <= 0) {
35
+ failPlan.failKey = undefined;
36
+ failPlan.failOp = undefined;
37
+ failPlan.failTimes = undefined;
38
+ }
39
+ }
32
40
  throw new Error(`Simulated ${op} failure for ${key}`);
33
41
  }
34
42
  };
@@ -51,6 +59,7 @@ jest.mock(
51
59
  store.clear();
52
60
  failPlan.failKey = undefined;
53
61
  failPlan.failOp = undefined;
62
+ failPlan.failTimes = undefined;
54
63
  },
55
64
  __getStore__: () => store,
56
65
  __failPlan__: failPlan,
@@ -92,7 +101,7 @@ jest.mock('../../utils/platformCrypto', () => ({
92
101
  interface SecureStoreTestHandle {
93
102
  __resetStore__: () => void;
94
103
  __getStore__: () => Map<string, string>;
95
- __failPlan__: { failKey?: string; failOp?: 'set' | 'get' };
104
+ __failPlan__: { failKey?: string; failOp?: 'set' | 'get'; failTimes?: number };
96
105
  }
97
106
 
98
107
  describe('KeyManager atomicity & recoverability under flaky storage', () => {
@@ -146,6 +155,36 @@ describe('KeyManager atomicity & recoverability under flaky storage', () => {
146
155
  expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
147
156
  });
148
157
 
158
+ it('a failed final backup refresh rejects and rolls back instead of succeeding with a stale backup', async () => {
159
+ const originalPublic = await KeyManager.createIdentity();
160
+ const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
161
+ const originalPriv = ss.__getStore__().get('oxy_identity_private_key');
162
+ resetCaches();
163
+
164
+ // Let the new primary write and verify, then fail exactly once while
165
+ // refreshing the backup to the new identity. This used to return success
166
+ // with primary=B and backup=A, enabling a later absent-primary restore to
167
+ // silently switch back to A.
168
+ ss.__failPlan__.failOp = 'set';
169
+ ss.__failPlan__.failKey = 'oxy_identity_backup_public_key';
170
+ ss.__failPlan__.failTimes = 1;
171
+ await expect(KeyManager.createIdentity({ overwrite: true })).rejects.toBeDefined();
172
+
173
+ resetCaches();
174
+
175
+ // The operation failed atomically: primary and backup both still identify
176
+ // the original account, so callers cannot observe success with a stale
177
+ // cross-account backup.
178
+ expect(await KeyManager.hasIdentity()).toBe(true);
179
+ expect(await KeyManager.getPublicKey()).toBe(originalPublic);
180
+ const m = ss.__getStore__();
181
+ expect(m.get('oxy_identity_private_key')).toBe(originalPriv);
182
+ expect(m.get('oxy_identity_public_key')).toBe(originalPublic);
183
+ expect(m.get('oxy_identity_backup_private_key')).toBe(originalPriv);
184
+ expect(m.get('oxy_identity_backup_public_key')).toBe(originalPublic);
185
+ });
186
+
187
+
149
188
  it('restoreIdentityFromBackup does NOT clobber a healthy primary that is only transiently unreadable', async () => {
150
189
  const original = await KeyManager.createIdentity();
151
190
  const ss = (await import('expo-secure-store' as string)) as unknown as SecureStoreTestHandle;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `signChallengeWithSharedKey` tests.
3
+ *
4
+ * Verifies the shared-key challenge signer mirrors `signChallenge` exactly —
5
+ * same `auth:${publicKey}:${challenge}:${timestamp}` message format so the
6
+ * server verification path is unchanged — but sources the SHARED key from
7
+ * `KeyManager` (not the primary device key). We mock the shared key access with
8
+ * a REAL elliptic secp256k1 keypair so signing/verification is genuine.
9
+ */
10
+
11
+ import { ec as EC } from 'elliptic';
12
+ import { KeyManager } from '../keyManager';
13
+ import { SignatureService } from '../signatureService';
14
+
15
+ const ec = new EC('secp256k1');
16
+
17
+ describe('SignatureService.signChallengeWithSharedKey', () => {
18
+ const sharedKeyPair = ec.genKeyPair();
19
+ const sharedPublicKey = sharedKeyPair.getPublic('hex');
20
+ const sharedPrivateKey = sharedKeyPair.getPrivate('hex');
21
+
22
+ afterEach(() => {
23
+ jest.restoreAllMocks();
24
+ });
25
+
26
+ it('signs with the shared key and uses the unchanged message format', async () => {
27
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(sharedPublicKey);
28
+ jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(sharedPrivateKey);
29
+ // Guard: it must NOT fall back to the primary device key.
30
+ const primarySpy = jest.spyOn(KeyManager, 'getPublicKey');
31
+
32
+ const result = await SignatureService.signChallengeWithSharedKey('chal-123');
33
+
34
+ expect(result.publicKey).toBe(sharedPublicKey);
35
+ expect(typeof result.challenge).toBe('string'); // the signature
36
+ expect(typeof result.timestamp).toBe('number');
37
+ expect(primarySpy).not.toHaveBeenCalled();
38
+
39
+ // The signature verifies against the SAME message format `signChallenge`
40
+ // uses, proving the format is unchanged and the shared key signed it.
41
+ const message = `auth:${sharedPublicKey}:chal-123:${result.timestamp}`;
42
+ await expect(
43
+ SignatureService.verify(message, result.challenge, sharedPublicKey),
44
+ ).resolves.toBe(true);
45
+ });
46
+
47
+ it('throws when no shared identity exists', async () => {
48
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(null);
49
+ jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(null);
50
+
51
+ await expect(
52
+ SignatureService.signChallengeWithSharedKey('chal-123'),
53
+ ).rejects.toThrow(/No shared identity/);
54
+ });
55
+
56
+ it('throws when the shared private key is missing even if the public key is present', async () => {
57
+ jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue(sharedPublicKey);
58
+ jest.spyOn(KeyManager, 'getSharedPrivateKey').mockResolvedValue(null);
59
+
60
+ await expect(
61
+ SignatureService.signChallengeWithSharedKey('chal-123'),
62
+ ).rejects.toThrow(/No shared identity/);
63
+ });
64
+ });
@@ -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;
@@ -129,6 +129,15 @@
129
129
  "title": "Reputation = Trust & Growth",
130
130
  "body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
131
131
  },
132
+ "name": {
133
+ "title": "What's your name?",
134
+ "body": "Add your name so people know who you are.",
135
+ "firstLabel": "First name",
136
+ "firstPlaceholder": "Your first name",
137
+ "lastLabel": "Last name",
138
+ "lastPlaceholder": "Your last name",
139
+ "saveFailed": "Could not save your name"
140
+ },
132
141
  "avatar": {
133
142
  "title": "Make It Yours",
134
143
  "body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
@@ -849,6 +849,15 @@
849
849
  "title": "Reputación = Confianza y crecimiento",
850
850
  "body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
851
851
  },
852
+ "name": {
853
+ "title": "¿Cuál es tu nombre?",
854
+ "body": "Añade tu nombre para que la gente sepa quién eres.",
855
+ "firstLabel": "Nombre",
856
+ "firstPlaceholder": "Tu nombre",
857
+ "lastLabel": "Apellidos",
858
+ "lastPlaceholder": "Tus apellidos",
859
+ "saveFailed": "No se pudo guardar tu nombre"
860
+ },
852
861
  "avatar": {
853
862
  "title": "Hazlo tuyo",
854
863
  "body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",