@oxyhq/core 12.7.0 → 12.9.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/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +844 -106
- package/dist/cjs/index.js +8 -4
- package/dist/cjs/mixins/OxyServices.auth.js +21 -6
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +843 -106
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +21 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +212 -3
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1026 -105
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +31 -7
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
|
@@ -46,6 +46,31 @@ export interface RunSessionColdBootOptions {
|
|
|
46
46
|
/** Invoked when the boot ended signed out. */
|
|
47
47
|
onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
|
|
48
48
|
onStepError?: (id: string, error: unknown) => void;
|
|
49
|
+
/**
|
|
50
|
+
* HARD overall deadline (ms) for the whole ordered step chain, forwarded to
|
|
51
|
+
* {@link runColdBoot}. Defense-in-depth so a single non-settling network step
|
|
52
|
+
* (a black-hole network that neither connects nor rejects) can NEVER hang the
|
|
53
|
+
* boot — and therefore app routing — indefinitely. Inert on healthy loads
|
|
54
|
+
* (every step settles well under it); only trips on pathological networks.
|
|
55
|
+
* When omitted there is no overall deadline (unchanged behavior).
|
|
56
|
+
*/
|
|
57
|
+
overallDeadlineMs?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Invoked once per step abandoned because {@link overallDeadlineMs} expired
|
|
60
|
+
* before it settled. Forwarded to {@link runColdBoot}. Must not throw.
|
|
61
|
+
*/
|
|
62
|
+
onStepDeadline?: (stepId: string) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Best-effort connectivity hint. When it returns `true` the two NETWORK steps
|
|
65
|
+
* (`device-secret-mint`, `shared-key-signin`) are skipped — an offline device
|
|
66
|
+
* cannot mint, and attempting to would burn the whole deadline on a doomed
|
|
67
|
+
* request before routing settles. The pure-local `warm-token-plant` step is
|
|
68
|
+
* NEVER gated by this: an offline returning user with an unexpired persisted
|
|
69
|
+
* token must still boot authenticated. Only an EXPLICIT offline verdict should
|
|
70
|
+
* be returned; the caller resolves unknown/timeout to `false` (assume online)
|
|
71
|
+
* so a flaky probe can never falsely skip a real sign-in.
|
|
72
|
+
*/
|
|
73
|
+
isOffline?: () => boolean;
|
|
49
74
|
}
|
|
50
75
|
/**
|
|
51
76
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity marker — a NON-secret, AndroidKeyStore-independent record that an
|
|
3
|
+
* identity exists (or existed) on this device.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: the identity private/public keys live in expo-secure-store,
|
|
6
|
+
* whose Android backing (a single `key_v1` AndroidKeyStore key by default) can be
|
|
7
|
+
* invalidated by an OS/vendor keystore event. When that happens SDK 57's
|
|
8
|
+
* expo-secure-store DELETES the undecryptable ciphertext on the read path and
|
|
9
|
+
* returns `null` — indistinguishable, from the keys alone, from a genuinely
|
|
10
|
+
* fresh install. That ambiguity is what lets a real identity get silently
|
|
11
|
+
* replaced by the onboarding "create" flow.
|
|
12
|
+
*
|
|
13
|
+
* The marker breaks the tie. It is written to AsyncStorage (RN) / localStorage
|
|
14
|
+
* (web) — storage that is NOT protected by the identity's AndroidKeyStore key —
|
|
15
|
+
* so it SURVIVES a keystore death. `getIdentityStatus()` reads it: keys empty +
|
|
16
|
+
* marker present ⇒ `lost` (route to recovery, NEVER welcome/create); keys empty
|
|
17
|
+
* + no marker ⇒ `absent` (the only path to fresh onboarding).
|
|
18
|
+
*
|
|
19
|
+
* It holds only the PUBLIC key plus provenance metadata — never any secret — so
|
|
20
|
+
* persisting it in plain KV storage adds no exposure.
|
|
21
|
+
*
|
|
22
|
+
* Every operation fails OPEN (returns null / false / resolves): the marker is a
|
|
23
|
+
* best-effort disambiguation signal layered on top of the authoritative
|
|
24
|
+
* secure-store reads, never a gate that can itself lock the user out.
|
|
25
|
+
*
|
|
26
|
+
* ESM-safe (no `require()`); zero React/RN static imports — the RN AsyncStorage
|
|
27
|
+
* module is reached only through `@oxyhq/protocol`'s per-platform dynamic loader.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* AsyncStorage / localStorage key holding the serialized {@link IdentityMarker}.
|
|
31
|
+
* `.v1` lets a future shape change ship a `.v2` key without misreading a stale
|
|
32
|
+
* blob. Distinct from every `oxy_identity_*` secure-store key so it never
|
|
33
|
+
* collides with the keychain material it disambiguates.
|
|
34
|
+
*/
|
|
35
|
+
export declare const IDENTITY_MARKER_STORAGE_KEY = "oxy_identity_marker_v1";
|
|
36
|
+
/**
|
|
37
|
+
* A durable, non-secret record that an identity was provisioned on this device.
|
|
38
|
+
*
|
|
39
|
+
* `publicKey` is the identity's public key (NOT secret) — it lets recovery
|
|
40
|
+
* validate that whatever it restores is the SAME account this marker records,
|
|
41
|
+
* never a silent account switch. `origin` records how the identity came to be.
|
|
42
|
+
* `onboardingComplete` mirrors the onboarding milestone (Workstream 3.4) so a
|
|
43
|
+
* lost SecureStore milestone flag cannot re-route a real identity into the
|
|
44
|
+
* onboarding wizard.
|
|
45
|
+
*/
|
|
46
|
+
export interface IdentityMarker {
|
|
47
|
+
v: 1;
|
|
48
|
+
/** The identity's PUBLIC key — never secret. */
|
|
49
|
+
publicKey: string;
|
|
50
|
+
createdAt: number;
|
|
51
|
+
origin: 'create' | 'import' | 'restore' | 'backfill';
|
|
52
|
+
/** Milestone mirror: `true` once onboarding has completed for this identity. */
|
|
53
|
+
onboardingComplete?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Fields accepted when creating a marker; `createdAt` defaults to now. */
|
|
56
|
+
export interface WriteIdentityMarkerInput {
|
|
57
|
+
publicKey: string;
|
|
58
|
+
origin: IdentityMarker['origin'];
|
|
59
|
+
createdAt?: number;
|
|
60
|
+
onboardingComplete?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Read the identity marker. Fails OPEN: returns `null` on any storage error,
|
|
64
|
+
* missing entry, or malformed blob — the caller treats "no marker" as the safe
|
|
65
|
+
* default (fresh install), and the authoritative secure-store read decides the
|
|
66
|
+
* rest.
|
|
67
|
+
*/
|
|
68
|
+
export declare function readIdentityMarker(): Promise<IdentityMarker | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
71
|
+
* `false` when storage was unavailable or the write threw. Callers treat a
|
|
72
|
+
* `false` as non-fatal — the marker is best-effort and a subsequent read
|
|
73
|
+
* re-backfills it from the healthy key pair.
|
|
74
|
+
*/
|
|
75
|
+
export declare function writeIdentityMarker(input: WriteIdentityMarkerInput): Promise<boolean>;
|
|
76
|
+
/**
|
|
77
|
+
* Merge a partial update into the existing marker, preserving every field the
|
|
78
|
+
* caller does not override (notably `createdAt` and `onboardingComplete`). When
|
|
79
|
+
* no marker exists yet, a partial carrying at least `publicKey` + `origin`
|
|
80
|
+
* creates one; otherwise the update is a no-op returning `false`.
|
|
81
|
+
*
|
|
82
|
+
* Used to (a) mirror the onboarding milestone (`{ onboardingComplete: true }`)
|
|
83
|
+
* without disturbing provenance, and (b) refresh `origin` on a same-identity
|
|
84
|
+
* re-persist without resetting `createdAt`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function updateIdentityMarker(partial: Partial<Omit<IdentityMarker, 'v'>>): Promise<boolean>;
|
|
87
|
+
/**
|
|
88
|
+
* Remove the marker. Called ONLY after the identity's keys have been
|
|
89
|
+
* successfully deleted (`KeyManager.deleteIdentity`), so a marker never outlives
|
|
90
|
+
* the identity it records. Fails open (swallows errors) — a leftover marker
|
|
91
|
+
* simply routes a truly-absent identity to `recovery` instead of `welcome`,
|
|
92
|
+
* which is the safe direction.
|
|
93
|
+
*/
|
|
94
|
+
export declare function clearIdentityMarker(): Promise<void>;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Private keys are stored securely using expo-secure-store and never leave the device.
|
|
6
6
|
*/
|
|
7
7
|
import type { ECKeyPair } from 'elliptic';
|
|
8
|
+
import { type IdentityMarker } from './identityMarker';
|
|
8
9
|
/**
|
|
9
10
|
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
10
11
|
* is invoked while a valid identity already exists on the device.
|
|
@@ -30,6 +31,64 @@ export declare class IdentityPersistError extends Error {
|
|
|
30
31
|
readonly name = "IdentityPersistError";
|
|
31
32
|
constructor(message: string, cause?: unknown | undefined);
|
|
32
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Thrown when identity storage cannot be read/written right now — the keychain
|
|
36
|
+
* is locked, the module failed to load, or a read threw — as opposed to the
|
|
37
|
+
* identity being genuinely absent.
|
|
38
|
+
*
|
|
39
|
+
* This is the crux of the corruption-vs-fresh-install fix: a storage THROW must
|
|
40
|
+
* NEVER be flattened into "no identity" (the old behavior, which let onboarding
|
|
41
|
+
* treat a momentarily-locked keystore as a blank device). Callers that used to
|
|
42
|
+
* tolerate a `false`/`null` from `hasIdentity()`/`getPublicKey()` on error must
|
|
43
|
+
* now treat this typed error as "cannot determine" — retry, surface a locked
|
|
44
|
+
* state, or abort a destructive path — never as "safe to create/overwrite".
|
|
45
|
+
*/
|
|
46
|
+
export declare class IdentityUnavailableError extends Error {
|
|
47
|
+
readonly cause?: unknown | undefined;
|
|
48
|
+
readonly name = "IdentityUnavailableError";
|
|
49
|
+
constructor(message: string, cause?: unknown | undefined);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Authoritative tri-state (plus `unavailable`) verdict on the on-device
|
|
53
|
+
* identity, from {@link KeyManager.getIdentityStatus}.
|
|
54
|
+
*
|
|
55
|
+
* - `present` — a healthy, round-tripping key pair exists.
|
|
56
|
+
* - `absent` — storage read succeeded and returned nothing, AND no marker
|
|
57
|
+
* records a prior identity → a genuine fresh device. The ONLY
|
|
58
|
+
* state that may route to create/onboarding.
|
|
59
|
+
* - `lost` — storage read succeeded but the keys are empty/unreadable while
|
|
60
|
+
* the independent {@link IdentityMarker} records that an identity
|
|
61
|
+
* DID exist here → corruption/keystore death. Route to recovery,
|
|
62
|
+
* NEVER to create.
|
|
63
|
+
* - `unavailable` — a storage read THREW (keychain locked, module load failure).
|
|
64
|
+
* Transient by assumption; NEVER cached; callers retry.
|
|
65
|
+
*/
|
|
66
|
+
export type IdentityStatus = {
|
|
67
|
+
state: 'present';
|
|
68
|
+
publicKey: string;
|
|
69
|
+
} | {
|
|
70
|
+
state: 'absent';
|
|
71
|
+
} | {
|
|
72
|
+
state: 'lost';
|
|
73
|
+
marker: IdentityMarker;
|
|
74
|
+
} | {
|
|
75
|
+
state: 'unavailable';
|
|
76
|
+
cause: unknown;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Result of {@link KeyManager.attemptIdentityRecovery}. On success it reports
|
|
80
|
+
* which independent, `key_v1`-surviving source restored the identity. On failure
|
|
81
|
+
* `reason` distinguishes "wasn't lost", "no surviving source", "a source held a
|
|
82
|
+
* DIFFERENT account" (never silently switched), and "storage unavailable".
|
|
83
|
+
*/
|
|
84
|
+
export type IdentityRecoveryResult = {
|
|
85
|
+
recovered: true;
|
|
86
|
+
source: 'backup' | 'shared';
|
|
87
|
+
publicKey: string;
|
|
88
|
+
} | {
|
|
89
|
+
recovered: false;
|
|
90
|
+
reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable';
|
|
91
|
+
};
|
|
33
92
|
export interface KeyPair {
|
|
34
93
|
publicKey: string;
|
|
35
94
|
privateKey: string;
|
|
@@ -39,11 +98,86 @@ export declare class KeyManager {
|
|
|
39
98
|
private static cachedHasIdentity;
|
|
40
99
|
private static cachedSharedPublicKey;
|
|
41
100
|
private static cachedHasSharedIdentity;
|
|
101
|
+
/**
|
|
102
|
+
* Distinguishes "public key genuinely absent (a successful empty read, safe to
|
|
103
|
+
* cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
|
|
104
|
+
* {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
|
|
105
|
+
* absence cacheable WITHOUT ever caching a null produced by a thrown read.
|
|
106
|
+
*/
|
|
107
|
+
private static cachedPublicKeyResolved;
|
|
108
|
+
/** Listeners notified synchronously whenever the identity verdict may have changed. */
|
|
109
|
+
private static readonly identityChangeListeners;
|
|
110
|
+
/**
|
|
111
|
+
* Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
|
|
112
|
+
* STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
|
|
113
|
+
* cached (the in-flight promise is cleared) so a later call retries once the
|
|
114
|
+
* keychain unlocks.
|
|
115
|
+
*/
|
|
116
|
+
private static slotMigrationPromise;
|
|
117
|
+
private static slotMigrationResult;
|
|
42
118
|
/**
|
|
43
119
|
* Invalidate cached identity state
|
|
44
120
|
* Called internally when identity is created/deleted/imported
|
|
45
121
|
*/
|
|
46
122
|
private static invalidateCache;
|
|
123
|
+
/**
|
|
124
|
+
* Subscribe to identity-verdict changes (create / import / delete / restore /
|
|
125
|
+
* cache invalidation). Fires synchronously; the returned function unsubscribes.
|
|
126
|
+
* Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
|
|
127
|
+
* queries the instant the identity state moves, without polling.
|
|
128
|
+
*/
|
|
129
|
+
static subscribeIdentityChanged(listener: () => void): () => void;
|
|
130
|
+
/** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
|
|
131
|
+
private static notifyIdentityChanged;
|
|
132
|
+
/** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
|
|
133
|
+
private static _slotOpts;
|
|
134
|
+
/** Build private-key write options (device-only accessibility) for a given keychain service. */
|
|
135
|
+
private static _privateWriteOpts;
|
|
136
|
+
/** True only when both keys are present, well-formed, AND the public derives from the private. */
|
|
137
|
+
private static _isHealthyPair;
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
|
|
140
|
+
* `null` off-RN / when unavailable. Independent of the keychain, so the flag
|
|
141
|
+
* cannot be taken down by the keystore event this whole subsystem defends
|
|
142
|
+
* against.
|
|
143
|
+
*/
|
|
144
|
+
private static _advisoryStorage;
|
|
145
|
+
private static _readSlotsMigratedFlag;
|
|
146
|
+
private static _setSlotsMigratedFlag;
|
|
147
|
+
/**
|
|
148
|
+
* Ensure the identity has been migrated onto the isolated v2 slots (or that we
|
|
149
|
+
* know we must read legacy this session). Memoized so concurrent callers share
|
|
150
|
+
* ONE run; a `deferred` (read-threw) outcome is not cached so a later call
|
|
151
|
+
* retries after the keychain unlocks. Every identity-slot accessor awaits this
|
|
152
|
+
* before touching storage.
|
|
153
|
+
*/
|
|
154
|
+
private static _ensureIdentitySlotsMigrated;
|
|
155
|
+
/**
|
|
156
|
+
* One-shot slot migration state machine. All reads are DIRECT and a thrown
|
|
157
|
+
* read defers everything (zero writes/deletes) so a locked keychain is never
|
|
158
|
+
* mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
|
|
159
|
+
* previously-existing identity remains — legacy is deleted ONLY after the v2
|
|
160
|
+
* copy is verified re-readable in its new (non-aliasable) location.
|
|
161
|
+
*/
|
|
162
|
+
private static _runSlotMigration;
|
|
163
|
+
/**
|
|
164
|
+
* Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
|
|
165
|
+
* otherwise mirrors the (already-verified) v2 primary material so a v2 backup
|
|
166
|
+
* always exists on an independent keychain key. Best-effort — a failure just
|
|
167
|
+
* defers backup population to the next {@link _persistIdentityAtomic}.
|
|
168
|
+
*/
|
|
169
|
+
private static _migrateBackupSlotToV2;
|
|
170
|
+
/** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
|
|
171
|
+
private static _bestEffortDelete;
|
|
172
|
+
private static _bestEffortDeleteV2Primary;
|
|
173
|
+
private static _bestEffortDeleteLegacyPrimaryAndBackup;
|
|
174
|
+
private static _bestEffortDeleteBackupsAllGenerations;
|
|
175
|
+
/**
|
|
176
|
+
* Clear the cross-app shared identity slot (force-delete only) so a deleted
|
|
177
|
+
* identity cannot be resurrected via the recovery ladder's shared rung.
|
|
178
|
+
* Best-effort — the shared slot is a redundant convenience copy.
|
|
179
|
+
*/
|
|
180
|
+
private static _clearSharedSlot;
|
|
47
181
|
/**
|
|
48
182
|
* Invalidate cached shared identity state
|
|
49
183
|
* Called internally when shared identity is created/deleted/imported
|
|
@@ -186,6 +320,15 @@ export declare class KeyManager {
|
|
|
186
320
|
* @internal
|
|
187
321
|
*/
|
|
188
322
|
private static _persistIdentityAtomic;
|
|
323
|
+
/**
|
|
324
|
+
* Write/refresh the identity marker after a successful persist. A same-identity
|
|
325
|
+
* re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
|
|
326
|
+
* and the `onboardingComplete` milestone by only updating `origin`; a NEW or
|
|
327
|
+
* switched identity writes a fresh marker. Best-effort — never throws.
|
|
328
|
+
*
|
|
329
|
+
* @internal
|
|
330
|
+
*/
|
|
331
|
+
private static _syncMarkerAfterPersist;
|
|
189
332
|
/**
|
|
190
333
|
* Restore the backup slot to a previously-snapshotted state. Best-effort so
|
|
191
334
|
* the original persistence error remains the one surfaced to the caller.
|
|
@@ -215,6 +358,15 @@ export declare class KeyManager {
|
|
|
215
358
|
static createIdentity(options?: {
|
|
216
359
|
overwrite?: boolean;
|
|
217
360
|
}): Promise<string>;
|
|
361
|
+
/**
|
|
362
|
+
* Read the primary key pair DIRECTLY from storage, bypassing the in-memory
|
|
363
|
+
* cache (which a prior transient failure could have poisoned). Awaits slot
|
|
364
|
+
* migration first. Throws {@link IdentityUnavailableError} if storage is
|
|
365
|
+
* deferred/locked or a read throws — so overwrite guards never write blind.
|
|
366
|
+
*
|
|
367
|
+
* @internal
|
|
368
|
+
*/
|
|
369
|
+
private static _readPrimaryDirect;
|
|
218
370
|
/**
|
|
219
371
|
* Import an existing key pair (e.g., from recovery phrase).
|
|
220
372
|
*
|
|
@@ -229,10 +381,19 @@ export declare class KeyManager {
|
|
|
229
381
|
/**
|
|
230
382
|
* Get the stored private key
|
|
231
383
|
* WARNING: Only use this for signing operations within the app
|
|
384
|
+
*
|
|
385
|
+
* Preserves the "return null on any storage failure" contract signing paths
|
|
386
|
+
* rely on (a locked keychain simply means "cannot sign now"); unlike
|
|
387
|
+
* {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
|
|
232
388
|
*/
|
|
233
389
|
static getPrivateKey(): Promise<string | null>;
|
|
234
390
|
/**
|
|
235
|
-
* Get the stored public key (cached for performance)
|
|
391
|
+
* Get the stored public key (cached for performance).
|
|
392
|
+
*
|
|
393
|
+
* Returns the public key, or `null` when a read SUCCEEDS and finds none.
|
|
394
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
|
|
395
|
+
* locked / module load failure) — a thrown read is NEVER flattened to `null`
|
|
396
|
+
* and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
|
|
236
397
|
*/
|
|
237
398
|
static getPublicKey(): Promise<string | null>;
|
|
238
399
|
/**
|
|
@@ -240,13 +401,35 @@ export declare class KeyManager {
|
|
|
240
401
|
*
|
|
241
402
|
* Returns `true` only when BOTH the private and public keys are present,
|
|
242
403
|
* both are well-formed, AND the public key derives from the private key.
|
|
243
|
-
* A partially-written or corrupted identity
|
|
244
|
-
* downstream code can resume the create / restore flow
|
|
404
|
+
* A partially-written or corrupted identity (read succeeded, bytes empty/bad)
|
|
405
|
+
* returns `false` so that downstream code can resume the create / restore flow.
|
|
406
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
|
|
407
|
+
* keychain must never be mistaken for "no identity" (the old behavior that let
|
|
408
|
+
* onboarding treat a transient lock as a blank device).
|
|
245
409
|
*
|
|
246
410
|
* Note: this does NOT perform the full sign/verify roundtrip — call
|
|
247
411
|
* `verifyIdentityIntegrity()` for that.
|
|
248
412
|
*/
|
|
249
413
|
static hasIdentity(): Promise<boolean>;
|
|
414
|
+
/**
|
|
415
|
+
* Authoritative identity verdict — the corruption-vs-fresh-install
|
|
416
|
+
* disambiguator that routing (commons) keys off of.
|
|
417
|
+
*
|
|
418
|
+
* - Healthy pair → `present` (and the marker is backfilled if missing or
|
|
419
|
+
* pointing at a different key, `origin: 'backfill'`).
|
|
420
|
+
* - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
|
|
421
|
+
* / corruption; route to recovery, NEVER to create).
|
|
422
|
+
* - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
|
|
423
|
+
* only state that may route to onboarding/create).
|
|
424
|
+
* - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
|
|
425
|
+
* cached, so a later call re-reads.
|
|
426
|
+
*
|
|
427
|
+
* @param opts.bypassCache When true, never reads OR writes the in-memory cache
|
|
428
|
+
* — a pure, fresh storage verdict for the auto-create interlock preflight.
|
|
429
|
+
*/
|
|
430
|
+
static getIdentityStatus(opts?: {
|
|
431
|
+
bypassCache?: boolean;
|
|
432
|
+
}): Promise<IdentityStatus>;
|
|
250
433
|
/**
|
|
251
434
|
* Delete the stored identity (both keys)
|
|
252
435
|
* Use with EXTREME caution - this is irreversible without a recovery phrase
|
|
@@ -290,6 +473,32 @@ export declare class KeyManager {
|
|
|
290
473
|
* conflicting key material is present) do we rebuild it from the backup.
|
|
291
474
|
*/
|
|
292
475
|
static restoreIdentityFromBackup(): Promise<boolean>;
|
|
476
|
+
/**
|
|
477
|
+
* Recovery ladder — restore a `lost` identity from an independent,
|
|
478
|
+
* `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
|
|
479
|
+
*
|
|
480
|
+
* Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
|
|
481
|
+
* - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
|
|
482
|
+
* - `unavailable` → `unavailable` (keychain locked; retry later)
|
|
483
|
+
*
|
|
484
|
+
* Rungs, tried in order, each fully validated (well-formed + derive-match +
|
|
485
|
+
* `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
|
|
486
|
+
* SKIPPED, never restored):
|
|
487
|
+
* 1. the v2 backup slot (independent keychain key from the primary), then
|
|
488
|
+
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
489
|
+
* group) — the copy that survives a primary+backup `key_v1` death.
|
|
490
|
+
*
|
|
491
|
+
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
492
|
+
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
493
|
+
* rung matches, the UI proceeds to recovery-phrase entry.
|
|
494
|
+
*/
|
|
495
|
+
static attemptIdentityRecovery(): Promise<IdentityRecoveryResult>;
|
|
496
|
+
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
497
|
+
private static _readBackupCandidate;
|
|
498
|
+
/** Read the cross-app shared slot as a healthy candidate, or null. @internal */
|
|
499
|
+
private static _readSharedCandidate;
|
|
500
|
+
/** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
|
|
501
|
+
private static _commitRecovery;
|
|
293
502
|
/**
|
|
294
503
|
* Get the elliptic curve key object from the stored private key
|
|
295
504
|
* Used internally for signing operations
|
package/dist/types/index.d.ts
CHANGED
|
@@ -47,8 +47,10 @@ export { SessionSyncRequiredError, AuthenticationFailedError, ensureValidToken,
|
|
|
47
47
|
export type { HandleApiErrorOptions } from './utils/authHelpers';
|
|
48
48
|
export { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual, } from './utils/sessionUtils';
|
|
49
49
|
export type { ClientSession, StorageKeys, MinimalUserData, SessionLoginResponse, } from './models/session';
|
|
50
|
-
export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, } from './crypto/keyManager';
|
|
51
|
-
export type { KeyPair } from './crypto/keyManager';
|
|
50
|
+
export { KeyManager, IdentityAlreadyExistsError, IdentityPersistError, IdentityUnavailableError, } from './crypto/keyManager';
|
|
51
|
+
export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
|
|
52
|
+
export { readIdentityMarker, updateIdentityMarker, } from './crypto/identityMarker';
|
|
53
|
+
export type { IdentityMarker } from './crypto/identityMarker';
|
|
52
54
|
export { SignatureService } from './crypto/signatureService';
|
|
53
55
|
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
54
56
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
@@ -258,8 +258,15 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
258
258
|
* The client must sign this challenge with their private key
|
|
259
259
|
*
|
|
260
260
|
* @param publicKey - The user's public key
|
|
261
|
+
* @param requestOptions - Optional per-call transport overrides (`retry`,
|
|
262
|
+
* `timeout`). Interactive callers omit it (defaults keep retries); the
|
|
263
|
+
* cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
|
|
264
|
+
* network cannot multiply boot latency via the inner retry loop.
|
|
261
265
|
*/
|
|
262
|
-
requestChallenge(publicKey: string
|
|
266
|
+
requestChallenge(publicKey: string, requestOptions?: {
|
|
267
|
+
retry?: boolean;
|
|
268
|
+
timeout?: number;
|
|
269
|
+
}): Promise<ChallengeResponse>;
|
|
263
270
|
/**
|
|
264
271
|
* Verify a signed challenge and create a session
|
|
265
272
|
*
|
|
@@ -269,8 +276,15 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
269
276
|
* @param timestamp - Timestamp when the signature was created
|
|
270
277
|
* @param deviceName - Optional device name
|
|
271
278
|
* @param deviceFingerprint - Optional device fingerprint
|
|
279
|
+
* @param requestOptions - Optional per-call transport overrides (`retry`,
|
|
280
|
+
* `timeout`). Interactive callers omit it (defaults keep retries); the
|
|
281
|
+
* cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
|
|
282
|
+
* network cannot multiply boot latency via the inner retry loop.
|
|
272
283
|
*/
|
|
273
|
-
verifyChallenge(publicKey: string, challenge: string, signature: string, timestamp: number, deviceName?: string, deviceFingerprint?: string
|
|
284
|
+
verifyChallenge(publicKey: string, challenge: string, signature: string, timestamp: number, deviceName?: string, deviceFingerprint?: string, requestOptions?: {
|
|
285
|
+
retry?: boolean;
|
|
286
|
+
timeout?: number;
|
|
287
|
+
}): Promise<SessionLoginResponse>;
|
|
274
288
|
/**
|
|
275
289
|
* Check if a public key is already registered
|
|
276
290
|
*/
|
|
@@ -333,10 +347,21 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
333
347
|
*
|
|
334
348
|
* The cold-boot wiring that CALLS this lives in `OxyContext`
|
|
335
349
|
* (`@oxyhq/services`); this method just performs the exchange.
|
|
350
|
+
*
|
|
351
|
+
* @param opts.requestOptions - Optional per-call transport overrides
|
|
352
|
+
* (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
|
|
353
|
+
* `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
|
|
354
|
+
* retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
|
|
355
|
+
* so this network step cannot multiply boot latency via the inner retry
|
|
356
|
+
* loop. The token-refresh scheduler / 401 lane still retry later.
|
|
336
357
|
*/
|
|
337
358
|
signInWithSharedIdentity(opts?: {
|
|
338
359
|
deviceName?: string;
|
|
339
360
|
deviceFingerprint?: string;
|
|
361
|
+
requestOptions?: {
|
|
362
|
+
retry?: boolean;
|
|
363
|
+
timeout?: number;
|
|
364
|
+
};
|
|
340
365
|
}): Promise<SessionLoginResponse | null>;
|
|
341
366
|
/**
|
|
342
367
|
* MECHANISM B (relying party) — begin a "Sign in with Oxy" handoff.
|
|
@@ -30,6 +30,14 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
|
|
|
30
30
|
* `no_active_session`) to decide whether to drop the secret and fall back or
|
|
31
31
|
* resolve signed-out.
|
|
32
32
|
*
|
|
33
|
+
* `retry: false`: the mint is a single logical attempt. The proactive
|
|
34
|
+
* token-refresh scheduler and the reactive 401 lane already own backoff and
|
|
35
|
+
* re-arm, so `HttpService`'s inner retry loop here would only multiply the
|
|
36
|
+
* mint's latency on a slow/black-hole network (3 retries × 5s timeout ≈ 20s
|
|
37
|
+
* per lane) with no correctness benefit — it is the dominant term in the cold
|
|
38
|
+
* boot's worst-case time-to-route. A transient failure surfaces once and the
|
|
39
|
+
* scheduler/401 path retries it later.
|
|
40
|
+
*
|
|
33
41
|
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
34
42
|
*/
|
|
35
43
|
mintFromDeviceSecret(deviceId: string, deviceSecret: string): Promise<DeviceTokenMintResponse>;
|
|
@@ -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
|
|
@@ -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