@necessarysdk/react-native 0.0.0-stage → 0.1.5
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/CHANGELOG.md +22 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1152 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +323 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +314 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +762 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +960 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +353 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +690 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +140 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +842 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +174 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
package/src/stepUpKey.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The step-up key (the public guide, section 3): a second key that the person must unlock, the
|
|
3
|
+
* options it is made with, and its re-enrolment proof.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NativeKeyException } from './errors';
|
|
7
|
+
import { base64UrlNoPadding, utf8Encode } from './util/bytes';
|
|
8
|
+
import { isObject, nativeFailure, stepUpKeyModule } from './util/native';
|
|
9
|
+
import { jwkThumbprint, orderedJwk, type PublicJwk } from './installationKey';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The optional step-up key as the native side reports it. [factor], [mode] and [windowSeconds]
|
|
13
|
+
* describe what the platform keystore actually enforces (read back from the key itself), which
|
|
14
|
+
* on Android 9 and 10 is a short hardware window rather than per-use.
|
|
15
|
+
*/
|
|
16
|
+
export class StepUpKeyMetadata {
|
|
17
|
+
readonly publicKey: PublicJwk;
|
|
18
|
+
readonly factor: string;
|
|
19
|
+
readonly mode: string;
|
|
20
|
+
readonly windowSeconds: number;
|
|
21
|
+
readonly securityLevel: string;
|
|
22
|
+
readonly hardwareBacked: boolean;
|
|
23
|
+
readonly created: boolean;
|
|
24
|
+
|
|
25
|
+
constructor(value: {
|
|
26
|
+
publicKey: PublicJwk;
|
|
27
|
+
factor: string;
|
|
28
|
+
mode: string;
|
|
29
|
+
windowSeconds: number;
|
|
30
|
+
securityLevel: string;
|
|
31
|
+
hardwareBacked: boolean;
|
|
32
|
+
created: boolean;
|
|
33
|
+
}) {
|
|
34
|
+
this.publicKey = value.publicKey;
|
|
35
|
+
this.factor = value.factor;
|
|
36
|
+
this.mode = value.mode;
|
|
37
|
+
this.windowSeconds = value.windowSeconds;
|
|
38
|
+
this.securityLevel = value.securityLevel;
|
|
39
|
+
this.hardwareBacked = value.hardwareBacked;
|
|
40
|
+
this.created = value.created;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get authJson(): Record<string, unknown> {
|
|
44
|
+
return { factor: this.factor, mode: this.mode, window_seconds: this.windowSeconds };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get description(): string {
|
|
48
|
+
return this.mode === 'per_use'
|
|
49
|
+
? `${this.factor}, per-use`
|
|
50
|
+
: `${this.factor}, ${this.windowSeconds}s hardware window`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** RFC 7638 thumbprint, the same form the server derives from the JWK. */
|
|
54
|
+
get thumbprint(): string {
|
|
55
|
+
return jwkThumbprint(this.publicKey);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static fromPlatform(value: Record<string, unknown>): StepUpKeyMetadata {
|
|
59
|
+
const rawPublicKey = value['public_key'];
|
|
60
|
+
const rawAuth = value['auth'];
|
|
61
|
+
if (!isObject(rawPublicKey) || !isObject(rawAuth)) {
|
|
62
|
+
throw new TypeError('The native step-up key provider returned no public key or auth.');
|
|
63
|
+
}
|
|
64
|
+
const publicKey = orderedJwk(rawPublicKey);
|
|
65
|
+
if (
|
|
66
|
+
publicKey['kty'] !== 'EC' ||
|
|
67
|
+
publicKey['crv'] !== 'P-256' ||
|
|
68
|
+
publicKey['alg'] !== 'ES256' ||
|
|
69
|
+
typeof publicKey['x'] !== 'string' ||
|
|
70
|
+
typeof publicKey['y'] !== 'string'
|
|
71
|
+
) {
|
|
72
|
+
throw new TypeError('The native step-up key provider did not return an EC P-256 public JWK.');
|
|
73
|
+
}
|
|
74
|
+
const factor = rawAuth['factor'];
|
|
75
|
+
const mode = rawAuth['mode'];
|
|
76
|
+
const window = rawAuth['window_seconds'];
|
|
77
|
+
if (typeof factor !== 'string' || typeof mode !== 'string' || typeof window !== 'number' || !Number.isInteger(window)) {
|
|
78
|
+
throw new TypeError('The native step-up key provider returned an invalid auth description.');
|
|
79
|
+
}
|
|
80
|
+
const securityLevel = value['security_level'];
|
|
81
|
+
return new StepUpKeyMetadata({
|
|
82
|
+
publicKey,
|
|
83
|
+
factor,
|
|
84
|
+
mode,
|
|
85
|
+
windowSeconds: window,
|
|
86
|
+
securityLevel: typeof securityLevel === 'string' ? securityLevel : 'unknown',
|
|
87
|
+
hardwareBacked: value['hardware_backed'] === true,
|
|
88
|
+
created: value['created'] === true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Where a backend takes a re-enrolled step-up key by default (the public guide, section 4). */
|
|
94
|
+
export const stepUpReenrolPath = '/v1/installations/stepup-key';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How the app wants its step-up key made (the public guide, section 3): [factor] `passcode` or
|
|
98
|
+
* `biometric`; [mode] `per_use`, or `windowed` with [windowSeconds] from 1. Android 9 and 10 cannot
|
|
99
|
+
* make a per-use passcode key and make a windowed one; iOS makes per-use keys only. [reenrolPath]
|
|
100
|
+
* is where the backend takes a new key (DeviceTrust.reenrolStepUp).
|
|
101
|
+
*/
|
|
102
|
+
export class StepUpOptions {
|
|
103
|
+
readonly factor: string;
|
|
104
|
+
readonly mode: string;
|
|
105
|
+
readonly windowSeconds: number;
|
|
106
|
+
readonly reenrolPath: string;
|
|
107
|
+
|
|
108
|
+
constructor(options: { factor?: string; mode?: string; windowSeconds?: number; reenrolPath?: string } = {}) {
|
|
109
|
+
this.factor = options.factor ?? 'passcode';
|
|
110
|
+
this.mode = options.mode ?? 'per_use';
|
|
111
|
+
this.windowSeconds = options.windowSeconds ?? 0;
|
|
112
|
+
this.reenrolPath = options.reenrolPath ?? stepUpReenrolPath;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A key made with other values, or already made: whether it satisfies these options -- the same
|
|
117
|
+
* factor (a backend refuses any other), and a per-use key or a window no longer than this one. A
|
|
118
|
+
* per-use key satisfies windowed options.
|
|
119
|
+
*/
|
|
120
|
+
accepts(key: StepUpKeyMetadata): boolean {
|
|
121
|
+
return (
|
|
122
|
+
key.factor === this.factor &&
|
|
123
|
+
(key.mode === 'per_use' || (this.mode === 'windowed' && key.windowSeconds <= this.windowSeconds))
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get description(): string {
|
|
128
|
+
return `${this.factor}, ${this.mode === 'per_use' ? 'per-use' : `${this.windowSeconds}s window`}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The proof a new step-up key signs at re-enrolment: the base64url of its JSON, the members in this
|
|
134
|
+
* order. [nonce] is the request proof's nonce of the re-enrolment request.
|
|
135
|
+
*/
|
|
136
|
+
export function stepUpReenrolProof(args: {
|
|
137
|
+
installationId: string;
|
|
138
|
+
stepUpKeyThumbprint: string;
|
|
139
|
+
nonce: string;
|
|
140
|
+
timestamp: number;
|
|
141
|
+
}): string {
|
|
142
|
+
return base64UrlNoPadding(
|
|
143
|
+
utf8Encode(
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
version: 1,
|
|
146
|
+
purpose: 'stepup_reenrol',
|
|
147
|
+
installation_id: args.installationId,
|
|
148
|
+
stepup_key_thumbprint: args.stepUpKeyThumbprint,
|
|
149
|
+
nonce: args.nonce,
|
|
150
|
+
timestamp: args.timestamp,
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether a step-up key [options] do not accept is replaced before the next registration: only
|
|
158
|
+
* where the new key can then be bound -- by a re-enrolment ([replaceMismatched]), which binds it
|
|
159
|
+
* with the person's re-authentication, or by this installation's first registration, which binds
|
|
160
|
+
* the key it is offered. A known installation's registration never binds an offered key; it only
|
|
161
|
+
* says whether it matches, so replacing the key there orphans the one the server holds: on a phone
|
|
162
|
+
* that cannot make the key the options ask for (Android 9 makes no per-use passcode key), the
|
|
163
|
+
* registration that follows every re-enrolment would replace the key the server had just bound,
|
|
164
|
+
* and step-up would stay unavailable.
|
|
165
|
+
*/
|
|
166
|
+
export function replacesStepUpKey(
|
|
167
|
+
key: StepUpKeyMetadata,
|
|
168
|
+
options: StepUpOptions,
|
|
169
|
+
flags: { replaceMismatched: boolean; knownInstallation: boolean },
|
|
170
|
+
): boolean {
|
|
171
|
+
return !key.created && !options.accepts(key) && (flags.replaceMismatched || !flags.knownInstallation);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const STEPUP_MODULE_MISSING = (): NativeKeyException =>
|
|
175
|
+
new NativeKeyException(
|
|
176
|
+
'NATIVE_STEPUP_PLUGIN_MISSING',
|
|
177
|
+
"The SDK's step-up key module is not linked into this app: rebuild the app.",
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
export class NativeStepUpKey {
|
|
181
|
+
/** The mode the app asks for, which decides whether a windowed key first tries to sign inside an open hardware window. */
|
|
182
|
+
configuredMode: string;
|
|
183
|
+
|
|
184
|
+
constructor(options: { configuredMode?: string } = {}) {
|
|
185
|
+
this.configuredMode = options.configuredMode ?? 'per_use';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Returns the existing key as the keystore enforces it, or creates one with these values: an
|
|
190
|
+
* existing key is never changed, so a caller that needs other values deletes it first.
|
|
191
|
+
*/
|
|
192
|
+
async getOrCreateKey(args: { factor: string; mode: string; windowSeconds: number }): Promise<StepUpKeyMetadata> {
|
|
193
|
+
const native = stepUpKeyModule();
|
|
194
|
+
if (native === null) {
|
|
195
|
+
throw STEPUP_MODULE_MISSING();
|
|
196
|
+
}
|
|
197
|
+
let value: unknown;
|
|
198
|
+
try {
|
|
199
|
+
value = await native.getOrCreateKey(args.factor, args.mode, args.windowSeconds);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw nativeFailure(error, 'The native step-up key provider failed.');
|
|
202
|
+
}
|
|
203
|
+
if (!isObject(value)) {
|
|
204
|
+
throw new NativeKeyException('EMPTY_NATIVE_RESPONSE', 'The native step-up key provider returned no key metadata.');
|
|
205
|
+
}
|
|
206
|
+
return StepUpKeyMetadata.fromPlatform(value);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Shows the system passcode (or biometric) prompt and completes when the person answers it. A cancelled prompt is STEPUP_CANCELLED. */
|
|
210
|
+
async sign(payloadBase64Url: string, options: { reason: string }): Promise<string> {
|
|
211
|
+
const native = stepUpKeyModule();
|
|
212
|
+
if (native === null) {
|
|
213
|
+
throw STEPUP_MODULE_MISSING();
|
|
214
|
+
}
|
|
215
|
+
let signature: unknown;
|
|
216
|
+
try {
|
|
217
|
+
signature = await native.sign(payloadBase64Url, options.reason, this.configuredMode);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw nativeFailure(error, 'The step-up key could not sign.');
|
|
220
|
+
}
|
|
221
|
+
if (typeof signature !== 'string' || signature.length === 0) {
|
|
222
|
+
throw new NativeKeyException('EMPTY_NATIVE_SIGNATURE', 'The step-up key returned no signature.');
|
|
223
|
+
}
|
|
224
|
+
return signature;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async deleteKey(): Promise<void> {
|
|
228
|
+
const native = stepUpKeyModule();
|
|
229
|
+
if (native === null) {
|
|
230
|
+
throw STEPUP_MODULE_MISSING();
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await native.deleteKey();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
throw nativeFailure(error, 'The step-up key could not be deleted.');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the SDK's messages reach your backend: by default over HTTPS, under the base URL your app
|
|
3
|
+
* sets (the public guide, section 4).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ApiException } from './errors';
|
|
7
|
+
import { isReleaseBuild } from './util/release';
|
|
8
|
+
import { normalizedTarget } from './util/target';
|
|
9
|
+
|
|
10
|
+
export type JsonObject = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
export interface SendOptions {
|
|
13
|
+
/** Sent exactly as given: it is what a request proof hashes. A GET sends no body. */
|
|
14
|
+
encodedBody?: string | null;
|
|
15
|
+
bearerToken?: string | null;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sends one request to your backend and answers its JSON object. A refusal is thrown as an
|
|
21
|
+
* [ApiException] with the backend's `code`, `message` and `details` (the public guide, section 4,
|
|
22
|
+
* Errors).
|
|
23
|
+
*/
|
|
24
|
+
export interface DeviceTrustTransport {
|
|
25
|
+
send(method: string, path: string, options?: SendOptions): Promise<JsonObject>;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isObject(value: unknown): value is JsonObject {
|
|
30
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* JSON over HTTPS under [baseUrl]. A release build refuses any other scheme, so a mistyped
|
|
35
|
+
* endpoint fails loudly instead of sending tokens in the clear.
|
|
36
|
+
*/
|
|
37
|
+
export class HttpTransport implements DeviceTrustTransport {
|
|
38
|
+
readonly baseUrl: string;
|
|
39
|
+
/** How long one call may take, in milliseconds. */
|
|
40
|
+
readonly timeoutMs: number;
|
|
41
|
+
private readonly fetchImpl: typeof fetch;
|
|
42
|
+
private readonly running = new Set<AbortController>();
|
|
43
|
+
private closed = false;
|
|
44
|
+
|
|
45
|
+
constructor(baseUrl: string, options: { timeoutMs?: number; fetch?: typeof fetch } = {}) {
|
|
46
|
+
this.baseUrl = baseUrl;
|
|
47
|
+
this.timeoutMs = options.timeoutMs ?? 15000;
|
|
48
|
+
this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private get base(): string {
|
|
52
|
+
if (this.baseUrl.length === 0) {
|
|
53
|
+
throw new ApiException('No API base URL was set for the backend.', { code: 'api_base_url_missing' });
|
|
54
|
+
}
|
|
55
|
+
if (isReleaseBuild() && !this.baseUrl.toLowerCase().startsWith('https://')) {
|
|
56
|
+
throw new ApiException('The API base URL must be an https:// URL in a release build.', {
|
|
57
|
+
code: 'api_base_url_insecure',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return this.baseUrl.endsWith('/') ? this.baseUrl.substring(0, this.baseUrl.length - 1) : this.baseUrl;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async send(method: string, path: string, options: SendOptions = {}): Promise<JsonObject> {
|
|
64
|
+
const url = `${this.base}${normalizedTarget(path)}`;
|
|
65
|
+
if (this.closed) {
|
|
66
|
+
throw new ApiException('HTTP connection failed: the transport is closed.');
|
|
67
|
+
}
|
|
68
|
+
const headers: Record<string, string> = {
|
|
69
|
+
Accept: 'application/json',
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
...(options.bearerToken != null ? { Authorization: `Bearer ${options.bearerToken}` } : {}),
|
|
72
|
+
...(options.headers ?? {}),
|
|
73
|
+
};
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
this.running.add(controller);
|
|
76
|
+
let timedOut = false;
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
timedOut = true;
|
|
79
|
+
controller.abort();
|
|
80
|
+
}, this.timeoutMs);
|
|
81
|
+
let status: number;
|
|
82
|
+
let text: string;
|
|
83
|
+
try {
|
|
84
|
+
const response = await this.fetchImpl(url, {
|
|
85
|
+
method,
|
|
86
|
+
headers,
|
|
87
|
+
body: method === 'GET' ? undefined : options.encodedBody ?? '{}',
|
|
88
|
+
signal: controller.signal,
|
|
89
|
+
});
|
|
90
|
+
status = response.status;
|
|
91
|
+
text = await response.text();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (timedOut) {
|
|
94
|
+
throw new ApiException('The server did not respond before the timeout.');
|
|
95
|
+
}
|
|
96
|
+
if (this.closed) {
|
|
97
|
+
throw new ApiException('HTTP connection failed: the transport is closed.');
|
|
98
|
+
}
|
|
99
|
+
const words = error instanceof Error ? error.message : String(error);
|
|
100
|
+
throw new ApiException(`Network connection failed: ${words}`);
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
this.running.delete(controller);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let decoded: JsonObject = {};
|
|
107
|
+
if (text.trim().length > 0) {
|
|
108
|
+
let parsed: unknown;
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
parsed = undefined;
|
|
113
|
+
}
|
|
114
|
+
if (!isObject(parsed)) {
|
|
115
|
+
throw new ApiException('The server returned a non-JSON response.', { statusCode: status });
|
|
116
|
+
}
|
|
117
|
+
decoded = parsed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (status < 200 || status >= 300) {
|
|
121
|
+
const error = decoded['error'];
|
|
122
|
+
const refusal = isObject(error) ? error : null;
|
|
123
|
+
const details = refusal?.['details'];
|
|
124
|
+
const message = refusal?.['message'];
|
|
125
|
+
const code = refusal?.['code'];
|
|
126
|
+
throw new ApiException(
|
|
127
|
+
typeof message === 'string' ? message : `The server rejected the request (${status}).`,
|
|
128
|
+
{
|
|
129
|
+
statusCode: status,
|
|
130
|
+
code: typeof code === 'string' ? code : undefined,
|
|
131
|
+
details: isObject(details) ? details : undefined,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return decoded;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
close(): void {
|
|
139
|
+
this.closed = true;
|
|
140
|
+
for (const controller of this.running) {
|
|
141
|
+
controller.abort();
|
|
142
|
+
}
|
|
143
|
+
this.running.clear();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bytes, the same on the phone and in Node: UTF-8, base64url without padding and SHA-256, written
|
|
3
|
+
* here so that nothing depends on what a JavaScript engine happens to provide.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The UTF-8 bytes of [text]; a lone surrogate becomes U+FFFD, as every UTF-8 encoder writes it. */
|
|
7
|
+
export function utf8Encode(text: string): Uint8Array {
|
|
8
|
+
const out: number[] = [];
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
let code = text.charCodeAt(i);
|
|
11
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {
|
|
12
|
+
const next = text.charCodeAt(i + 1);
|
|
13
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
14
|
+
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
|
|
15
|
+
i++;
|
|
16
|
+
} else {
|
|
17
|
+
code = 0xfffd;
|
|
18
|
+
}
|
|
19
|
+
} else if (code >= 0xd800 && code <= 0xdfff) {
|
|
20
|
+
code = 0xfffd;
|
|
21
|
+
}
|
|
22
|
+
if (code < 0x80) {
|
|
23
|
+
out.push(code);
|
|
24
|
+
} else if (code < 0x800) {
|
|
25
|
+
out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
|
26
|
+
} else if (code < 0x10000) {
|
|
27
|
+
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
|
28
|
+
} else {
|
|
29
|
+
out.push(
|
|
30
|
+
0xf0 | (code >> 18),
|
|
31
|
+
0x80 | ((code >> 12) & 0x3f),
|
|
32
|
+
0x80 | ((code >> 6) & 0x3f),
|
|
33
|
+
0x80 | (code & 0x3f),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return Uint8Array.from(out);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The text of UTF-8 [bytes]; a malformed sequence becomes U+FFFD. */
|
|
41
|
+
export function utf8Decode(bytes: Uint8Array): string {
|
|
42
|
+
let out = '';
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < bytes.length) {
|
|
45
|
+
const b0 = bytes[i];
|
|
46
|
+
let code = 0xfffd;
|
|
47
|
+
let length = 1;
|
|
48
|
+
if (b0 < 0x80) {
|
|
49
|
+
code = b0;
|
|
50
|
+
} else if (b0 >= 0xc2 && b0 < 0xe0 && i + 1 < bytes.length && (bytes[i + 1] & 0xc0) === 0x80) {
|
|
51
|
+
code = ((b0 & 0x1f) << 6) | (bytes[i + 1] & 0x3f);
|
|
52
|
+
length = 2;
|
|
53
|
+
} else if (
|
|
54
|
+
b0 >= 0xe0 && b0 < 0xf0 && i + 2 < bytes.length &&
|
|
55
|
+
(bytes[i + 1] & 0xc0) === 0x80 && (bytes[i + 2] & 0xc0) === 0x80
|
|
56
|
+
) {
|
|
57
|
+
const value = ((b0 & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f);
|
|
58
|
+
if (value >= 0x800 && (value < 0xd800 || value > 0xdfff)) {
|
|
59
|
+
code = value;
|
|
60
|
+
length = 3;
|
|
61
|
+
}
|
|
62
|
+
} else if (
|
|
63
|
+
b0 >= 0xf0 && b0 < 0xf5 && i + 3 < bytes.length &&
|
|
64
|
+
(bytes[i + 1] & 0xc0) === 0x80 && (bytes[i + 2] & 0xc0) === 0x80 && (bytes[i + 3] & 0xc0) === 0x80
|
|
65
|
+
) {
|
|
66
|
+
const value =
|
|
67
|
+
((b0 & 0x07) << 18) | ((bytes[i + 1] & 0x3f) << 12) | ((bytes[i + 2] & 0x3f) << 6) | (bytes[i + 3] & 0x3f);
|
|
68
|
+
if (value >= 0x10000 && value <= 0x10ffff) {
|
|
69
|
+
code = value;
|
|
70
|
+
length = 4;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (code > 0xffff) {
|
|
74
|
+
code -= 0x10000;
|
|
75
|
+
out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff));
|
|
76
|
+
} else {
|
|
77
|
+
out += String.fromCharCode(code);
|
|
78
|
+
}
|
|
79
|
+
i += length;
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
85
|
+
|
|
86
|
+
/** Base64url without padding: how every binary value travels. */
|
|
87
|
+
export function base64UrlNoPadding(bytes: Uint8Array | readonly number[]): string {
|
|
88
|
+
let out = '';
|
|
89
|
+
let i = 0;
|
|
90
|
+
for (; i + 2 < bytes.length; i += 3) {
|
|
91
|
+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
|
92
|
+
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
|
|
93
|
+
}
|
|
94
|
+
const rest = bytes.length - i;
|
|
95
|
+
if (rest === 1) {
|
|
96
|
+
const n = bytes[i] << 16;
|
|
97
|
+
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63];
|
|
98
|
+
} else if (rest === 2) {
|
|
99
|
+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
|
|
100
|
+
out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63];
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The bytes of base64url [text], with or without padding; throws on any other character. */
|
|
106
|
+
export function base64UrlDecode(text: string): Uint8Array {
|
|
107
|
+
const clean = text.replace(/=+$/, '');
|
|
108
|
+
const out: number[] = [];
|
|
109
|
+
let buffer = 0;
|
|
110
|
+
let bits = 0;
|
|
111
|
+
for (const char of clean) {
|
|
112
|
+
const value = ALPHABET.indexOf(char === '+' ? '-' : char === '/' ? '_' : char);
|
|
113
|
+
if (value < 0) {
|
|
114
|
+
throw new TypeError(`Not base64url: ${JSON.stringify(text)}`);
|
|
115
|
+
}
|
|
116
|
+
buffer = (buffer << 6) | value;
|
|
117
|
+
bits += 6;
|
|
118
|
+
if (bits >= 8) {
|
|
119
|
+
bits -= 8;
|
|
120
|
+
out.push((buffer >> bits) & 0xff);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Uint8Array.from(out);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const K = new Uint32Array([
|
|
127
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
128
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
129
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
130
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
131
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
132
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
133
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
134
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
/** The SHA-256 digest of [bytes]. */
|
|
138
|
+
export function sha256(bytes: Uint8Array): Uint8Array {
|
|
139
|
+
const h = new Uint32Array([
|
|
140
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
141
|
+
]);
|
|
142
|
+
const bitLength = bytes.length * 8;
|
|
143
|
+
const padded = new Uint8Array(((bytes.length + 9 + 63) >> 6) << 6);
|
|
144
|
+
padded.set(bytes);
|
|
145
|
+
padded[bytes.length] = 0x80;
|
|
146
|
+
const view = new DataView(padded.buffer);
|
|
147
|
+
view.setUint32(padded.length - 8, Math.floor(bitLength / 0x100000000));
|
|
148
|
+
view.setUint32(padded.length - 4, bitLength >>> 0);
|
|
149
|
+
const w = new Uint32Array(64);
|
|
150
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
151
|
+
for (let t = 0; t < 16; t++) {
|
|
152
|
+
w[t] = view.getUint32(offset + t * 4);
|
|
153
|
+
}
|
|
154
|
+
for (let t = 16; t < 64; t++) {
|
|
155
|
+
const x = w[t - 15];
|
|
156
|
+
const y = w[t - 2];
|
|
157
|
+
const s0 = ((x >>> 7) | (x << 25)) ^ ((x >>> 18) | (x << 14)) ^ (x >>> 3);
|
|
158
|
+
const s1 = ((y >>> 17) | (y << 15)) ^ ((y >>> 19) | (y << 13)) ^ (y >>> 10);
|
|
159
|
+
w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
|
|
160
|
+
}
|
|
161
|
+
let a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], k = h[7];
|
|
162
|
+
for (let t = 0; t < 64; t++) {
|
|
163
|
+
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
|
|
164
|
+
const ch = (e & f) ^ (~e & g);
|
|
165
|
+
const t1 = (k + S1 + ch + K[t] + w[t]) >>> 0;
|
|
166
|
+
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
|
|
167
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
168
|
+
const t2 = (S0 + maj) >>> 0;
|
|
169
|
+
k = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
170
|
+
}
|
|
171
|
+
h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0;
|
|
172
|
+
h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + k) >>> 0;
|
|
173
|
+
}
|
|
174
|
+
const out = new Uint8Array(32);
|
|
175
|
+
const outView = new DataView(out.buffer);
|
|
176
|
+
for (let i = 0; i < 8; i++) {
|
|
177
|
+
outView.setUint32(i * 4, h[i]);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Lowercase hex of [bytes]. */
|
|
183
|
+
export function hex(bytes: Uint8Array): string {
|
|
184
|
+
let out = '';
|
|
185
|
+
for (const byte of bytes) {
|
|
186
|
+
out += (byte < 16 ? '0' : '') + byte.toString(16);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The SHA-256 of [text]'s UTF-8 bytes, in lowercase hex. */
|
|
192
|
+
export function sha256Hex(text: string): string {
|
|
193
|
+
return hex(sha256(utf8Encode(text)));
|
|
194
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's native modules, loaded when first used -- so that tests, which replace them, never
|
|
3
|
+
* load React Native -- and their failures as [NativeKeyException]s.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NativeKeyException } from '../errors';
|
|
7
|
+
import type { Spec as InstallationKeySpec } from '../native/NativeNecessaryInstallationKey';
|
|
8
|
+
import type { Spec as IntegritySpec } from '../native/NativeNecessaryIntegrity';
|
|
9
|
+
import type { Spec as SecureScreenSpec } from '../native/NativeNecessarySecureScreen';
|
|
10
|
+
import type { Spec as StepUpKeySpec } from '../native/NativeNecessaryStepUpKey';
|
|
11
|
+
import type { Spec as StorageSpec } from '../native/NativeNecessaryStorage';
|
|
12
|
+
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
14
|
+
export function installationKeyModule(): InstallationKeySpec | null {
|
|
15
|
+
return require('../native/NativeNecessaryInstallationKey').default ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function integrityModule(): IntegritySpec | null {
|
|
19
|
+
return require('../native/NativeNecessaryIntegrity').default ?? null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stepUpKeyModule(): StepUpKeySpec | null {
|
|
23
|
+
return require('../native/NativeNecessaryStepUpKey').default ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function secureScreenModule(): SecureScreenSpec | null {
|
|
27
|
+
return require('../native/NativeNecessarySecureScreen').default ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function storageModule(): StorageSpec | null {
|
|
31
|
+
return require('../native/NativeNecessaryStorage').default ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The phone's platform, as the guide names it: `android` or `ios`. */
|
|
35
|
+
export function currentPlatform(): string {
|
|
36
|
+
return require('react-native').Platform.OS as string;
|
|
37
|
+
}
|
|
38
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
39
|
+
|
|
40
|
+
/** A native module's refusal, with the code and the words it gave. */
|
|
41
|
+
export function nativeFailure(error: unknown, fallback: string): NativeKeyException {
|
|
42
|
+
if (error instanceof NativeKeyException) {
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
const failure = (typeof error === 'object' && error !== null ? error : {}) as {
|
|
46
|
+
code?: unknown;
|
|
47
|
+
message?: unknown;
|
|
48
|
+
};
|
|
49
|
+
const code = typeof failure.code === 'string' && failure.code.length > 0 ? failure.code : 'NATIVE_ERROR';
|
|
50
|
+
const message = typeof failure.message === 'string' && failure.message.length > 0 ? failure.message : fallback;
|
|
51
|
+
return new NativeKeyException(code, message);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
55
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
56
|
+
}
|