@mindees/updates 0.1.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/LICENSE +31 -0
- package/README.md +125 -0
- package/dist/client.d.ts +74 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +227 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto.d.ts +42 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +63 -0
- package/dist/crypto.js.map +1 -0
- package/dist/delta.d.ts +50 -0
- package/dist/delta.d.ts.map +1 -0
- package/dist/delta.js +238 -0
- package/dist/delta.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +83 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +134 -0
- package/dist/manifest.js.map +1 -0
- package/dist/sdui.d.ts +80 -0
- package/dist/sdui.d.ts.map +1 -0
- package/dist/sdui.js +275 -0
- package/dist/sdui.js.map +1 -0
- package/dist/server.d.ts +83 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +109 -0
- package/dist/server.js.map +1 -0
- package/dist/signing.d.ts +54 -0
- package/dist/signing.d.ts.map +1 -0
- package/dist/signing.js +64 -0
- package/dist/signing.js.map +1 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
MindeesNative is dual-licensed under either of:
|
|
4
|
+
|
|
5
|
+
- **Apache License, Version 2.0** ([LICENSE-APACHE](./LICENSE-APACHE) or
|
|
6
|
+
<https://www.apache.org/licenses/LICENSE-2.0>)
|
|
7
|
+
- **MIT license** ([LICENSE-MIT](./LICENSE-MIT) or
|
|
8
|
+
<https://opensource.org/licenses/MIT>)
|
|
9
|
+
|
|
10
|
+
at your option.
|
|
11
|
+
|
|
12
|
+
This `MIT OR Apache-2.0` dual-license is the same model used by the Rust
|
|
13
|
+
ecosystem and many modern open-source projects. It gives downstream users
|
|
14
|
+
maximum flexibility: the MIT option is short and permissive, while the Apache
|
|
15
|
+
option adds an explicit patent grant.
|
|
16
|
+
|
|
17
|
+
## SPDX identifier
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
SPDX-License-Identifier: MIT OR Apache-2.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Contribution
|
|
24
|
+
|
|
25
|
+
Unless you explicitly state otherwise, any contribution intentionally
|
|
26
|
+
submitted for inclusion in the work by you, as defined in the Apache-2.0
|
|
27
|
+
license, shall be dual-licensed as above, without any additional terms or
|
|
28
|
+
conditions.
|
|
29
|
+
|
|
30
|
+
Contributions are accepted under the **Developer Certificate of Origin (DCO)**.
|
|
31
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on signing off your commits.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @mindees/updates
|
|
2
|
+
|
|
3
|
+
**Pulse** โ signed over-the-air (OTA) updates: ship new JavaScript + assets to
|
|
4
|
+
installed apps without an app-store release, safely.
|
|
5
|
+
|
|
6
|
+
> Status: ๐งช **Experimental** โ Phase 9 (Pulse) is complete in its current scope.
|
|
7
|
+
> Implemented and tested: signed OTA core, differential bundle diffing, the reference
|
|
8
|
+
> update server, and server-driven UI (SDUI). The WASM module runtime is a ๐ฌ research
|
|
9
|
+
> track (`createWasmModuleRuntime()` throws `NotImplementedError`). See the repository
|
|
10
|
+
> [STATUS.md](../../STATUS.md).
|
|
11
|
+
|
|
12
|
+
## What works today
|
|
13
|
+
|
|
14
|
+
- **Hash-addressed manifest** โ `UpdateManifest` enumerates a bundle's files, each by
|
|
15
|
+
SHA-256. One signature over the manifest's canonical bytes transitively secures
|
|
16
|
+
every file. `canonicalManifestJson` is deterministic (key-sorted, compact);
|
|
17
|
+
`parseManifest` strictly validates untrusted input.
|
|
18
|
+
- **Ed25519 signing** โ `signManifest` / `verifySignedManifest` over **detached
|
|
19
|
+
canonical bytes** (the verifier never re-serializes). A `threshold` (default 1)
|
|
20
|
+
requires that many valid signatures from **distinct** trusted keys โ key rotation
|
|
21
|
+
and multi-party signing. Pure-JS [`@noble`](https://github.com/paulmillr/noble-curves),
|
|
22
|
+
so it runs on Node, browsers, and Hermes/React Native โ no WebCrypto or native module.
|
|
23
|
+
- **Content-addressed storage** โ `UpdateStorage` stores blobs by SHA-256, so files
|
|
24
|
+
shared across updates are stored once and **unchanged assets are never re-downloaded**.
|
|
25
|
+
`createMemoryStorage()` is the reference implementation; bring your own for FS/S3/R2/RN.
|
|
26
|
+
- **Differential (delta) downloads** โ a zero-dependency, pure-TS byte-level delta codec
|
|
27
|
+
(`diff` build-side, `applyDelta` on-device, a rolling-hash COPY/INSERT scheme). A
|
|
28
|
+
changed asset can carry an `AssetEntry.patch` descriptor `{ base, delta }` in the
|
|
29
|
+
signed manifest; the client fetches only the small delta, reconstructs the asset
|
|
30
|
+
against a base blob it already holds, and verifies the result against the asset's
|
|
31
|
+
SHA-256. A bad or forged delta can never install โ it falls back to a full download.
|
|
32
|
+
- **Safe update client** โ `createUpdateClient()`:
|
|
33
|
+
- `check()` โ fetch + verify the signed manifest; apply the signature, expiry,
|
|
34
|
+
runtime-compatibility, and monotonic-version (anti-rollback) gates.
|
|
35
|
+
- `download()` โ fetch only assets not already stored, hash-verify each, record a
|
|
36
|
+
`pending` generation (never touches the live one).
|
|
37
|
+
- `apply()` โ verify all assets present, then **atomically** flip the current
|
|
38
|
+
generation (keeping `previous` + the embedded build as fallbacks).
|
|
39
|
+
- `boot()` โ on startup, roll back a generation that **crash-loops** before it
|
|
40
|
+
confirms itself (readiness handshake), down to previous โ embedded.
|
|
41
|
+
- `notifyReady()` / `rollback()` โ confirm a good launch, or revert manually.
|
|
42
|
+
|
|
43
|
+
## Reference update server
|
|
44
|
+
|
|
45
|
+
The `@mindees/updates/server` subpath ships a **pure, capability-injected**
|
|
46
|
+
`createUpdateServer` โ the other side of the wire. It **never signs** (it serves
|
|
47
|
+
**pre-signed** manifests; signing stays offline) and is deterministic + headlessly
|
|
48
|
+
testable. `resolveUpdate({ runtimeVersion, channel, currentVersion, rolloutKey })`
|
|
49
|
+
does channel selection, **deterministic staged rollout**, an **anti-downgrade** mirror
|
|
50
|
+
of the client's own gate, **freeze** (expiry), and **rollback directives**; `getAsset`
|
|
51
|
+
serves content-addressed blobs (delta blobs included). A runnable `node:http` adapter
|
|
52
|
+
lives in [`examples/pulse-server/`](../../examples/pulse-server/). See
|
|
53
|
+
[ADR-0010](../../docs/adr/0010-pulse-reference-server.md).
|
|
54
|
+
|
|
55
|
+
## Server-driven UI (SDUI)
|
|
56
|
+
|
|
57
|
+
The `@mindees/updates/sdui` subpath ships UI **as data** over OTA. `compileSdui`
|
|
58
|
+
validates an untrusted, schema-versioned JSON tree against an injected **allowlist**
|
|
59
|
+
registry and compiles it to a `@mindees/core` `MindeesNode`:
|
|
60
|
+
|
|
61
|
+
- **named actions** โ `{ "onPress": { "$action": "increment", "args": {โฆ} } }` โ
|
|
62
|
+
a function calling a pre-registered handler (**no code is ever transported or `eval`'d**),
|
|
63
|
+
- **reactive bindings** โ `{ "label": { "$bind": "count" } }` โ a `() => value`
|
|
64
|
+
accessor the renderer treats as a fine-grained reactive region,
|
|
65
|
+
- **fail-closed + safe** โ unknown tags/actions, missing bindings, dangerous keys
|
|
66
|
+
(`__proto__`/`constructor`/`prototype`), and depth/node/string/prop limit breaches all
|
|
67
|
+
throw `SduiError`.
|
|
68
|
+
|
|
69
|
+
Incremental updates use a pure-TS RFC 7396 merge-patch (`applyMergePatch`) and a safe
|
|
70
|
+
RFC 6902 subset (`applyJsonPatch` โ `add`/`remove`/`replace`); a patched tree must be
|
|
71
|
+
re-run through `compileSdui` before render. Design: [ADR-0011](../../docs/adr/0011-pulse-sdui.md).
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import {
|
|
77
|
+
createUpdateClient,
|
|
78
|
+
createMemoryStorage,
|
|
79
|
+
generateKeypair,
|
|
80
|
+
signManifest,
|
|
81
|
+
toHex,
|
|
82
|
+
} from '@mindees/updates'
|
|
83
|
+
|
|
84
|
+
// On your build server: sign a manifest (keep the secret key off-device).
|
|
85
|
+
const { secretKey, publicKey } = generateKeypair()
|
|
86
|
+
const signed = signManifest(manifest, [{ keyId: 'release-2026', secretKey }])
|
|
87
|
+
|
|
88
|
+
// In the app: embed the public key + the runtime version, then drive the flow.
|
|
89
|
+
const client = createUpdateClient({
|
|
90
|
+
storage: createMemoryStorage(),
|
|
91
|
+
trustedKeys: [{ keyId: 'release-2026', publicKey: toHex(publicKey) }],
|
|
92
|
+
runtimeVersion: '1.0.0',
|
|
93
|
+
embeddedVersion: 1,
|
|
94
|
+
fetchManifest: async () => fetchJson('/updates/latest'),
|
|
95
|
+
fetchAsset: async (asset) => fetchBytes(`/updates/assets/${asset.sha256}`),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await client.boot() // call once at startup, before rendering, to recover from a bad update
|
|
99
|
+
const result = await client.check()
|
|
100
|
+
if (result.available) {
|
|
101
|
+
await client.download(result.manifest)
|
|
102
|
+
await client.apply(result.manifest.id) // takes effect next launch
|
|
103
|
+
}
|
|
104
|
+
// โฆonce the new version has launched and rendered successfully:
|
|
105
|
+
await client.notifyReady()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Security model
|
|
109
|
+
|
|
110
|
+
A minimal subset of [The Update Framework](https://theupdateframework.io/) (TUF):
|
|
111
|
+
|
|
112
|
+
- **Tamper** โ the manifest signature plus a per-file SHA-256 check.
|
|
113
|
+
- **Rollback / downgrade** โ the client persists the highest version ever applied and
|
|
114
|
+
rejects anything not strictly newer.
|
|
115
|
+
- **Freeze** โ a past `expires` is rejected.
|
|
116
|
+
- **Mix-and-match** โ the manifest enumerates the exact set of files.
|
|
117
|
+
- **Compromised CDN** โ signatures are end-to-end; the CDN never holds a private key.
|
|
118
|
+
- **Native incompatibility** โ a `runtimeVersion` mismatch is reported as not-available,
|
|
119
|
+
so an OTA update can never carry native changes.
|
|
120
|
+
|
|
121
|
+
Design rationale: [ADR-0008](../../docs/adr/0008-pulse-ota.md).
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
`MIT OR Apache-2.0`
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AssetEntry } from "./manifest.js";
|
|
2
|
+
import { SignedManifest, TrustedKey, VerifiedManifest } from "./signing.js";
|
|
3
|
+
import { GenerationMeta, UpdateState, UpdateStorage } from "./store.js";
|
|
4
|
+
|
|
5
|
+
//#region src/client.d.ts
|
|
6
|
+
/** Options for {@link createUpdateClient}. */
|
|
7
|
+
interface UpdateClientOptions {
|
|
8
|
+
/** Where blobs + state live. */
|
|
9
|
+
readonly storage: UpdateStorage;
|
|
10
|
+
/** Public keys this app trusts to sign updates. */
|
|
11
|
+
readonly trustedKeys: readonly TrustedKey[];
|
|
12
|
+
/** The app's native runtime version (gates compatibility). */
|
|
13
|
+
readonly runtimeVersion: string;
|
|
14
|
+
/** Version baked into the binary (the rollback floor). Default 0. */
|
|
15
|
+
readonly embeddedVersion?: number;
|
|
16
|
+
/** Valid signatures required from distinct trusted keys. Default 1. */
|
|
17
|
+
readonly threshold?: number;
|
|
18
|
+
/** Fetch the current signed manifest from the server. */
|
|
19
|
+
readonly fetchManifest: () => Promise<SignedManifest>;
|
|
20
|
+
/** Fetch an asset's bytes. */
|
|
21
|
+
readonly fetchAsset: (asset: AssetEntry) => Promise<Uint8Array>;
|
|
22
|
+
/** Boots into an unconfirmed generation before rolling back. Default 1. */
|
|
23
|
+
readonly maxBootAttempts?: number;
|
|
24
|
+
/** Clock, for expiry checks + tests. Default `() => Date.now()`. */
|
|
25
|
+
readonly now?: () => number;
|
|
26
|
+
}
|
|
27
|
+
/** Result of {@link UpdateClient.check}. */
|
|
28
|
+
type UpdateCheck = {
|
|
29
|
+
readonly available: true;
|
|
30
|
+
readonly manifest: VerifiedManifest;
|
|
31
|
+
} | {
|
|
32
|
+
readonly available: false;
|
|
33
|
+
readonly reason: 'up-to-date' | 'runtime-mismatch';
|
|
34
|
+
};
|
|
35
|
+
/** Result of {@link UpdateClient.boot}. */
|
|
36
|
+
interface BootResult {
|
|
37
|
+
/** True when boot fell back to the embedded build after a failed generation. */
|
|
38
|
+
readonly isEmergencyLaunch: boolean;
|
|
39
|
+
/** The active generation id after boot (`null` = embedded). */
|
|
40
|
+
readonly current: string | null;
|
|
41
|
+
}
|
|
42
|
+
/** The OTA update client. */
|
|
43
|
+
interface UpdateClient {
|
|
44
|
+
/** Run the boot/recovery check (call once at startup, before rendering). */
|
|
45
|
+
boot(): Promise<BootResult>;
|
|
46
|
+
/** Check the server for a newer, valid, compatible update. */
|
|
47
|
+
check(): Promise<UpdateCheck>;
|
|
48
|
+
/**
|
|
49
|
+
* Download a verified manifest's missing assets and record a pending generation.
|
|
50
|
+
* Accepts only a {@link VerifiedManifest} (from {@link UpdateClient.check}), and
|
|
51
|
+
* re-asserts the freeze / runtime / anti-downgrade gates, so it is safe even if
|
|
52
|
+
* called without `check()` first.
|
|
53
|
+
*/
|
|
54
|
+
download(manifest: VerifiedManifest): Promise<GenerationMeta>;
|
|
55
|
+
/** Atomically make a downloaded generation the current one (applies next launch). */
|
|
56
|
+
apply(generationId: string): Promise<void>;
|
|
57
|
+
/** Confirm the current generation launched successfully. */
|
|
58
|
+
notifyReady(): Promise<void>;
|
|
59
|
+
/** Manually roll back the current generation to the previous / embedded one. */
|
|
60
|
+
rollback(): Promise<void>;
|
|
61
|
+
/** The persisted state. */
|
|
62
|
+
state(): Promise<UpdateState>;
|
|
63
|
+
/**
|
|
64
|
+
* True when the most recent {@link UpdateClient.boot} (or {@link UpdateClient.rollback})
|
|
65
|
+
* fell back to the embedded build after a failed update. Reflects only the latest
|
|
66
|
+
* call โ it does not latch across boots.
|
|
67
|
+
*/
|
|
68
|
+
readonly isEmergencyLaunch: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** Create an {@link UpdateClient}. */
|
|
71
|
+
declare function createUpdateClient(options: UpdateClientOptions): UpdateClient;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { BootResult, UpdateCheck, UpdateClient, UpdateClientOptions, createUpdateClient };
|
|
74
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","names":[],"sources":["../src/client.ts"],"mappings":";;;;;;UAoCiB,mBAAA;EAMN;EAAA,SAJA,OAAA,EAAS,aAAA;EAQT;EAAA,SANA,WAAA,WAAsB,UAAA;EAQD;EAAA,SANrB,cAAA;EAQA;EAAA,SANA,eAAA;EAMa;EAAA,SAJb,SAAA;EAI2C;EAAA,SAF3C,aAAA,QAAqB,OAAA,CAAQ,cAAA;EAM7B;EAAA,SAJA,UAAA,GAAa,KAAA,EAAO,UAAA,KAAe,OAAA,CAAQ,UAAA;EAIxC;EAAA,SAFH,eAAA;EAMY;EAAA,SAJZ,GAAA;AAAA;;KAIC,WAAA;EAAA,SACG,SAAA;EAAA,SAA0B,QAAA,EAAU,gBAAgB;AAAA;EAAA,SACpD,SAAA;EAAA,SAA2B,MAAA;AAAA;;UAGzB,UAAA;EAEN;EAAA,SAAA,iBAAA;EAMM;EAAA,SAJN,OAAO;AAAA;;UAID,YAAA;EAIE;EAFjB,IAAA,IAAQ,OAAA,CAAQ,UAAA;EASG;EAPnB,KAAA,IAAS,OAAA,CAAQ,WAAA;EAOqB;;;;;;EAAtC,QAAA,CAAS,QAAA,EAAU,gBAAA,GAAmB,OAAA,CAAQ,cAAA;EAQ9B;EANhB,KAAA,CAAM,YAAA,WAAuB,OAAA;EAXrB;EAaR,WAAA,IAAe,OAAA;EAXf;EAaA,QAAA,IAAY,OAAA;EAbK;EAejB,KAAA,IAAS,OAAA,CAAQ,WAAA;EARE;;;;;EAAA,SAcV,iBAAA;AAAA;;iBAsCK,kBAAA,CAAmB,OAAA,EAAS,mBAAA,GAAsB,YAAY"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { sha256Hex } from "./crypto.js";
|
|
2
|
+
import { UpdateError } from "./errors.js";
|
|
3
|
+
import { applyDelta } from "./delta.js";
|
|
4
|
+
import { allAssets, canonicalManifestJson, parseManifest } from "./manifest.js";
|
|
5
|
+
import { verifySignedManifest } from "./signing.js";
|
|
6
|
+
import { initialState } from "./store.js";
|
|
7
|
+
//#region src/client.ts
|
|
8
|
+
/**
|
|
9
|
+
* The Pulse update client โ ties the manifest, signing, and storage together into a
|
|
10
|
+
* safe OTA flow:
|
|
11
|
+
*
|
|
12
|
+
* - **check()** โ fetch + verify the signed manifest; apply the signature, expiry,
|
|
13
|
+
* runtime-compatibility, and monotonic-version gates.
|
|
14
|
+
* - **download()** โ fetch only the assets whose hash isn't already stored, verify
|
|
15
|
+
* each blob's SHA-256, and record a `pending` generation. Never mutates the live one.
|
|
16
|
+
* - **apply()** โ verify the generation's assets are present, then atomically flip
|
|
17
|
+
* `current`, retaining `previous` + the embedded build as fallbacks.
|
|
18
|
+
* - **boot()** โ on startup, roll back a generation that crash-loops before it
|
|
19
|
+
* confirms itself (readiness handshake), down to the embedded build.
|
|
20
|
+
* - **notifyReady()** โ the app calls this once it has launched successfully.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Compute the state after rolling back the current generation: mark it `failed`,
|
|
26
|
+
* promote the previous good generation to `current` (or fall back to the embedded
|
|
27
|
+
* build when there is none), and **re-arm crash-loop detection** on the rolled-to
|
|
28
|
+
* generation so a *second* failure falls through to the next fallback
|
|
29
|
+
* (`current โ previous โ embedded`). The anti-downgrade floor (`highestVersion`) is
|
|
30
|
+
* preserved, so the failed version is never silently re-accepted. Shared by
|
|
31
|
+
* `boot()`'s crash-loop path and the manual `rollback()`.
|
|
32
|
+
*/
|
|
33
|
+
function rolledBackState(st) {
|
|
34
|
+
const generations = { ...st.generations };
|
|
35
|
+
if (st.current) {
|
|
36
|
+
const failing = generations[st.current];
|
|
37
|
+
if (failing) generations[st.current] = {
|
|
38
|
+
...failing,
|
|
39
|
+
status: "failed"
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const rolledTo = st.previous && generations[st.previous] ? st.previous : null;
|
|
43
|
+
if (rolledTo) {
|
|
44
|
+
const restored = generations[rolledTo];
|
|
45
|
+
if (restored) generations[rolledTo] = {
|
|
46
|
+
...restored,
|
|
47
|
+
status: "current"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
current: rolledTo,
|
|
52
|
+
previous: null,
|
|
53
|
+
highestVersion: st.highestVersion,
|
|
54
|
+
pendingVerification: rolledTo !== null,
|
|
55
|
+
bootAttempts: 0,
|
|
56
|
+
generations
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Create an {@link UpdateClient}. */
|
|
60
|
+
function createUpdateClient(options) {
|
|
61
|
+
const { storage, trustedKeys, runtimeVersion, embeddedVersion = 0, threshold = 1, fetchManifest, fetchAsset, maxBootAttempts = 1, now = () => Date.now() } = options;
|
|
62
|
+
if (trustedKeys.length < 1) throw new TypeError("createUpdateClient: trustedKeys must contain at least one key");
|
|
63
|
+
const distinctKeyCount = new Set(trustedKeys.map((k) => k.publicKey)).size;
|
|
64
|
+
if (!Number.isInteger(threshold) || threshold < 1 || threshold > distinctKeyCount) throw new TypeError(`createUpdateClient: threshold must be an integer in [1, ${distinctKeyCount}], got ${threshold}`);
|
|
65
|
+
if (typeof runtimeVersion !== "string" || runtimeVersion.length === 0) throw new TypeError("createUpdateClient: runtimeVersion must be a non-empty string");
|
|
66
|
+
if (!Number.isInteger(embeddedVersion) || embeddedVersion < 0) throw new TypeError(`createUpdateClient: embeddedVersion must be a non-negative integer, got ${embeddedVersion}`);
|
|
67
|
+
if (!Number.isInteger(maxBootAttempts) || maxBootAttempts < 1) throw new TypeError(`createUpdateClient: maxBootAttempts must be a positive integer, got ${maxBootAttempts}`);
|
|
68
|
+
let emergency = false;
|
|
69
|
+
const readStateOrInit = async () => await storage.readState() ?? initialState(embeddedVersion);
|
|
70
|
+
/** Throw if the manifest has expired (freeze protection). */
|
|
71
|
+
const assertNotExpired = (manifest) => {
|
|
72
|
+
if (manifest.expires !== void 0 && Date.parse(manifest.expires) <= now()) throw new UpdateError("MANIFEST_EXPIRED", `manifest ${manifest.id} expired at ${manifest.expires}`);
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Try to reconstruct `asset` from its delta against a stored base blob. Returns the
|
|
76
|
+
* verified bytes, or `null` on any failure (missing/bad delta blob, malformed delta,
|
|
77
|
+
* reconstruction hash mismatch) so the caller falls back to a full fetch. The
|
|
78
|
+
* `sha256Hex(result) === asset.sha256` check here is the trust boundary.
|
|
79
|
+
*/
|
|
80
|
+
const tryReconstruct = async (asset, patch) => {
|
|
81
|
+
try {
|
|
82
|
+
const deltaBytes = await fetchAsset(patch.delta);
|
|
83
|
+
if (sha256Hex(deltaBytes) !== patch.delta.sha256) return null;
|
|
84
|
+
const result = applyDelta(await storage.readBlob(patch.base), deltaBytes, { maxBytes: asset.size });
|
|
85
|
+
if (sha256Hex(result) !== asset.sha256) return null;
|
|
86
|
+
return result;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
get isEmergencyLaunch() {
|
|
93
|
+
return emergency;
|
|
94
|
+
},
|
|
95
|
+
state: readStateOrInit,
|
|
96
|
+
async check() {
|
|
97
|
+
const manifest = verifySignedManifest(await fetchManifest(), trustedKeys, threshold);
|
|
98
|
+
assertNotExpired(manifest);
|
|
99
|
+
if (manifest.runtimeVersion !== runtimeVersion) return {
|
|
100
|
+
available: false,
|
|
101
|
+
reason: "runtime-mismatch"
|
|
102
|
+
};
|
|
103
|
+
const st = await readStateOrInit();
|
|
104
|
+
if (manifest.version <= st.highestVersion) return {
|
|
105
|
+
available: false,
|
|
106
|
+
reason: "up-to-date"
|
|
107
|
+
};
|
|
108
|
+
return {
|
|
109
|
+
available: true,
|
|
110
|
+
manifest
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
async download(manifest) {
|
|
114
|
+
assertNotExpired(manifest);
|
|
115
|
+
if (manifest.runtimeVersion !== runtimeVersion) throw new UpdateError("RUNTIME_MISMATCH", `manifest ${manifest.id} targets runtime ${manifest.runtimeVersion}, app is ${runtimeVersion}`);
|
|
116
|
+
const st = await readStateOrInit();
|
|
117
|
+
if (manifest.version <= st.highestVersion) throw new UpdateError("VERSION_NOT_NEWER", `manifest ${manifest.id} version ${manifest.version} is not newer than ${st.highestVersion}`);
|
|
118
|
+
for (const asset of allAssets(manifest)) {
|
|
119
|
+
if (await storage.hasBlob(asset.sha256)) continue;
|
|
120
|
+
if (asset.patch && await storage.hasBlob(asset.patch.base)) {
|
|
121
|
+
const reconstructed = await tryReconstruct(asset, asset.patch);
|
|
122
|
+
if (reconstructed) {
|
|
123
|
+
await storage.writeBlob(asset.sha256, reconstructed);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const bytes = await fetchAsset(asset);
|
|
128
|
+
const actual = sha256Hex(bytes);
|
|
129
|
+
if (actual !== asset.sha256) throw new UpdateError("HASH_MISMATCH", `asset ${asset.path}: expected ${asset.sha256}, got ${actual}`);
|
|
130
|
+
await storage.writeBlob(asset.sha256, bytes);
|
|
131
|
+
}
|
|
132
|
+
const generation = {
|
|
133
|
+
id: manifest.id,
|
|
134
|
+
version: manifest.version,
|
|
135
|
+
manifest: canonicalManifestJson(manifest),
|
|
136
|
+
status: "pending"
|
|
137
|
+
};
|
|
138
|
+
await storage.writeState({
|
|
139
|
+
...st,
|
|
140
|
+
generations: {
|
|
141
|
+
...st.generations,
|
|
142
|
+
[generation.id]: generation
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return generation;
|
|
146
|
+
},
|
|
147
|
+
async apply(generationId) {
|
|
148
|
+
const st = await readStateOrInit();
|
|
149
|
+
const generation = st.generations[generationId];
|
|
150
|
+
if (!generation) throw new UpdateError("GENERATION_UNKNOWN", `no generation ${generationId}`);
|
|
151
|
+
if (generationId === st.current && generation.status === "current") return;
|
|
152
|
+
if (generation.status === "failed") throw new UpdateError("GENERATION_FAILED", `generation ${generationId} previously failed`);
|
|
153
|
+
if (generation.version < st.highestVersion || generation.version === st.highestVersion && generationId !== st.current) throw new UpdateError("VERSION_NOT_NEWER", `generation ${generationId} version ${generation.version} is not newer than ${st.highestVersion}`);
|
|
154
|
+
for (const asset of allAssets(parseManifest(generation.manifest))) if (!await storage.hasBlob(asset.sha256)) throw new UpdateError("ASSET_MISSING", `generation ${generationId} missing asset ${asset.path}`);
|
|
155
|
+
const previous = st.current && !st.pendingVerification ? st.current : st.previous;
|
|
156
|
+
const generations = { ...st.generations };
|
|
157
|
+
if (st.current) {
|
|
158
|
+
const outgoing = generations[st.current];
|
|
159
|
+
if (outgoing) generations[st.current] = {
|
|
160
|
+
...outgoing,
|
|
161
|
+
status: st.current === previous ? "previous" : "failed"
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
generations[generationId] = {
|
|
165
|
+
...generation,
|
|
166
|
+
status: "current"
|
|
167
|
+
};
|
|
168
|
+
await storage.writeState({
|
|
169
|
+
current: generationId,
|
|
170
|
+
previous,
|
|
171
|
+
highestVersion: Math.max(st.highestVersion, generation.version),
|
|
172
|
+
pendingVerification: true,
|
|
173
|
+
bootAttempts: 0,
|
|
174
|
+
generations
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
async boot() {
|
|
178
|
+
emergency = false;
|
|
179
|
+
const st = await readStateOrInit();
|
|
180
|
+
if (st.current !== null && st.pendingVerification) {
|
|
181
|
+
const attempts = st.bootAttempts + 1;
|
|
182
|
+
if (attempts > maxBootAttempts) {
|
|
183
|
+
const next = rolledBackState(st);
|
|
184
|
+
await storage.writeState(next);
|
|
185
|
+
emergency = next.current === null;
|
|
186
|
+
return {
|
|
187
|
+
isEmergencyLaunch: emergency,
|
|
188
|
+
current: next.current
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
await storage.writeState({
|
|
192
|
+
...st,
|
|
193
|
+
bootAttempts: attempts
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
isEmergencyLaunch: false,
|
|
197
|
+
current: st.current
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (await storage.readState() === null) await storage.writeState(st);
|
|
201
|
+
return {
|
|
202
|
+
isEmergencyLaunch: false,
|
|
203
|
+
current: st.current
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
async notifyReady() {
|
|
207
|
+
const st = await readStateOrInit();
|
|
208
|
+
if (!st.pendingVerification && st.bootAttempts === 0) return;
|
|
209
|
+
await storage.writeState({
|
|
210
|
+
...st,
|
|
211
|
+
pendingVerification: false,
|
|
212
|
+
bootAttempts: 0
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
async rollback() {
|
|
216
|
+
const st = await readStateOrInit();
|
|
217
|
+
if (st.current === null) return;
|
|
218
|
+
const next = rolledBackState(st);
|
|
219
|
+
await storage.writeState(next);
|
|
220
|
+
emergency = next.current === null;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
//#endregion
|
|
225
|
+
export { createUpdateClient };
|
|
226
|
+
|
|
227
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["/**\n * The Pulse update client โ ties the manifest, signing, and storage together into a\n * safe OTA flow:\n *\n * - **check()** โ fetch + verify the signed manifest; apply the signature, expiry,\n * runtime-compatibility, and monotonic-version gates.\n * - **download()** โ fetch only the assets whose hash isn't already stored, verify\n * each blob's SHA-256, and record a `pending` generation. Never mutates the live one.\n * - **apply()** โ verify the generation's assets are present, then atomically flip\n * `current`, retaining `previous` + the embedded build as fallbacks.\n * - **boot()** โ on startup, roll back a generation that crash-loops before it\n * confirms itself (readiness handshake), down to the embedded build.\n * - **notifyReady()** โ the app calls this once it has launched successfully.\n *\n * @module\n */\n\nimport { sha256Hex } from './crypto'\nimport { applyDelta } from './delta'\nimport { UpdateError } from './errors'\nimport {\n type AssetEntry,\n allAssets,\n canonicalManifestJson,\n type PatchDescriptor,\n parseManifest,\n} from './manifest'\nimport {\n type SignedManifest,\n type TrustedKey,\n type VerifiedManifest,\n verifySignedManifest,\n} from './signing'\nimport { type GenerationMeta, initialState, type UpdateState, type UpdateStorage } from './store'\n\n/** Options for {@link createUpdateClient}. */\nexport interface UpdateClientOptions {\n /** Where blobs + state live. */\n readonly storage: UpdateStorage\n /** Public keys this app trusts to sign updates. */\n readonly trustedKeys: readonly TrustedKey[]\n /** The app's native runtime version (gates compatibility). */\n readonly runtimeVersion: string\n /** Version baked into the binary (the rollback floor). Default 0. */\n readonly embeddedVersion?: number\n /** Valid signatures required from distinct trusted keys. Default 1. */\n readonly threshold?: number\n /** Fetch the current signed manifest from the server. */\n readonly fetchManifest: () => Promise<SignedManifest>\n /** Fetch an asset's bytes. */\n readonly fetchAsset: (asset: AssetEntry) => Promise<Uint8Array>\n /** Boots into an unconfirmed generation before rolling back. Default 1. */\n readonly maxBootAttempts?: number\n /** Clock, for expiry checks + tests. Default `() => Date.now()`. */\n readonly now?: () => number\n}\n\n/** Result of {@link UpdateClient.check}. */\nexport type UpdateCheck =\n | { readonly available: true; readonly manifest: VerifiedManifest }\n | { readonly available: false; readonly reason: 'up-to-date' | 'runtime-mismatch' }\n\n/** Result of {@link UpdateClient.boot}. */\nexport interface BootResult {\n /** True when boot fell back to the embedded build after a failed generation. */\n readonly isEmergencyLaunch: boolean\n /** The active generation id after boot (`null` = embedded). */\n readonly current: string | null\n}\n\n/** The OTA update client. */\nexport interface UpdateClient {\n /** Run the boot/recovery check (call once at startup, before rendering). */\n boot(): Promise<BootResult>\n /** Check the server for a newer, valid, compatible update. */\n check(): Promise<UpdateCheck>\n /**\n * Download a verified manifest's missing assets and record a pending generation.\n * Accepts only a {@link VerifiedManifest} (from {@link UpdateClient.check}), and\n * re-asserts the freeze / runtime / anti-downgrade gates, so it is safe even if\n * called without `check()` first.\n */\n download(manifest: VerifiedManifest): Promise<GenerationMeta>\n /** Atomically make a downloaded generation the current one (applies next launch). */\n apply(generationId: string): Promise<void>\n /** Confirm the current generation launched successfully. */\n notifyReady(): Promise<void>\n /** Manually roll back the current generation to the previous / embedded one. */\n rollback(): Promise<void>\n /** The persisted state. */\n state(): Promise<UpdateState>\n /**\n * True when the most recent {@link UpdateClient.boot} (or {@link UpdateClient.rollback})\n * fell back to the embedded build after a failed update. Reflects only the latest\n * call โ it does not latch across boots.\n */\n readonly isEmergencyLaunch: boolean\n}\n\n/**\n * Compute the state after rolling back the current generation: mark it `failed`,\n * promote the previous good generation to `current` (or fall back to the embedded\n * build when there is none), and **re-arm crash-loop detection** on the rolled-to\n * generation so a *second* failure falls through to the next fallback\n * (`current โ previous โ embedded`). The anti-downgrade floor (`highestVersion`) is\n * preserved, so the failed version is never silently re-accepted. Shared by\n * `boot()`'s crash-loop path and the manual `rollback()`.\n */\nfunction rolledBackState(st: UpdateState): UpdateState {\n const generations = { ...st.generations }\n if (st.current) {\n const failing = generations[st.current]\n if (failing) generations[st.current] = { ...failing, status: 'failed' }\n }\n const rolledTo = st.previous && generations[st.previous] ? st.previous : null\n if (rolledTo) {\n const restored = generations[rolledTo]\n if (restored) generations[rolledTo] = { ...restored, status: 'current' }\n }\n return {\n current: rolledTo,\n // The single retained fallback was just consumed; the next failure on the\n // rolled-to generation must fall through to the embedded build.\n previous: null,\n highestVersion: st.highestVersion,\n // Re-arm: a real rolled-to generation is on probation and must re-confirm via\n // notifyReady(); the embedded build (null) is always trusted, so don't arm it.\n pendingVerification: rolledTo !== null,\n bootAttempts: 0,\n generations,\n }\n}\n\n/** Create an {@link UpdateClient}. */\nexport function createUpdateClient(options: UpdateClientOptions): UpdateClient {\n const {\n storage,\n trustedKeys,\n runtimeVersion,\n embeddedVersion = 0,\n threshold = 1,\n fetchManifest,\n fetchAsset,\n maxBootAttempts = 1,\n now = () => Date.now(),\n } = options\n\n // Fail fast on misconfiguration: a silently-broken trust/rollback setup is worse\n // than a thrown error at construction. These are programmer errors, not protocol\n // errors, so they surface as TypeError rather than UpdateError.\n if (trustedKeys.length < 1) {\n throw new TypeError('createUpdateClient: trustedKeys must contain at least one key')\n }\n // Bound the threshold by distinct *public keys*: verifySignedManifest counts unique\n // keys, so a threshold above the distinct-key count is unattainable (every check()\n // would fail). Duplicate keyId aliases for one key must not inflate the ceiling.\n const distinctKeyCount = new Set(trustedKeys.map((k) => k.publicKey)).size\n if (!Number.isInteger(threshold) || threshold < 1 || threshold > distinctKeyCount) {\n throw new TypeError(\n `createUpdateClient: threshold must be an integer in [1, ${distinctKeyCount}], got ${threshold}`,\n )\n }\n if (typeof runtimeVersion !== 'string' || runtimeVersion.length === 0) {\n throw new TypeError('createUpdateClient: runtimeVersion must be a non-empty string')\n }\n if (!Number.isInteger(embeddedVersion) || embeddedVersion < 0) {\n throw new TypeError(\n `createUpdateClient: embeddedVersion must be a non-negative integer, got ${embeddedVersion}`,\n )\n }\n if (!Number.isInteger(maxBootAttempts) || maxBootAttempts < 1) {\n throw new TypeError(\n `createUpdateClient: maxBootAttempts must be a positive integer, got ${maxBootAttempts}`,\n )\n }\n\n let emergency = false\n\n const readStateOrInit = async (): Promise<UpdateState> =>\n (await storage.readState()) ?? initialState(embeddedVersion)\n\n /** Throw if the manifest has expired (freeze protection). */\n const assertNotExpired = (manifest: VerifiedManifest): void => {\n if (manifest.expires !== undefined && Date.parse(manifest.expires) <= now()) {\n throw new UpdateError(\n 'MANIFEST_EXPIRED',\n `manifest ${manifest.id} expired at ${manifest.expires}`,\n )\n }\n }\n\n /**\n * Try to reconstruct `asset` from its delta against a stored base blob. Returns the\n * verified bytes, or `null` on any failure (missing/bad delta blob, malformed delta,\n * reconstruction hash mismatch) so the caller falls back to a full fetch. The\n * `sha256Hex(result) === asset.sha256` check here is the trust boundary.\n */\n const tryReconstruct = async (\n asset: AssetEntry,\n patch: PatchDescriptor,\n ): Promise<Uint8Array | null> => {\n try {\n const deltaBytes = await fetchAsset(patch.delta)\n if (sha256Hex(deltaBytes) !== patch.delta.sha256) return null // delta blob corrupt\n const baseBytes = await storage.readBlob(patch.base)\n // Bound the allocation by the signed expected size โ an honest delta always\n // reconstructs exactly asset.size bytes, so this never rejects a valid one.\n const result = applyDelta(baseBytes, deltaBytes, { maxBytes: asset.size })\n if (sha256Hex(result) !== asset.sha256) return null // reconstruction mismatch\n return result\n } catch {\n return null\n }\n }\n\n return {\n get isEmergencyLaunch() {\n return emergency\n },\n\n state: readStateOrInit,\n\n async check(): Promise<UpdateCheck> {\n const signed = await fetchManifest()\n // Throws SIGNATURE_INVALID if < threshold valid trusted signatures.\n const manifest = verifySignedManifest(signed, trustedKeys, threshold)\n assertNotExpired(manifest)\n if (manifest.runtimeVersion !== runtimeVersion) {\n return { available: false, reason: 'runtime-mismatch' }\n }\n const st = await readStateOrInit()\n if (manifest.version <= st.highestVersion) {\n return { available: false, reason: 'up-to-date' } // never downgrade\n }\n return { available: true, manifest }\n },\n\n async download(manifest): Promise<GenerationMeta> {\n // `manifest` is signature-verified (the VerifiedManifest brand), but freshness,\n // runtime compatibility, and the anti-downgrade floor depend on *now* + current\n // state โ re-assert them here so download() is safe even without a prior check().\n assertNotExpired(manifest)\n if (manifest.runtimeVersion !== runtimeVersion) {\n throw new UpdateError(\n 'RUNTIME_MISMATCH',\n `manifest ${manifest.id} targets runtime ${manifest.runtimeVersion}, app is ${runtimeVersion}`,\n )\n }\n const st = await readStateOrInit()\n if (manifest.version <= st.highestVersion) {\n throw new UpdateError(\n 'VERSION_NOT_NEWER',\n `manifest ${manifest.id} version ${manifest.version} is not newer than ${st.highestVersion}`,\n )\n }\n for (const asset of allAssets(manifest)) {\n if (await storage.hasBlob(asset.sha256)) continue // content-addressed: already have it\n // Differential path: reconstruct from a delta if we already hold the base blob.\n // The post-apply SHA-256 gate below is the trust boundary, so a bad/forged\n // delta just yields null and falls through to a full fetch.\n if (asset.patch && (await storage.hasBlob(asset.patch.base))) {\n const reconstructed = await tryReconstruct(asset, asset.patch)\n if (reconstructed) {\n await storage.writeBlob(asset.sha256, reconstructed)\n continue\n }\n }\n const bytes = await fetchAsset(asset)\n const actual = sha256Hex(bytes)\n if (actual !== asset.sha256) {\n throw new UpdateError(\n 'HASH_MISMATCH',\n `asset ${asset.path}: expected ${asset.sha256}, got ${actual}`,\n )\n }\n await storage.writeBlob(asset.sha256, bytes)\n }\n const generation: GenerationMeta = {\n id: manifest.id,\n version: manifest.version,\n manifest: canonicalManifestJson(manifest),\n status: 'pending',\n }\n await storage.writeState({\n ...st,\n generations: { ...st.generations, [generation.id]: generation },\n })\n return generation\n },\n\n async apply(generationId): Promise<void> {\n const st = await readStateOrInit()\n const generation = st.generations[generationId]\n if (!generation) throw new UpdateError('GENERATION_UNKNOWN', `no generation ${generationId}`)\n // Idempotent re-apply: the requested generation is ALREADY current. Short-circuit\n // before rewriting state โ re-running the flip would reset pendingVerification and\n // bootAttempts, un-confirming a generation that may have already passed its\n // readiness handshake (and re-arming crash-loop rollback against a known-good build).\n if (generationId === st.current && generation.status === 'current') return\n // Never re-activate a generation that previously failed (e.g. crash-looped).\n if (generation.status === 'failed') {\n throw new UpdateError('GENERATION_FAILED', `generation ${generationId} previously failed`)\n }\n // Never activate something that is not strictly newer than the high-water mark\n // (anti-downgrade). download()/check()/the server all enforce a `<=` floor;\n // apply() is the final gate before code goes live, so it must too โ otherwise a\n // DIFFERENT bundle signed at the SAME version could laterally replace a\n // confirmed-good current generation. Re-applying the already-current generation\n // stays idempotent (allowed).\n if (\n generation.version < st.highestVersion ||\n (generation.version === st.highestVersion && generationId !== st.current)\n ) {\n throw new UpdateError(\n 'VERSION_NOT_NEWER',\n `generation ${generationId} version ${generation.version} is not newer than ${st.highestVersion}`,\n )\n }\n // All assets must be present before we make it current.\n for (const asset of allAssets(parseManifest(generation.manifest))) {\n if (!(await storage.hasBlob(asset.sha256))) {\n throw new UpdateError(\n 'ASSET_MISSING',\n `generation ${generationId} missing asset ${asset.path}`,\n )\n }\n }\n // Keep as the rollback target only a *confirmed* outgoing generation. An\n // unconfirmed `current` (still pending) never proved itself, so we retain the\n // older known-good `previous` instead of stranding it behind a suspect build.\n const previous = st.current && !st.pendingVerification ? st.current : st.previous\n const generations = { ...st.generations }\n if (st.current) {\n const outgoing = generations[st.current]\n if (outgoing) {\n generations[st.current] = {\n ...outgoing,\n status: st.current === previous ? 'previous' : 'failed',\n }\n }\n }\n generations[generationId] = { ...generation, status: 'current' }\n await storage.writeState({\n current: generationId,\n previous,\n highestVersion: Math.max(st.highestVersion, generation.version),\n pendingVerification: true,\n bootAttempts: 0,\n generations,\n })\n },\n\n async boot(): Promise<BootResult> {\n emergency = false // reflects only *this* launch โ never latches across boots\n const st = await readStateOrInit()\n if (st.current !== null && st.pendingVerification) {\n const attempts = st.bootAttempts + 1\n if (attempts > maxBootAttempts) {\n // The current generation crash-looped before confirming โ roll back.\n const next = rolledBackState(st)\n await storage.writeState(next)\n emergency = next.current === null\n return { isEmergencyLaunch: emergency, current: next.current }\n }\n // Give the current generation another chance to confirm this launch.\n await storage.writeState({ ...st, bootAttempts: attempts })\n return { isEmergencyLaunch: false, current: st.current }\n }\n if ((await storage.readState()) === null) await storage.writeState(st) // persist initial\n return { isEmergencyLaunch: false, current: st.current }\n },\n\n async notifyReady(): Promise<void> {\n const st = await readStateOrInit()\n if (!st.pendingVerification && st.bootAttempts === 0) return\n await storage.writeState({ ...st, pendingVerification: false, bootAttempts: 0 })\n },\n\n async rollback(): Promise<void> {\n const st = await readStateOrInit()\n if (st.current === null) return // already on the embedded build\n const next = rolledBackState(st)\n await storage.writeState(next)\n emergency = next.current === null\n },\n }\n}\n"],"mappings":"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"}
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/crypto.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cryptographic primitives for Pulse: Ed25519 signing/verification and SHA-256
|
|
4
|
+
* content hashing.
|
|
5
|
+
*
|
|
6
|
+
* These use the pure-JavaScript [@noble](https://github.com/paulmillr/noble-curves)
|
|
7
|
+
* libraries, which run on Node, browsers, **and Hermes/React Native** โ where
|
|
8
|
+
* WebCrypto's Ed25519 is unavailable. No native module, no `crypto.subtle`
|
|
9
|
+
* dependency, so the verifier works on every MindeesNative target.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** An Ed25519 keypair (raw 32-byte keys). */
|
|
14
|
+
interface Keypair {
|
|
15
|
+
/** The 32-byte secret (signing) key. Keep offline; never ship it. */
|
|
16
|
+
readonly secretKey: Uint8Array;
|
|
17
|
+
/** The 32-byte public (verification) key. Safe to embed in the app. */
|
|
18
|
+
readonly publicKey: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
/** Generate a fresh Ed25519 keypair. */
|
|
21
|
+
declare function generateKeypair(): Keypair;
|
|
22
|
+
/** Derive the public key for a given secret key. */
|
|
23
|
+
declare function getPublicKey(secretKey: Uint8Array): Uint8Array;
|
|
24
|
+
/** Sign `message` with `secretKey`, returning the 64-byte Ed25519 signature. */
|
|
25
|
+
declare function sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array;
|
|
26
|
+
/**
|
|
27
|
+
* Verify `signature` over `message` against `publicKey`. Returns `false` (never
|
|
28
|
+
* throws) for a wrong signature **or** malformed input โ a verifier on untrusted
|
|
29
|
+
* data must treat any failure as "not valid".
|
|
30
|
+
*/
|
|
31
|
+
declare function verify(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean;
|
|
32
|
+
/** SHA-256 of `data`, as a lowercase hex string. */
|
|
33
|
+
declare function sha256Hex(data: Uint8Array): string;
|
|
34
|
+
/** UTF-8 encode a string to bytes (no DOM/Node `TextEncoder` dependency). */
|
|
35
|
+
declare function utf8(text: string): Uint8Array;
|
|
36
|
+
/** Lowercase-hex encode bytes (e.g. to serialize a public key). */
|
|
37
|
+
declare function toHex(bytes: Uint8Array): string;
|
|
38
|
+
/** Decode a hex string to bytes. Throws on invalid hex. */
|
|
39
|
+
declare function fromHex(hex: string): Uint8Array;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { Keypair, fromHex, generateKeypair, getPublicKey, sha256Hex, sign, toHex, utf8, verify };
|
|
42
|
+
//# sourceMappingURL=crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","names":[],"sources":["../src/crypto.ts"],"mappings":";;AAiBA;;;;;;;;;AAIgC;AAIhC;AAAA,UARiB,OAAA;;WAEN,SAAA,EAAW,UAAA;EAMoB;EAAA,SAJ/B,SAAA,EAAW,UAAU;AAAA;;iBAIhB,eAAA,IAAmB,OAAO;;iBAM1B,YAAA,CAAa,SAAA,EAAW,UAAA,GAAa,UAAU;;iBAK/C,IAAA,CAAK,OAAA,EAAS,UAAA,EAAY,SAAA,EAAW,UAAA,GAAa,UAAA;AALH;AAK/D;;;;AAL+D,iBAc/C,MAAA,CAAO,SAAA,EAAW,UAAA,EAAY,OAAA,EAAS,UAAA,EAAY,SAAA,EAAW,UAAA;;iBAS9D,SAAA,CAAU,IAAgB,EAAV,UAAU;;iBAK1B,IAAA,CAAK,IAAA,WAAe,UAAU;;iBAK9B,KAAA,CAAM,KAAiB,EAAV,UAAU;;iBAKvB,OAAA,CAAQ,GAAA,WAAc,UAAU"}
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
3
|
+
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
4
|
+
//#region src/crypto.ts
|
|
5
|
+
/**
|
|
6
|
+
* Cryptographic primitives for Pulse: Ed25519 signing/verification and SHA-256
|
|
7
|
+
* content hashing.
|
|
8
|
+
*
|
|
9
|
+
* These use the pure-JavaScript [@noble](https://github.com/paulmillr/noble-curves)
|
|
10
|
+
* libraries, which run on Node, browsers, **and Hermes/React Native** โ where
|
|
11
|
+
* WebCrypto's Ed25519 is unavailable. No native module, no `crypto.subtle`
|
|
12
|
+
* dependency, so the verifier works on every MindeesNative target.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
/** Generate a fresh Ed25519 keypair. */
|
|
17
|
+
function generateKeypair() {
|
|
18
|
+
const { secretKey, publicKey } = ed25519.keygen();
|
|
19
|
+
return {
|
|
20
|
+
secretKey,
|
|
21
|
+
publicKey
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Derive the public key for a given secret key. */
|
|
25
|
+
function getPublicKey(secretKey) {
|
|
26
|
+
return ed25519.getPublicKey(secretKey);
|
|
27
|
+
}
|
|
28
|
+
/** Sign `message` with `secretKey`, returning the 64-byte Ed25519 signature. */
|
|
29
|
+
function sign(message, secretKey) {
|
|
30
|
+
return ed25519.sign(message, secretKey);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Verify `signature` over `message` against `publicKey`. Returns `false` (never
|
|
34
|
+
* throws) for a wrong signature **or** malformed input โ a verifier on untrusted
|
|
35
|
+
* data must treat any failure as "not valid".
|
|
36
|
+
*/
|
|
37
|
+
function verify(signature, message, publicKey) {
|
|
38
|
+
try {
|
|
39
|
+
return ed25519.verify(signature, message, publicKey);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** SHA-256 of `data`, as a lowercase hex string. */
|
|
45
|
+
function sha256Hex(data) {
|
|
46
|
+
return bytesToHex(sha256(data));
|
|
47
|
+
}
|
|
48
|
+
/** UTF-8 encode a string to bytes (no DOM/Node `TextEncoder` dependency). */
|
|
49
|
+
function utf8(text) {
|
|
50
|
+
return utf8ToBytes(text);
|
|
51
|
+
}
|
|
52
|
+
/** Lowercase-hex encode bytes (e.g. to serialize a public key). */
|
|
53
|
+
function toHex(bytes) {
|
|
54
|
+
return bytesToHex(bytes);
|
|
55
|
+
}
|
|
56
|
+
/** Decode a hex string to bytes. Throws on invalid hex. */
|
|
57
|
+
function fromHex(hex) {
|
|
58
|
+
return hexToBytes(hex);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { fromHex, generateKeypair, getPublicKey, sha256Hex, sign, toHex, utf8, verify };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=crypto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.js","names":[],"sources":["../src/crypto.ts"],"sourcesContent":["/**\n * Cryptographic primitives for Pulse: Ed25519 signing/verification and SHA-256\n * content hashing.\n *\n * These use the pure-JavaScript [@noble](https://github.com/paulmillr/noble-curves)\n * libraries, which run on Node, browsers, **and Hermes/React Native** โ where\n * WebCrypto's Ed25519 is unavailable. No native module, no `crypto.subtle`\n * dependency, so the verifier works on every MindeesNative target.\n *\n * @module\n */\n\nimport { ed25519 } from '@noble/curves/ed25519.js'\nimport { sha256 } from '@noble/hashes/sha2.js'\nimport { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'\n\n/** An Ed25519 keypair (raw 32-byte keys). */\nexport interface Keypair {\n /** The 32-byte secret (signing) key. Keep offline; never ship it. */\n readonly secretKey: Uint8Array\n /** The 32-byte public (verification) key. Safe to embed in the app. */\n readonly publicKey: Uint8Array\n}\n\n/** Generate a fresh Ed25519 keypair. */\nexport function generateKeypair(): Keypair {\n const { secretKey, publicKey } = ed25519.keygen()\n return { secretKey, publicKey }\n}\n\n/** Derive the public key for a given secret key. */\nexport function getPublicKey(secretKey: Uint8Array): Uint8Array {\n return ed25519.getPublicKey(secretKey)\n}\n\n/** Sign `message` with `secretKey`, returning the 64-byte Ed25519 signature. */\nexport function sign(message: Uint8Array, secretKey: Uint8Array): Uint8Array {\n return ed25519.sign(message, secretKey)\n}\n\n/**\n * Verify `signature` over `message` against `publicKey`. Returns `false` (never\n * throws) for a wrong signature **or** malformed input โ a verifier on untrusted\n * data must treat any failure as \"not valid\".\n */\nexport function verify(signature: Uint8Array, message: Uint8Array, publicKey: Uint8Array): boolean {\n try {\n return ed25519.verify(signature, message, publicKey)\n } catch {\n return false\n }\n}\n\n/** SHA-256 of `data`, as a lowercase hex string. */\nexport function sha256Hex(data: Uint8Array): string {\n return bytesToHex(sha256(data))\n}\n\n/** UTF-8 encode a string to bytes (no DOM/Node `TextEncoder` dependency). */\nexport function utf8(text: string): Uint8Array {\n return utf8ToBytes(text)\n}\n\n/** Lowercase-hex encode bytes (e.g. to serialize a public key). */\nexport function toHex(bytes: Uint8Array): string {\n return bytesToHex(bytes)\n}\n\n/** Decode a hex string to bytes. Throws on invalid hex. */\nexport function fromHex(hex: string): Uint8Array {\n return hexToBytes(hex)\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,SAAgB,kBAA2B;CACzC,MAAM,EAAE,WAAW,cAAc,QAAQ,OAAO;CAChD,OAAO;EAAE;EAAW;CAAU;AAChC;;AAGA,SAAgB,aAAa,WAAmC;CAC9D,OAAO,QAAQ,aAAa,SAAS;AACvC;;AAGA,SAAgB,KAAK,SAAqB,WAAmC;CAC3E,OAAO,QAAQ,KAAK,SAAS,SAAS;AACxC;;;;;;AAOA,SAAgB,OAAO,WAAuB,SAAqB,WAAgC;CACjG,IAAI;EACF,OAAO,QAAQ,OAAO,WAAW,SAAS,SAAS;CACrD,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,UAAU,MAA0B;CAClD,OAAO,WAAW,OAAO,IAAI,CAAC;AAChC;;AAGA,SAAgB,KAAK,MAA0B;CAC7C,OAAO,YAAY,IAAI;AACzB;;AAGA,SAAgB,MAAM,OAA2B;CAC/C,OAAO,WAAW,KAAK;AACzB;;AAGA,SAAgB,QAAQ,KAAyB;CAC/C,OAAO,WAAW,GAAG;AACvB"}
|