@oxy.so/core 1.5.1 → 1.6.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 (126) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +72 -9
  3. package/dist/cjs/crypto/identityMove.js +53 -34
  4. package/dist/cjs/crypto/identityProof.js +28 -0
  5. package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
  6. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  7. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  9. package/dist/cjs/i18n/locales/en-US.json +1 -0
  10. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  11. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  12. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  13. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  14. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  23. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  24. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  25. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  26. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  27. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  28. package/dist/cjs/index.js +22 -14
  29. package/dist/cjs/inference/OxyInferenceClient.js +62 -5
  30. package/dist/cjs/mixins/OxyServices.auth.js +129 -0
  31. package/dist/cjs/mixins/OxyServices.identity.js +11 -44
  32. package/dist/cjs/mixins/index.js +0 -2
  33. package/dist/cjs/server/index.js +10 -1
  34. package/dist/cjs/server/securityHeaders.js +9 -2
  35. package/dist/cjs/server/workloadIdentity.js +170 -0
  36. package/dist/cjs/session/refresh.js +12 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/HttpService.js +72 -9
  39. package/dist/esm/crypto/identityMove.js +51 -33
  40. package/dist/esm/crypto/identityProof.js +24 -0
  41. package/dist/esm/crypto/webIdentityCarrier.js +137 -77
  42. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  43. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  44. package/dist/esm/i18n/locales/de-DE.json +1 -0
  45. package/dist/esm/i18n/locales/en-US.json +1 -0
  46. package/dist/esm/i18n/locales/es-ES.json +1 -0
  47. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  48. package/dist/esm/i18n/locales/it-IT.json +1 -0
  49. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  50. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  51. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  52. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  54. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  55. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  56. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  59. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  60. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  61. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  62. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  63. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  64. package/dist/esm/index.js +5 -4
  65. package/dist/esm/inference/OxyInferenceClient.js +63 -6
  66. package/dist/esm/mixins/OxyServices.auth.js +96 -0
  67. package/dist/esm/mixins/OxyServices.identity.js +11 -44
  68. package/dist/esm/mixins/index.js +0 -2
  69. package/dist/esm/server/index.js +7 -0
  70. package/dist/esm/server/securityHeaders.js +9 -2
  71. package/dist/esm/server/workloadIdentity.js +166 -0
  72. package/dist/esm/session/refresh.js +12 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/HttpService.d.ts +24 -0
  75. package/dist/types/crypto/identityMove.d.ts +48 -32
  76. package/dist/types/crypto/identityProof.d.ts +16 -0
  77. package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
  78. package/dist/types/index.d.ts +7 -6
  79. package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
  80. package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
  81. package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
  82. package/dist/types/mixins/index.d.ts +1 -2
  83. package/dist/types/server/index.d.ts +8 -0
  84. package/dist/types/server/securityHeaders.d.ts +8 -0
  85. package/dist/types/server/workloadIdentity.d.ts +32 -0
  86. package/package.json +1 -1
  87. package/src/HttpService.ts +74 -9
  88. package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
  89. package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
  90. package/src/crypto/__tests__/identityMove.test.ts +93 -51
  91. package/src/crypto/__tests__/identityProof.test.ts +81 -0
  92. package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
  93. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
  94. package/src/crypto/identityMove.ts +77 -43
  95. package/src/crypto/identityProof.ts +36 -0
  96. package/src/crypto/webIdentityCarrier.ts +185 -117
  97. package/src/i18n/locales/ar-SA.json +1 -0
  98. package/src/i18n/locales/ca-ES.json +1 -0
  99. package/src/i18n/locales/de-DE.json +1 -0
  100. package/src/i18n/locales/en-US.json +1 -0
  101. package/src/i18n/locales/es-ES.json +1 -0
  102. package/src/i18n/locales/fr-FR.json +1 -0
  103. package/src/i18n/locales/it-IT.json +1 -0
  104. package/src/i18n/locales/ja-JP.json +1 -0
  105. package/src/i18n/locales/ko-KR.json +1 -0
  106. package/src/i18n/locales/pt-PT.json +1 -0
  107. package/src/i18n/locales/zh-CN.json +1 -0
  108. package/src/index.ts +26 -15
  109. package/src/inference/OxyInferenceClient.ts +84 -7
  110. package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
  111. package/src/mixins/OxyServices.auth.ts +100 -1
  112. package/src/mixins/OxyServices.identity.ts +17 -67
  113. package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
  114. package/src/mixins/index.ts +0 -3
  115. package/src/server/__tests__/securityHeaders.test.ts +15 -0
  116. package/src/server/__tests__/workloadIdentity.test.ts +128 -0
  117. package/src/server/index.ts +9 -0
  118. package/src/server/securityHeaders.ts +19 -6
  119. package/src/server/workloadIdentity.ts +207 -0
  120. package/src/session/__tests__/refresh.test.ts +53 -3
  121. package/src/session/refresh.ts +12 -0
  122. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
  123. package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
  124. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
  125. package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
  126. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
@@ -1,271 +0,0 @@
1
- /**
2
- * Device-to-device identity transfer mixin tests (b3 Feature 2 — "add a device").
3
- *
4
- * Exercises the E2E crypto path THROUGH the mixins with two independent
5
- * OxyServices instances (old device + new device) wired to a shared in-memory
6
- * "relay" that never sees a decryption key:
7
- * - ECDH symmetry: the old device seals with `ECDH(oldEphPriv, newEphPub)` and
8
- * the new device opens with `ECDH(newEphPriv, oldEphPub)` — same transfer key.
9
- * - full round-trip: the private key the old device holds is recovered and
10
- * imported byte-for-byte on the new device.
11
- * - tamper: a flipped ciphertext byte fails authentication (never imports).
12
- *
13
- * The socket is forced OFF (getSocketIO → null) so the deterministic poll path
14
- * drives the flow; KeyManager + SignatureService.sign are stubbed (the identity
15
- * key material and the approval signature are not the unit under test here — the
16
- * server-side signature verification is covered in the api service tests).
17
- */
18
-
19
- jest.mock('../../session/socketLoader', () => ({
20
- getSocketIO: jest.fn(async () => null),
21
- }));
22
-
23
- import {
24
- deriveSecp256k1PublicKey,
25
- generateSecp256k1KeyPair,
26
- } from '@oxy.so/protocol/secp256k1';
27
- import { OxyServices } from '../../OxyServices';
28
- import { KeyManager } from '../../crypto/keyManager';
29
- import { SignatureService } from '../../crypto/signatureService';
30
- import { deriveSharedSecret } from '../../crypto/ecdh';
31
- import { hkdfSha256 } from '../../crypto/kdf';
32
- import { encryptAead, decryptAead } from '../../crypto/aead';
33
- import { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8 } from '@noble/hashes/utils';
34
- import type { DeviceTransferInfoResponse } from '@oxy.so/contracts';
35
-
36
- /** A shared in-memory relay: a single pairing row, mirroring the API's shape. */
37
- interface RelayState {
38
- pairingId: string;
39
- newDeviceEphemeralPublicKey: string;
40
- newDeviceLabel: string | null;
41
- status: 'pending' | 'approved' | 'denied' | 'expired';
42
- expiresAt: string;
43
- oldDeviceEphemeralPublicKey: string | null;
44
- ciphertext: string | null;
45
- nonce: string | null;
46
- }
47
-
48
- function makeRelay() {
49
- const state: { row: RelayState | null } = { row: null };
50
- const infoDto = (): DeviceTransferInfoResponse => {
51
- const row = state.row!;
52
- const approved = row.status === 'approved';
53
- return {
54
- pairingId: row.pairingId,
55
- newDeviceEphemeralPublicKey: row.newDeviceEphemeralPublicKey,
56
- newDeviceLabel: row.newDeviceLabel,
57
- status: row.status,
58
- expiresAt: row.expiresAt,
59
- oldDeviceEphemeralPublicKey: approved ? row.oldDeviceEphemeralPublicKey : null,
60
- ciphertext: approved ? row.ciphertext : null,
61
- nonce: approved ? row.nonce : null,
62
- };
63
- };
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
- const handle = (method: string, url: string, data?: any): Promise<unknown> => {
66
- if (method === 'POST' && url === '/identity/device-transfer/init') {
67
- state.row = {
68
- pairingId: 'a'.repeat(32),
69
- newDeviceEphemeralPublicKey: data.newEphPub,
70
- newDeviceLabel: data.newDeviceLabel ?? null,
71
- status: 'pending',
72
- expiresAt: new Date(Date.now() + 180_000).toISOString(),
73
- oldDeviceEphemeralPublicKey: null,
74
- ciphertext: null,
75
- nonce: null,
76
- };
77
- return Promise.resolve({ pairingId: state.row.pairingId, expiresAt: state.row.expiresAt });
78
- }
79
- if (method === 'GET' && url.startsWith('/identity/device-transfer/')) {
80
- return Promise.resolve(infoDto());
81
- }
82
- if (method === 'POST' && url.endsWith('/approve')) {
83
- state.row!.oldDeviceEphemeralPublicKey = data.oldEphPub;
84
- state.row!.ciphertext = data.ciphertext;
85
- state.row!.nonce = data.nonce;
86
- state.row!.status = 'approved';
87
- return Promise.resolve({ success: true, pairingId: state.row!.pairingId, status: 'approved' });
88
- }
89
- if (method === 'POST' && url.endsWith('/deny')) {
90
- state.row!.status = 'denied';
91
- return Promise.resolve({ success: true, pairingId: state.row!.pairingId, status: 'denied' });
92
- }
93
- return Promise.reject(new Error(`unexpected request ${method} ${url}`));
94
- };
95
- return { state, handle };
96
- }
97
-
98
- describe('OxyServices.deviceTransfer', () => {
99
- let identityPriv: string;
100
- let identityPub: string;
101
- let importedPrivateKey: string | null;
102
-
103
- beforeEach(() => {
104
- const idKey = generateSecp256k1KeyPair();
105
- identityPriv = idKey.privateKey;
106
- identityPub = idKey.publicKey;
107
- importedPrivateKey = null;
108
-
109
- // Old device HOLDS the identity; new device IMPORTS it. One KeyManager is
110
- // shared, but the two roles call disjoint methods (get* vs import).
111
- jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(identityPriv);
112
- jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(identityPub);
113
- jest.spyOn(KeyManager, 'importKeyPair').mockImplementation(async (priv: string) => {
114
- importedPrivateKey = priv;
115
- return deriveSecp256k1PublicKey(priv);
116
- });
117
- // The server (api service test) verifies the signature; here it is opaque.
118
- jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
119
- });
120
-
121
- afterEach(() => {
122
- jest.restoreAllMocks();
123
- });
124
-
125
- it('clones the identity end-to-end (ECDH symmetry + full round-trip via the mixins)', async () => {
126
- const relay = makeRelay();
127
- const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
128
- const oldDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
129
- jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
130
- jest.spyOn(oldDevice, 'makeRequest').mockImplementation(relay.handle as never);
131
-
132
- // 1. New device registers a pairing (generates + stores its ephemeral key).
133
- const init = await newDevice.initDeviceTransfer('New iPhone');
134
- expect(init.pairingId).toHaveLength(32);
135
- expect(relay.state.row?.newDeviceEphemeralPublicKey).toBe(init.newEphemeralPublicKey);
136
-
137
- // 2. Old device resolves the QR handle and approves (seals the identity key).
138
- const approveResult = await oldDevice.approveDeviceTransfer(init.pairingId);
139
- expect(approveResult).toEqual({ success: true, pairingId: init.pairingId, status: 'approved' });
140
- // The relay stored ONLY ephemeral pubkeys + opaque ciphertext — never the key.
141
- expect(relay.state.row?.ciphertext).toBeTruthy();
142
- expect(relay.state.row?.ciphertext).not.toContain(identityPriv);
143
-
144
- // 3. New device awaits approval, decrypts, and imports the SAME private key.
145
- const outcome = await new Promise<{ status: string; publicKey?: string }>((resolve) => {
146
- newDevice.subscribeDeviceTransfer(init.pairingId, resolve);
147
- });
148
-
149
- expect(outcome).toEqual({ status: 'approved', publicKey: identityPub });
150
- // The recovered private key is byte-for-byte the old device's identity key.
151
- expect(importedPrivateKey).toBe(identityPriv);
152
- // Imported WITHOUT overwrite — a fresh device must never clobber an identity.
153
- expect(KeyManager.importKeyPair).toHaveBeenCalledWith(identityPriv, { overwrite: false });
154
- });
155
-
156
- it('reports a denied transfer to the subscriber without importing', async () => {
157
- const relay = makeRelay();
158
- const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
159
- jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
160
-
161
- await newDevice.initDeviceTransfer();
162
- relay.state.row!.status = 'denied';
163
-
164
- const outcome = await new Promise<{ status: string }>((resolve) => {
165
- newDevice.subscribeDeviceTransfer(relay.state.row!.pairingId, resolve);
166
- });
167
-
168
- expect(outcome).toEqual({ status: 'denied' });
169
- expect(KeyManager.importKeyPair).not.toHaveBeenCalled();
170
- });
171
-
172
- it('throws if subscribing to a pairing this instance never initiated', () => {
173
- const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
174
- expect(() => newDevice.subscribeDeviceTransfer('unknown-pair', () => {})).toThrow(
175
- /initDeviceTransfer first/i,
176
- );
177
- });
178
-
179
- it('refuses to approve a non-pending pairing', async () => {
180
- const relay = makeRelay();
181
- const newDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
182
- const oldDevice = new OxyServices({ baseURL: 'http://relay.invalid' });
183
- jest.spyOn(newDevice, 'makeRequest').mockImplementation(relay.handle as never);
184
- jest.spyOn(oldDevice, 'makeRequest').mockImplementation(relay.handle as never);
185
-
186
- const init = await newDevice.initDeviceTransfer();
187
- relay.state.row!.status = 'denied';
188
-
189
- await expect(oldDevice.approveDeviceTransfer(init.pairingId)).rejects.toThrow(
190
- /can no longer be approved/i,
191
- );
192
- });
193
- });
194
-
195
- /**
196
- * The exact crypto derivation the mixin uses, pinned independently: two ephemeral
197
- * pairs agree on a symmetric key, seal/open a JSON key blob, and any tampering
198
- * with the ciphertext fails authentication.
199
- */
200
- describe('device-transfer crypto derivation', () => {
201
- const HKDF_INFO = 'oxy-device-transfer-v1';
202
- const deriveTransferKey = (shared: Uint8Array, pairingId: string): Uint8Array =>
203
- hkdfSha256(shared, utf8ToBytes(pairingId), utf8ToBytes(HKDF_INFO), 32);
204
-
205
- it('is symmetric and round-trips the sealed identity key', () => {
206
- const pairingId = 'b'.repeat(32);
207
- const oldEph = generateSecp256k1KeyPair();
208
- const newEph = generateSecp256k1KeyPair();
209
-
210
- const sharedOld = deriveSharedSecret(oldEph.privateKey, newEph.publicKey);
211
- const sharedNew = deriveSharedSecret(newEph.privateKey, oldEph.publicKey);
212
- expect(bytesToHex(sharedOld)).toBe(bytesToHex(sharedNew));
213
-
214
- const keyOld = deriveTransferKey(sharedOld, pairingId);
215
- const keyNew = deriveTransferKey(sharedNew, pairingId);
216
- expect(bytesToHex(keyOld)).toBe(bytesToHex(keyNew));
217
-
218
- const identity = { privateKey: 'ff'.repeat(32), publicKey: '04' + 'ab'.repeat(64) };
219
- const { nonce, ciphertext } = encryptAead(keyOld, utf8ToBytes(JSON.stringify(identity)));
220
-
221
- const opened = JSON.parse(bytesToUtf8(decryptAead(keyNew, nonce, ciphertext)));
222
- expect(opened).toEqual(identity);
223
- });
224
-
225
- it('fails authentication when the ciphertext is tampered', () => {
226
- const pairingId = 'c'.repeat(32);
227
- const oldEph = generateSecp256k1KeyPair();
228
- const newEph = generateSecp256k1KeyPair();
229
- const key = deriveTransferKey(
230
- deriveSharedSecret(oldEph.privateKey, newEph.publicKey),
231
- pairingId,
232
- );
233
- const { nonce, ciphertext } = encryptAead(key, utf8ToBytes('{"privateKey":"deadbeef"}'));
234
-
235
- const tampered = Uint8Array.from(ciphertext);
236
- tampered[0] ^= 0x01; // flip one bit
237
- const keyNew = deriveTransferKey(
238
- deriveSharedSecret(newEph.privateKey, oldEph.publicKey),
239
- pairingId,
240
- );
241
- expect(() => decryptAead(keyNew, nonce, tampered)).toThrow();
242
- // And a wrong pairingId (wrong HKDF salt) also fails — binds to the pairing.
243
- const wrongSaltKey = deriveTransferKey(
244
- deriveSharedSecret(newEph.privateKey, oldEph.publicKey),
245
- 'd'.repeat(32),
246
- );
247
- expect(() => decryptAead(wrongSaltKey, nonce, ciphertext)).toThrow();
248
- });
249
-
250
- it('re-derives from hex the way the wire transports the material', () => {
251
- const pairingId = 'e'.repeat(32);
252
- const oldEph = generateSecp256k1KeyPair();
253
- const newEph = generateSecp256k1KeyPair();
254
- const keyOld = deriveTransferKey(
255
- deriveSharedSecret(oldEph.privateKey, newEph.publicKey),
256
- pairingId,
257
- );
258
- const { nonce, ciphertext } = encryptAead(keyOld, utf8ToBytes('{"k":1}'));
259
-
260
- // Wire form: hex strings (exactly what the mixin sends/receives).
261
- const nonceHex = bytesToHex(nonce);
262
- const ciphertextHex = bytesToHex(ciphertext);
263
-
264
- const keyNew = deriveTransferKey(
265
- deriveSharedSecret(newEph.privateKey, oldEph.publicKey),
266
- pairingId,
267
- );
268
- const opened = bytesToUtf8(decryptAead(keyNew, hexToBytes(nonceHex), hexToBytes(ciphertextHex)));
269
- expect(opened).toBe('{"k":1}');
270
- });
271
- });