@oxyhq/core 12.2.0 → 12.3.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/aead.js +79 -0
- package/dist/cjs/crypto/ecdh.js +53 -0
- package/dist/cjs/crypto/kdf.js +39 -0
- package/dist/cjs/crypto/keyManager.js +7 -0
- package/dist/cjs/crypto/recoveryPhrase.js +89 -1
- package/dist/cjs/i18n/locales/ar-SA.json +2 -0
- package/dist/cjs/i18n/locales/ca-ES.json +2 -0
- package/dist/cjs/i18n/locales/de-DE.json +2 -0
- package/dist/cjs/i18n/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/fr-FR.json +2 -0
- package/dist/cjs/i18n/locales/it-IT.json +2 -0
- package/dist/cjs/i18n/locales/ja-JP.json +2 -0
- package/dist/cjs/i18n/locales/ko-KR.json +2 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +2 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +2 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +2 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +2 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +2 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +2 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +2 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +2 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +2 -0
- package/dist/cjs/i18n/locales/pt-PT.json +2 -0
- package/dist/cjs/i18n/locales/zh-CN.json +2 -0
- package/dist/cjs/index.js +14 -4
- package/dist/cjs/mixins/OxyServices.identity.js +166 -0
- package/dist/cjs/mixins/OxyServices.identityBackup.js +161 -0
- package/dist/cjs/mixins/OxyServices.user.js +43 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/aead.js +74 -0
- package/dist/esm/crypto/ecdh.js +51 -0
- package/dist/esm/crypto/kdf.js +36 -0
- package/dist/esm/crypto/keyManager.js +7 -0
- package/dist/esm/crypto/recoveryPhrase.js +88 -0
- package/dist/esm/i18n/locales/ar-SA.json +2 -0
- package/dist/esm/i18n/locales/ca-ES.json +2 -0
- package/dist/esm/i18n/locales/de-DE.json +2 -0
- package/dist/esm/i18n/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/fr-FR.json +2 -0
- package/dist/esm/i18n/locales/it-IT.json +2 -0
- package/dist/esm/i18n/locales/ja-JP.json +2 -0
- package/dist/esm/i18n/locales/ko-KR.json +2 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +2 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +2 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +2 -0
- package/dist/esm/i18n/locales/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +2 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +2 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +2 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +2 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +2 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +2 -0
- package/dist/esm/i18n/locales/pt-PT.json +2 -0
- package/dist/esm/i18n/locales/zh-CN.json +2 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.identity.js +166 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +158 -0
- package/dist/esm/mixins/OxyServices.user.js +43 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/aead.d.ts +56 -0
- package/dist/types/crypto/ecdh.d.ts +29 -0
- package/dist/types/crypto/kdf.d.ts +25 -0
- package/dist/types/crypto/keyManager.d.ts +5 -0
- package/dist/types/crypto/recoveryPhrase.d.ts +85 -0
- package/dist/types/index.d.ts +7 -3
- package/dist/types/mixins/OxyServices.identity.d.ts +95 -0
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
- package/dist/types/mixins/OxyServices.user.d.ts +38 -8
- package/dist/types/mixins/index.d.ts +2 -1
- package/package.json +4 -2
- package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
- package/src/crypto/__tests__/cryptoPrimitives.test.ts +225 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +33 -0
- package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
- package/src/crypto/aead.ts +97 -0
- package/src/crypto/ecdh.ts +60 -0
- package/src/crypto/kdf.ts +43 -0
- package/src/crypto/keyManager.ts +8 -0
- package/src/crypto/recoveryPhrase.ts +133 -0
- package/src/i18n/locales/ar-SA.json +2 -0
- package/src/i18n/locales/ca-ES.json +2 -0
- package/src/i18n/locales/de-DE.json +2 -0
- package/src/i18n/locales/en-US.json +7 -0
- package/src/i18n/locales/es-ES.json +7 -0
- package/src/i18n/locales/fr-FR.json +2 -0
- package/src/i18n/locales/it-IT.json +2 -0
- package/src/i18n/locales/ja-JP.json +2 -0
- package/src/i18n/locales/ko-KR.json +2 -0
- package/src/i18n/locales/pt-PT.json +2 -0
- package/src/i18n/locales/zh-CN.json +2 -0
- package/src/index.ts +16 -1
- package/src/mixins/OxyServices.identity.ts +250 -0
- package/src/mixins/OxyServices.identityBackup.ts +237 -0
- package/src/mixins/OxyServices.user.ts +82 -4
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +277 -0
- package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
- package/src/mixins/__tests__/identityBackup.test.ts +258 -0
- package/src/mixins/index.ts +5 -0
- package/src/types/elliptic.d.ts +10 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `getFollowStatuses` — batched follow-status resolution tests.
|
|
3
|
+
*
|
|
4
|
+
* The SDK method collapses N per-button `getFollowStatus` calls into one bulk
|
|
5
|
+
* `POST /users/follow-status/bulk` per chunk. It must:
|
|
6
|
+
* - dedup + drop blank ids, and resolve to `{}` with NO network call on empty
|
|
7
|
+
* input;
|
|
8
|
+
* - POST `{ userIds }` uncached (`{ cache: false }`) so the UI store owns
|
|
9
|
+
* follow-status freshness;
|
|
10
|
+
* - chunk at the server cap (200) and MERGE the per-chunk `{ statuses }` maps
|
|
11
|
+
* into one record covering every requested id.
|
|
12
|
+
*
|
|
13
|
+
* `makeRequest` is stubbed so the tests run with no network.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { OxyServices } from '../../OxyServices';
|
|
17
|
+
|
|
18
|
+
describe('OxyServices.getFollowStatuses — batched follow status', () => {
|
|
19
|
+
let oxy: OxyServices;
|
|
20
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
24
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
jest.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns {} and performs no network call for empty / whitespace input', async () => {
|
|
32
|
+
await expect(oxy.getFollowStatuses([])).resolves.toEqual({});
|
|
33
|
+
await expect(oxy.getFollowStatuses(['', ' '])).resolves.toEqual({});
|
|
34
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('POSTs { userIds } uncached and returns the statuses map', async () => {
|
|
38
|
+
makeRequestSpy.mockResolvedValueOnce({ statuses: { a: true, b: false } });
|
|
39
|
+
|
|
40
|
+
const result = await oxy.getFollowStatuses(['a', 'b']);
|
|
41
|
+
|
|
42
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
44
|
+
'POST',
|
|
45
|
+
'/users/follow-status/bulk',
|
|
46
|
+
{ userIds: ['a', 'b'] },
|
|
47
|
+
{ cache: false },
|
|
48
|
+
);
|
|
49
|
+
expect(result).toEqual({ a: true, b: false });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('de-duplicates ids and drops blanks before the request', async () => {
|
|
53
|
+
makeRequestSpy.mockResolvedValueOnce({ statuses: { a: true } });
|
|
54
|
+
|
|
55
|
+
await oxy.getFollowStatuses(['a', 'a', ' ', 'a']);
|
|
56
|
+
|
|
57
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
59
|
+
'POST',
|
|
60
|
+
'/users/follow-status/bulk',
|
|
61
|
+
{ userIds: ['a'] },
|
|
62
|
+
{ cache: false },
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('chunks at 200 ids per request and merges the per-chunk maps', async () => {
|
|
67
|
+
const ids = Array.from({ length: 450 }, (_, i) => `id-${i}`);
|
|
68
|
+
makeRequestSpy.mockImplementation(
|
|
69
|
+
async (
|
|
70
|
+
_method: string,
|
|
71
|
+
_url: string,
|
|
72
|
+
data?: { userIds: string[] },
|
|
73
|
+
): Promise<{ statuses: Record<string, boolean> }> => {
|
|
74
|
+
const statuses: Record<string, boolean> = {};
|
|
75
|
+
for (const id of data?.userIds ?? []) {
|
|
76
|
+
statuses[id] = id.endsWith('0'); // deterministic mix of true/false
|
|
77
|
+
}
|
|
78
|
+
return { statuses };
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const result = await oxy.getFollowStatuses(ids);
|
|
83
|
+
|
|
84
|
+
// 450 unique ids => 200 + 200 + 50 across three POSTs.
|
|
85
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(3);
|
|
86
|
+
const chunkSizes = makeRequestSpy.mock.calls.map(
|
|
87
|
+
(call) => (call[2] as { userIds: string[] }).userIds.length,
|
|
88
|
+
);
|
|
89
|
+
expect(chunkSizes).toEqual([200, 200, 50]);
|
|
90
|
+
// Every requested id is present in the merged result.
|
|
91
|
+
expect(Object.keys(result)).toHaveLength(450);
|
|
92
|
+
expect(result['id-10']).toBe(true);
|
|
93
|
+
expect(result['id-11']).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted off-device identity backup mixin (b3 Feature 1).
|
|
3
|
+
*
|
|
4
|
+
* Exercises the full client contract with a REAL crypto round-trip (only
|
|
5
|
+
* `KeyManager.importKeyPair` — the native SecureStore write — is mocked):
|
|
6
|
+
* - `createEncryptedBackup` derives the material, encrypts the identity, and
|
|
7
|
+
* POSTs the envelope + raw `lookupId` (the server never sees the phrase/key).
|
|
8
|
+
* - the uploaded envelope decrypts back to the SAME private key via
|
|
9
|
+
* `restoreFromEncryptedBackup` (round-trip).
|
|
10
|
+
* - any tamper (ciphertext / nonce / AAD-bound publicKeyHint) fails the
|
|
11
|
+
* Poly1305 check → restore rejects.
|
|
12
|
+
* - an existing on-device identity surfaces `IdentityAlreadyExistsError`
|
|
13
|
+
* UNCHANGED (not flattened by `handleError`), and `overwrite` forwards.
|
|
14
|
+
*/
|
|
15
|
+
import { OxyServices } from '../../OxyServices';
|
|
16
|
+
import { KeyManager, IdentityAlreadyExistsError } from '../../crypto/keyManager';
|
|
17
|
+
import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
|
|
18
|
+
import type { EncryptedBackupEnvelope, BackupUploadRequest } from '@oxyhq/contracts';
|
|
19
|
+
|
|
20
|
+
const FIXED_PHRASE =
|
|
21
|
+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
|
22
|
+
const EXPECTED_LOOKUP_ID = '8cad137ca961bfc62a2ef329869e8369777737c7c5353a8d94bb70d888c0ad0d';
|
|
23
|
+
const EXPECTED_PRIVATE_KEY = '5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1';
|
|
24
|
+
|
|
25
|
+
/** Build a non-verified JWT whose payload decodes to the given claims. */
|
|
26
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
27
|
+
const b64url = (obj: Record<string, unknown>): string =>
|
|
28
|
+
Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
29
|
+
const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
|
|
30
|
+
return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A JSON `Response` returning the body verbatim (no `{ data }` wrapper). */
|
|
34
|
+
function plainResponse(body: unknown, status = 200): Response {
|
|
35
|
+
return new Response(JSON.stringify(body), {
|
|
36
|
+
status,
|
|
37
|
+
headers: { 'content-type': 'application/json' },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('encrypted identity backup mixin', () => {
|
|
42
|
+
let originalFetch: typeof globalThis.fetch;
|
|
43
|
+
let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
|
|
44
|
+
let oxy: OxyServices;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
originalFetch = globalThis.fetch;
|
|
48
|
+
fetchMock = jest.fn();
|
|
49
|
+
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
|
50
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
51
|
+
oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
globalThis.fetch = originalFetch;
|
|
56
|
+
jest.restoreAllMocks();
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Read + parse the body of the Nth fetch call as the upload request. */
|
|
61
|
+
function uploadBodyFromCall(index: number): BackupUploadRequest {
|
|
62
|
+
const init = fetchMock.mock.calls[index][1];
|
|
63
|
+
return JSON.parse(String(init?.body)) as BackupUploadRequest;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it('createEncryptedBackup uploads an envelope + raw lookupId derived from the phrase', async () => {
|
|
67
|
+
fetchMock.mockResolvedValueOnce(
|
|
68
|
+
plainResponse({ exists: true, publicKeyHint: '04abc', createdAt: 'x' }),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
72
|
+
|
|
73
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
74
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
75
|
+
expect(String(url)).toBe('http://test.invalid/identity/backup');
|
|
76
|
+
expect(init?.method).toBe('POST');
|
|
77
|
+
|
|
78
|
+
const body = uploadBodyFromCall(0);
|
|
79
|
+
// Envelope metadata.
|
|
80
|
+
expect(body.version).toBe(1);
|
|
81
|
+
expect(body.algorithm).toBe('xchacha20poly1305');
|
|
82
|
+
expect(body.kdfInfo).toBe('oxy-backup-encryption-key');
|
|
83
|
+
// The raw lookup id is the phrase-derived value (server will hash it).
|
|
84
|
+
expect(body.lookupId).toBe(EXPECTED_LOOKUP_ID);
|
|
85
|
+
// Hex ciphertext + 24-byte (48 hex) nonce.
|
|
86
|
+
expect(body.nonce).toMatch(/^[0-9a-f]{48}$/);
|
|
87
|
+
expect(body.ciphertext).toMatch(/^[0-9a-f]+$/);
|
|
88
|
+
// The hint is a 16-char prefix of the identity's real public key.
|
|
89
|
+
const publicKey = KeyManager.derivePublicKey(EXPECTED_PRIVATE_KEY);
|
|
90
|
+
expect(body.publicKeyHint).toBe(publicKey.slice(0, 16));
|
|
91
|
+
expect(body.publicKeyHint).toHaveLength(16);
|
|
92
|
+
// The plaintext private key NEVER appears on the wire.
|
|
93
|
+
expect(String(init?.body)).not.toContain(EXPECTED_PRIVATE_KEY);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('round-trips: the uploaded envelope decrypts back to the SAME private key', async () => {
|
|
97
|
+
// 1) Encrypt + capture the real envelope.
|
|
98
|
+
fetchMock.mockResolvedValueOnce(
|
|
99
|
+
plainResponse({ exists: true, publicKeyHint: '04', createdAt: 'x' }),
|
|
100
|
+
);
|
|
101
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
102
|
+
const uploaded = uploadBodyFromCall(0);
|
|
103
|
+
const envelope: EncryptedBackupEnvelope = {
|
|
104
|
+
version: uploaded.version,
|
|
105
|
+
algorithm: uploaded.algorithm,
|
|
106
|
+
kdfInfo: uploaded.kdfInfo,
|
|
107
|
+
nonce: uploaded.nonce,
|
|
108
|
+
ciphertext: uploaded.ciphertext,
|
|
109
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
110
|
+
createdAt: uploaded.createdAt,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// 2) Restore: intercept the native SecureStore write.
|
|
114
|
+
const importSpy = jest
|
|
115
|
+
.spyOn(KeyManager, 'importKeyPair')
|
|
116
|
+
.mockResolvedValue('restored-public-key');
|
|
117
|
+
fetchMock.mockResolvedValueOnce(plainResponse(envelope));
|
|
118
|
+
|
|
119
|
+
const publicKey = await oxy.restoreFromEncryptedBackup(FIXED_PHRASE);
|
|
120
|
+
|
|
121
|
+
// The GET targets the locator; import receives the decrypted private key.
|
|
122
|
+
const [url, init] = fetchMock.mock.calls[1];
|
|
123
|
+
expect(String(url)).toBe(`http://test.invalid/identity/backup/${EXPECTED_LOOKUP_ID}`);
|
|
124
|
+
expect(init?.method).toBe('GET');
|
|
125
|
+
expect(importSpy).toHaveBeenCalledWith(EXPECTED_PRIVATE_KEY, { overwrite: false });
|
|
126
|
+
expect(publicKey).toBe('restored-public-key');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rejects a tampered ciphertext (Poly1305 authentication fails)', async () => {
|
|
130
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
131
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
132
|
+
const uploaded = uploadBodyFromCall(0);
|
|
133
|
+
|
|
134
|
+
// Flip the first ciphertext byte.
|
|
135
|
+
const firstByte = uploaded.ciphertext.slice(0, 2);
|
|
136
|
+
const flipped = (Number.parseInt(firstByte, 16) ^ 0xff).toString(16).padStart(2, '0');
|
|
137
|
+
const tampered: EncryptedBackupEnvelope = {
|
|
138
|
+
version: uploaded.version,
|
|
139
|
+
algorithm: uploaded.algorithm,
|
|
140
|
+
kdfInfo: uploaded.kdfInfo,
|
|
141
|
+
nonce: uploaded.nonce,
|
|
142
|
+
ciphertext: flipped + uploaded.ciphertext.slice(2),
|
|
143
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
144
|
+
createdAt: uploaded.createdAt,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
|
|
148
|
+
fetchMock.mockResolvedValueOnce(plainResponse(tampered));
|
|
149
|
+
|
|
150
|
+
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
|
|
151
|
+
expect(importSpy).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects when the AAD-bound publicKeyHint is altered', async () => {
|
|
155
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
156
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
157
|
+
const uploaded = uploadBodyFromCall(0);
|
|
158
|
+
|
|
159
|
+
const tampered: EncryptedBackupEnvelope = {
|
|
160
|
+
version: uploaded.version,
|
|
161
|
+
algorithm: uploaded.algorithm,
|
|
162
|
+
kdfInfo: uploaded.kdfInfo,
|
|
163
|
+
nonce: uploaded.nonce,
|
|
164
|
+
ciphertext: uploaded.ciphertext,
|
|
165
|
+
publicKeyHint: 'deadbeefdeadbeef', // wrong hint → AAD mismatch
|
|
166
|
+
createdAt: uploaded.createdAt,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue('x');
|
|
170
|
+
fetchMock.mockResolvedValueOnce(plainResponse(tampered));
|
|
171
|
+
|
|
172
|
+
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
|
|
173
|
+
expect(importSpy).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('rejects a mismatched nonce', async () => {
|
|
177
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
178
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
179
|
+
const uploaded = uploadBodyFromCall(0);
|
|
180
|
+
|
|
181
|
+
const tampered: EncryptedBackupEnvelope = {
|
|
182
|
+
version: uploaded.version,
|
|
183
|
+
algorithm: uploaded.algorithm,
|
|
184
|
+
kdfInfo: uploaded.kdfInfo,
|
|
185
|
+
nonce: '00'.repeat(24), // wrong nonce
|
|
186
|
+
ciphertext: uploaded.ciphertext,
|
|
187
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
188
|
+
createdAt: uploaded.createdAt,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
fetchMock.mockResolvedValueOnce(plainResponse(tampered));
|
|
192
|
+
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toThrow();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('propagates IdentityAlreadyExistsError UNCHANGED (so the caller can offer overwrite)', async () => {
|
|
196
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
197
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
198
|
+
const uploaded = uploadBodyFromCall(0);
|
|
199
|
+
const envelope: EncryptedBackupEnvelope = {
|
|
200
|
+
version: uploaded.version,
|
|
201
|
+
algorithm: uploaded.algorithm,
|
|
202
|
+
kdfInfo: uploaded.kdfInfo,
|
|
203
|
+
nonce: uploaded.nonce,
|
|
204
|
+
ciphertext: uploaded.ciphertext,
|
|
205
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
206
|
+
createdAt: uploaded.createdAt,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
jest
|
|
210
|
+
.spyOn(KeyManager, 'importKeyPair')
|
|
211
|
+
.mockRejectedValue(new IdentityAlreadyExistsError('04existingkey'));
|
|
212
|
+
fetchMock.mockResolvedValueOnce(plainResponse(envelope));
|
|
213
|
+
|
|
214
|
+
await expect(oxy.restoreFromEncryptedBackup(FIXED_PHRASE)).rejects.toBeInstanceOf(
|
|
215
|
+
IdentityAlreadyExistsError,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('forwards overwrite:true to importKeyPair', async () => {
|
|
220
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ exists: true }));
|
|
221
|
+
await oxy.createEncryptedBackup(FIXED_PHRASE);
|
|
222
|
+
const uploaded = uploadBodyFromCall(0);
|
|
223
|
+
const envelope: EncryptedBackupEnvelope = {
|
|
224
|
+
version: uploaded.version,
|
|
225
|
+
algorithm: uploaded.algorithm,
|
|
226
|
+
kdfInfo: uploaded.kdfInfo,
|
|
227
|
+
nonce: uploaded.nonce,
|
|
228
|
+
ciphertext: uploaded.ciphertext,
|
|
229
|
+
publicKeyHint: uploaded.publicKeyHint,
|
|
230
|
+
createdAt: uploaded.createdAt,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const importSpy = jest
|
|
234
|
+
.spyOn(KeyManager, 'importKeyPair')
|
|
235
|
+
.mockResolvedValue('restored-public-key');
|
|
236
|
+
fetchMock.mockResolvedValueOnce(plainResponse(envelope));
|
|
237
|
+
|
|
238
|
+
await oxy.restoreFromEncryptedBackup(FIXED_PHRASE, { overwrite: true });
|
|
239
|
+
expect(importSpy).toHaveBeenCalledWith(EXPECTED_PRIVATE_KEY, { overwrite: true });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('getBackupStatus + deleteBackup hit the right endpoints', async () => {
|
|
243
|
+
fetchMock.mockResolvedValueOnce(
|
|
244
|
+
plainResponse({ exists: true, publicKeyHint: '04abc', createdAt: 'iso' }),
|
|
245
|
+
);
|
|
246
|
+
const status = await oxy.getBackupStatus();
|
|
247
|
+
expect(status).toEqual({ exists: true, publicKeyHint: '04abc', createdAt: 'iso' });
|
|
248
|
+
let [url, init] = fetchMock.mock.calls[0];
|
|
249
|
+
expect(String(url)).toBe('http://test.invalid/identity/backup/status');
|
|
250
|
+
expect(init?.method).toBe('GET');
|
|
251
|
+
|
|
252
|
+
fetchMock.mockResolvedValueOnce(plainResponse({ success: true }));
|
|
253
|
+
await expect(oxy.deleteBackup()).resolves.toEqual({ success: true });
|
|
254
|
+
[url, init] = fetchMock.mock.calls[1];
|
|
255
|
+
expect(String(url)).toBe('http://test.invalid/identity/backup');
|
|
256
|
+
expect(init?.method).toBe('DELETE');
|
|
257
|
+
});
|
|
258
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { OxyServicesBase } from '../OxyServices.base';
|
|
|
9
9
|
import { OxyServicesAuthMixin } from './OxyServices.auth';
|
|
10
10
|
import { OxyServicesUserMixin } from './OxyServices.user';
|
|
11
11
|
import { OxyServicesIdentityMixin } from './OxyServices.identity';
|
|
12
|
+
import { OxyServicesIdentityBackupMixin } from './OxyServices.identityBackup';
|
|
12
13
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
13
14
|
import { OxyServicesLanguageMixin } from './OxyServices.language';
|
|
14
15
|
import { OxyServicesPaymentMixin } from './OxyServices.payment';
|
|
@@ -43,6 +44,7 @@ type AllMixinInstances =
|
|
|
43
44
|
& InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>>
|
|
44
45
|
& InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
|
|
45
46
|
& InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
|
|
47
|
+
& InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>>
|
|
46
48
|
& InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
|
|
47
49
|
& InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
|
|
48
50
|
& InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
|
|
@@ -98,6 +100,9 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
98
100
|
OxyServicesUserMixin,
|
|
99
101
|
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
|
|
100
102
|
OxyServicesIdentityMixin,
|
|
103
|
+
// Encrypted off-device identity backup (b3 Feature 1): store/restore an
|
|
104
|
+
// encrypted copy of the self-custody key, keyed off the recovery phrase.
|
|
105
|
+
OxyServicesIdentityBackupMixin,
|
|
101
106
|
OxyServicesPrivacyMixin,
|
|
102
107
|
|
|
103
108
|
// Feature mixins
|
package/src/types/elliptic.d.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
declare module 'elliptic' {
|
|
2
|
+
export interface BN {
|
|
3
|
+
toArray(endian?: 'be' | 'le', length?: number): number[];
|
|
4
|
+
toString(base?: number): string;
|
|
5
|
+
}
|
|
6
|
+
|
|
2
7
|
export interface KeyPair {
|
|
3
8
|
getPrivate(enc?: 'hex' | 'array' | 'bn'): string | number[] | any;
|
|
4
|
-
getPublic(
|
|
5
|
-
getPublic(enc
|
|
9
|
+
getPublic(): Point;
|
|
10
|
+
getPublic(enc: 'hex'): string;
|
|
11
|
+
getPublic(enc: 'array'): number[];
|
|
6
12
|
getPublic(compressed: boolean, enc?: 'hex'): string;
|
|
7
13
|
getPublic(compressed: boolean, enc?: 'array'): number[];
|
|
8
14
|
sign(msg: string | number[]): Signature;
|
|
9
15
|
verify(msg: string | number[], signature: Signature | string | { r: string | number[]; s: string | number[] }): boolean;
|
|
16
|
+
/** ECDH: derive the shared point's x-coordinate against another party's public point. */
|
|
17
|
+
derive(pub: Point): BN;
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
export interface Signature {
|