@forthtilliath/backup-crypto 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Forthtilliath
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
1
+ # @forthtilliath/backup-crypto
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Encrypt an app's backup (typically a JSON export) with a random app-generated key — no password for the user to pick or remember. Pure TypeScript on top of audited libraries ([`@noble/ciphers`](https://github.com/paulmillr/noble-ciphers), [`@noble/hashes`](https://github.com/paulmillr/noble-hashes), [`@scure/base`](https://github.com/paulmillr/scure-base)): no native module, runs in React Native, browsers and Node.
4
+
5
+ Used by [`@forthtilliath/expo-backup`](../expo-backup#readme), which stores the keys (secure store + private Google Drive copy). Use this package directly only for another platform (e.g. a web app reading the same backups).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @forthtilliath/backup-crypto
11
+ ```
12
+
13
+ ## Format
14
+
15
+ An encrypted backup is still JSON — file pickers filtering on `application/json` keep working — but only its header is readable:
16
+
17
+ ```json
18
+ {
19
+ "format": "forthtilliath.encrypted-backup",
20
+ "version": 1,
21
+ "app": "glucodose",
22
+ "keyId": "3f9c0a1b2c3d4e5f",
23
+ "cipher": "xchacha20-poly1305",
24
+ "nonce": "…",
25
+ "ciphertext": "…"
26
+ }
27
+ ```
28
+
29
+ - **XChaCha20-Poly1305** with a 256-bit key and a fresh random 192-bit nonce per backup: safe to encrypt every backup with the same key.
30
+ - The header (format, version, app, keyId, cipher) is authenticated: editing it makes decryption fail instead of being trusted.
31
+ - `keyId` is a domain-separated hash of the key (not the key): it says which key of a keyring to use.
32
+
33
+ ## Usage
34
+
35
+ ```ts
36
+ import {
37
+ decryptBackupWithKeyring,
38
+ emptyKeyring,
39
+ encryptBackup,
40
+ ensureCurrentKey,
41
+ formatRecoveryCode,
42
+ getCurrentKey,
43
+ isEncryptedBackup,
44
+ parseRecoveryCode,
45
+ } from "@forthtilliath/backup-crypto";
46
+
47
+ let keyring = ensureCurrentKey(emptyKeyring); // generates a key
48
+ const key = getCurrentKey(keyring)!.key;
49
+
50
+ const file = encryptBackup(JSON.stringify(data), key, { app: "glucodose" });
51
+ isEncryptedBackup(file); // true
52
+ const json = decryptBackupWithKeyring(file, keyring);
53
+
54
+ // Human-friendly form of the key, to write down: 55 Crockford base32 chars
55
+ // with a checksum, e.g. "7K3QD-…". Tolerates case, spaces and I/L/O typos.
56
+ const code = formatRecoveryCode(key);
57
+ parseRecoveryCode(code); // the key again
58
+ ```
59
+
60
+ ### Keyrings
61
+
62
+ A keyring keeps every key an app ever encrypted with, so files written before a key change (other phone, reinstall) stay readable; new backups use `currentId`. All helpers are immutable:
63
+
64
+ | Function | |
65
+ | ------------------------------------------------- | ----------------------------------------------------- |
66
+ | `ensureCurrentKey(keyring, randomBytes?)` | Adds a current key if there's none |
67
+ | `addKeyToKeyring(keyring, key, { makeCurrent? })` | Adds a key (current if the keyring had none) |
68
+ | `mergeKeyrings(preferred, other)` | Union; `preferred`'s current key wins |
69
+ | `sameKeyring(a, b)` | Nothing to save or upload |
70
+ | `serializeKeyring` / `parseKeyring` | JSON form (contains the raw keys — store it securely) |
71
+
72
+ ### API reference
73
+
74
+ All functions are pure (no I/O); the keyring helpers return new objects.
75
+
76
+ | Export | Module | |
77
+ | ----------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------- |
78
+ | `generateBackupKey(randomBytes?)` | `backupKey` | A new random 256-bit key (`BACKUP_KEY_LENGTH` = 32 bytes) |
79
+ | `getBackupKeyId(key)` | `backupKey` | Short non-secret id (16 hex chars) of a key, stored in each envelope |
80
+ | `encodeBackupKey(key)` / `decodeBackupKey(text)` | `backupKey` | Base64 form of a key, for storage; decoding throws `corrupted` for anything but a 256-bit key |
81
+ | `defaultRandomBytes(length)` | `backupKey` | `crypto.getRandomValues`, or throws `no-random-source` |
82
+ | `encryptBackup(text, key, { app?, randomBytes? })` | `envelope` | The JSON envelope to write as is |
83
+ | `decryptBackup(content, key)` | `envelope` | Plaintext; throws `unknown-key` or `corrupted` |
84
+ | `isEncryptedBackup(content)` | `envelope` | Whether a file is an envelope (of any version) |
85
+ | `readEncryptedBackup(content)` | `envelope` | Validated header (`app`, `keyId`, `version`…) without decrypting |
86
+ | `ENCRYPTED_BACKUP_FORMAT` / `ENCRYPTED_BACKUP_VERSION` | `envelope` | `format` field of an envelope / version this package writes |
87
+ | `emptyKeyring`, `getCurrentKey(keyring)`, `findKeyringKey(keyring, id)` | `keyring` | Keyring basics |
88
+ | `decryptBackupWithKeyring(content, keyring)` | `keyring` | Decrypts with whichever key wrote the file |
89
+ | `BACKUP_KEYRING_FORMAT` | `keyring` | `format` field of a serialized keyring |
90
+ | `formatRecoveryCode(key)` / `parseRecoveryCode(code)` | `recoveryCode` | Human-friendly form of a key, with a checksum |
91
+
92
+ Everything is exported from the package root; each module is also importable on its own (`@forthtilliath/backup-crypto/keyring`…).
93
+
94
+ ### Errors
95
+
96
+ Everything throws a `BackupCryptoError` with a `code`: `not-encrypted`, `unsupported-version`, `unknown-key` (ask for the recovery code of the device that wrote it), `corrupted` (truncated or tampered with), `invalid-recovery-code` (typo), `no-random-source`.
97
+
98
+ ### Randomness
99
+
100
+ Keys and nonces come from `crypto.getRandomValues`, or the `randomBytes` option — pass `expo-crypto`'s `getRandomBytes` in React Native. There's no `Math.random` fallback: without a secure source, encryption throws `no-random-source`.
@@ -0,0 +1,33 @@
1
+ /** Length in bytes of a backup key (XChaCha20-Poly1305 uses 256-bit keys). */
2
+ export declare const BACKUP_KEY_LENGTH = 32;
3
+ /** Fills a new array with `length` cryptographically secure random bytes. */
4
+ export type RandomBytes = (length: number) => Uint8Array;
5
+ /**
6
+ * The random source used when no `randomBytes` option is given:
7
+ * `crypto.getRandomValues` (browsers, Node, React Native with `expo-crypto`'s
8
+ * polyfill). Throws rather than ever falling back to `Math.random`.
9
+ */
10
+ export declare function defaultRandomBytes(length: number): Uint8Array;
11
+ /**
12
+ * Generates a new random 256-bit backup key.
13
+ *
14
+ * @param randomBytes - Source of randomness. Defaults to
15
+ * `crypto.getRandomValues`; pass
16
+ * expo-crypto's `getRandomBytes` where the global is missing.
17
+ * @returns The 32-byte key. Keep it secret (secure store, private cloud file).
18
+ */
19
+ export declare function generateBackupKey(randomBytes?: RandomBytes): Uint8Array;
20
+ /**
21
+ * A short, stable, non-secret identifier of a key (16 hex chars), stored next
22
+ * to each encrypted backup so the right key can be picked from a keyring.
23
+ * Domain-separated hash: it reveals nothing usable about the key itself.
24
+ */
25
+ export declare function getBackupKeyId(key: Uint8Array): string;
26
+ /** Base64 form of a key, for storage (secure store, keyring file). */
27
+ export declare function encodeBackupKey(key: Uint8Array): string;
28
+ /**
29
+ * Reverse of {@link encodeBackupKey}. Throws `corrupted` on anything else than
30
+ * a 256-bit key.
31
+ */
32
+ export declare function decodeBackupKey(encoded: string): Uint8Array;
33
+ //# sourceMappingURL=backupKey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backupKey.d.ts","sourceRoot":"","sources":["../src/backupKey.ts"],"names":[],"mappings":"AAMA,8EAA8E;AAC9E,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,6EAA6E;AAC7E,MAAM,MAAM,WAAW,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,UAAU,CAAC;AAIzD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAS7D;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,GAAE,WAAgC,GAC5C,UAAU,CASZ;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAKtD;AAED,sEAAsE;AACtE,wBAAgB,eAAe,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAEvD;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAW3D"}
@@ -0,0 +1,66 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import { utf8ToBytes } from "@noble/hashes/utils.js";
3
+ import { base64, hex } from "@scure/base";
4
+ import { BackupCryptoError } from "./errors.js";
5
+ /** Length in bytes of a backup key (XChaCha20-Poly1305 uses 256-bit keys). */
6
+ export const BACKUP_KEY_LENGTH = 32;
7
+ const KEY_ID_DOMAIN = utf8ToBytes("forthtilliath.backup-key-id");
8
+ /**
9
+ * The random source used when no `randomBytes` option is given:
10
+ * `crypto.getRandomValues` (browsers, Node, React Native with `expo-crypto`'s
11
+ * polyfill). Throws rather than ever falling back to `Math.random`.
12
+ */
13
+ export function defaultRandomBytes(length) {
14
+ const webCrypto = globalThis.crypto;
15
+ if (typeof webCrypto?.getRandomValues !== "function") {
16
+ throw new BackupCryptoError("no-random-source", "No secure random source: pass a `randomBytes` option (e.g. expo-crypto's getRandomBytes).");
17
+ }
18
+ return webCrypto.getRandomValues(new Uint8Array(length));
19
+ }
20
+ /**
21
+ * Generates a new random 256-bit backup key.
22
+ *
23
+ * @param randomBytes - Source of randomness. Defaults to
24
+ * `crypto.getRandomValues`; pass
25
+ * expo-crypto's `getRandomBytes` where the global is missing.
26
+ * @returns The 32-byte key. Keep it secret (secure store, private cloud file).
27
+ */
28
+ export function generateBackupKey(randomBytes = defaultRandomBytes) {
29
+ const key = randomBytes(BACKUP_KEY_LENGTH);
30
+ if (key.length !== BACKUP_KEY_LENGTH) {
31
+ throw new BackupCryptoError("no-random-source", `randomBytes returned ${key.length} bytes instead of ${BACKUP_KEY_LENGTH}.`);
32
+ }
33
+ return key;
34
+ }
35
+ /**
36
+ * A short, stable, non-secret identifier of a key (16 hex chars), stored next
37
+ * to each encrypted backup so the right key can be picked from a keyring.
38
+ * Domain-separated hash: it reveals nothing usable about the key itself.
39
+ */
40
+ export function getBackupKeyId(key) {
41
+ const input = new Uint8Array(KEY_ID_DOMAIN.length + key.length);
42
+ input.set(KEY_ID_DOMAIN);
43
+ input.set(key, KEY_ID_DOMAIN.length);
44
+ return hex.encode(sha256(input).subarray(0, 8));
45
+ }
46
+ /** Base64 form of a key, for storage (secure store, keyring file). */
47
+ export function encodeBackupKey(key) {
48
+ return base64.encode(key);
49
+ }
50
+ /**
51
+ * Reverse of {@link encodeBackupKey}. Throws `corrupted` on anything else than
52
+ * a 256-bit key.
53
+ */
54
+ export function decodeBackupKey(encoded) {
55
+ let key;
56
+ try {
57
+ key = base64.decode(encoded);
58
+ }
59
+ catch {
60
+ throw new BackupCryptoError("corrupted", "Invalid backup key encoding.");
61
+ }
62
+ if (key.length !== BACKUP_KEY_LENGTH) {
63
+ throw new BackupCryptoError("corrupted", "Invalid backup key length.");
64
+ }
65
+ return key;
66
+ }
@@ -0,0 +1,72 @@
1
+ import { type RandomBytes } from "./backupKey.js";
2
+ /**
3
+ * Value of the `format` field that marks a file as an encrypted backup
4
+ * envelope.
5
+ */
6
+ export declare const ENCRYPTED_BACKUP_FORMAT = "forthtilliath.encrypted-backup";
7
+ /**
8
+ * Envelope version this package writes; a higher one is refused as
9
+ * `unsupported-version`.
10
+ */
11
+ export declare const ENCRYPTED_BACKUP_VERSION = 1;
12
+ declare const CIPHER = "xchacha20-poly1305";
13
+ /**
14
+ * What an encrypted backup file contains. Still JSON (so pickers filtering on
15
+ * `application/json` keep working), but only the header is readable: the
16
+ * app's data is in `ciphertext`.
17
+ */
18
+ export interface EncryptedBackupEnvelope {
19
+ /** Always {@link ENCRYPTED_BACKUP_FORMAT}. */
20
+ format: typeof ENCRYPTED_BACKUP_FORMAT;
21
+ /**
22
+ * Envelope version, {@link ENCRYPTED_BACKUP_VERSION} when written by this
23
+ * package.
24
+ */
25
+ version: number;
26
+ /** Free label of the app that wrote it — authenticated, not secret. */
27
+ app: string;
28
+ /** {@link getBackupKeyId} of the key it was encrypted with. */
29
+ keyId: string;
30
+ /** The algorithm, always `"xchacha20-poly1305"`. */
31
+ cipher: typeof CIPHER;
32
+ /** The random 24-byte nonce, base64-encoded. */
33
+ nonce: string;
34
+ /**
35
+ * The encrypted backup followed by its 16-byte authentication tag,
36
+ * base64-encoded.
37
+ */
38
+ ciphertext: string;
39
+ }
40
+ /** Options of {@link encryptBackup}. */
41
+ export interface EncryptBackupOptions {
42
+ /** Stored in clear and authenticated, e.g. `"glucodose"`. */
43
+ app?: string;
44
+ /**
45
+ * Source of the nonce. Defaults to the platform's secure generator
46
+ * (`crypto.getRandomValues`); override it only in tests, for a deterministic
47
+ * output.
48
+ */
49
+ randomBytes?: RandomBytes;
50
+ }
51
+ /**
52
+ * Encrypts a backup's text (typically the app's JSON export) with
53
+ * XChaCha20-Poly1305 and a fresh random 192-bit nonce — safe to reuse the
54
+ * same key for every backup. Returns the JSON envelope to write as is.
55
+ */
56
+ export declare function encryptBackup(plaintext: string, key: Uint8Array, { app, randomBytes }?: EncryptBackupOptions): string;
57
+ /** Whether `content` is an encrypted backup envelope (of any version). */
58
+ export declare function isEncryptedBackup(content: string): boolean;
59
+ /**
60
+ * Validates and returns an encrypted backup's header. Throws
61
+ * `not-encrypted` for anything that isn't an envelope, `unsupported-version`
62
+ * for a newer one, `corrupted` for a malformed one.
63
+ */
64
+ export declare function readEncryptedBackup(content: string): EncryptedBackupEnvelope;
65
+ /**
66
+ * Decrypts an envelope written by {@link encryptBackup}. Throws `unknown-key`
67
+ * when `key` isn't the one it was encrypted with, `corrupted` when the file
68
+ * was truncated or tampered with.
69
+ */
70
+ export declare function decryptBackup(content: string, key: Uint8Array): string;
71
+ export {};
72
+ //# sourceMappingURL=envelope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"envelope.d.ts","sourceRoot":"","sources":["../src/envelope.ts"],"names":[],"mappings":"AAIA,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,gBAAgB,CAAC;AAGxB;;;GAGG;AACH,eAAO,MAAM,uBAAuB,mCAAmC,CAAC;AACxE;;;GAGG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,QAAA,MAAM,MAAM,uBAAuB,CAAC;AAGpC;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,8CAA8C;IAC9C,MAAM,EAAE,OAAO,uBAAuB,CAAC;IACvC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,MAAM,EAAE,OAAO,MAAM,CAAC;IACtB,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wCAAwC;AACxC,MAAM,WAAW,oBAAoB;IACnC,6DAA6D;IAC7D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AA2BD;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,UAAU,EACf,EAAE,GAAQ,EAAE,WAAgC,EAAE,GAAE,oBAAyB,GACxE,MAAM,CAwBR;AAcD,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG1D;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,uBAAuB,CA8B5E;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,MAAM,CAqBtE"}
@@ -0,0 +1,128 @@
1
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
2
+ import { bytesToUtf8, utf8ToBytes } from "@noble/ciphers/utils.js";
3
+ import { base64 } from "@scure/base";
4
+ import { BACKUP_KEY_LENGTH, defaultRandomBytes, getBackupKeyId, } from "./backupKey.js";
5
+ import { BackupCryptoError } from "./errors.js";
6
+ /**
7
+ * Value of the `format` field that marks a file as an encrypted backup
8
+ * envelope.
9
+ */
10
+ export const ENCRYPTED_BACKUP_FORMAT = "forthtilliath.encrypted-backup";
11
+ /**
12
+ * Envelope version this package writes; a higher one is refused as
13
+ * `unsupported-version`.
14
+ */
15
+ export const ENCRYPTED_BACKUP_VERSION = 1;
16
+ const CIPHER = "xchacha20-poly1305";
17
+ const NONCE_LENGTH = 24;
18
+ // The header is bound to the ciphertext as associated data: editing the
19
+ // app/keyId/version of a file makes decryption fail instead of being trusted.
20
+ function associatedData(envelope) {
21
+ return utf8ToBytes(JSON.stringify([
22
+ envelope.format,
23
+ envelope.version,
24
+ envelope.app,
25
+ envelope.keyId,
26
+ envelope.cipher,
27
+ ]));
28
+ }
29
+ function assertKey(key) {
30
+ if (key.length !== BACKUP_KEY_LENGTH) {
31
+ throw new BackupCryptoError("corrupted", "Invalid backup key length.");
32
+ }
33
+ }
34
+ /**
35
+ * Encrypts a backup's text (typically the app's JSON export) with
36
+ * XChaCha20-Poly1305 and a fresh random 192-bit nonce — safe to reuse the
37
+ * same key for every backup. Returns the JSON envelope to write as is.
38
+ */
39
+ export function encryptBackup(plaintext, key, { app = "", randomBytes = defaultRandomBytes } = {}) {
40
+ assertKey(key);
41
+ const nonce = randomBytes(NONCE_LENGTH);
42
+ if (nonce.length !== NONCE_LENGTH) {
43
+ throw new BackupCryptoError("no-random-source");
44
+ }
45
+ const header = {
46
+ format: ENCRYPTED_BACKUP_FORMAT,
47
+ version: ENCRYPTED_BACKUP_VERSION,
48
+ app,
49
+ keyId: getBackupKeyId(key),
50
+ cipher: CIPHER,
51
+ };
52
+ const ciphertext = xchacha20poly1305(key, nonce, associatedData(header)).encrypt(utf8ToBytes(plaintext));
53
+ const envelope = {
54
+ ...header,
55
+ nonce: base64.encode(nonce),
56
+ ciphertext: base64.encode(ciphertext),
57
+ };
58
+ return JSON.stringify(envelope);
59
+ }
60
+ function tryParseJson(content) {
61
+ try {
62
+ return JSON.parse(content);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ function isRecord(value) {
69
+ return typeof value === "object" && value !== null;
70
+ }
71
+ /** Whether `content` is an encrypted backup envelope (of any version). */
72
+ export function isEncryptedBackup(content) {
73
+ const parsed = tryParseJson(content);
74
+ return isRecord(parsed) && parsed.format === ENCRYPTED_BACKUP_FORMAT;
75
+ }
76
+ /**
77
+ * Validates and returns an encrypted backup's header. Throws
78
+ * `not-encrypted` for anything that isn't an envelope, `unsupported-version`
79
+ * for a newer one, `corrupted` for a malformed one.
80
+ */
81
+ export function readEncryptedBackup(content) {
82
+ const parsed = tryParseJson(content);
83
+ if (!isRecord(parsed) || parsed.format !== ENCRYPTED_BACKUP_FORMAT) {
84
+ throw new BackupCryptoError("not-encrypted");
85
+ }
86
+ if (typeof parsed.version !== "number" ||
87
+ parsed.version > ENCRYPTED_BACKUP_VERSION) {
88
+ throw new BackupCryptoError("unsupported-version");
89
+ }
90
+ const { app, keyId, cipher, nonce, ciphertext } = parsed;
91
+ if (typeof app !== "string" ||
92
+ typeof keyId !== "string" ||
93
+ cipher !== CIPHER ||
94
+ typeof nonce !== "string" ||
95
+ typeof ciphertext !== "string") {
96
+ throw new BackupCryptoError("corrupted", "Malformed encrypted backup.");
97
+ }
98
+ return {
99
+ format: ENCRYPTED_BACKUP_FORMAT,
100
+ version: parsed.version,
101
+ app,
102
+ keyId,
103
+ cipher,
104
+ nonce,
105
+ ciphertext,
106
+ };
107
+ }
108
+ /**
109
+ * Decrypts an envelope written by {@link encryptBackup}. Throws `unknown-key`
110
+ * when `key` isn't the one it was encrypted with, `corrupted` when the file
111
+ * was truncated or tampered with.
112
+ */
113
+ export function decryptBackup(content, key) {
114
+ assertKey(key);
115
+ const envelope = readEncryptedBackup(content);
116
+ if (envelope.keyId !== getBackupKeyId(key)) {
117
+ throw new BackupCryptoError("unknown-key");
118
+ }
119
+ try {
120
+ const nonce = base64.decode(envelope.nonce);
121
+ const ciphertext = base64.decode(envelope.ciphertext);
122
+ const plaintext = xchacha20poly1305(key, nonce, associatedData(envelope)).decrypt(ciphertext);
123
+ return bytesToUtf8(plaintext);
124
+ }
125
+ catch {
126
+ throw new BackupCryptoError("corrupted", "Encrypted backup failed authentication.");
127
+ }
128
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Why a backup couldn't be encrypted or decrypted:
3
+ * - `not-encrypted`: the content isn't an encrypted backup at all (e.g. a
4
+ * plain JSON export) — callers usually fall back to reading it as is.
5
+ * - `unsupported-version`: written by a newer version of this package.
6
+ * - `unknown-key`: encrypted with a key that isn't in the keyring — ask for
7
+ * the recovery code of the device that wrote it.
8
+ * - `corrupted`: malformed, truncated or tampered with (authentication failed).
9
+ * - `invalid-recovery-code`: a typo in a recovery code (checksum mismatch).
10
+ * - `no-random-source`: no `crypto.getRandomValues` and no `randomBytes`
11
+ * option — never encrypt with a weak random source instead.
12
+ */
13
+ export type BackupCryptoErrorCode = "not-encrypted" | "unsupported-version" | "unknown-key" | "corrupted" | "invalid-recovery-code" | "no-random-source";
14
+ /**
15
+ * The only error this package throws: `code` says what went wrong (see {@link
16
+ * BackupCryptoErrorCode}), so the app can show the right message — e.g. ask for
17
+ * the recovery key on `unknown-key`.
18
+ */
19
+ export declare class BackupCryptoError extends Error {
20
+ readonly code: BackupCryptoErrorCode;
21
+ constructor(code: BackupCryptoErrorCode, message?: string);
22
+ }
23
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,qBAAqB,GAC7B,eAAe,GACf,qBAAqB,GACrB,aAAa,GACb,WAAW,GACX,uBAAuB,GACvB,kBAAkB,CAAC;AAEvB;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;gBAEzB,IAAI,EAAE,qBAAqB,EAAE,OAAO,CAAC,EAAE,MAAM;CAK1D"}
package/dist/errors.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The only error this package throws: `code` says what went wrong (see {@link
3
+ * BackupCryptoErrorCode}), so the app can show the right message — e.g. ask for
4
+ * the recovery key on `unknown-key`.
5
+ */
6
+ export class BackupCryptoError extends Error {
7
+ code;
8
+ constructor(code, message) {
9
+ super(message ?? code);
10
+ this.name = "BackupCryptoError";
11
+ this.code = code;
12
+ }
13
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./backupKey.js";
2
+ export * from "./envelope.js";
3
+ export * from "./errors.js";
4
+ export * from "./keyring.js";
5
+ export * from "./recoveryCode.js";
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,aAAa,CAAC;AAC5B,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./backupKey.js";
2
+ export * from "./envelope.js";
3
+ export * from "./errors.js";
4
+ export * from "./keyring.js";
5
+ export * from "./recoveryCode.js";
@@ -0,0 +1,87 @@
1
+ import { type RandomBytes } from "./backupKey.js";
2
+ /**
3
+ * Value of the `format` field of a serialized keyring (see {@link
4
+ * serializeKeyring}).
5
+ */
6
+ export declare const BACKUP_KEYRING_FORMAT = "forthtilliath.backup-keyring";
7
+ /** One key of a keyring. */
8
+ export interface BackupKeyringEntry {
9
+ /**
10
+ * {@link getBackupKeyId} of `key`: short, non-secret, stored in every
11
+ * envelope it encrypts.
12
+ */
13
+ id: string;
14
+ /**
15
+ * The raw 32-byte key. Secret: keep it in a secure store or a private cloud
16
+ * file.
17
+ */
18
+ key: Uint8Array;
19
+ /** Epoch ms. */
20
+ createdAt: number;
21
+ }
22
+ /**
23
+ * Every key an app has encrypted backups with, so a file written before a
24
+ * key change (another phone, a reinstall…) stays readable. New backups always
25
+ * use `currentId`. Immutable: every helper returns a new keyring.
26
+ */
27
+ export interface BackupKeyring {
28
+ /**
29
+ * Id of the key new backups are encrypted with, `null` while the keyring is
30
+ * empty.
31
+ */
32
+ currentId: string | null;
33
+ /**
34
+ * Every key known on this device — older ones are kept to decrypt older
35
+ * backups.
36
+ */
37
+ keys: readonly BackupKeyringEntry[];
38
+ }
39
+ /**
40
+ * A keyring with no key, the starting point before {@link ensureCurrentKey}.
41
+ */
42
+ export declare const emptyKeyring: BackupKeyring;
43
+ /**
44
+ * Looks a key up by id.
45
+ * @param keyring - The keyring to search.
46
+ * @param id - A key id, e.g. an envelope's `keyId`.
47
+ * @returns The entry, or `undefined` when this keyring doesn't have it.
48
+ */
49
+ export declare function findKeyringKey(keyring: BackupKeyring, id: string): BackupKeyringEntry | undefined;
50
+ /**
51
+ * Returns the key new backups should be encrypted with.
52
+ * @param keyring - The keyring.
53
+ * @returns The current entry, or `undefined` when the keyring has none.
54
+ */
55
+ export declare function getCurrentKey(keyring: BackupKeyring): BackupKeyringEntry | undefined;
56
+ /**
57
+ * Adds `key` (a no-op if already there). It becomes the current key when
58
+ * `makeCurrent` is set or when the keyring had none.
59
+ */
60
+ export declare function addKeyToKeyring(keyring: BackupKeyring, key: Uint8Array, { makeCurrent, createdAt }?: {
61
+ makeCurrent?: boolean | undefined;
62
+ createdAt?: number | undefined;
63
+ }): BackupKeyring;
64
+ /** Returns the keyring with a current key, generating one if it had none. */
65
+ export declare function ensureCurrentKey(keyring: BackupKeyring, randomBytes?: RandomBytes): BackupKeyring;
66
+ /**
67
+ * Union of two keyrings. `preferred`'s current key wins (e.g. the one already
68
+ * on the cloud, so every device converges on it), falling back to `other`'s.
69
+ */
70
+ export declare function mergeKeyrings(preferred: BackupKeyring, other: BackupKeyring): BackupKeyring;
71
+ /** Same current key and same set of keys — i.e. nothing to save or upload. */
72
+ export declare function sameKeyring(a: BackupKeyring, b: BackupKeyring): boolean;
73
+ /**
74
+ * Decrypts with whichever key of the keyring the backup was written with.
75
+ * Throws `unknown-key` if none matches.
76
+ */
77
+ export declare function decryptBackupWithKeyring(content: string, keyring: BackupKeyring): string;
78
+ /**
79
+ * JSON form, for a secure store or a private cloud file. Contains the raw keys.
80
+ */
81
+ export declare function serializeKeyring(keyring: BackupKeyring): string;
82
+ /**
83
+ * Reverse of {@link serializeKeyring}. Key ids are recomputed from the keys
84
+ * rather than trusted; a `currentId` that matches none of them is dropped.
85
+ */
86
+ export declare function parseKeyring(content: string): BackupKeyring;
87
+ //# sourceMappingURL=keyring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyring.d.ts","sourceRoot":"","sources":["../src/keyring.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,WAAW,EACjB,MAAM,gBAAgB,CAAC;AAIxB;;;GAGG;AACH,eAAO,MAAM,qBAAqB,iCAAiC,CAAC;AAGpE,4BAA4B;AAC5B,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,EAAE,EAAE,MAAM,CAAC;IACX;;;OAGG;IACH,GAAG,EAAE,UAAU,CAAC;IAChB,gBAAgB;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B;;;OAGG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;OAGG;IACH,IAAI,EAAE,SAAS,kBAAkB,EAAE,CAAC;CACrC;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,aAA6C,CAAC;AAEzE;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,aAAa,EACtB,EAAE,EAAE,MAAM,GACT,kBAAkB,GAAG,SAAS,CAEhC;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,aAAa,GACrB,kBAAkB,GAAG,SAAS,CAIhC;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,aAAa,EACtB,GAAG,EAAE,UAAU,EACf,EAAE,WAAmB,EAAE,SAAsB,EAAE;;;CAAK,GACnD,aAAa,CAQf;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,aAAa,EACtB,WAAW,GAAE,WAAgC,GAC5C,aAAa,CAKf;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,aAAa,EACxB,KAAK,EAAE,aAAa,GACnB,aAAa,CAMf;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,GAAG,OAAO,CAMvE;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,aAAa,GACrB,MAAM,CAKR;AASD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAW/D;AAMD;;;GAGG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAmC3D"}
@@ -0,0 +1,134 @@
1
+ import { decodeBackupKey, defaultRandomBytes, encodeBackupKey, generateBackupKey, getBackupKeyId, } from "./backupKey.js";
2
+ import { decryptBackup, readEncryptedBackup } from "./envelope.js";
3
+ import { BackupCryptoError } from "./errors.js";
4
+ /**
5
+ * Value of the `format` field of a serialized keyring (see {@link
6
+ * serializeKeyring}).
7
+ */
8
+ export const BACKUP_KEYRING_FORMAT = "forthtilliath.backup-keyring";
9
+ const BACKUP_KEYRING_VERSION = 1;
10
+ /**
11
+ * A keyring with no key, the starting point before {@link ensureCurrentKey}.
12
+ */
13
+ export const emptyKeyring = { currentId: null, keys: [] };
14
+ /**
15
+ * Looks a key up by id.
16
+ * @param keyring - The keyring to search.
17
+ * @param id - A key id, e.g. an envelope's `keyId`.
18
+ * @returns The entry, or `undefined` when this keyring doesn't have it.
19
+ */
20
+ export function findKeyringKey(keyring, id) {
21
+ return keyring.keys.find((entry) => entry.id === id);
22
+ }
23
+ /**
24
+ * Returns the key new backups should be encrypted with.
25
+ * @param keyring - The keyring.
26
+ * @returns The current entry, or `undefined` when the keyring has none.
27
+ */
28
+ export function getCurrentKey(keyring) {
29
+ return keyring.currentId === null
30
+ ? undefined
31
+ : findKeyringKey(keyring, keyring.currentId);
32
+ }
33
+ /**
34
+ * Adds `key` (a no-op if already there). It becomes the current key when
35
+ * `makeCurrent` is set or when the keyring had none.
36
+ */
37
+ export function addKeyToKeyring(keyring, key, { makeCurrent = false, createdAt = Date.now() } = {}) {
38
+ const id = getBackupKeyId(key);
39
+ const keys = findKeyringKey(keyring, id)
40
+ ? keyring.keys
41
+ : [...keyring.keys, { id, key, createdAt }];
42
+ const currentId = makeCurrent || keyring.currentId === null ? id : keyring.currentId;
43
+ return { currentId, keys };
44
+ }
45
+ /** Returns the keyring with a current key, generating one if it had none. */
46
+ export function ensureCurrentKey(keyring, randomBytes = defaultRandomBytes) {
47
+ if (getCurrentKey(keyring))
48
+ return keyring;
49
+ return addKeyToKeyring(keyring, generateBackupKey(randomBytes), {
50
+ makeCurrent: true,
51
+ });
52
+ }
53
+ /**
54
+ * Union of two keyrings. `preferred`'s current key wins (e.g. the one already
55
+ * on the cloud, so every device converges on it), falling back to `other`'s.
56
+ */
57
+ export function mergeKeyrings(preferred, other) {
58
+ const keys = [...preferred.keys];
59
+ for (const entry of other.keys) {
60
+ if (!findKeyringKey(preferred, entry.id))
61
+ keys.push(entry);
62
+ }
63
+ return { currentId: preferred.currentId ?? other.currentId, keys };
64
+ }
65
+ /** Same current key and same set of keys — i.e. nothing to save or upload. */
66
+ export function sameKeyring(a, b) {
67
+ return (a.currentId === b.currentId &&
68
+ a.keys.length === b.keys.length &&
69
+ a.keys.every((entry) => findKeyringKey(b, entry.id)));
70
+ }
71
+ /**
72
+ * Decrypts with whichever key of the keyring the backup was written with.
73
+ * Throws `unknown-key` if none matches.
74
+ */
75
+ export function decryptBackupWithKeyring(content, keyring) {
76
+ const { keyId } = readEncryptedBackup(content);
77
+ const entry = findKeyringKey(keyring, keyId);
78
+ if (!entry)
79
+ throw new BackupCryptoError("unknown-key");
80
+ return decryptBackup(content, entry.key);
81
+ }
82
+ /**
83
+ * JSON form, for a secure store or a private cloud file. Contains the raw keys.
84
+ */
85
+ export function serializeKeyring(keyring) {
86
+ const serialized = {
87
+ format: BACKUP_KEYRING_FORMAT,
88
+ version: BACKUP_KEYRING_VERSION,
89
+ currentId: keyring.currentId,
90
+ keys: keyring.keys.map(({ key, createdAt }) => ({
91
+ key: encodeBackupKey(key),
92
+ createdAt,
93
+ })),
94
+ };
95
+ return JSON.stringify(serialized);
96
+ }
97
+ function corrupted() {
98
+ return new BackupCryptoError("corrupted", "Malformed backup keyring.");
99
+ }
100
+ /**
101
+ * Reverse of {@link serializeKeyring}. Key ids are recomputed from the keys
102
+ * rather than trusted; a `currentId` that matches none of them is dropped.
103
+ */
104
+ export function parseKeyring(content) {
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(content);
108
+ }
109
+ catch {
110
+ throw corrupted();
111
+ }
112
+ const data = parsed;
113
+ if (data?.format !== BACKUP_KEYRING_FORMAT) {
114
+ throw corrupted();
115
+ }
116
+ if (typeof data.version !== "number" ||
117
+ data.version > BACKUP_KEYRING_VERSION) {
118
+ throw new BackupCryptoError("unsupported-version");
119
+ }
120
+ if (!Array.isArray(data.keys))
121
+ throw corrupted();
122
+ let keyring = emptyKeyring;
123
+ for (const entry of data.keys) {
124
+ const { key, createdAt } = (entry ?? {});
125
+ if (typeof key !== "string" || typeof createdAt !== "number")
126
+ throw corrupted();
127
+ keyring = addKeyToKeyring(keyring, decodeBackupKey(key), { createdAt });
128
+ }
129
+ const currentId = typeof data.currentId === "string" &&
130
+ findKeyringKey(keyring, data.currentId)
131
+ ? data.currentId
132
+ : null;
133
+ return { ...keyring, currentId };
134
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Human-friendly form of a key, to write down or store in a password manager:
3
+ * Crockford base32 (no I/L/O/U, case-insensitive) in dash-separated groups of
4
+ * 5, with a 16-bit checksum so a typo is reported as such instead of silently
5
+ * yielding a different key. 55 characters, e.g. `7K3QD-…-XW0`.
6
+ */
7
+ export declare function formatRecoveryCode(key: Uint8Array): string;
8
+ /**
9
+ * Reverse of {@link formatRecoveryCode}. Tolerant of what people actually
10
+ * type: lowercase, spaces, missing or extra dashes, and I/L/O mistaken for
11
+ * 1/1/0. Throws `invalid-recovery-code` on a wrong length or checksum.
12
+ */
13
+ export declare function parseRecoveryCode(code: string): Uint8Array;
14
+ //# sourceMappingURL=recoveryCode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recoveryCode.d.ts","sourceRoot":"","sources":["../src/recoveryCode.ts"],"names":[],"mappings":"AAaA;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,GAAG,MAAM,CAU1D;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAkB1D"}
@@ -0,0 +1,51 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+ import { base32crockford } from "@scure/base";
3
+ import { BACKUP_KEY_LENGTH } from "./backupKey.js";
4
+ import { BackupCryptoError } from "./errors.js";
5
+ const CHECKSUM_LENGTH = 2;
6
+ const GROUP_SIZE = 5;
7
+ function checksum(key) {
8
+ return sha256(key).subarray(0, CHECKSUM_LENGTH);
9
+ }
10
+ /**
11
+ * Human-friendly form of a key, to write down or store in a password manager:
12
+ * Crockford base32 (no I/L/O/U, case-insensitive) in dash-separated groups of
13
+ * 5, with a 16-bit checksum so a typo is reported as such instead of silently
14
+ * yielding a different key. 55 characters, e.g. `7K3QD-…-XW0`.
15
+ */
16
+ export function formatRecoveryCode(key) {
17
+ const payload = new Uint8Array(BACKUP_KEY_LENGTH + CHECKSUM_LENGTH);
18
+ payload.set(key);
19
+ payload.set(checksum(key), BACKUP_KEY_LENGTH);
20
+ const code = base32crockford.encode(payload);
21
+ const groups = [];
22
+ for (let i = 0; i < code.length; i += GROUP_SIZE) {
23
+ groups.push(code.slice(i, i + GROUP_SIZE));
24
+ }
25
+ return groups.join("-");
26
+ }
27
+ /**
28
+ * Reverse of {@link formatRecoveryCode}. Tolerant of what people actually
29
+ * type: lowercase, spaces, missing or extra dashes, and I/L/O mistaken for
30
+ * 1/1/0. Throws `invalid-recovery-code` on a wrong length or checksum.
31
+ */
32
+ export function parseRecoveryCode(code) {
33
+ const normalized = code.toUpperCase().replace(/[^0-9A-Z]/g, "");
34
+ let payload;
35
+ try {
36
+ payload = base32crockford.decode(normalized);
37
+ }
38
+ catch {
39
+ throw new BackupCryptoError("invalid-recovery-code");
40
+ }
41
+ if (payload.length !== BACKUP_KEY_LENGTH + CHECKSUM_LENGTH) {
42
+ throw new BackupCryptoError("invalid-recovery-code");
43
+ }
44
+ const key = payload.slice(0, BACKUP_KEY_LENGTH);
45
+ const expected = checksum(key);
46
+ const actual = payload.subarray(BACKUP_KEY_LENGTH);
47
+ if (expected[0] !== actual[0] || expected[1] !== actual[1]) {
48
+ throw new BackupCryptoError("invalid-recovery-code");
49
+ }
50
+ return key;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@forthtilliath/backup-crypto",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/Forthtilliath/forthtilliath-packages.git",
13
+ "directory": "packages/backup-crypto"
14
+ },
15
+ "homepage": "https://github.com/Forthtilliath/forthtilliath-packages/tree/main/packages/backup-crypto#readme",
16
+ "bugs": "https://github.com/Forthtilliath/forthtilliath-packages/issues",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "exports": {
21
+ ".": "./dist/index.js",
22
+ "./*": "./dist/*.js"
23
+ },
24
+ "dependencies": {
25
+ "@noble/ciphers": "^2.4.0",
26
+ "@noble/hashes": "^2.4.0",
27
+ "@scure/base": "^2.4.0"
28
+ },
29
+ "devDependencies": {
30
+ "@vitest/coverage-v8": "5.0.5",
31
+ "eslint": "10.14.0",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^5.0.5",
34
+ "@forthtilliath/eslint-config": "0.10.2",
35
+ "@forthtilliath/typescript-config": "0.1.1"
36
+ },
37
+ "scripts": {
38
+ "lint": "eslint . --max-warnings 0",
39
+ "check-types": "tsc --noEmit",
40
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
41
+ "dev": "tsc --watch -p tsconfig.build.json",
42
+ "build": "pnpm run clean && tsc -p tsconfig.build.json",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "coverage": "vitest run --coverage"
46
+ }
6
47
  }