@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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted off-device identity backup mixin (b3 Feature 1).
|
|
3
|
+
*
|
|
4
|
+
* Lets a self-custody identity store an ENCRYPTED copy of its key off-device so
|
|
5
|
+
* a lost/wiped device can be recovered from the recovery phrase alone — while the
|
|
6
|
+
* platform never sees the phrase, the derived key, or the plaintext private key.
|
|
7
|
+
*
|
|
8
|
+
* Key schedule (from the recovery phrase; see
|
|
9
|
+
* {@link RecoveryPhraseService.deriveBackupMaterial}):
|
|
10
|
+
* seed = bip39.mnemonicToSeed(phrase) // 64 bytes, UNCHANGED
|
|
11
|
+
* backupKey = HKDF(seed, 'oxy-identity-backup-v1', 'oxy-backup-encryption-key')
|
|
12
|
+
* lookupId = HKDF(seed, 'oxy-identity-backup-v1', 'oxy-backup-lookup-id') // hex
|
|
13
|
+
*
|
|
14
|
+
* The two derivations require the FULL seed, so a device compromise that leaks
|
|
15
|
+
* only the raw 32-byte private key can neither locate nor decrypt the backup.
|
|
16
|
+
*
|
|
17
|
+
* Wire shapes come from `@oxyhq/contracts` (`EncryptedBackupEnvelope`,
|
|
18
|
+
* `BackupUploadRequest`, `BackupStatusResponse`) — the API validates its
|
|
19
|
+
* request/response against the same schemas, so producer and consumer cannot
|
|
20
|
+
* drift.
|
|
21
|
+
*
|
|
22
|
+
* Encryption/derivation are cross-platform (pure `@noble/*`), but persisting a
|
|
23
|
+
* restored key is NATIVE-ONLY: `restoreFromEncryptedBackup` ends in
|
|
24
|
+
* `KeyManager.importKeyPair`, which throws on web (SecureStore does not exist
|
|
25
|
+
* there) — decryption still succeeds, only the local write is native-only.
|
|
26
|
+
*/
|
|
27
|
+
import type {
|
|
28
|
+
BackupStatusResponse,
|
|
29
|
+
BackupUploadRequest,
|
|
30
|
+
EncryptedBackupEnvelope,
|
|
31
|
+
} from '@oxyhq/contracts';
|
|
32
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
33
|
+
import { KeyManager, IdentityAlreadyExistsError } from '../crypto/keyManager';
|
|
34
|
+
import { RecoveryPhraseService, BACKUP_KDF_ENCRYPTION_INFO } from '../crypto/recoveryPhrase';
|
|
35
|
+
import { encryptAead, decryptAead } from '../crypto/aead';
|
|
36
|
+
|
|
37
|
+
/** Envelope/KDF version — bump only on a breaking scheme change. */
|
|
38
|
+
const BACKUP_ENVELOPE_VERSION = 1;
|
|
39
|
+
/** The AEAD the backup is sealed with. Pinned so a mismatched decryptor fails loudly. */
|
|
40
|
+
const BACKUP_ALGORITHM = 'xchacha20poly1305' as const;
|
|
41
|
+
/**
|
|
42
|
+
* Length (hex chars) of the public-key HINT stored/echoed with a backup — enough
|
|
43
|
+
* to let the owner recognise WHICH identity a backup belongs to, but only a
|
|
44
|
+
* prefix (the full key is public anyway; a prefix keeps the record minimal).
|
|
45
|
+
*/
|
|
46
|
+
const PUBLIC_KEY_HINT_LENGTH = 16;
|
|
47
|
+
|
|
48
|
+
/** The decrypted backup payload. */
|
|
49
|
+
interface BackupPayload {
|
|
50
|
+
privateKey: string;
|
|
51
|
+
publicKey: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Encode bytes as lowercase hex (cross-platform, no Buffer dependency). */
|
|
56
|
+
function toHex(bytes: Uint8Array): string {
|
|
57
|
+
let out = '';
|
|
58
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
59
|
+
out += bytes[i].toString(16).padStart(2, '0');
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Decode a lowercase/uppercase hex string to bytes. Throws on malformed input. */
|
|
65
|
+
function fromHex(hex: string): Uint8Array {
|
|
66
|
+
if (hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) {
|
|
67
|
+
throw new Error('Malformed hex in encrypted backup envelope.');
|
|
68
|
+
}
|
|
69
|
+
const out = new Uint8Array(hex.length / 2);
|
|
70
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
71
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The AEAD associated data binds the ciphertext to its `{version, publicKeyHint}`
|
|
78
|
+
* context: the exact bytes must be reproduced at decrypt time, so a mismatched
|
|
79
|
+
* version or hint (e.g. an envelope re-stamped by a tamperer) fails the
|
|
80
|
+
* Poly1305 check. Deterministic: both sides build the SAME object literal, so
|
|
81
|
+
* `JSON.stringify` yields identical bytes.
|
|
82
|
+
*/
|
|
83
|
+
function buildBackupAad(version: number, publicKeyHint: string): Uint8Array {
|
|
84
|
+
return new TextEncoder().encode(JSON.stringify({ version, publicKeyHint }));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function OxyServicesIdentityBackupMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
88
|
+
return class extends Base {
|
|
89
|
+
constructor(...args: any[]) {
|
|
90
|
+
super(...(args as [any]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Derive the backup key material from the recovery phrase, encrypt the
|
|
95
|
+
* identity's `{privateKey, publicKey, createdAt}` with it, and upload the
|
|
96
|
+
* ciphertext + raw `lookupId` (`POST /identity/backup`, bearer). The server
|
|
97
|
+
* stores only `sha256(lookupId)` + the ciphertext. Idempotent per user: a
|
|
98
|
+
* re-upload REPLACES the prior backup (upsert by user id).
|
|
99
|
+
*
|
|
100
|
+
* The identity is derived from the PHRASE (not read from SecureStore), so
|
|
101
|
+
* this works cross-platform and does not require an on-device key.
|
|
102
|
+
*
|
|
103
|
+
* @param phrase - The identity's BIP-39 recovery phrase.
|
|
104
|
+
* @returns The post-write backup status (`{ exists: true, publicKeyHint, createdAt }`).
|
|
105
|
+
*/
|
|
106
|
+
async createEncryptedBackup(phrase: string): Promise<BackupStatusResponse> {
|
|
107
|
+
try {
|
|
108
|
+
const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
|
|
109
|
+
const privateKey = await RecoveryPhraseService.derivePrivateKeyFromPhrase(phrase);
|
|
110
|
+
const publicKey = KeyManager.derivePublicKey(privateKey);
|
|
111
|
+
const createdAt = new Date().toISOString();
|
|
112
|
+
const publicKeyHint = publicKey.slice(0, PUBLIC_KEY_HINT_LENGTH);
|
|
113
|
+
|
|
114
|
+
const payload: BackupPayload = { privateKey, publicKey, createdAt };
|
|
115
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
116
|
+
const aad = buildBackupAad(BACKUP_ENVELOPE_VERSION, publicKeyHint);
|
|
117
|
+
const { nonce, ciphertext } = encryptAead(backupKey, plaintext, aad);
|
|
118
|
+
|
|
119
|
+
const body: BackupUploadRequest = {
|
|
120
|
+
version: BACKUP_ENVELOPE_VERSION,
|
|
121
|
+
algorithm: BACKUP_ALGORITHM,
|
|
122
|
+
kdfInfo: BACKUP_KDF_ENCRYPTION_INFO,
|
|
123
|
+
nonce: toHex(nonce),
|
|
124
|
+
ciphertext: toHex(ciphertext),
|
|
125
|
+
publicKeyHint,
|
|
126
|
+
createdAt,
|
|
127
|
+
lookupId,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return await this.makeRequest<BackupStatusResponse>(
|
|
131
|
+
'POST',
|
|
132
|
+
'/identity/backup',
|
|
133
|
+
body,
|
|
134
|
+
{ cache: false },
|
|
135
|
+
);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw this.handleError(error);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether the authenticated user has a stored encrypted backup, plus the
|
|
143
|
+
* non-sensitive hint + timestamp when one exists (`GET /identity/backup/status`,
|
|
144
|
+
* bearer). Returns no ciphertext and no locator.
|
|
145
|
+
*/
|
|
146
|
+
async getBackupStatus(): Promise<BackupStatusResponse> {
|
|
147
|
+
try {
|
|
148
|
+
return await this.makeRequest<BackupStatusResponse>(
|
|
149
|
+
'GET',
|
|
150
|
+
'/identity/backup/status',
|
|
151
|
+
undefined,
|
|
152
|
+
{ cache: false },
|
|
153
|
+
);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw this.handleError(error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Delete the authenticated user's stored backup (`DELETE /identity/backup`,
|
|
161
|
+
* bearer). Idempotent — deleting a non-existent backup still succeeds.
|
|
162
|
+
*/
|
|
163
|
+
async deleteBackup(): Promise<{ success: boolean }> {
|
|
164
|
+
try {
|
|
165
|
+
return await this.makeRequest<{ success: boolean }>(
|
|
166
|
+
'DELETE',
|
|
167
|
+
'/identity/backup',
|
|
168
|
+
undefined,
|
|
169
|
+
{ cache: false },
|
|
170
|
+
);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw this.handleError(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Restore an identity from its encrypted off-device backup using ONLY the
|
|
178
|
+
* recovery phrase: re-derive `{backupKey, lookupId}`, fetch the envelope by
|
|
179
|
+
* `lookupId` (`GET /identity/backup/:lookupId`, PUBLIC — the 256-bit locator
|
|
180
|
+
* is the protection), decrypt + authenticate locally, then persist the key.
|
|
181
|
+
*
|
|
182
|
+
* NATIVE-ONLY persistence: `KeyManager.importKeyPair` throws on web. It also
|
|
183
|
+
* refuses to clobber a DIFFERENT existing on-device identity unless
|
|
184
|
+
* `overwrite: true` — the {@link import('../crypto/keyManager').IdentityAlreadyExistsError}
|
|
185
|
+
* propagates to the caller (never swallowed) so the UI can confirm before
|
|
186
|
+
* overwriting.
|
|
187
|
+
*
|
|
188
|
+
* @param phrase - The identity's BIP-39 recovery phrase.
|
|
189
|
+
* @param options.overwrite - Replace a different existing on-device identity.
|
|
190
|
+
* @returns The restored identity's public key.
|
|
191
|
+
* @throws if the phrase is invalid, no backup exists (404), the ciphertext
|
|
192
|
+
* fails authentication (tamper), or an existing identity blocks the import.
|
|
193
|
+
*/
|
|
194
|
+
async restoreFromEncryptedBackup(
|
|
195
|
+
phrase: string,
|
|
196
|
+
options?: { overwrite?: boolean },
|
|
197
|
+
): Promise<string> {
|
|
198
|
+
try {
|
|
199
|
+
const { backupKey, lookupId } = await RecoveryPhraseService.deriveBackupMaterial(phrase);
|
|
200
|
+
|
|
201
|
+
const envelope = await this.makeRequest<EncryptedBackupEnvelope>(
|
|
202
|
+
'GET',
|
|
203
|
+
`/identity/backup/${encodeURIComponent(lookupId)}`,
|
|
204
|
+
undefined,
|
|
205
|
+
{ cache: false },
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (envelope.algorithm !== BACKUP_ALGORITHM) {
|
|
209
|
+
throw new Error(`Unsupported backup algorithm: ${envelope.algorithm}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const aad = buildBackupAad(envelope.version, envelope.publicKeyHint);
|
|
213
|
+
const plaintext = decryptAead(
|
|
214
|
+
backupKey,
|
|
215
|
+
fromHex(envelope.nonce),
|
|
216
|
+
fromHex(envelope.ciphertext),
|
|
217
|
+
aad,
|
|
218
|
+
);
|
|
219
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext)) as BackupPayload;
|
|
220
|
+
|
|
221
|
+
// Persist the recovered key. Native-only; refuses to clobber a different
|
|
222
|
+
// identity unless overwrite — the IdentityAlreadyExistsError propagates.
|
|
223
|
+
return await KeyManager.importKeyPair(payload.privateKey, {
|
|
224
|
+
overwrite: options?.overwrite === true,
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// Preserve the typed "an identity already exists" signal so the caller
|
|
228
|
+
// can prompt for overwrite. `handleError` would flatten it to a generic
|
|
229
|
+
// Error and lose that discrimination.
|
|
230
|
+
if (error instanceof IdentityAlreadyExistsError) {
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
throw this.handleError(error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -31,6 +31,33 @@ import { extractErrorStatus } from '../utils/errorUtils';
|
|
|
31
31
|
*/
|
|
32
32
|
const USERS_BY_IDS_CHUNK_SIZE = 100;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Maximum number of ids sent per `POST /users/follow-status/bulk` request.
|
|
36
|
+
* Matches the server-side `MAX_BULK_FOLLOW` cap; larger inputs are split into
|
|
37
|
+
* multiple chunked calls whose result maps are merged.
|
|
38
|
+
*/
|
|
39
|
+
const FOLLOW_STATUS_CHUNK_SIZE = 200;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Response of the single follow/unfollow toggle route
|
|
43
|
+
* (`POST /users/:id/follow` and `DELETE /users/:id/follow`). The route reports a
|
|
44
|
+
* status message, which side of the toggle was applied, and the post-write
|
|
45
|
+
* follower/following counts for the affected users.
|
|
46
|
+
*/
|
|
47
|
+
export interface FollowMutationResult {
|
|
48
|
+
/** Human-readable status message. */
|
|
49
|
+
message: string;
|
|
50
|
+
/** Which side of the toggle was applied, when reported by the route. */
|
|
51
|
+
action?: 'follow' | 'unfollow';
|
|
52
|
+
/** Post-write counts, when reported by the route. */
|
|
53
|
+
counts?: {
|
|
54
|
+
/** The target user's follower count after the write. */
|
|
55
|
+
followers: number;
|
|
56
|
+
/** The viewer's following count after the write. */
|
|
57
|
+
following: number;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
34
61
|
/** Per-user outcome returned by `POST /users/follow/bulk`. */
|
|
35
62
|
export interface BulkFollowEntry {
|
|
36
63
|
/** The user ID that was processed. */
|
|
@@ -679,9 +706,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
679
706
|
* UI (the "follow resets after navigating away and back" bug).
|
|
680
707
|
* `clearCacheEntry` deletes every identity-scoped variant of the key.
|
|
681
708
|
*/
|
|
682
|
-
async followUser(userId: string): Promise<
|
|
709
|
+
async followUser(userId: string): Promise<FollowMutationResult> {
|
|
683
710
|
try {
|
|
684
|
-
const result = await this.makeRequest<
|
|
711
|
+
const result = await this.makeRequest<FollowMutationResult>('POST', `/users/${userId}/follow`, undefined, { cache: false });
|
|
685
712
|
this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
|
|
686
713
|
// The follow changed the viewer's graph — bust the cached consolidated
|
|
687
714
|
// `GET /users/me/graph` so the next read reflects the new following/
|
|
@@ -748,9 +775,9 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
748
775
|
/**
|
|
749
776
|
* Unfollow a user
|
|
750
777
|
*/
|
|
751
|
-
async unfollowUser(userId: string): Promise<
|
|
778
|
+
async unfollowUser(userId: string): Promise<FollowMutationResult> {
|
|
752
779
|
try {
|
|
753
|
-
const result = await this.makeRequest<
|
|
780
|
+
const result = await this.makeRequest<FollowMutationResult>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
|
|
754
781
|
// Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
|
|
755
782
|
this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
|
|
756
783
|
// The unfollow changed the viewer's graph — bust the consolidated cache.
|
|
@@ -775,6 +802,57 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
775
802
|
}
|
|
776
803
|
}
|
|
777
804
|
|
|
805
|
+
/**
|
|
806
|
+
* Resolve the viewer's follow status for MANY users in one round-trip per
|
|
807
|
+
* chunk. Built for list UIs (a page of `FollowButton`s) that would otherwise
|
|
808
|
+
* fire one `getFollowStatus` per button (the classic N+1).
|
|
809
|
+
*
|
|
810
|
+
* Ids are deduplicated and validated (empty/blank ids dropped), split into
|
|
811
|
+
* chunks of {@link FOLLOW_STATUS_CHUNK_SIZE} (the server's bulk cap), and
|
|
812
|
+
* POSTed to `/users/follow-status/bulk` as `{ userIds }`. The per-chunk
|
|
813
|
+
* `{ statuses }` maps are merged into one `Record<string, boolean>` covering
|
|
814
|
+
* every requested id — ids the viewer does not follow come back `false`.
|
|
815
|
+
*
|
|
816
|
+
* Uncached (`{ cache: false }`): the UI store owns follow-status freshness
|
|
817
|
+
* and writes optimistically on every mutation, so an SDK cache here would
|
|
818
|
+
* serve a stale status right after a follow/unfollow. An empty/whitespace-
|
|
819
|
+
* only input resolves immediately with `{}` and performs no network call.
|
|
820
|
+
*/
|
|
821
|
+
async getFollowStatuses(userIds: string[]): Promise<Record<string, boolean>> {
|
|
822
|
+
const uniqueIds = Array.from(
|
|
823
|
+
new Set(userIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)),
|
|
824
|
+
);
|
|
825
|
+
if (uniqueIds.length === 0) {
|
|
826
|
+
return {};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const chunks: string[][] = [];
|
|
830
|
+
for (let i = 0; i < uniqueIds.length; i += FOLLOW_STATUS_CHUNK_SIZE) {
|
|
831
|
+
chunks.push(uniqueIds.slice(i, i + FOLLOW_STATUS_CHUNK_SIZE));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
try {
|
|
835
|
+
const responses = await Promise.all(
|
|
836
|
+
chunks.map((chunk) =>
|
|
837
|
+
this.makeRequest<{ statuses: Record<string, boolean> }>(
|
|
838
|
+
'POST',
|
|
839
|
+
'/users/follow-status/bulk',
|
|
840
|
+
{ userIds: chunk },
|
|
841
|
+
{ cache: false },
|
|
842
|
+
),
|
|
843
|
+
),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const merged: Record<string, boolean> = {};
|
|
847
|
+
for (const response of responses) {
|
|
848
|
+
Object.assign(merged, response?.statuses ?? {});
|
|
849
|
+
}
|
|
850
|
+
return merged;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
throw this.handleError(error);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
778
856
|
/**
|
|
779
857
|
* Get user followers
|
|
780
858
|
*/
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key-rotation mixin tests (b3 Feature 3).
|
|
3
|
+
*
|
|
4
|
+
* Cover the client orchestration of `oxy.identity.rotateKey(...)`:
|
|
5
|
+
* - the EXACT signed rotation payload (must match the server's reconstruction
|
|
6
|
+
* byte-for-byte);
|
|
7
|
+
* - device-proof signs with the on-device key; phrase-proof re-derives and
|
|
8
|
+
* signs with the OLD key (proving the last credential can be replaced);
|
|
9
|
+
* - BOTH proofs are sent: the OLD key signs `rotate_key`, the NEW key signs the
|
|
10
|
+
* `rotate_key_new` proof-of-possession;
|
|
11
|
+
* - the safety ordering (local key persisted ONLY after server confirmation);
|
|
12
|
+
* - the local-persist failure path still surfaces the new phrase;
|
|
13
|
+
* - the ambiguous-network-failure reconciliation against the DID document.
|
|
14
|
+
*
|
|
15
|
+
* `makeRequest` is stubbed so the tests run with no network. The real
|
|
16
|
+
* secp256k1 signing runs in the phrase-proof test so we can cryptographically
|
|
17
|
+
* assert which key produced each signature.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DidDocument } from '@oxyhq/contracts';
|
|
21
|
+
import * as protocol from '@oxyhq/protocol';
|
|
22
|
+
import { ec as EC } from 'elliptic';
|
|
23
|
+
import { OxyServices } from '../../OxyServices';
|
|
24
|
+
import { KeyManager } from '../../crypto/keyManager';
|
|
25
|
+
import { SignatureService } from '../../crypto/signatureService';
|
|
26
|
+
import { RecoveryPhraseService } from '../../crypto/recoveryPhrase';
|
|
27
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
28
|
+
|
|
29
|
+
const ec = new EC('secp256k1');
|
|
30
|
+
const oldKeyPair = ec.genKeyPair();
|
|
31
|
+
const newKeyPair = ec.genKeyPair();
|
|
32
|
+
const OLD_PUBLIC = oldKeyPair.getPublic('hex');
|
|
33
|
+
const NEW_PUBLIC = newKeyPair.getPublic('hex');
|
|
34
|
+
const NEW_PRIVATE = newKeyPair.getPrivate('hex');
|
|
35
|
+
|
|
36
|
+
const pendingFixture = {
|
|
37
|
+
phrase: 'alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima',
|
|
38
|
+
words: ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima'],
|
|
39
|
+
privateKey: NEW_PRIVATE,
|
|
40
|
+
publicKey: NEW_PUBLIC,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function didDocWithKey(publicKeyHex: string): DidDocument {
|
|
44
|
+
return {
|
|
45
|
+
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
46
|
+
id: 'did:web:oxy.so:u:user-123',
|
|
47
|
+
controller: ['did:web:oxy.so:u:user-123', 'did:web:oxy.so'],
|
|
48
|
+
verificationMethod: [
|
|
49
|
+
{
|
|
50
|
+
id: 'did:web:oxy.so:u:user-123#key-1',
|
|
51
|
+
type: 'EcdsaSecp256k1VerificationKey2019',
|
|
52
|
+
controller: 'did:web:oxy.so:u:user-123',
|
|
53
|
+
publicKeyHex,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
authentication: ['did:web:oxy.so:u:user-123#key-1'],
|
|
57
|
+
assertionMethod: ['did:web:oxy.so:u:user-123#key-1'],
|
|
58
|
+
alsoKnownAs: [],
|
|
59
|
+
service: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('OxyServices.rotateKey', () => {
|
|
64
|
+
let oxy: OxyServices;
|
|
65
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
66
|
+
let clearEntrySpy: jest.SpyInstance;
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
setPlatformOS('ios'); // native → the new key is persisted locally after success
|
|
70
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
71
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
72
|
+
jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
|
|
73
|
+
clearEntrySpy = jest.spyOn(oxy, 'clearCacheEntry').mockReturnValue(undefined);
|
|
74
|
+
jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue('user-123');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
jest.restoreAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('device proof', () => {
|
|
82
|
+
it('signs the exact rotation payload, POSTs challenge→complete, then persists the new key locally', async () => {
|
|
83
|
+
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
|
|
84
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
85
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
86
|
+
const signSpy = jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
87
|
+
// The new-key proof is signed with the pending private key via protocol.signMessage.
|
|
88
|
+
const newKeySignSpy = jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
89
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
|
|
90
|
+
makeRequestSpy
|
|
91
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
92
|
+
.mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
|
|
93
|
+
|
|
94
|
+
const result = await oxy.rotateKey({ proof: 'device' });
|
|
95
|
+
|
|
96
|
+
expect(result).toEqual({ newPublicKey: NEW_PUBLIC, newPhrase: pendingFixture.phrase, words: pendingFixture.words });
|
|
97
|
+
|
|
98
|
+
// The OLD-key signed message MUST match the server's reconstruction byte-for-byte.
|
|
99
|
+
const expectedMessage = JSON.stringify({
|
|
100
|
+
action: 'rotate_key',
|
|
101
|
+
userId: 'user-123',
|
|
102
|
+
oldPublicKey: KeyManager.canonicalPublicKey(OLD_PUBLIC),
|
|
103
|
+
newPublicKey: NEW_PUBLIC,
|
|
104
|
+
challenge: 'chal-1',
|
|
105
|
+
timestamp: 1700000000000,
|
|
106
|
+
});
|
|
107
|
+
expect(signSpy).toHaveBeenCalledWith(expectedMessage);
|
|
108
|
+
// The NEW-key proof is signed with the pending private key over rotate_key_new.
|
|
109
|
+
const expectedNewKeyMessage = JSON.stringify({
|
|
110
|
+
action: 'rotate_key_new',
|
|
111
|
+
userId: 'user-123',
|
|
112
|
+
newPublicKey: NEW_PUBLIC,
|
|
113
|
+
challenge: 'chal-1',
|
|
114
|
+
timestamp: 1700000000000,
|
|
115
|
+
});
|
|
116
|
+
expect(newKeySignSpy).toHaveBeenCalledWith(expectedNewKeyMessage, NEW_PRIVATE);
|
|
117
|
+
|
|
118
|
+
expect(makeRequestSpy).toHaveBeenNthCalledWith(1, 'POST', '/auth/rotate/challenge', undefined, expect.objectContaining({ cache: false }));
|
|
119
|
+
expect(makeRequestSpy).toHaveBeenNthCalledWith(
|
|
120
|
+
2,
|
|
121
|
+
'POST',
|
|
122
|
+
'/auth/rotate/complete',
|
|
123
|
+
{ newPublicKey: NEW_PUBLIC, challenge: 'chal-1', signature: 'sig-hex', newKeyProof: 'newkeyproof-hex', timestamp: 1700000000000 },
|
|
124
|
+
expect.objectContaining({ cache: false }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Persisted ONLY after the server confirmed the swap.
|
|
128
|
+
expect(importSpy).toHaveBeenCalledWith(NEW_PRIVATE, { overwrite: true });
|
|
129
|
+
// DID + auth-method caches are swept.
|
|
130
|
+
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
131
|
+
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/auth/methods');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('forwards signOutEverywhere in the complete body', async () => {
|
|
135
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
136
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
137
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
138
|
+
jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
139
|
+
jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
|
|
140
|
+
makeRequestSpy
|
|
141
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
142
|
+
.mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
|
|
143
|
+
|
|
144
|
+
await oxy.rotateKey({ proof: 'device', signOutEverywhere: true });
|
|
145
|
+
|
|
146
|
+
expect(makeRequestSpy).toHaveBeenNthCalledWith(
|
|
147
|
+
2,
|
|
148
|
+
'POST',
|
|
149
|
+
'/auth/rotate/complete',
|
|
150
|
+
expect.objectContaining({ signOutEverywhere: true, newKeyProof: 'newkeyproof-hex' }),
|
|
151
|
+
expect.anything(),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does NOT throw and surfaces the new phrase when the local key persist fails after a server rotation', async () => {
|
|
156
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
157
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
158
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
159
|
+
jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
160
|
+
// The server rotated successfully, but the on-device persist fails.
|
|
161
|
+
jest.spyOn(KeyManager, 'importKeyPair').mockRejectedValue(new Error('secure store write failed'));
|
|
162
|
+
makeRequestSpy
|
|
163
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
164
|
+
.mockResolvedValueOnce({ success: true, publicKey: NEW_PUBLIC, message: 'ok' });
|
|
165
|
+
|
|
166
|
+
const result = await oxy.rotateKey({ proof: 'device' });
|
|
167
|
+
|
|
168
|
+
// The phrase for the now-live key is surfaced, with a failure flag — never lost.
|
|
169
|
+
expect(result).toEqual({
|
|
170
|
+
newPublicKey: NEW_PUBLIC,
|
|
171
|
+
newPhrase: pendingFixture.phrase,
|
|
172
|
+
words: pendingFixture.words,
|
|
173
|
+
localPersistFailed: true,
|
|
174
|
+
});
|
|
175
|
+
// Caches are still swept (the server key is the new one).
|
|
176
|
+
expect(clearEntrySpy).toHaveBeenCalledWith('GET:/u/user-123/did.json');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('throws (no network) when the device holds no identity', async () => {
|
|
180
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
181
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
|
|
182
|
+
|
|
183
|
+
await expect(oxy.rotateKey({ proof: 'device' })).rejects.toThrow(/No on-device identity/);
|
|
184
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('phrase proof (replace the LAST credential)', () => {
|
|
189
|
+
it('re-derives the CURRENT key from the entered phrase and signs the proof with it', async () => {
|
|
190
|
+
// A real "current" identity whose phrase the user re-enters, and a real new one.
|
|
191
|
+
const current = await RecoveryPhraseService.derivePendingIdentity();
|
|
192
|
+
const next = await RecoveryPhraseService.derivePendingIdentity();
|
|
193
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(next);
|
|
194
|
+
jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(next.publicKey);
|
|
195
|
+
|
|
196
|
+
let completeBody: Record<string, unknown> | undefined;
|
|
197
|
+
makeRequestSpy.mockImplementation((_m: string, path: string, body?: unknown) => {
|
|
198
|
+
if (path === '/auth/rotate/challenge') return Promise.resolve({ challenge: 'chal-x', expiresAt: '2999-01-01T00:00:00.000Z' });
|
|
199
|
+
if (path === '/auth/rotate/complete') {
|
|
200
|
+
completeBody = body as Record<string, unknown>;
|
|
201
|
+
return Promise.resolve({ success: true, publicKey: next.publicKey, message: 'ok' });
|
|
202
|
+
}
|
|
203
|
+
return Promise.reject(new Error(`unexpected ${path}`));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = await oxy.rotateKey({ proof: 'phrase', phrase: current.phrase });
|
|
207
|
+
expect(result.newPublicKey).toBe(next.publicKey);
|
|
208
|
+
|
|
209
|
+
// The OLD-key signature MUST verify against the current key over the exact message.
|
|
210
|
+
const message = JSON.stringify({
|
|
211
|
+
action: 'rotate_key',
|
|
212
|
+
userId: 'user-123',
|
|
213
|
+
oldPublicKey: KeyManager.canonicalPublicKey(current.publicKey),
|
|
214
|
+
newPublicKey: next.publicKey,
|
|
215
|
+
challenge: 'chal-x',
|
|
216
|
+
timestamp: completeBody?.timestamp,
|
|
217
|
+
});
|
|
218
|
+
expect(await protocol.verifySignature(message, completeBody?.signature as string, current.publicKey)).toBe(true);
|
|
219
|
+
// …and must NOT verify against the new key (proves it was signed by the current key).
|
|
220
|
+
expect(await protocol.verifySignature(message, completeBody?.signature as string, next.publicKey)).toBe(false);
|
|
221
|
+
|
|
222
|
+
// The NEW-key proof-of-possession MUST verify against the NEW key.
|
|
223
|
+
const newKeyMessage = JSON.stringify({
|
|
224
|
+
action: 'rotate_key_new',
|
|
225
|
+
userId: 'user-123',
|
|
226
|
+
newPublicKey: next.publicKey,
|
|
227
|
+
challenge: 'chal-x',
|
|
228
|
+
timestamp: completeBody?.timestamp,
|
|
229
|
+
});
|
|
230
|
+
expect(await protocol.verifySignature(newKeyMessage, completeBody?.newKeyProof as string, next.publicKey)).toBe(true);
|
|
231
|
+
// …and must NOT verify against the old key (proves possession of the new key).
|
|
232
|
+
expect(await protocol.verifySignature(newKeyMessage, completeBody?.newKeyProof as string, current.publicKey)).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('throws when proof is phrase but no phrase is supplied', async () => {
|
|
236
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
237
|
+
await expect(oxy.rotateKey({ proof: 'phrase' })).rejects.toThrow(/recovery phrase is required/);
|
|
238
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('ambiguous network failure', () => {
|
|
243
|
+
it('reconciles a lost complete-response against the DID and treats a landed swap as done', async () => {
|
|
244
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
245
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
246
|
+
jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
247
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
248
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
|
|
249
|
+
makeRequestSpy
|
|
250
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
251
|
+
.mockRejectedValueOnce(new Error('network lost'))
|
|
252
|
+
.mockResolvedValueOnce(didDocWithKey(NEW_PUBLIC)); // DID already advertises the new key
|
|
253
|
+
|
|
254
|
+
const result = await oxy.rotateKey({ proof: 'device' });
|
|
255
|
+
|
|
256
|
+
expect(result.newPublicKey).toBe(NEW_PUBLIC);
|
|
257
|
+
expect(makeRequestSpy).toHaveBeenNthCalledWith(3, 'GET', '/u/user-123/did.json', undefined, expect.objectContaining({ cache: false }));
|
|
258
|
+
// The swap landed server-side, so the new key is persisted locally.
|
|
259
|
+
expect(importSpy).toHaveBeenCalledWith(NEW_PRIVATE, { overwrite: true });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('surfaces the error and does NOT persist locally when the DID shows the swap did not land', async () => {
|
|
263
|
+
jest.spyOn(RecoveryPhraseService, 'derivePendingIdentity').mockResolvedValue(pendingFixture);
|
|
264
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(OLD_PUBLIC);
|
|
265
|
+
jest.spyOn(protocol, 'signMessage').mockResolvedValue('newkeyproof-hex');
|
|
266
|
+
jest.spyOn(SignatureService, 'sign').mockResolvedValue('sig-hex');
|
|
267
|
+
const importSpy = jest.spyOn(KeyManager, 'importKeyPair').mockResolvedValue(NEW_PUBLIC);
|
|
268
|
+
makeRequestSpy
|
|
269
|
+
.mockResolvedValueOnce({ challenge: 'chal-1', expiresAt: '2999-01-01T00:00:00.000Z' })
|
|
270
|
+
.mockRejectedValueOnce(new Error('network lost'))
|
|
271
|
+
.mockResolvedValueOnce(didDocWithKey(OLD_PUBLIC)); // DID still shows the OLD key
|
|
272
|
+
|
|
273
|
+
await expect(oxy.rotateKey({ proof: 'device' })).rejects.toBeDefined();
|
|
274
|
+
expect(importSpy).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|