@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
|
@@ -35,6 +35,10 @@ const refresh_1 = require("../session/refresh");
|
|
|
35
35
|
async function runSessionColdBoot(opts) {
|
|
36
36
|
const { oxy, store } = opts;
|
|
37
37
|
const isNative = opts.platform?.isNative ?? (0, platform_1.isNative)();
|
|
38
|
+
// Best-effort connectivity gate for the NETWORK steps only. A missing hint or
|
|
39
|
+
// any non-`true` verdict means "assume online" — never falsely skip a real
|
|
40
|
+
// sign-in on an ambiguous probe.
|
|
41
|
+
const isOffline = () => opts.isOffline?.() ?? false;
|
|
38
42
|
// Boot-local (not module-level) so it cannot leak across boots or break under
|
|
39
43
|
// bundler re-evaluation.
|
|
40
44
|
let signedOutReason = 'no_session';
|
|
@@ -86,6 +90,9 @@ async function runSessionColdBoot(opts) {
|
|
|
86
90
|
// the durable store always converges on the true `current` secret.
|
|
87
91
|
steps.push({
|
|
88
92
|
id: 'device-secret-mint',
|
|
93
|
+
// Network step — skip entirely when the caller reports the device offline so
|
|
94
|
+
// a doomed mint cannot burn the overall deadline before routing settles.
|
|
95
|
+
enabled: () => !isOffline(),
|
|
89
96
|
run: async () => {
|
|
90
97
|
const result = await (0, refresh_1.refreshDeviceSecretArm)({ oxy, store });
|
|
91
98
|
switch (result.status) {
|
|
@@ -130,12 +137,16 @@ async function runSessionColdBoot(opts) {
|
|
|
130
137
|
}
|
|
131
138
|
},
|
|
132
139
|
});
|
|
133
|
-
// 3. shared-key-signin (native) — re-mint from the shared identity.
|
|
140
|
+
// 3. shared-key-signin (native) — re-mint from the shared identity. Native
|
|
141
|
+
// AND online: it is a network step (challenge + verify round-trips), so it
|
|
142
|
+
// is gated by the same offline hint as the mint lane. `{ retry: false }`
|
|
143
|
+
// keeps the two round-trips as single attempts — the refresh scheduler /
|
|
144
|
+
// 401 lane own later retries — so this step cannot multiply boot latency.
|
|
134
145
|
steps.push({
|
|
135
146
|
id: 'shared-key-signin',
|
|
136
|
-
enabled: () => isNative,
|
|
147
|
+
enabled: () => isNative && !isOffline(),
|
|
137
148
|
run: async () => {
|
|
138
|
-
const session = await oxy.signInWithSharedIdentity();
|
|
149
|
+
const session = await oxy.signInWithSharedIdentity({ requestOptions: { retry: false } });
|
|
139
150
|
if (!session?.accessToken) {
|
|
140
151
|
return { kind: 'skip' };
|
|
141
152
|
}
|
|
@@ -164,6 +175,8 @@ async function runSessionColdBoot(opts) {
|
|
|
164
175
|
});
|
|
165
176
|
const outcome = await (0, coldBoot_1.runColdBoot)({
|
|
166
177
|
steps,
|
|
178
|
+
overallDeadlineMs: opts.overallDeadlineMs,
|
|
179
|
+
onStepDeadline: opts.onStepDeadline,
|
|
167
180
|
onStepError: (id, error) => {
|
|
168
181
|
signedOutReason = 'error';
|
|
169
182
|
opts.onStepError?.(id, error);
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Identity marker — a NON-secret, AndroidKeyStore-independent record that an
|
|
4
|
+
* identity exists (or existed) on this device.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS: the identity private/public keys live in expo-secure-store,
|
|
7
|
+
* whose Android backing (a single `key_v1` AndroidKeyStore key by default) can be
|
|
8
|
+
* invalidated by an OS/vendor keystore event. When that happens SDK 57's
|
|
9
|
+
* expo-secure-store DELETES the undecryptable ciphertext on the read path and
|
|
10
|
+
* returns `null` — indistinguishable, from the keys alone, from a genuinely
|
|
11
|
+
* fresh install. That ambiguity is what lets a real identity get silently
|
|
12
|
+
* replaced by the onboarding "create" flow.
|
|
13
|
+
*
|
|
14
|
+
* The marker breaks the tie. It is written to AsyncStorage (RN) / localStorage
|
|
15
|
+
* (web) — storage that is NOT protected by the identity's AndroidKeyStore key —
|
|
16
|
+
* so it SURVIVES a keystore death. `getIdentityStatus()` reads it: keys empty +
|
|
17
|
+
* marker present ⇒ `lost` (route to recovery, NEVER welcome/create); keys empty
|
|
18
|
+
* + no marker ⇒ `absent` (the only path to fresh onboarding).
|
|
19
|
+
*
|
|
20
|
+
* It holds only the PUBLIC key plus provenance metadata — never any secret — so
|
|
21
|
+
* persisting it in plain KV storage adds no exposure.
|
|
22
|
+
*
|
|
23
|
+
* Every operation fails OPEN (returns null / false / resolves): the marker is a
|
|
24
|
+
* best-effort disambiguation signal layered on top of the authoritative
|
|
25
|
+
* secure-store reads, never a gate that can itself lock the user out.
|
|
26
|
+
*
|
|
27
|
+
* ESM-safe (no `require()`); zero React/RN static imports — the RN AsyncStorage
|
|
28
|
+
* module is reached only through `@oxyhq/protocol`'s per-platform dynamic loader.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.IDENTITY_MARKER_STORAGE_KEY = void 0;
|
|
32
|
+
exports.readIdentityMarker = readIdentityMarker;
|
|
33
|
+
exports.writeIdentityMarker = writeIdentityMarker;
|
|
34
|
+
exports.updateIdentityMarker = updateIdentityMarker;
|
|
35
|
+
exports.clearIdentityMarker = clearIdentityMarker;
|
|
36
|
+
const protocol_1 = require("@oxyhq/protocol");
|
|
37
|
+
const logger_1 = require("../logger");
|
|
38
|
+
const log = (0, logger_1.createLogger)('IdentityMarker');
|
|
39
|
+
/**
|
|
40
|
+
* AsyncStorage / localStorage key holding the serialized {@link IdentityMarker}.
|
|
41
|
+
* `.v1` lets a future shape change ship a `.v2` key without misreading a stale
|
|
42
|
+
* blob. Distinct from every `oxy_identity_*` secure-store key so it never
|
|
43
|
+
* collides with the keychain material it disambiguates.
|
|
44
|
+
*/
|
|
45
|
+
exports.IDENTITY_MARKER_STORAGE_KEY = 'oxy_identity_marker_v1';
|
|
46
|
+
/** RN detection identical to `DeviceManager` — chooses AsyncStorage vs localStorage. */
|
|
47
|
+
function isReactNative() {
|
|
48
|
+
return typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the platform KV store, or `null` when none is reachable (SSR, a
|
|
52
|
+
* sandboxed iframe whose `localStorage` getter throws, AsyncStorage not linked).
|
|
53
|
+
* A `null` store makes every marker operation a no-op that fails open.
|
|
54
|
+
*/
|
|
55
|
+
async function getStorage() {
|
|
56
|
+
try {
|
|
57
|
+
if (isReactNative()) {
|
|
58
|
+
// `loadAsyncStorage` is per-platform: the RN variant statically imports
|
|
59
|
+
// @react-native-async-storage/async-storage; the default variant throws
|
|
60
|
+
// (never reached here because of the `isReactNative()` gate).
|
|
61
|
+
const asyncStorageModule = await (0, protocol_1.loadAsyncStorage)();
|
|
62
|
+
const storage = asyncStorageModule.default;
|
|
63
|
+
return {
|
|
64
|
+
getItem: storage.getItem.bind(storage),
|
|
65
|
+
setItem: storage.setItem.bind(storage),
|
|
66
|
+
removeItem: storage.removeItem.bind(storage),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Web: read `localStorage` through a try — merely ACCESSING it can throw a
|
|
70
|
+
// `SecurityError` in a sandboxed/cross-origin iframe.
|
|
71
|
+
if (typeof globalThis !== 'undefined') {
|
|
72
|
+
const ls = globalThis.localStorage;
|
|
73
|
+
if (ls) {
|
|
74
|
+
return {
|
|
75
|
+
getItem: async (key) => ls.getItem(key),
|
|
76
|
+
setItem: async (key, value) => {
|
|
77
|
+
ls.setItem(key, value);
|
|
78
|
+
},
|
|
79
|
+
removeItem: async (key) => {
|
|
80
|
+
ls.removeItem(key);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
log.warn('Identity marker storage is unavailable', undefined, error);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse + shape-validate a stored blob. Returns `null` for anything that is not
|
|
94
|
+
* a well-formed {@link IdentityMarker} so a corrupt/foreign entry degrades to
|
|
95
|
+
* "no marker" rather than throwing.
|
|
96
|
+
*/
|
|
97
|
+
function deserialize(raw) {
|
|
98
|
+
if (!raw) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(raw);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const candidate = parsed;
|
|
112
|
+
if (candidate.v !== 1) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
if (typeof candidate.publicKey !== 'string' || candidate.publicKey.length === 0) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (typeof candidate.createdAt !== 'number' || !Number.isFinite(candidate.createdAt)) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const origin = candidate.origin;
|
|
122
|
+
if (origin !== 'create' && origin !== 'import' && origin !== 'restore' && origin !== 'backfill') {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const marker = {
|
|
126
|
+
v: 1,
|
|
127
|
+
publicKey: candidate.publicKey,
|
|
128
|
+
createdAt: candidate.createdAt,
|
|
129
|
+
origin,
|
|
130
|
+
};
|
|
131
|
+
if (typeof candidate.onboardingComplete === 'boolean') {
|
|
132
|
+
marker.onboardingComplete = candidate.onboardingComplete;
|
|
133
|
+
}
|
|
134
|
+
return marker;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Read the identity marker. Fails OPEN: returns `null` on any storage error,
|
|
138
|
+
* missing entry, or malformed blob — the caller treats "no marker" as the safe
|
|
139
|
+
* default (fresh install), and the authoritative secure-store read decides the
|
|
140
|
+
* rest.
|
|
141
|
+
*/
|
|
142
|
+
async function readIdentityMarker() {
|
|
143
|
+
const storage = await getStorage();
|
|
144
|
+
if (!storage) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
return deserialize(await storage.getItem(exports.IDENTITY_MARKER_STORAGE_KEY));
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
log.warn('Failed to read identity marker', undefined, error);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Write (create/replace) the marker. Returns `true` when it durably landed,
|
|
157
|
+
* `false` when storage was unavailable or the write threw. Callers treat a
|
|
158
|
+
* `false` as non-fatal — the marker is best-effort and a subsequent read
|
|
159
|
+
* re-backfills it from the healthy key pair.
|
|
160
|
+
*/
|
|
161
|
+
async function writeIdentityMarker(input) {
|
|
162
|
+
const storage = await getStorage();
|
|
163
|
+
if (!storage) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const marker = {
|
|
167
|
+
v: 1,
|
|
168
|
+
publicKey: input.publicKey,
|
|
169
|
+
createdAt: input.createdAt ?? Date.now(),
|
|
170
|
+
origin: input.origin,
|
|
171
|
+
};
|
|
172
|
+
if (typeof input.onboardingComplete === 'boolean') {
|
|
173
|
+
marker.onboardingComplete = input.onboardingComplete;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await storage.setItem(exports.IDENTITY_MARKER_STORAGE_KEY, JSON.stringify(marker));
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
log.warn('Failed to write identity marker', undefined, error);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Merge a partial update into the existing marker, preserving every field the
|
|
186
|
+
* caller does not override (notably `createdAt` and `onboardingComplete`). When
|
|
187
|
+
* no marker exists yet, a partial carrying at least `publicKey` + `origin`
|
|
188
|
+
* creates one; otherwise the update is a no-op returning `false`.
|
|
189
|
+
*
|
|
190
|
+
* Used to (a) mirror the onboarding milestone (`{ onboardingComplete: true }`)
|
|
191
|
+
* without disturbing provenance, and (b) refresh `origin` on a same-identity
|
|
192
|
+
* re-persist without resetting `createdAt`.
|
|
193
|
+
*/
|
|
194
|
+
async function updateIdentityMarker(partial) {
|
|
195
|
+
const storage = await getStorage();
|
|
196
|
+
if (!storage) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
let existing = null;
|
|
200
|
+
try {
|
|
201
|
+
existing = deserialize(await storage.getItem(exports.IDENTITY_MARKER_STORAGE_KEY));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
log.warn('Failed to read identity marker before update', undefined, error);
|
|
205
|
+
existing = null;
|
|
206
|
+
}
|
|
207
|
+
if (!existing) {
|
|
208
|
+
if (typeof partial.publicKey === 'string' && partial.publicKey.length > 0 && partial.origin) {
|
|
209
|
+
return writeIdentityMarker({
|
|
210
|
+
publicKey: partial.publicKey,
|
|
211
|
+
origin: partial.origin,
|
|
212
|
+
createdAt: partial.createdAt,
|
|
213
|
+
onboardingComplete: partial.onboardingComplete,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const nextOnboarding = partial.onboardingComplete !== undefined ? partial.onboardingComplete : existing.onboardingComplete;
|
|
219
|
+
const next = {
|
|
220
|
+
v: 1,
|
|
221
|
+
publicKey: partial.publicKey ?? existing.publicKey,
|
|
222
|
+
createdAt: partial.createdAt ?? existing.createdAt,
|
|
223
|
+
origin: partial.origin ?? existing.origin,
|
|
224
|
+
};
|
|
225
|
+
if (typeof nextOnboarding === 'boolean') {
|
|
226
|
+
next.onboardingComplete = nextOnboarding;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
await storage.setItem(exports.IDENTITY_MARKER_STORAGE_KEY, JSON.stringify(next));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
log.warn('Failed to update identity marker', undefined, error);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Remove the marker. Called ONLY after the identity's keys have been
|
|
239
|
+
* successfully deleted (`KeyManager.deleteIdentity`), so a marker never outlives
|
|
240
|
+
* the identity it records. Fails open (swallows errors) — a leftover marker
|
|
241
|
+
* simply routes a truly-absent identity to `recovery` instead of `welcome`,
|
|
242
|
+
* which is the safe direction.
|
|
243
|
+
*/
|
|
244
|
+
async function clearIdentityMarker() {
|
|
245
|
+
const storage = await getStorage();
|
|
246
|
+
if (!storage) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
await storage.removeItem(exports.IDENTITY_MARKER_STORAGE_KEY);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
log.warn('Failed to clear identity marker', undefined, error);
|
|
254
|
+
}
|
|
255
|
+
}
|