@necessarysdk/react-native 0.0.0-stage → 0.1.6
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 +40 -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 +1157 -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 +338 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -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 +767 -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 +959 -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 +370 -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 +699 -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 +134 -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/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -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 +851 -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 +168 -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/answers.ts +15 -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/report.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** The scan (the public guide, section 2): your backend's challenge, the measurements it asks for, and the report the installation key signs. */
|
|
2
|
+
|
|
3
|
+
import type { NativeIntegrityCollector } from './collector';
|
|
4
|
+
import { ApiException } from './errors';
|
|
5
|
+
import { answerText } from './util/answers';
|
|
6
|
+
import { base64UrlNoPadding, utf8Encode } from './util/bytes';
|
|
7
|
+
import { isObject } from './util/native';
|
|
8
|
+
import type { InstallationIdentity } from './installationKey';
|
|
9
|
+
|
|
10
|
+
/** The most a backend takes: a report of 65,536 bytes of JSON (the measurement contract, section 1). */
|
|
11
|
+
export const reportByteLimit = 65536;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The lists a report may shorten, by measurement -- each beside the `truncated` flag that says the
|
|
15
|
+
* phone had more (the measurement contract, section 6).
|
|
16
|
+
*/
|
|
17
|
+
export const shortenableLists: Readonly<Record<string, readonly string[]>> = {
|
|
18
|
+
accessibility: ['services', 'settings_only'],
|
|
19
|
+
notification_access: ['listeners'],
|
|
20
|
+
other_access: ['device_admins', 'remote_access_apps'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* [report] made to fit [reportByteLimit]: the longest list it may shorten is halved, and marked
|
|
25
|
+
* `truncated`, until it fits. A report that cannot fit is never cut anywhere else: the answer is an
|
|
26
|
+
* error naming the limit.
|
|
27
|
+
*/
|
|
28
|
+
export function fitReport(report: Record<string, unknown>): Record<string, unknown> {
|
|
29
|
+
const probes: Record<string, unknown> = { ...(report['probe_results'] as Record<string, unknown>) };
|
|
30
|
+
const fitted: Record<string, unknown> = { ...report, probe_results: probes };
|
|
31
|
+
const size = (): number => utf8Encode(JSON.stringify(fitted)).length;
|
|
32
|
+
for (let bytes = size(); bytes > reportByteLimit; bytes = size()) {
|
|
33
|
+
let probe: string | null = null;
|
|
34
|
+
let field: string | null = null;
|
|
35
|
+
let longest = 0;
|
|
36
|
+
for (const [name, fields] of Object.entries(shortenableLists)) {
|
|
37
|
+
const result = probes[name];
|
|
38
|
+
for (const list of fields) {
|
|
39
|
+
const items = isObject(result) ? result[list] : undefined;
|
|
40
|
+
if (Array.isArray(items) && items.length > longest) {
|
|
41
|
+
probe = name;
|
|
42
|
+
field = list;
|
|
43
|
+
longest = items.length;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (probe === null || field === null) {
|
|
48
|
+
throw new ApiException(`The report would be ${bytes} bytes; a backend takes at most ${reportByteLimit}.`, {
|
|
49
|
+
code: 'report_too_large',
|
|
50
|
+
details: { bytes, limit: reportByteLimit },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const shortened: Record<string, unknown> = { ...(probes[probe] as Record<string, unknown>) };
|
|
54
|
+
shortened[field] = (shortened[field] as unknown[]).slice(0, Math.floor(longest / 2));
|
|
55
|
+
shortened['truncated'] = true;
|
|
56
|
+
probes[probe] = shortened;
|
|
57
|
+
}
|
|
58
|
+
return fitted;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A challenge for a scan, as your backend sent it. */
|
|
62
|
+
export class ScanChallenge {
|
|
63
|
+
readonly challengeId: string;
|
|
64
|
+
readonly nonce: string;
|
|
65
|
+
readonly platform: string;
|
|
66
|
+
/** The measurements the report must answer. */
|
|
67
|
+
readonly requiredProbes: string[];
|
|
68
|
+
/** The personal measurements asked for, answered only where the app enabled them; none from a backend that names none. */
|
|
69
|
+
readonly optionalProbes: string[];
|
|
70
|
+
/** The challenge whole. */
|
|
71
|
+
readonly answer: Record<string, unknown>;
|
|
72
|
+
|
|
73
|
+
constructor(value: {
|
|
74
|
+
challengeId: string;
|
|
75
|
+
nonce: string;
|
|
76
|
+
platform: string;
|
|
77
|
+
requiredProbes: string[];
|
|
78
|
+
optionalProbes: string[];
|
|
79
|
+
answer: Record<string, unknown>;
|
|
80
|
+
}) {
|
|
81
|
+
this.challengeId = value.challengeId;
|
|
82
|
+
this.nonce = value.nonce;
|
|
83
|
+
this.platform = value.platform;
|
|
84
|
+
this.requiredProbes = value.requiredProbes;
|
|
85
|
+
this.optionalProbes = value.optionalProbes;
|
|
86
|
+
this.answer = value.answer;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static fromJson(value: Record<string, unknown>): ScanChallenge {
|
|
90
|
+
const names = (list: unknown): string[] => (Array.isArray(list) ? list.map((item) => String(item)) : []);
|
|
91
|
+
return new ScanChallenge({
|
|
92
|
+
challengeId: answerText(value, 'challenge_id'),
|
|
93
|
+
nonce: answerText(value, 'nonce'),
|
|
94
|
+
platform: answerText(value, 'platform'),
|
|
95
|
+
requiredProbes: names(value['required_probes']),
|
|
96
|
+
optionalProbes: names(value['optional_probes']),
|
|
97
|
+
answer: value,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A report as the installation key signed it. */
|
|
103
|
+
export class SignedReport {
|
|
104
|
+
/** The report, as signed. */
|
|
105
|
+
readonly report: Record<string, unknown>;
|
|
106
|
+
/** `report_payload`: the base64url of the report's JSON bytes. */
|
|
107
|
+
readonly payload: string;
|
|
108
|
+
/** `report_signature`. */
|
|
109
|
+
readonly signature: string;
|
|
110
|
+
|
|
111
|
+
constructor(value: { report: Record<string, unknown>; payload: string; signature: string }) {
|
|
112
|
+
this.report = value.report;
|
|
113
|
+
this.payload = value.payload;
|
|
114
|
+
this.signature = value.signature;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
toJSON(): Record<string, unknown> {
|
|
118
|
+
return { report_payload: this.payload, report_signature: this.signature };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Measures what [challenge] asks for -- and, of the personal measurements it asks for, those in
|
|
124
|
+
* [enabledMeasurements] -- and signs the report with [identity]'s key.
|
|
125
|
+
*/
|
|
126
|
+
export async function collectReport(args: {
|
|
127
|
+
challenge: ScanChallenge;
|
|
128
|
+
identity: InstallationIdentity;
|
|
129
|
+
collector: NativeIntegrityCollector;
|
|
130
|
+
enabledMeasurements: string[];
|
|
131
|
+
}): Promise<SignedReport> {
|
|
132
|
+
const { challenge, identity, collector, enabledMeasurements } = args;
|
|
133
|
+
const native = await collector.collect({
|
|
134
|
+
requiredProbes: challenge.requiredProbes,
|
|
135
|
+
challengeNonce: challenge.nonce,
|
|
136
|
+
enabledMeasurements,
|
|
137
|
+
optionalProbes: challenge.optionalProbes,
|
|
138
|
+
});
|
|
139
|
+
if (!isObject(native['probes'])) {
|
|
140
|
+
throw new ApiException('The native collector returned no probe map.', {
|
|
141
|
+
code: 'invalid_native_integrity_result',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (native['platform'] !== challenge.platform || native['challenge_nonce_echo'] !== challenge.nonce) {
|
|
145
|
+
throw new ApiException('The native integrity response did not match the server challenge.', {
|
|
146
|
+
code: 'integrity_challenge_binding_failed',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const version = native['collector_version'];
|
|
150
|
+
if (version != null && typeof version !== 'number') {
|
|
151
|
+
throw new TypeError('The native collector returned an invalid collector_version.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const report = fitReport({
|
|
155
|
+
challenge_id: challenge.challengeId,
|
|
156
|
+
challenge_nonce: challenge.nonce,
|
|
157
|
+
installation_id: identity.installationId,
|
|
158
|
+
platform: challenge.platform,
|
|
159
|
+
collector_version: typeof version === 'number' ? Math.trunc(version) : 1,
|
|
160
|
+
collected_at: Math.floor(Date.now() / 1000),
|
|
161
|
+
probe_results: { ...(native['probes'] as Record<string, unknown>) },
|
|
162
|
+
// The personal measurements this app enabled, whether or not this scan asked for them.
|
|
163
|
+
enabled_measurements: enabledMeasurements,
|
|
164
|
+
version: 1,
|
|
165
|
+
});
|
|
166
|
+
const payload = base64UrlNoPadding(utf8Encode(JSON.stringify(report)));
|
|
167
|
+
return new SignedReport({ report, payload, signature: await identity.signPayload(payload) });
|
|
168
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request proofs (the public guide, section 3): the installation key signs each request, so a
|
|
3
|
+
* copied session token is useless without the key; with step-up on the person approves one
|
|
4
|
+
* request with the step-up key, and with the app PIN on a request carries the backend's proof of
|
|
5
|
+
* the PIN. Also the description of the one request a scan is to approve (section 2).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NativeKeyException } from './errors';
|
|
9
|
+
import { base64UrlDecode, base64UrlNoPadding, sha256Hex, utf8Encode } from './util/bytes';
|
|
10
|
+
import { storageModule, nativeFailure } from './util/native';
|
|
11
|
+
import { parseTarget } from './util/target';
|
|
12
|
+
import type { InstallationIdentity } from './installationKey';
|
|
13
|
+
|
|
14
|
+
export { base64UrlNoPadding } from './util/bytes';
|
|
15
|
+
|
|
16
|
+
/** Where [freshNonce] takes its bytes: the platform's secure random source, replaced in tests. */
|
|
17
|
+
export let secureRandomBytes: (count: number) => Promise<Uint8Array> = async (count: number) => {
|
|
18
|
+
const native = storageModule();
|
|
19
|
+
if (native === null) {
|
|
20
|
+
throw new NativeKeyException(
|
|
21
|
+
'NATIVE_STORAGE_PLUGIN_MISSING',
|
|
22
|
+
"The SDK's storage module is not linked into this app: rebuild the app.",
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
let encoded: string;
|
|
26
|
+
try {
|
|
27
|
+
encoded = await native.randomBytes(count);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw nativeFailure(error, 'The platform gave no random bytes.');
|
|
30
|
+
}
|
|
31
|
+
const bytes = base64UrlDecode(encoded);
|
|
32
|
+
if (bytes.length !== count) {
|
|
33
|
+
throw new NativeKeyException('RANDOM_BYTES_SHORT', `The platform gave ${bytes.length} random bytes of ${count}.`);
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Replaces the secure random source -- for tests, which run where no phone is. */
|
|
39
|
+
export function setSecureRandomSource(source: (count: number) => Promise<Uint8Array>): void {
|
|
40
|
+
secureRandomBytes = source;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 32 random bytes from the platform's secure source. */
|
|
44
|
+
export function freshNonce(): Promise<Uint8Array> {
|
|
45
|
+
return secureRandomBytes(32);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nowSeconds(): number {
|
|
49
|
+
return Math.floor(Date.now() / 1000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The person's approval of one request: the step-up key's [factor], and [sign], which has the
|
|
54
|
+
* step-up key sign a payload -- the phone asks the person (NativeStepUpKey.sign).
|
|
55
|
+
*/
|
|
56
|
+
export interface StepUpApproval {
|
|
57
|
+
factor: string;
|
|
58
|
+
sign: (payload: string) => Promise<string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RequestProofFields {
|
|
62
|
+
bearerToken: string;
|
|
63
|
+
method: string;
|
|
64
|
+
path: string;
|
|
65
|
+
encodedBody: string;
|
|
66
|
+
payload: string;
|
|
67
|
+
signature: string;
|
|
68
|
+
timestamp: number;
|
|
69
|
+
nonce: string;
|
|
70
|
+
stepUpPayload?: string | null;
|
|
71
|
+
stepUpSignature?: string | null;
|
|
72
|
+
appPinProof?: string | null;
|
|
73
|
+
integrityReport?: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* What the installation key signed for one request -- with step-up, what the step-up key signed;
|
|
78
|
+
* with the app PIN, the backend's proof of it; for a request a scan approves, that scan's report --
|
|
79
|
+
* and the headers that carry them beside `Authorization: Bearer`.
|
|
80
|
+
*/
|
|
81
|
+
export class RequestProof {
|
|
82
|
+
readonly bearerToken: string;
|
|
83
|
+
/** The request the proof was signed for: its method, its path with the query, and the exact body. */
|
|
84
|
+
readonly method: string;
|
|
85
|
+
readonly path: string;
|
|
86
|
+
readonly encodedBody: string;
|
|
87
|
+
/** `X-Access-Proof`: the base64url of the proof's JSON bytes. */
|
|
88
|
+
readonly payload: string;
|
|
89
|
+
/** `X-Access-Signature`. */
|
|
90
|
+
readonly signature: string;
|
|
91
|
+
readonly timestamp: number;
|
|
92
|
+
readonly nonce: string;
|
|
93
|
+
/** `X-Step-Up-Proof` and `X-Step-Up-Signature`, for a request the person approved. */
|
|
94
|
+
readonly stepUpPayload: string | null;
|
|
95
|
+
readonly stepUpSignature: string | null;
|
|
96
|
+
/** `X-App-PIN-Proof`: the backend's proof that the person entered the app PIN for this request. */
|
|
97
|
+
readonly appPinProof: string | null;
|
|
98
|
+
/** `X-Integrity-Report`: the report of the scan made for this request alone (the public guide, section 2). */
|
|
99
|
+
readonly integrityReport: string | null;
|
|
100
|
+
|
|
101
|
+
constructor(fields: RequestProofFields) {
|
|
102
|
+
this.bearerToken = fields.bearerToken;
|
|
103
|
+
this.method = fields.method;
|
|
104
|
+
this.path = fields.path;
|
|
105
|
+
this.encodedBody = fields.encodedBody;
|
|
106
|
+
this.payload = fields.payload;
|
|
107
|
+
this.signature = fields.signature;
|
|
108
|
+
this.timestamp = fields.timestamp;
|
|
109
|
+
this.nonce = fields.nonce;
|
|
110
|
+
this.stepUpPayload = fields.stepUpPayload ?? null;
|
|
111
|
+
this.stepUpSignature = fields.stepUpSignature ?? null;
|
|
112
|
+
this.appPinProof = fields.appPinProof ?? null;
|
|
113
|
+
this.integrityReport = fields.integrityReport ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get headers(): Record<string, string> {
|
|
117
|
+
const headers: Record<string, string> = {
|
|
118
|
+
'X-Access-Proof': this.payload,
|
|
119
|
+
'X-Access-Signature': this.signature,
|
|
120
|
+
};
|
|
121
|
+
if (this.stepUpPayload !== null) headers['X-Step-Up-Proof'] = this.stepUpPayload;
|
|
122
|
+
if (this.stepUpSignature !== null) headers['X-Step-Up-Signature'] = this.stepUpSignature;
|
|
123
|
+
if (this.appPinProof !== null) headers['X-App-PIN-Proof'] = this.appPinProof;
|
|
124
|
+
if (this.integrityReport !== null) headers['X-Integrity-Report'] = this.integrityReport;
|
|
125
|
+
return headers;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** This request, unlocked by the backend's [proof] of the app PIN. */
|
|
129
|
+
withAppPinProof(proof: string): RequestProof {
|
|
130
|
+
return this.with(proof, this.integrityReport);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** This request, naming [reportId], the scan made for it alone. */
|
|
134
|
+
withIntegrityReport(reportId: string): RequestProof {
|
|
135
|
+
return this.with(this.appPinProof, reportId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private with(appPinProof: string | null, integrityReport: string | null): RequestProof {
|
|
139
|
+
return new RequestProof({
|
|
140
|
+
bearerToken: this.bearerToken,
|
|
141
|
+
method: this.method,
|
|
142
|
+
path: this.path,
|
|
143
|
+
encodedBody: this.encodedBody,
|
|
144
|
+
payload: this.payload,
|
|
145
|
+
signature: this.signature,
|
|
146
|
+
timestamp: this.timestamp,
|
|
147
|
+
nonce: this.nonce,
|
|
148
|
+
stepUpPayload: this.stepUpPayload,
|
|
149
|
+
stepUpSignature: this.stepUpSignature,
|
|
150
|
+
appPinProof,
|
|
151
|
+
integrityReport,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What the app sends to have its backend check the app PIN for one request: the PIN, and that
|
|
158
|
+
* request as its request proof signs it -- method, path, query, the hash of the body sent and the
|
|
159
|
+
* request proof's nonce.
|
|
160
|
+
*/
|
|
161
|
+
export function appPinProofRequest(args: {
|
|
162
|
+
pin: string;
|
|
163
|
+
method: string;
|
|
164
|
+
path: string;
|
|
165
|
+
encodedBody: string;
|
|
166
|
+
nonce: string;
|
|
167
|
+
}): Record<string, unknown> {
|
|
168
|
+
const target = parseTarget(args.path);
|
|
169
|
+
return {
|
|
170
|
+
pin: args.pin,
|
|
171
|
+
request: {
|
|
172
|
+
method: args.method.toUpperCase(),
|
|
173
|
+
path: target.path,
|
|
174
|
+
query: target.query,
|
|
175
|
+
body_sha256: sha256Hex(args.encodedBody),
|
|
176
|
+
nonce: args.nonce,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The step-up proof, version 2, that the step-up key signs for one request: the base64url of its
|
|
183
|
+
* JSON, the members in this order. [nonce] is the request proof's nonce for the same request, so
|
|
184
|
+
* the approval is used up with it.
|
|
185
|
+
*/
|
|
186
|
+
export function stepUpProof(args: {
|
|
187
|
+
installationId: string;
|
|
188
|
+
accessToken: string;
|
|
189
|
+
method: string;
|
|
190
|
+
path: string;
|
|
191
|
+
encodedBody: string;
|
|
192
|
+
factor: string;
|
|
193
|
+
nonce: string;
|
|
194
|
+
timestamp: number;
|
|
195
|
+
}): string {
|
|
196
|
+
const target = parseTarget(args.path);
|
|
197
|
+
return base64UrlNoPadding(
|
|
198
|
+
utf8Encode(
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
version: 2,
|
|
201
|
+
purpose: 'stepup',
|
|
202
|
+
installation_id: args.installationId,
|
|
203
|
+
access_token_sha256: sha256Hex(args.accessToken),
|
|
204
|
+
method: args.method.toUpperCase(),
|
|
205
|
+
path: target.path,
|
|
206
|
+
query: target.query,
|
|
207
|
+
body_sha256: sha256Hex(args.encodedBody),
|
|
208
|
+
factor: args.factor,
|
|
209
|
+
nonce: args.nonce,
|
|
210
|
+
timestamp: args.timestamp,
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Signs [method] [path] with [encodedBody] under [bearerToken]: proof version 2, which signs the
|
|
218
|
+
* query string too, exactly as sent. A GET signs an empty body. With [stepUp], the step-up key signs
|
|
219
|
+
* first -- the phone asks the person -- and the request proof after, with the same nonce, so the
|
|
220
|
+
* time the person takes does not count against the request proof's clock window.
|
|
221
|
+
* [installationId], [timestamp] and [nonce] replace what the request proof would otherwise carry,
|
|
222
|
+
* for the shared test vectors and for testing a backend's refusals.
|
|
223
|
+
*/
|
|
224
|
+
export async function signRequest(args: {
|
|
225
|
+
identity: InstallationIdentity;
|
|
226
|
+
method: string;
|
|
227
|
+
path: string;
|
|
228
|
+
bearerToken: string;
|
|
229
|
+
encodedBody?: string;
|
|
230
|
+
stepUp?: StepUpApproval | null;
|
|
231
|
+
installationId?: string | null;
|
|
232
|
+
timestamp?: number | null;
|
|
233
|
+
nonce?: Uint8Array | readonly number[] | null;
|
|
234
|
+
}): Promise<RequestProof> {
|
|
235
|
+
const normalizedMethod = args.method.toUpperCase();
|
|
236
|
+
const body = normalizedMethod === 'GET' ? '' : args.encodedBody ?? '';
|
|
237
|
+
const nonceText = base64UrlNoPadding(args.nonce ?? (await freshNonce()));
|
|
238
|
+
let stepUpPayload: string | null = null;
|
|
239
|
+
let stepUpSignature: string | null = null;
|
|
240
|
+
if (args.stepUp != null) {
|
|
241
|
+
stepUpPayload = stepUpProof({
|
|
242
|
+
installationId: args.identity.installationId,
|
|
243
|
+
accessToken: args.bearerToken,
|
|
244
|
+
method: normalizedMethod,
|
|
245
|
+
path: args.path,
|
|
246
|
+
encodedBody: body,
|
|
247
|
+
factor: args.stepUp.factor,
|
|
248
|
+
nonce: nonceText,
|
|
249
|
+
timestamp: nowSeconds(),
|
|
250
|
+
});
|
|
251
|
+
stepUpSignature = await args.stepUp.sign(stepUpPayload);
|
|
252
|
+
}
|
|
253
|
+
const signedAt = args.timestamp ?? nowSeconds();
|
|
254
|
+
const target = parseTarget(args.path);
|
|
255
|
+
// The members in this order: the backend checks each against the request it received, and the
|
|
256
|
+
// shared vectors hold both sides to these bytes.
|
|
257
|
+
const proof = {
|
|
258
|
+
access_token_sha256: sha256Hex(args.bearerToken),
|
|
259
|
+
body_sha256: sha256Hex(body),
|
|
260
|
+
installation_id: args.installationId ?? args.identity.installationId,
|
|
261
|
+
method: normalizedMethod,
|
|
262
|
+
nonce: nonceText,
|
|
263
|
+
path: target.path,
|
|
264
|
+
query: target.query,
|
|
265
|
+
timestamp: signedAt,
|
|
266
|
+
version: 2,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// The key signs the exact JSON bytes this base64url value carries; the private key never leaves
|
|
270
|
+
// the phone's keystore.
|
|
271
|
+
const payload = base64UrlNoPadding(utf8Encode(JSON.stringify(proof)));
|
|
272
|
+
return new RequestProof({
|
|
273
|
+
bearerToken: args.bearerToken,
|
|
274
|
+
method: normalizedMethod,
|
|
275
|
+
path: args.path,
|
|
276
|
+
encodedBody: body,
|
|
277
|
+
payload,
|
|
278
|
+
signature: await args.identity.signPayload(payload),
|
|
279
|
+
timestamp: signedAt,
|
|
280
|
+
nonce: nonceText,
|
|
281
|
+
stepUpPayload,
|
|
282
|
+
stepUpSignature,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The one request a scan is to approve, described exactly as it will be sent: what the challenge names as its `operation`. */
|
|
287
|
+
export function describeOperation(method: string, path: string, encodedBody: string): Record<string, unknown> {
|
|
288
|
+
const target = parseTarget(path);
|
|
289
|
+
return {
|
|
290
|
+
method: method.toUpperCase(),
|
|
291
|
+
path: target.path,
|
|
292
|
+
query: target.query,
|
|
293
|
+
body_sha256: sha256Hex(encodedBody),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Protection of the app's window while a secret is typed. */
|
|
2
|
+
|
|
3
|
+
import { secureScreenModule } from './util/native';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Protects the app's window while a secret is typed. Android: no screenshots, recordings or casts
|
|
7
|
+
* of the window, other apps' windows not drawn over it (Android 12 and later), and touches that
|
|
8
|
+
* passed through another app's window dropped. iOS cannot block screenshots; it covers the window
|
|
9
|
+
* while the screen is recorded or mirrored.
|
|
10
|
+
*
|
|
11
|
+
* Calls nest: each `protect(true)` needs its `protect(false)`, and the window is put back as it
|
|
12
|
+
* was -- a window the app keeps secure itself stays secure -- when the last one ends. Other apps'
|
|
13
|
+
* overlays, which no public call reads, are shown again then. Returns what the window has now; a
|
|
14
|
+
* build without the native module answers nothing, and the entry stays usable.
|
|
15
|
+
*/
|
|
16
|
+
export class SecureScreen {
|
|
17
|
+
static async protect(on: boolean): Promise<Record<string, unknown>> {
|
|
18
|
+
try {
|
|
19
|
+
const native = secureScreenModule();
|
|
20
|
+
if (native === null) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
const state = (await native.protect(on)) as Record<string, unknown> | null;
|
|
24
|
+
return state ?? {};
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|