@oxyhq/core 12.6.0 → 12.7.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/keyManager.js +50 -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/locales/en-US.json +7 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/keyManager.js +50 -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/locales/en-US.json +7 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/keyManager.d.ts +20 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/keyManager.ts +55 -0
- package/src/i18n/locales/en-US.json +7 -0
- package/src/i18n/locales/es-ES.json +7 -0
- package/src/index.ts +4 -0
- package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
- package/src/mixins/index.ts +6 -0
|
@@ -321,6 +321,26 @@ export declare class KeyManager {
|
|
|
321
321
|
* "not-hex" pass through as a valid (but compromised, near-zero) key.
|
|
322
322
|
*/
|
|
323
323
|
static isValidPrivateKey(privateKey: string): boolean;
|
|
324
|
+
/**
|
|
325
|
+
* Derive a 32-byte, domain-separated seed from the on-device Oxy identity
|
|
326
|
+
* private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
|
|
327
|
+
*
|
|
328
|
+
* The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
|
|
329
|
+
* so distinct apps/purposes get independent seeds from the same identity.
|
|
330
|
+
* The output is HKDF keying material, never the private key itself — a
|
|
331
|
+
* consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
|
|
332
|
+
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
333
|
+
*
|
|
334
|
+
* Key source (native only): prefers the shared ecosystem identity written to
|
|
335
|
+
* `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
|
|
336
|
+
* back to this device's primary identity (Commons/Accounts). Both reproduce
|
|
337
|
+
* from the user's Oxy recovery phrase, so the derived seed is recoverable.
|
|
338
|
+
*
|
|
339
|
+
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
340
|
+
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
341
|
+
* identity key is available on this device.
|
|
342
|
+
*/
|
|
343
|
+
static deriveScopedSeed(info: string): Promise<Uint8Array | null>;
|
|
324
344
|
/**
|
|
325
345
|
* Get a shortened version of the public key for display
|
|
326
346
|
* Format: first 8 chars...last 8 chars
|
package/dist/types/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
|
26
26
|
export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
|
|
27
27
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
28
28
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
|
|
29
|
+
export type { InitDeviceTransferResult, DeviceTransferOutcome, } from './mixins/OxyServices.deviceTransfer';
|
|
29
30
|
export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, FollowMutationResult, ViewerGraph, } from './mixins/OxyServices.user';
|
|
30
31
|
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
|
|
31
32
|
export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-to-device Identity Transfer Mixin (b3 Feature 2 — "add a device")
|
|
3
|
+
*
|
|
4
|
+
* Clones an existing device's secp256k1 identity onto a fresh device over a
|
|
5
|
+
* short-lived, unauthenticated relay, WITHOUT the server ever holding a
|
|
6
|
+
* decryption key. Both devices end up holding the SAME private key.
|
|
7
|
+
*
|
|
8
|
+
* The two devices agree on a symmetric key via an ephemeral secp256k1 ECDH
|
|
9
|
+
* handshake (Phase-0 crypto): `deriveSharedSecret` → `hkdfSha256` → a per-pairing
|
|
10
|
+
* transfer key, used with `encryptAead`/`decryptAead` (XChaCha20-Poly1305) to
|
|
11
|
+
* seal `{ privateKey, publicKey }`. The relay carries only ephemeral public keys
|
|
12
|
+
* plus opaque ciphertext — a passive/at-rest-compromised backend cannot decrypt.
|
|
13
|
+
*
|
|
14
|
+
* Roles:
|
|
15
|
+
* - NEW device (no identity): {@link initDeviceTransfer} (generate ephemeral
|
|
16
|
+
* pair, register the pairing, render `pairingId` as a QR) then
|
|
17
|
+
* {@link subscribeDeviceTransfer} (await approval over the `/device-pair`
|
|
18
|
+
* socket with a poll fallback, decrypt, and import the key).
|
|
19
|
+
* - OLD device (has identity): {@link getDeviceTransferInfo} (resolve the
|
|
20
|
+
* scanned `pairingId` server-side — the QR is NOT self-contained) then
|
|
21
|
+
* {@link approveDeviceTransfer} (biometric-gate in the UI, seal the key
|
|
22
|
+
* material, and post it with a fresh signature over the CURRENT identity key).
|
|
23
|
+
*
|
|
24
|
+
* SECURITY: E2E against a passive relay only. Explicitly NOT hardened against an
|
|
25
|
+
* actively-malicious backend MITM'ing the ephemeral keys (same trust boundary as
|
|
26
|
+
* the existing QR sign-in; SAS compare deferred per owner decision). Approve
|
|
27
|
+
* requires BOTH a bearer token AND a fresh identity-key signature.
|
|
28
|
+
*/
|
|
29
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
30
|
+
import type { DeviceTransferInfoResponse, DeviceTransferApproveResponse, DeviceTransferDenyResponse } from '@oxyhq/contracts';
|
|
31
|
+
/** Result of {@link OxyServicesDeviceTransferMixin.initDeviceTransfer}. */
|
|
32
|
+
export interface InitDeviceTransferResult {
|
|
33
|
+
/** 128-bit single-use handle to render in the QR (also the HKDF salt). */
|
|
34
|
+
pairingId: string;
|
|
35
|
+
/** ISO-8601 expiry (3 minutes). */
|
|
36
|
+
expiresAt: string;
|
|
37
|
+
/** The new device's ephemeral public key registered with the relay. */
|
|
38
|
+
newEphemeralPublicKey: string;
|
|
39
|
+
}
|
|
40
|
+
/** Terminal outcome delivered to {@link subscribeDeviceTransfer}'s callback. */
|
|
41
|
+
export type DeviceTransferOutcome = {
|
|
42
|
+
status: 'approved';
|
|
43
|
+
publicKey: string;
|
|
44
|
+
} | {
|
|
45
|
+
status: 'denied';
|
|
46
|
+
} | {
|
|
47
|
+
status: 'expired';
|
|
48
|
+
};
|
|
49
|
+
export declare function OxyServicesDeviceTransferMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
50
|
+
new (...args: any[]): {
|
|
51
|
+
/**
|
|
52
|
+
* NEW device — begin an "add a device" transfer. Generates a single-use
|
|
53
|
+
* ephemeral secp256k1 pair, registers the pairing, and returns the
|
|
54
|
+
* `pairingId` to render as a QR. The ephemeral private key is held in memory
|
|
55
|
+
* (keyed by `pairingId`) for the subsequent {@link subscribeDeviceTransfer}.
|
|
56
|
+
*
|
|
57
|
+
* @param label - Optional human-readable label for this new device.
|
|
58
|
+
*/
|
|
59
|
+
initDeviceTransfer(label?: string): Promise<InitDeviceTransferResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a pairing server-side (the QR carries only `pairingId`). The OLD
|
|
62
|
+
* device calls this after scanning to read the new device's ephemeral public
|
|
63
|
+
* key + label; the NEW device polls it to fetch the sealed material once
|
|
64
|
+
* approved. Public — no auth required.
|
|
65
|
+
*/
|
|
66
|
+
getDeviceTransferInfo(pairingId: string): Promise<DeviceTransferInfoResponse>;
|
|
67
|
+
/**
|
|
68
|
+
* OLD device — approve a scanned transfer. Reads the new device's ephemeral
|
|
69
|
+
* public key, derives the shared transfer key, AEAD-seals
|
|
70
|
+
* `{ privateKey, publicKey }`, and posts it PLUS a fresh signature over
|
|
71
|
+
* `{ action:'approve_device_transfer', pairingId, timestamp }` made with the
|
|
72
|
+
* CURRENT identity key (dual-proof alongside the bearer token).
|
|
73
|
+
*
|
|
74
|
+
* NATIVE-ONLY: requires a stored identity (throws otherwise). The UI must
|
|
75
|
+
* biometric-gate before calling this — a key clone leaves the device.
|
|
76
|
+
*/
|
|
77
|
+
approveDeviceTransfer(pairingId: string): Promise<DeviceTransferApproveResponse>;
|
|
78
|
+
/**
|
|
79
|
+
* OLD device — deny (cancel) a scanned transfer so the waiting new device
|
|
80
|
+
* stops. Public — no auth required.
|
|
81
|
+
*/
|
|
82
|
+
denyDeviceTransfer(pairingId: string): Promise<DeviceTransferDenyResponse>;
|
|
83
|
+
/**
|
|
84
|
+
* NEW device — await approval for a pairing started with
|
|
85
|
+
* {@link initDeviceTransfer}, then decrypt and import the transferred
|
|
86
|
+
* identity key. Primary path is an instant `device_pair_update` push over the
|
|
87
|
+
* `/device-pair` socket; a poll backstops a socket that can't connect.
|
|
88
|
+
*
|
|
89
|
+
* On `approved`: re-derives the shared transfer key from the old device's
|
|
90
|
+
* ephemeral public key, decrypts `{ privateKey, publicKey }`, imports it via
|
|
91
|
+
* `KeyManager.importKeyPair(privateKey, { overwrite: false })`, and invokes
|
|
92
|
+
* `onOutcome({ status:'approved', publicKey })`. The caller then runs the
|
|
93
|
+
* NORMAL challenge/verify sign-in — this method does not mint a session.
|
|
94
|
+
*
|
|
95
|
+
* @returns An unsubscribe function; call it to stop waiting (also called
|
|
96
|
+
* automatically once the transfer settles).
|
|
97
|
+
*/
|
|
98
|
+
subscribeDeviceTransfer(pairingId: string, onOutcome: (outcome: DeviceTransferOutcome) => void): () => void;
|
|
99
|
+
httpService: import("../HttpService").HttpService;
|
|
100
|
+
cloudURL: string;
|
|
101
|
+
config: import("../OxyServices.base").OxyConfig;
|
|
102
|
+
__resetTokensForTests(): void;
|
|
103
|
+
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
104
|
+
getBaseURL(): string;
|
|
105
|
+
getClient(): import("../HttpService").HttpService;
|
|
106
|
+
createLinkedClient(config: import("../OxyServices.base").OxyConfig): import("..").LinkedHttpClient;
|
|
107
|
+
getMetrics(): {
|
|
108
|
+
totalRequests: number;
|
|
109
|
+
successfulRequests: number;
|
|
110
|
+
failedRequests: number;
|
|
111
|
+
cacheHits: number;
|
|
112
|
+
cacheMisses: number;
|
|
113
|
+
averageResponseTime: number;
|
|
114
|
+
};
|
|
115
|
+
clearCache(): void;
|
|
116
|
+
clearCacheEntry(key: string): void;
|
|
117
|
+
clearCacheByPrefix(prefix: string): number;
|
|
118
|
+
getCacheStats(): {
|
|
119
|
+
size: number;
|
|
120
|
+
hits: number;
|
|
121
|
+
misses: number;
|
|
122
|
+
hitRate: number;
|
|
123
|
+
};
|
|
124
|
+
getCloudURL(): string;
|
|
125
|
+
setTokens(accessToken: string): void;
|
|
126
|
+
clearTokens(): void;
|
|
127
|
+
onTokensChanged(listener: (accessToken: string | null) => void): () => void;
|
|
128
|
+
_cachedUserId: string | null | undefined;
|
|
129
|
+
_cachedAccessToken: string | null;
|
|
130
|
+
getCurrentUserId(): string | null;
|
|
131
|
+
hasValidToken(): boolean;
|
|
132
|
+
getAccessToken(): string | null;
|
|
133
|
+
getAccessTokenExpiry(): number | null;
|
|
134
|
+
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
135
|
+
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
136
|
+
maxRetries?: number;
|
|
137
|
+
retryDelay?: number;
|
|
138
|
+
authTimeoutMs?: number;
|
|
139
|
+
}): Promise<T_1>;
|
|
140
|
+
validate(): Promise<boolean>;
|
|
141
|
+
handleError(error: unknown): Error;
|
|
142
|
+
healthCheck(): Promise<{
|
|
143
|
+
status: string;
|
|
144
|
+
users?: number;
|
|
145
|
+
timestamp?: string;
|
|
146
|
+
[key: string]: any;
|
|
147
|
+
}>;
|
|
148
|
+
};
|
|
149
|
+
} & T;
|
|
@@ -29,6 +29,7 @@ import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
|
29
29
|
import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
30
30
|
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
31
31
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
32
|
+
import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
32
33
|
/**
|
|
33
34
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
34
35
|
* `composeOxyServices()` produces a class whose instances expose all of
|
|
@@ -38,7 +39,7 @@ import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
|
38
39
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
39
40
|
* are visible without a cast.
|
|
40
41
|
*/
|
|
41
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
42
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceTransferMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
42
43
|
/**
|
|
43
44
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
44
45
|
* a new constructor that augments its input; reducing across the pipeline
|
package/package.json
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { setPlatformOS } from '../../utils/platform';
|
|
2
|
+
|
|
3
|
+
jest.mock(
|
|
4
|
+
'expo-secure-store',
|
|
5
|
+
() => {
|
|
6
|
+
const store = new Map<string, string>();
|
|
7
|
+
return {
|
|
8
|
+
__esModule: true,
|
|
9
|
+
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY',
|
|
10
|
+
WHEN_UNLOCKED: 'WHEN_UNLOCKED',
|
|
11
|
+
setItemAsync: jest.fn(async (k: string, v: string) => { store.set(k, v); }),
|
|
12
|
+
getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
|
|
13
|
+
deleteItemAsync: jest.fn(async (k: string) => { store.delete(k); }),
|
|
14
|
+
__resetStore__: () => store.clear(),
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
{ virtual: true },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
jest.mock(
|
|
21
|
+
'expo-crypto',
|
|
22
|
+
() => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
getRandomBytes: (length: number) => {
|
|
25
|
+
const out = new Uint8Array(length);
|
|
26
|
+
for (let i = 0; i < length; i++) out[i] = (Math.random() * 256) & 0xff;
|
|
27
|
+
return out;
|
|
28
|
+
},
|
|
29
|
+
digestStringAsync: async () => '0'.repeat(64),
|
|
30
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
31
|
+
}),
|
|
32
|
+
{ virtual: true },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
jest.mock('@oxyhq/protocol', () => ({
|
|
36
|
+
__esModule: true,
|
|
37
|
+
...jest.requireActual('@oxyhq/protocol'),
|
|
38
|
+
loadExpoCrypto: async () => require('expo-crypto'),
|
|
39
|
+
loadSecureStore: async () => require('expo-secure-store'),
|
|
40
|
+
loadNodeCrypto: async () => require('crypto'),
|
|
41
|
+
loadSharedIdentityBridge: async () => null,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const FIXED_PRIV = 'aa'.repeat(32);
|
|
45
|
+
const EXPECTED_FAIR = '4b90d900a11b0a1737ed643db3446e5f28035d86f1a4fda92474ea8ab152adf5';
|
|
46
|
+
const EXPECTED_OTHER = 'cdedf1f076b0f4766c769e55bc1e90c5bf44d8630f6e5fb147615ee7c330c905';
|
|
47
|
+
const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex');
|
|
48
|
+
|
|
49
|
+
describe('KeyManager.deriveScopedSeed', () => {
|
|
50
|
+
let KeyManager: typeof import('../keyManager').KeyManager;
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
jest.resetModules();
|
|
54
|
+
setPlatformOS('ios');
|
|
55
|
+
const secureStore = (await import('expo-secure-store' as string)) as unknown as {
|
|
56
|
+
__resetStore__: () => void;
|
|
57
|
+
};
|
|
58
|
+
secureStore.__resetStore__();
|
|
59
|
+
const km = await import('../keyManager');
|
|
60
|
+
KeyManager = km.KeyManager;
|
|
61
|
+
// Store a known shared identity key so derivation is deterministic.
|
|
62
|
+
await KeyManager.importSharedIdentity(FIXED_PRIV);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('derives the pinned 32-byte seed for a fixed identity + info', async () => {
|
|
66
|
+
const seed = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
|
|
67
|
+
if (!seed) throw new Error('expected a seed');
|
|
68
|
+
expect(seed).toHaveLength(32);
|
|
69
|
+
expect(toHex(seed)).toBe(EXPECTED_FAIR);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is deterministic (same identity + info → identical seed)', async () => {
|
|
73
|
+
const a = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
|
|
74
|
+
const b = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
|
|
75
|
+
if (!a || !b) throw new Error('expected seeds');
|
|
76
|
+
expect(toHex(a)).toBe(toHex(b));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('domain-separates by info (different info → different seed)', async () => {
|
|
80
|
+
const fair = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
|
|
81
|
+
const other = await KeyManager.deriveScopedSeed('oxypay/other/v1');
|
|
82
|
+
if (!fair || !other) throw new Error('expected seeds');
|
|
83
|
+
expect(toHex(other)).toBe(EXPECTED_OTHER);
|
|
84
|
+
expect(toHex(fair)).not.toBe(toHex(other));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('never returns the raw private key (no leak)', async () => {
|
|
88
|
+
const seed = await KeyManager.deriveScopedSeed('oxypay/faircoin/v1');
|
|
89
|
+
if (!seed) throw new Error('expected a seed');
|
|
90
|
+
expect(toHex(seed)).not.toBe(FIXED_PRIV);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns null on web (no identity key available)', async () => {
|
|
94
|
+
// `jest.resetModules()` gives KeyManager its own bound instance of
|
|
95
|
+
// `utils/platform` (its cached OS locks in as soon as `beforeEach`'s
|
|
96
|
+
// `importSharedIdentity` first checks it). The file-level `setPlatformOS`
|
|
97
|
+
// import above is bound to a different, earlier instance, so mutating it
|
|
98
|
+
// here would not be observed by the already-imported `KeyManager`. Reset
|
|
99
|
+
// again and re-import both from the same fresh module graph so the
|
|
100
|
+
// platform flip actually reaches the KeyManager instance under test.
|
|
101
|
+
//
|
|
102
|
+
// `jest.resetModules()` also re-runs the virtual `expo-secure-store`
|
|
103
|
+
// mock factory, handing this fresh KeyManager a brand-new empty
|
|
104
|
+
// in-memory store — the identity `beforeEach` imported lives only in the
|
|
105
|
+
// pre-reset store instance. Without re-storing an identity here, a
|
|
106
|
+
// `null` result would be ambiguous between the web gate and the
|
|
107
|
+
// no-identity fallback. Re-import the identity on THIS fresh instance
|
|
108
|
+
// (while still native — `importSharedIdentity` itself is native-gated
|
|
109
|
+
// and throws on web) BEFORE flipping to web, so the later `null` can
|
|
110
|
+
// only be explained by the web gate, not a missing identity.
|
|
111
|
+
jest.resetModules();
|
|
112
|
+
const platform = await import('../../utils/platform');
|
|
113
|
+
const km = await import('../keyManager');
|
|
114
|
+
await km.KeyManager.importSharedIdentity(FIXED_PRIV);
|
|
115
|
+
platform.setPlatformOS('web');
|
|
116
|
+
expect(await km.KeyManager.deriveScopedSeed('oxypay/faircoin/v1')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns null when no identity exists on the device', async () => {
|
|
120
|
+
const secureStore = (await import('expo-secure-store' as string)) as unknown as {
|
|
121
|
+
__resetStore__: () => void;
|
|
122
|
+
};
|
|
123
|
+
secureStore.__resetStore__();
|
|
124
|
+
expect(await KeyManager.deriveScopedSeed('oxypay/faircoin/v1')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/crypto/keyManager.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { ECKeyPair } from 'elliptic';
|
|
|
10
10
|
import { isWeb, isIOS, isAndroid } from '../utils/platform';
|
|
11
11
|
import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxyhq/protocol';
|
|
12
12
|
import { isDev, logger } from '../logger';
|
|
13
|
+
import { hkdfSha256 } from './kdf';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Options for expo-secure-store calls made by KeyManager.
|
|
@@ -79,6 +80,28 @@ export class IdentityPersistError extends Error {
|
|
|
79
80
|
|
|
80
81
|
const ec = new EC('secp256k1');
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* HKDF salt that domain-separates every identity-scoped seed produced by
|
|
85
|
+
* {@link KeyManager.deriveScopedSeed}. Versioned so a future scheme change is a
|
|
86
|
+
* new, non-colliding tag. The per-app domain (e.g. Oxy Pay's FairCoin wallet)
|
|
87
|
+
* is carried by the caller's `info` string, not this salt.
|
|
88
|
+
*/
|
|
89
|
+
const SCOPED_SEED_KDF_SALT = 'oxy-identity-scoped-seed-v1';
|
|
90
|
+
|
|
91
|
+
/** UTF-8 encode an ASCII label to bytes (HKDF salt/info). */
|
|
92
|
+
function utf8ToBytes(label: string): Uint8Array {
|
|
93
|
+
return new TextEncoder().encode(label);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Decode a hex string to bytes. Inverse of {@link uint8ArrayToHex}. */
|
|
97
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
98
|
+
const out = new Uint8Array(hex.length / 2);
|
|
99
|
+
for (let i = 0; i < out.length; i++) {
|
|
100
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
82
105
|
const STORAGE_KEYS = {
|
|
83
106
|
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
84
107
|
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
@@ -1559,6 +1582,38 @@ export class KeyManager {
|
|
|
1559
1582
|
}
|
|
1560
1583
|
}
|
|
1561
1584
|
|
|
1585
|
+
/**
|
|
1586
|
+
* Derive a 32-byte, domain-separated seed from the on-device Oxy identity
|
|
1587
|
+
* private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
|
|
1588
|
+
*
|
|
1589
|
+
* The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
|
|
1590
|
+
* so distinct apps/purposes get independent seeds from the same identity.
|
|
1591
|
+
* The output is HKDF keying material, never the private key itself — a
|
|
1592
|
+
* consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
|
|
1593
|
+
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
1594
|
+
*
|
|
1595
|
+
* Key source (native only): prefers the shared ecosystem identity written to
|
|
1596
|
+
* `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
|
|
1597
|
+
* back to this device's primary identity (Commons/Accounts). Both reproduce
|
|
1598
|
+
* from the user's Oxy recovery phrase, so the derived seed is recoverable.
|
|
1599
|
+
*
|
|
1600
|
+
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
1601
|
+
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
1602
|
+
* identity key is available on this device.
|
|
1603
|
+
*/
|
|
1604
|
+
static async deriveScopedSeed(info: string): Promise<Uint8Array | null> {
|
|
1605
|
+
if (isWebPlatform()) {
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
const privateKey =
|
|
1609
|
+
(await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
1610
|
+
if (!privateKey) {
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
const ikm = hexToBytes(KeyManager.canonicalPrivateKey(privateKey));
|
|
1614
|
+
return hkdfSha256(ikm, utf8ToBytes(SCOPED_SEED_KDF_SALT), utf8ToBytes(info), 32);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1562
1617
|
/**
|
|
1563
1618
|
* Get a shortened version of the public key for display
|
|
1564
1619
|
* Format: first 8 chars...last 8 chars
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "No photos yet",
|
|
1450
1450
|
"emptySubtitle": "Upload from your device to get started"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "File Details",
|
|
1454
|
+
"download": "Download",
|
|
1455
|
+
"type": "Type",
|
|
1456
|
+
"uploaded": "Uploaded",
|
|
1457
|
+
"description": "Description"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Show all files",
|
|
1454
1461
|
"viewPhotos": "Show photos only",
|
|
@@ -1449,6 +1449,13 @@
|
|
|
1449
1449
|
"emptyTitle": "Aún no hay fotos",
|
|
1450
1450
|
"emptySubtitle": "Sube desde tu dispositivo para empezar"
|
|
1451
1451
|
},
|
|
1452
|
+
"details": {
|
|
1453
|
+
"title": "Detalles del archivo",
|
|
1454
|
+
"download": "Descargar",
|
|
1455
|
+
"type": "Tipo",
|
|
1456
|
+
"uploaded": "Subido",
|
|
1457
|
+
"description": "Descripción"
|
|
1458
|
+
},
|
|
1452
1459
|
"a11y": {
|
|
1453
1460
|
"viewAll": "Mostrar todos los archivos",
|
|
1454
1461
|
"viewPhotos": "Mostrar solo fotos",
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,10 @@ export type {
|
|
|
48
48
|
ContactDiscoveryMatch,
|
|
49
49
|
ContactDiscoveryResponse,
|
|
50
50
|
} from './mixins/OxyServices.contacts';
|
|
51
|
+
export type {
|
|
52
|
+
InitDeviceTransferResult,
|
|
53
|
+
DeviceTransferOutcome,
|
|
54
|
+
} from './mixins/OxyServices.deviceTransfer';
|
|
51
55
|
export type {
|
|
52
56
|
BulkFollowEntry,
|
|
53
57
|
BulkFollowResult,
|