@scalebun/react-native 1.13.0 → 2.0.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/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
- package/dist/scalebun.full.js +127 -422
- package/dist/scalebun.slim.js +127 -422
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/lib/commonjs/core/config/schema.js +3 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
- package/lib/commonjs/public/ScaleBunFacade.js +5 -41
- package/lib/module/core/config/schema.js +3 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +108 -60
- package/lib/module/public/ScaleBunFacade.js +5 -41
- package/lib/typescript/core/config/schema.d.ts +0 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/core/config/schema.ts +3 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +116 -70
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/public/ScaleBunFacade.ts +5 -53
- package/src/specs/NativeScaleBunOta.ts +32 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolated lazy loader for the OPTIONAL `@noble/ed25519` dep.
|
|
3
|
-
*
|
|
4
|
-
* WHY ITS OWN FILE: Metro's dependency collector emits a dependency map one
|
|
5
|
-
* entry short for a module containing TWO OR MORE different-string inline
|
|
6
|
-
* `require()` calls, so the require indices desync and `_dependencyMap[N]`
|
|
7
|
-
* reads `undefined` ("Requiring unknown module 'undefined'"). Exactly ONE
|
|
8
|
-
* inline require per module avoids the miscount. `loadSha512` is a sibling file
|
|
9
|
-
* for the same reason — see `push/adapters/loadNotifee.ts`, which hit this first.
|
|
10
|
-
*
|
|
11
|
-
* The string must still be STATICALLY resolvable at bundle time; hosts that do
|
|
12
|
-
* not install it stub it via `withScaleBun`.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** The subset of the @noble/ed25519 surface this SDK uses. */
|
|
16
|
-
export interface NobleEd25519 {
|
|
17
|
-
verifyAsync?: (sig: Uint8Array, msg: Uint8Array, pub: Uint8Array) => Promise<boolean>;
|
|
18
|
-
verify?: (sig: Uint8Array, msg: Uint8Array, pub: Uint8Array) => boolean;
|
|
19
|
-
/** v3 shape: `ed.hashes.sha512` */
|
|
20
|
-
hashes?: { sha512?: unknown; sha512Async?: unknown };
|
|
21
|
-
/** v2 shape: `ed.etc.sha512Sync` */
|
|
22
|
-
etc?: { sha512Sync?: unknown; sha512Async?: unknown };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function loadEd25519(): NobleEd25519 | null {
|
|
26
|
-
try {
|
|
27
|
-
const mod = require('@noble/ed25519');
|
|
28
|
-
const ed = (mod?.default ?? mod) as NobleEd25519;
|
|
29
|
-
// Validate the API actually used, not mere truthiness: when the package
|
|
30
|
-
// is absent `withScaleBun` resolves it to an EMPTY STUB MODULE, which is
|
|
31
|
-
// a perfectly truthy object. A stub must read as "unavailable", because
|
|
32
|
-
// treating it as available would make every signed update fail with a
|
|
33
|
-
// crypto error instead of the actionable "no verifier" message.
|
|
34
|
-
if (typeof ed?.verifyAsync === 'function' || typeof ed?.verify === 'function') {
|
|
35
|
-
return ed;
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
} catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolated lazy loader for the OPTIONAL `@noble/hashes` SHA-512.
|
|
3
|
-
*
|
|
4
|
-
* Separate file from `loadEd25519` because Metro miscounts a module holding two
|
|
5
|
-
* different-string inline `require()` calls — see the comment there.
|
|
6
|
-
*
|
|
7
|
-
* ed25519 is defined in terms of SHA-512, and React Native has no native
|
|
8
|
-
* SHA-512, so @noble/ed25519 cannot verify anything until a hash provider is
|
|
9
|
-
* wired into it. Which property it expects differs by major version, so the
|
|
10
|
-
* wiring lives in `builtinVerifier`, not here; this module only obtains the
|
|
11
|
-
* function.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export type Sha512Fn = (msg: Uint8Array) => Uint8Array;
|
|
15
|
-
|
|
16
|
-
export function loadSha512(): Sha512Fn | null {
|
|
17
|
-
try {
|
|
18
|
-
// The `.js` SUFFIX IS REQUIRED, not stylistic. @noble/hashes v2 declares
|
|
19
|
-
// its exports map with extensions — `"./sha2.js"`, not `"./sha2"` — so
|
|
20
|
-
// the extensionless form misses the map entirely. Node still resolves it
|
|
21
|
-
// by falling back to file-based lookup, which is why it passes here,
|
|
22
|
-
// but Metro prints
|
|
23
|
-
// "Attempted to import … not listed in the exports … Falling back to
|
|
24
|
-
// file-based resolution"
|
|
25
|
-
// on every bundle, and that fallback is exactly what a stricter resolver
|
|
26
|
-
// (or a future Metro) drops. Caught while bundling a real Expo app.
|
|
27
|
-
const mod = require('@noble/hashes/sha2.js');
|
|
28
|
-
const sha512 = (mod?.sha512 ?? mod?.default?.sha512) as Sha512Fn | undefined;
|
|
29
|
-
// A stubbed module yields undefined here, which correctly reads as
|
|
30
|
-
// "unavailable" rather than throwing deep inside the curve code later.
|
|
31
|
-
return typeof sha512 === 'function' ? sha512 : null;
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native ed25519 verifier — platform crypto wrapped as a SignatureVerifier.
|
|
3
|
-
*
|
|
4
|
-
* WHY IT EXISTS. The built-in @noble verifier runs in JS, inside the very
|
|
5
|
-
* bundle it protects: an attacker who lands one malicious bundle can neuter a
|
|
6
|
-
* JS check for every update after it. Platform crypto (CryptoKit on iOS,
|
|
7
|
-
* Android's conscrypt on API 33+) sits outside the bundle's reach — and using
|
|
8
|
-
* it also drops the runtime dependency on the optional @noble peers wherever
|
|
9
|
-
* the OS provides ed25519.
|
|
10
|
-
*
|
|
11
|
-
* WHAT IT DOES NOT FIX, stated plainly: the ORCHESTRATION still lives in JS.
|
|
12
|
-
* A hostile bundle can skip calling any verifier and drive the native staging
|
|
13
|
-
* methods directly. Moving the crypto native shrinks the attack surface (no
|
|
14
|
-
* more tampering with a bundled crypto lib to flip a verdict) but the full
|
|
15
|
-
* close needs native-ENFORCED staging with a natively-pinned key — an
|
|
16
|
-
* architectural change tracked separately, not smuggled into this one.
|
|
17
|
-
*
|
|
18
|
-
* VERDICT CONTRACT (mirrors the spec): the native side resolves
|
|
19
|
-
* 'valid' | 'invalid' | 'unavailable'. 'invalid' is a definitive NO.
|
|
20
|
-
* 'unavailable' (Android < 33, malformed input, machinery failure) means this
|
|
21
|
-
* source cannot answer — the composed verifier below then delegates to the
|
|
22
|
-
* @noble builtin, and when that is absent too it THROWS, which the policy
|
|
23
|
-
* layer converts to a rejection. Every path that cannot verify refuses.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import NativeScaleBunOta from '../../../specs/NativeScaleBunOta';
|
|
27
|
-
import { logger } from '../../../core/logger/internalLogger';
|
|
28
|
-
import { decodeSignature, getBuiltinVerifier } from './builtinVerifier';
|
|
29
|
-
import type { SignatureVerifier } from '../signature';
|
|
30
|
-
|
|
31
|
-
const ED25519_PUBLIC_KEY_HEX_CHARS = 64;
|
|
32
|
-
|
|
33
|
-
/** bytes → lowercase hex (the native side takes clean hex only). */
|
|
34
|
-
function bytesToHex(bytes: Uint8Array): string {
|
|
35
|
-
let out = '';
|
|
36
|
-
for (const b of bytes) out += b.toString(16).padStart(2, '0');
|
|
37
|
-
return out;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Strict lowercase-hex normalization; null when the value is not hex. */
|
|
41
|
-
function normalizeHex(value: string, expectedChars: number): string | null {
|
|
42
|
-
const clean = value.trim().toLowerCase().replace(/^0x/, '');
|
|
43
|
-
if (clean.length !== expectedChars || /[^0-9a-f]/.test(clean)) return null;
|
|
44
|
-
return clean;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let resolved: SignatureVerifier | null | undefined;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The native-first verifier, or null when the native module (or its
|
|
51
|
-
* verifyEd25519 method — an app running new JS against an old binary) is
|
|
52
|
-
* absent. Cached like the builtin: module availability does not change at
|
|
53
|
-
* runtime, and this is consulted on every update check.
|
|
54
|
-
*/
|
|
55
|
-
export function getNativeVerifier(): SignatureVerifier | null {
|
|
56
|
-
if (resolved !== undefined) return resolved;
|
|
57
|
-
|
|
58
|
-
const native = NativeScaleBunOta;
|
|
59
|
-
if (!native || typeof (native as any).verifyEd25519 !== 'function') {
|
|
60
|
-
// Old binary, Jest, web/SSR — not an error, just not this source.
|
|
61
|
-
resolved = null;
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
resolved = async ({ messageHex, signature, publicKey }) => {
|
|
66
|
-
// All decoding and validation happens HERE, in one place, so both
|
|
67
|
-
// platforms' native code only ever sees clean fixed-length hex. The
|
|
68
|
-
// signature may arrive hex or base64 (the wire allows both) — reuse
|
|
69
|
-
// the builtin's decoder rather than growing a second, subtly
|
|
70
|
-
// different one.
|
|
71
|
-
const message = normalizeHex(messageHex, 64);
|
|
72
|
-
const key = normalizeHex(publicKey, ED25519_PUBLIC_KEY_HEX_CHARS);
|
|
73
|
-
const sigBytes = decodeSignature(signature);
|
|
74
|
-
if (!message || !key || !sigBytes) {
|
|
75
|
-
// Same messages-by-symptom philosophy as the builtin: a mistyped
|
|
76
|
-
// key must not present as "every update is a forgery".
|
|
77
|
-
logger.error(
|
|
78
|
-
'[OTA] Signature inputs malformed (bundle hash, signature, or public key) — refusing to stage.',
|
|
79
|
-
);
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let verdict: string;
|
|
84
|
-
try {
|
|
85
|
-
verdict = await (native as any).verifyEd25519(message, bytesToHex(sigBytes), key);
|
|
86
|
-
} catch (err: any) {
|
|
87
|
-
// A rejected promise is machinery failure, not a forgery verdict.
|
|
88
|
-
logger.warn(`[OTA] Native ed25519 verify failed to run: ${err?.message ?? err}`);
|
|
89
|
-
verdict = 'unavailable';
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (verdict === 'valid') return true;
|
|
93
|
-
if (verdict === 'invalid') return false;
|
|
94
|
-
|
|
95
|
-
// 'unavailable' (Android < 33) — fall back to the JS verifier so those
|
|
96
|
-
// devices keep verifying rather than losing enforcement.
|
|
97
|
-
const builtin = getBuiltinVerifier();
|
|
98
|
-
if (builtin) {
|
|
99
|
-
return builtin({ messageHex, signature, publicKey });
|
|
100
|
-
}
|
|
101
|
-
// No source can verify. Throwing (rather than returning false) keeps
|
|
102
|
-
// the policy layer's outcome labels honest: this surfaces as machinery
|
|
103
|
-
// failure with an actionable message, not as "the bundle is forged".
|
|
104
|
-
throw new Error(
|
|
105
|
-
'ed25519 unavailable: this OS has no native implementation (Android < 13) and the ' +
|
|
106
|
-
'optional peers @noble/ed25519 + @noble/hashes are not installed.',
|
|
107
|
-
);
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
__DEV__ && logger.debug('[OTA] Using native ed25519 verifier (platform crypto).');
|
|
111
|
-
return resolved;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Test seam — clears the cached resolution. */
|
|
115
|
-
export function _resetNativeVerifier(): void {
|
|
116
|
-
resolved = undefined;
|
|
117
|
-
}
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { logger } from '../../core/logger/internalLogger';
|
|
2
|
-
import { getBuiltinVerifier } from './crypto/builtinVerifier';
|
|
3
|
-
import { getNativeVerifier } from './crypto/nativeVerifier';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Bundle signature verification (OTA-03).
|
|
7
|
-
*
|
|
8
|
-
* WHAT WAS WRONG. `ota-signing-key.service.ts` documents the intended model
|
|
9
|
-
* exactly: the CLI generates an ed25519 keypair locally, uploads only the public
|
|
10
|
-
* half, and "the device verifies every bundle's signature before install and
|
|
11
|
-
* rejects forgeries." Two of those three steps did not exist. The CLI had no
|
|
12
|
-
* signing code, and this SDK received `bundle.signature` in the check response
|
|
13
|
-
* and never read it. The only client-side check was SHA-256 against a hash
|
|
14
|
-
* delivered in the same response as the URL — which defends against a corrupted
|
|
15
|
-
* download and nothing else. On a platform whose entire purpose is remote code
|
|
16
|
-
* delivery, that is the control that matters most.
|
|
17
|
-
*
|
|
18
|
-
* WHY IT IS SHAPED LIKE THIS. React Native has no built-in ed25519, so the
|
|
19
|
-
* verification primitive has to come from somewhere. It is resolved in order:
|
|
20
|
-
* a host-supplied verifier, else the built-in one assembled from the OPTIONAL
|
|
21
|
-
* `@noble/ed25519` + `@noble/hashes` peers, else nothing — and the SDK's job is
|
|
22
|
-
* to decide, unambiguously, what happens in that last case.
|
|
23
|
-
*
|
|
24
|
-
* The built-in path exists because requiring every adopter to hand-write a
|
|
25
|
-
* verifier put the most security-critical operation in the product in code the
|
|
26
|
-
* SDK could neither test nor audit, and made a routine dependency bump able to
|
|
27
|
-
* silently stop all updates. See `crypto/builtinVerifier.ts`. Signing stays
|
|
28
|
-
* opt-in, and apps that never adopt it carry no curve arithmetic.
|
|
29
|
-
*
|
|
30
|
-
* THE POLICY, which is the important part:
|
|
31
|
-
*
|
|
32
|
-
* - A bundle carrying a signature, with a configured public key and a
|
|
33
|
-
* verifier: verified. Failure REJECTS the install. This is the goal state.
|
|
34
|
-
* - A bundle carrying a signature, with a public key configured, but no
|
|
35
|
-
* verifier available: REJECT. The app asked for signature enforcement by
|
|
36
|
-
* configuring a key; silently installing unverified code because a helper is
|
|
37
|
-
* missing would turn a security feature into a placebo.
|
|
38
|
-
* - No public key configured: skip, and say so once. Signing is opt-in per app,
|
|
39
|
-
* and an app that has not adopted it must keep working.
|
|
40
|
-
*
|
|
41
|
-
* The failure mode is the whole design: an app that opts in cannot be
|
|
42
|
-
* accidentally downgraded to unverified installs.
|
|
43
|
-
*/
|
|
44
|
-
|
|
45
|
-
/** Verifies a detached ed25519 signature. Supplied by the host app. */
|
|
46
|
-
export type SignatureVerifier = (input: {
|
|
47
|
-
/** Hex-encoded SHA-256 of the bundle — what the signature is over. */
|
|
48
|
-
messageHex: string;
|
|
49
|
-
/** Hex or base64 detached signature from the check response. */
|
|
50
|
-
signature: string;
|
|
51
|
-
/** The app's configured ed25519 public key. */
|
|
52
|
-
publicKey: string;
|
|
53
|
-
}) => boolean | Promise<boolean>;
|
|
54
|
-
|
|
55
|
-
export interface SignatureConfig {
|
|
56
|
-
/**
|
|
57
|
-
* ed25519 public key(s) shipped in the app binary. Absent = signing not
|
|
58
|
-
* adopted. An ARRAY pins several keys at once and a bundle is accepted when
|
|
59
|
-
* ANY of them verifies — this is what makes key rotation possible without an
|
|
60
|
-
* app-store release: ship a build pinning [old, new], start signing with new,
|
|
61
|
-
* drop old from the next build. With a single pinnable key, losing the
|
|
62
|
-
* private key meant no OTA capability until a new binary cleared review —
|
|
63
|
-
* the exact emergency OTA exists to solve.
|
|
64
|
-
*/
|
|
65
|
-
publicKey?: string | string[];
|
|
66
|
-
/** Host-provided ed25519 verification function. */
|
|
67
|
-
verifier?: SignatureVerifier;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export type SignatureOutcome =
|
|
71
|
-
| { ok: true; reason: 'verified' | 'not_configured' }
|
|
72
|
-
| {
|
|
73
|
-
ok: false;
|
|
74
|
-
reason: 'no_verifier' | 'invalid_signature' | 'missing_signature' | 'verifier_threw' | 'no_keys';
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
let warnedNotConfigured = false;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Decide whether a bundle may be staged.
|
|
81
|
-
*
|
|
82
|
-
* Returns a structured outcome rather than a boolean so the caller can emit a
|
|
83
|
-
* precise telemetry reason — "we shipped unverified code because no key was
|
|
84
|
-
* configured" and "we refused because the signature was forged" are very
|
|
85
|
-
* different operational events and must not collapse into one.
|
|
86
|
-
*/
|
|
87
|
-
export async function verifyBundleSignature(
|
|
88
|
-
bundleSha256: string,
|
|
89
|
-
signature: string | undefined,
|
|
90
|
-
config: SignatureConfig | undefined,
|
|
91
|
-
): Promise<SignatureOutcome> {
|
|
92
|
-
const rawKey = config?.publicKey;
|
|
93
|
-
|
|
94
|
-
// Normalize to a list. A single non-empty string is the pre-rotation config
|
|
95
|
-
// shape and behaves exactly as before; an array pins several keys at once.
|
|
96
|
-
const keys = (Array.isArray(rawKey) ? rawKey : rawKey ? [rawKey] : []).filter(
|
|
97
|
-
(k): k is string => typeof k === 'string' && k.trim().length > 0,
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
// Signing not adopted by this app — nothing to enforce. Parity note: a
|
|
101
|
-
// falsy single value (undefined, '') has always meant "not configured" and
|
|
102
|
-
// still does.
|
|
103
|
-
if (rawKey === undefined || rawKey === '' || rawKey === null) {
|
|
104
|
-
if (!warnedNotConfigured) {
|
|
105
|
-
warnedNotConfigured = true;
|
|
106
|
-
logger.warn(
|
|
107
|
-
'[OTA] No signing public key configured — bundles are accepted on SHA-256 ' +
|
|
108
|
-
'integrity alone. Configure `ota.publicSigningKey` to enforce authenticity.',
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
return { ok: true, reason: 'not_configured' };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// The host PROVIDED a key config that resolves to zero usable keys — an
|
|
115
|
-
// empty array, or an array of blank strings. That is a broken attempt to
|
|
116
|
-
// enable signing, not an absence of one, and the two must not collapse:
|
|
117
|
-
// treating `publicSigningKeys: []` as "not configured" would let a config
|
|
118
|
-
// mistake silently downgrade an app to unverified installs. Fail closed.
|
|
119
|
-
if (keys.length === 0) {
|
|
120
|
-
logger.error(
|
|
121
|
-
'[OTA] Signing key config resolves to zero usable keys (empty array or blank ' +
|
|
122
|
-
'strings) — refusing to stage. Remove the config to disable signing, or pin ' +
|
|
123
|
-
'at least one real key.',
|
|
124
|
-
);
|
|
125
|
-
return { ok: false, reason: 'no_keys' };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// The app opted in, so a bundle without a signature is a refusal, not a pass.
|
|
129
|
-
if (!signature) {
|
|
130
|
-
logger.error(
|
|
131
|
-
'[OTA] Bundle has no signature but a signing key is configured — refusing to stage.',
|
|
132
|
-
);
|
|
133
|
-
return { ok: false, reason: 'missing_signature' };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Resolution order, and the order matters:
|
|
137
|
-
// 1. A host-supplied verifier ALWAYS wins. A team with its own crypto
|
|
138
|
-
// policy must be able to override whatever the SDK would otherwise pick.
|
|
139
|
-
// 2. Otherwise the NATIVE verifier (CryptoKit / Android 13+ platform
|
|
140
|
-
// ed25519). Preferred over the JS one because it runs outside the
|
|
141
|
-
// bundle it protects and needs no optional peers; on OS levels without
|
|
142
|
-
// ed25519 it delegates to the builtin internally.
|
|
143
|
-
// 3. Otherwise the built-in @noble verifier, if the optional peers are
|
|
144
|
-
// installed. This is the path that removes ~100 lines of hand-written
|
|
145
|
-
// crypto plumbing from every app that adopts signing.
|
|
146
|
-
// 4. Otherwise reject, because a configured key is a request for
|
|
147
|
-
// enforcement and quietly installing unverified code would turn a
|
|
148
|
-
// security feature into a placebo.
|
|
149
|
-
const verifier =
|
|
150
|
-
typeof config?.verifier === 'function'
|
|
151
|
-
? config.verifier
|
|
152
|
-
: (getNativeVerifier() ?? getBuiltinVerifier());
|
|
153
|
-
|
|
154
|
-
if (!verifier) {
|
|
155
|
-
logger.error(
|
|
156
|
-
'[OTA] A signing key is configured but no signature verifier is available — ' +
|
|
157
|
-
'refusing to stage. Either install the optional peers ' +
|
|
158
|
-
'`@noble/ed25519` and `@noble/hashes` (the SDK then verifies with no ' +
|
|
159
|
-
'further code), or provide your own `ota.verifySignature`.',
|
|
160
|
-
);
|
|
161
|
-
return { ok: false, reason: 'no_verifier' };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Try every pinned key; any single match accepts. The verifier contract is
|
|
165
|
-
// unchanged (one key per call) so host-supplied verifiers keep working.
|
|
166
|
-
//
|
|
167
|
-
// Outcome labelling when nothing matched, and why it matters: if at least
|
|
168
|
-
// one verifier call RAN TO COMPLETION and said no, the bundle failed
|
|
169
|
-
// verification — 'invalid_signature'. Only when EVERY call threw is the
|
|
170
|
-
// machinery itself suspect — 'verifier_threw'. Collapsing those (e.g. by
|
|
171
|
-
// letting the last key's throw win) would point an investigation at the
|
|
172
|
-
// host's verifier when the actual event was a forged bundle, or vice versa.
|
|
173
|
-
let sawThrow = false;
|
|
174
|
-
let sawCompletion = false;
|
|
175
|
-
for (const key of keys) {
|
|
176
|
-
try {
|
|
177
|
-
const valid = await verifier({
|
|
178
|
-
messageHex: bundleSha256,
|
|
179
|
-
signature,
|
|
180
|
-
publicKey: key,
|
|
181
|
-
});
|
|
182
|
-
sawCompletion = true;
|
|
183
|
-
if (valid) return { ok: true, reason: 'verified' };
|
|
184
|
-
} catch (err: any) {
|
|
185
|
-
// A throwing verifier is treated as a failed verification for this key,
|
|
186
|
-
// never as a pass — but the remaining keys still get their chance.
|
|
187
|
-
sawThrow = true;
|
|
188
|
-
logger.error(`[OTA] Signature verifier threw: ${err?.message ?? err}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (sawThrow && !sawCompletion) {
|
|
193
|
-
return { ok: false, reason: 'verifier_threw' };
|
|
194
|
-
}
|
|
195
|
-
logger.error(
|
|
196
|
-
keys.length > 1
|
|
197
|
-
? `[OTA] Bundle signature is INVALID under all ${keys.length} pinned keys — refusing to stage.`
|
|
198
|
-
: '[OTA] Bundle signature is INVALID — refusing to stage.',
|
|
199
|
-
);
|
|
200
|
-
return { ok: false, reason: 'invalid_signature' };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Test seam — resets the once-per-process "not configured" warning. */
|
|
204
|
-
export function _resetSignatureWarnings(): void {
|
|
205
|
-
warnedNotConfigured = false;
|
|
206
|
-
}
|