@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
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
import { loadAsyncStorage } from '@oxyhq/protocol';
|
|
31
|
+
import { createLogger } from '../logger';
|
|
32
|
+
|
|
33
|
+
const log = createLogger('IdentityMarker');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* AsyncStorage / localStorage key holding the serialized {@link IdentityMarker}.
|
|
37
|
+
* `.v1` lets a future shape change ship a `.v2` key without misreading a stale
|
|
38
|
+
* blob. Distinct from every `oxy_identity_*` secure-store key so it never
|
|
39
|
+
* collides with the keychain material it disambiguates.
|
|
40
|
+
*/
|
|
41
|
+
export const IDENTITY_MARKER_STORAGE_KEY = 'oxy_identity_marker_v1';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A durable, non-secret record that an identity was provisioned on this device.
|
|
45
|
+
*
|
|
46
|
+
* `publicKey` is the identity's public key (NOT secret) — it lets recovery
|
|
47
|
+
* validate that whatever it restores is the SAME account this marker records,
|
|
48
|
+
* never a silent account switch. `origin` records how the identity came to be.
|
|
49
|
+
* `onboardingComplete` mirrors the onboarding milestone (Workstream 3.4) so a
|
|
50
|
+
* lost SecureStore milestone flag cannot re-route a real identity into the
|
|
51
|
+
* onboarding wizard.
|
|
52
|
+
*/
|
|
53
|
+
export interface IdentityMarker {
|
|
54
|
+
v: 1;
|
|
55
|
+
/** The identity's PUBLIC key — never secret. */
|
|
56
|
+
publicKey: string;
|
|
57
|
+
createdAt: number;
|
|
58
|
+
origin: 'create' | 'import' | 'restore' | 'backfill';
|
|
59
|
+
/** Milestone mirror: `true` once onboarding has completed for this identity. */
|
|
60
|
+
onboardingComplete?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Minimal async KV surface the marker needs (AsyncStorage + localStorage both satisfy it). */
|
|
64
|
+
interface MarkerKeyValueStorage {
|
|
65
|
+
getItem(key: string): Promise<string | null>;
|
|
66
|
+
setItem(key: string, value: string): Promise<void>;
|
|
67
|
+
removeItem(key: string): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** RN detection identical to `DeviceManager` — chooses AsyncStorage vs localStorage. */
|
|
71
|
+
function isReactNative(): boolean {
|
|
72
|
+
return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the platform KV store, or `null` when none is reachable (SSR, a
|
|
77
|
+
* sandboxed iframe whose `localStorage` getter throws, AsyncStorage not linked).
|
|
78
|
+
* A `null` store makes every marker operation a no-op that fails open.
|
|
79
|
+
*/
|
|
80
|
+
async function getStorage(): Promise<MarkerKeyValueStorage | null> {
|
|
81
|
+
try {
|
|
82
|
+
if (isReactNative()) {
|
|
83
|
+
// `loadAsyncStorage` is per-platform: the RN variant statically imports
|
|
84
|
+
// @react-native-async-storage/async-storage; the default variant throws
|
|
85
|
+
// (never reached here because of the `isReactNative()` gate).
|
|
86
|
+
const asyncStorageModule = await loadAsyncStorage();
|
|
87
|
+
const storage = asyncStorageModule.default;
|
|
88
|
+
return {
|
|
89
|
+
getItem: storage.getItem.bind(storage),
|
|
90
|
+
setItem: storage.setItem.bind(storage),
|
|
91
|
+
removeItem: storage.removeItem.bind(storage),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Web: read `localStorage` through a try — merely ACCESSING it can throw a
|
|
95
|
+
// `SecurityError` in a sandboxed/cross-origin iframe.
|
|
96
|
+
if (typeof globalThis !== 'undefined') {
|
|
97
|
+
const ls = (globalThis as { localStorage?: Storage }).localStorage;
|
|
98
|
+
if (ls) {
|
|
99
|
+
return {
|
|
100
|
+
getItem: async (key: string) => ls.getItem(key),
|
|
101
|
+
setItem: async (key: string, value: string) => {
|
|
102
|
+
ls.setItem(key, value);
|
|
103
|
+
},
|
|
104
|
+
removeItem: async (key: string) => {
|
|
105
|
+
ls.removeItem(key);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
log.warn('Identity marker storage is unavailable', undefined, error);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse + shape-validate a stored blob. Returns `null` for anything that is not
|
|
119
|
+
* a well-formed {@link IdentityMarker} so a corrupt/foreign entry degrades to
|
|
120
|
+
* "no marker" rather than throwing.
|
|
121
|
+
*/
|
|
122
|
+
function deserialize(raw: string | null): IdentityMarker | null {
|
|
123
|
+
if (!raw) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
let parsed: unknown;
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const candidate = parsed as Record<string, unknown>;
|
|
136
|
+
if (candidate.v !== 1) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (typeof candidate.publicKey !== 'string' || candidate.publicKey.length === 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (typeof candidate.createdAt !== 'number' || !Number.isFinite(candidate.createdAt)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const origin = candidate.origin;
|
|
146
|
+
if (origin !== 'create' && origin !== 'import' && origin !== 'restore' && origin !== 'backfill') {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const marker: IdentityMarker = {
|
|
150
|
+
v: 1,
|
|
151
|
+
publicKey: candidate.publicKey,
|
|
152
|
+
createdAt: candidate.createdAt,
|
|
153
|
+
origin,
|
|
154
|
+
};
|
|
155
|
+
if (typeof candidate.onboardingComplete === 'boolean') {
|
|
156
|
+
marker.onboardingComplete = candidate.onboardingComplete;
|
|
157
|
+
}
|
|
158
|
+
return marker;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Fields accepted when creating a marker; `createdAt` defaults to now. */
|
|
162
|
+
export interface WriteIdentityMarkerInput {
|
|
163
|
+
publicKey: string;
|
|
164
|
+
origin: IdentityMarker['origin'];
|
|
165
|
+
createdAt?: number;
|
|
166
|
+
onboardingComplete?: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Read the identity marker. Fails OPEN: returns `null` on any storage error,
|
|
171
|
+
* missing entry, or malformed blob — the caller treats "no marker" as the safe
|
|
172
|
+
* default (fresh install), and the authoritative secure-store read decides the
|
|
173
|
+
* rest.
|
|
174
|
+
*/
|
|
175
|
+
export async function readIdentityMarker(): Promise<IdentityMarker | null> {
|
|
176
|
+
const storage = await getStorage();
|
|
177
|
+
if (!storage) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
return deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
log.warn('Failed to read identity marker', undefined, error);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
190
|
+
* `false` when storage was unavailable or the write threw. Callers treat a
|
|
191
|
+
* `false` as non-fatal — the marker is best-effort and a subsequent read
|
|
192
|
+
* re-backfills it from the healthy key pair.
|
|
193
|
+
*/
|
|
194
|
+
export async function writeIdentityMarker(input: WriteIdentityMarkerInput): Promise<boolean> {
|
|
195
|
+
const storage = await getStorage();
|
|
196
|
+
if (!storage) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const marker: IdentityMarker = {
|
|
200
|
+
v: 1,
|
|
201
|
+
publicKey: input.publicKey,
|
|
202
|
+
createdAt: input.createdAt ?? Date.now(),
|
|
203
|
+
origin: input.origin,
|
|
204
|
+
};
|
|
205
|
+
if (typeof input.onboardingComplete === 'boolean') {
|
|
206
|
+
marker.onboardingComplete = input.onboardingComplete;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
await storage.setItem(IDENTITY_MARKER_STORAGE_KEY, JSON.stringify(marker));
|
|
210
|
+
return true;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
log.warn('Failed to write identity marker', undefined, error);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge a partial update into the existing marker, preserving every field the
|
|
219
|
+
* caller does not override (notably `createdAt` and `onboardingComplete`). When
|
|
220
|
+
* no marker exists yet, a partial carrying at least `publicKey` + `origin`
|
|
221
|
+
* creates one; otherwise the update is a no-op returning `false`.
|
|
222
|
+
*
|
|
223
|
+
* Used to (a) mirror the onboarding milestone (`{ onboardingComplete: true }`)
|
|
224
|
+
* without disturbing provenance, and (b) refresh `origin` on a same-identity
|
|
225
|
+
* re-persist without resetting `createdAt`.
|
|
226
|
+
*/
|
|
227
|
+
export async function updateIdentityMarker(
|
|
228
|
+
partial: Partial<Omit<IdentityMarker, 'v'>>,
|
|
229
|
+
): Promise<boolean> {
|
|
230
|
+
const storage = await getStorage();
|
|
231
|
+
if (!storage) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
let existing: IdentityMarker | null = null;
|
|
235
|
+
try {
|
|
236
|
+
existing = deserialize(await storage.getItem(IDENTITY_MARKER_STORAGE_KEY));
|
|
237
|
+
} catch (error) {
|
|
238
|
+
log.warn('Failed to read identity marker before update', undefined, error);
|
|
239
|
+
existing = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!existing) {
|
|
243
|
+
if (typeof partial.publicKey === 'string' && partial.publicKey.length > 0 && partial.origin) {
|
|
244
|
+
return writeIdentityMarker({
|
|
245
|
+
publicKey: partial.publicKey,
|
|
246
|
+
origin: partial.origin,
|
|
247
|
+
createdAt: partial.createdAt,
|
|
248
|
+
onboardingComplete: partial.onboardingComplete,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const nextOnboarding =
|
|
255
|
+
partial.onboardingComplete !== undefined ? partial.onboardingComplete : existing.onboardingComplete;
|
|
256
|
+
const next: IdentityMarker = {
|
|
257
|
+
v: 1,
|
|
258
|
+
publicKey: partial.publicKey ?? existing.publicKey,
|
|
259
|
+
createdAt: partial.createdAt ?? existing.createdAt,
|
|
260
|
+
origin: partial.origin ?? existing.origin,
|
|
261
|
+
};
|
|
262
|
+
if (typeof nextOnboarding === 'boolean') {
|
|
263
|
+
next.onboardingComplete = nextOnboarding;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
await storage.setItem(IDENTITY_MARKER_STORAGE_KEY, JSON.stringify(next));
|
|
267
|
+
return true;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
log.warn('Failed to update identity marker', undefined, error);
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Remove the marker. Called ONLY after the identity's keys have been
|
|
276
|
+
* successfully deleted (`KeyManager.deleteIdentity`), so a marker never outlives
|
|
277
|
+
* the identity it records. Fails open (swallows errors) — a leftover marker
|
|
278
|
+
* simply routes a truly-absent identity to `recovery` instead of `welcome`,
|
|
279
|
+
* which is the safe direction.
|
|
280
|
+
*/
|
|
281
|
+
export async function clearIdentityMarker(): Promise<void> {
|
|
282
|
+
const storage = await getStorage();
|
|
283
|
+
if (!storage) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
await storage.removeItem(IDENTITY_MARKER_STORAGE_KEY);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
log.warn('Failed to clear identity marker', undefined, error);
|
|
290
|
+
}
|
|
291
|
+
}
|