@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.
Files changed (56) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/sessionColdBoot.js +16 -3
  3. package/dist/cjs/crypto/identityMarker.js +255 -0
  4. package/dist/cjs/crypto/keyManager.js +844 -106
  5. package/dist/cjs/index.js +8 -4
  6. package/dist/cjs/mixins/OxyServices.auth.js +21 -6
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
  8. package/dist/cjs/mixins/OxyServices.utility.js +11 -1
  9. package/dist/cjs/server/auth.js +3 -0
  10. package/dist/cjs/server/index.js +2 -1
  11. package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
  12. package/dist/esm/.tsbuildinfo +1 -1
  13. package/dist/esm/boot/sessionColdBoot.js +16 -3
  14. package/dist/esm/crypto/identityMarker.js +248 -0
  15. package/dist/esm/crypto/keyManager.js +843 -106
  16. package/dist/esm/index.js +2 -1
  17. package/dist/esm/mixins/OxyServices.auth.js +21 -6
  18. package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
  19. package/dist/esm/mixins/OxyServices.utility.js +11 -1
  20. package/dist/esm/server/auth.js +2 -0
  21. package/dist/esm/server/index.js +1 -1
  22. package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
  23. package/dist/types/.tsbuildinfo +1 -1
  24. package/dist/types/boot/sessionColdBoot.d.ts +25 -0
  25. package/dist/types/crypto/identityMarker.d.ts +94 -0
  26. package/dist/types/crypto/keyManager.d.ts +212 -3
  27. package/dist/types/index.d.ts +4 -2
  28. package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
  29. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
  30. package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
  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/boot/__tests__/sessionColdBoot.test.ts +113 -0
  36. package/src/boot/sessionColdBoot.ts +42 -3
  37. package/src/crypto/__tests__/identityMocks.ts +125 -0
  38. package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
  39. package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
  40. package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
  41. package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
  42. package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
  43. package/src/crypto/__tests__/keyManager.test.ts +77 -87
  44. package/src/crypto/identityMarker.ts +291 -0
  45. package/src/crypto/keyManager.ts +1026 -105
  46. package/src/index.ts +7 -1
  47. package/src/mixins/OxyServices.auth.ts +31 -7
  48. package/src/mixins/OxyServices.deviceBoot.ts +9 -1
  49. package/src/mixins/OxyServices.utility.ts +19 -1
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
  51. package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
  52. package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
  53. package/src/server/auth.ts +5 -0
  54. package/src/server/index.ts +2 -0
  55. package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
  56. 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
+ }