@oxyhq/core 12.6.0 → 12.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/keyManager.js +50 -0
  3. package/dist/cjs/i18n/locales/en-US.json +7 -0
  4. package/dist/cjs/i18n/locales/es-ES.json +7 -0
  5. package/dist/cjs/i18n/locales/locales/en-US.json +7 -0
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +7 -0
  7. package/dist/cjs/mixins/OxyServices.deviceTransfer.js +319 -0
  8. package/dist/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/mixins/index.js +4 -0
  10. package/dist/cjs/server/auth.js +3 -0
  11. package/dist/cjs/server/index.js +2 -1
  12. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/crypto/keyManager.js +50 -0
  15. package/dist/esm/i18n/locales/en-US.json +7 -0
  16. package/dist/esm/i18n/locales/es-ES.json +7 -0
  17. package/dist/esm/i18n/locales/locales/en-US.json +7 -0
  18. package/dist/esm/i18n/locales/locales/es-ES.json +7 -0
  19. package/dist/esm/mixins/OxyServices.deviceTransfer.js +317 -0
  20. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  21. package/dist/esm/mixins/index.js +4 -0
  22. package/dist/esm/server/auth.js +2 -0
  23. package/dist/esm/server/index.js +1 -1
  24. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  25. package/dist/types/.tsbuildinfo +1 -1
  26. package/dist/types/crypto/keyManager.d.ts +20 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
  29. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  30. package/dist/types/mixins/index.d.ts +2 -1
  31. package/dist/types/server/auth.d.ts +4 -0
  32. package/dist/types/server/index.d.ts +2 -2
  33. package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
  34. package/package.json +1 -1
  35. package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
  36. package/src/crypto/keyManager.ts +55 -0
  37. package/src/i18n/locales/en-US.json +7 -0
  38. package/src/i18n/locales/es-ES.json +7 -0
  39. package/src/index.ts +4 -0
  40. package/src/mixins/OxyServices.deviceTransfer.ts +397 -0
  41. package/src/mixins/OxyServices.utility.ts +19 -1
  42. package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +270 -0
  43. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  44. package/src/mixins/index.ts +6 -0
  45. package/src/server/auth.ts +5 -0
  46. package/src/server/index.ts +2 -0
  47. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  48. package/src/utils/oxyServiceEnvironment.ts +17 -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
@@ -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;
@@ -1,5 +1,6 @@
1
1
  import type { ApiError, User } from '../models/interfaces';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
+ import { type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
3
4
  /**
4
5
  * Result from the service-acting-as verification endpoint.
5
6
  * Confirms that a given service app holds an active delegation grant for
@@ -28,6 +29,8 @@ export interface ServiceApp {
28
29
  scopes: string[];
29
30
  /** The credentialId of the specific service credential that minted this token. */
30
31
  credentialId: string;
32
+ /** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
33
+ environment: OxyServiceEnvironment;
31
34
  }
32
35
  /**
33
36
  * Options for oxyClient.auth() middleware
@@ -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
@@ -1,5 +1,8 @@
1
1
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import type { OxyServices } from '../OxyServices';
3
+ import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
4
+ export { OXY_SERVICE_ENVIRONMENTS };
5
+ export type { OxyServiceEnvironment };
3
6
  export interface OxyRequestUser {
4
7
  id: string;
5
8
  _id?: string;
@@ -13,6 +16,7 @@ export interface OxyServiceAppContext {
13
16
  appName: string;
14
17
  scopes: string[];
15
18
  credentialId: string;
19
+ environment: OxyServiceEnvironment;
16
20
  }
17
21
  export interface OxyServiceActingAsContext {
18
22
  userId: string;
@@ -14,8 +14,8 @@
14
14
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
15
  * ```
16
16
  */
17
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, } from './auth';
18
- export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
18
+ export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
21
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Environment segregation for Oxy service-token JWTs (test/live isolation).
3
+ * Mirrors `ApplicationCredentialEnvironment` on the API's `ApplicationCredential`
4
+ * model (`packages/api/src/models/ApplicationCredential.ts`) as an INDEPENDENT
5
+ * literal union — `@oxyhq/core` has zero dependency on `@oxyhq/api`, so this is
6
+ * kept in sync by hand, not by import.
7
+ *
8
+ * Defined here (not in `server/auth.ts` or `mixins/OxyServices.utility.ts`
9
+ * directly) because BOTH of those files need it and neither may import from
10
+ * the other: `server/` types import `express` (Node-only, a peer dependency
11
+ * `mixins/` deliberately avoids so it stays safe to bundle into RN/browser
12
+ * consumers — see the "Local request/response/socket typing" comment in
13
+ * `OxyServices.utility.ts`). This file has zero imports, so both sides can
14
+ * depend on it without crossing that boundary.
15
+ */
16
+ export declare const OXY_SERVICE_ENVIRONMENTS: readonly ["development", "staging", "production"];
17
+ export type OxyServiceEnvironment = (typeof OXY_SERVICE_ENVIRONMENTS)[number];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "12.6.0",
3
+ "version": "12.8.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -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
+ });
@@ -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,