@forthtilliath/backup-crypto 0.0.0-stage → 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 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,78 @@
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
+ ### Errors
73
+
74
+ 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`.
75
+
76
+ ### Randomness
77
+
78
+ 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,23 @@
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
+ /** Generates a new random 256-bit backup key. */
12
+ export declare function generateBackupKey(randomBytes?: RandomBytes): Uint8Array;
13
+ /**
14
+ * A short, stable, non-secret identifier of a key (16 hex chars), stored next
15
+ * to each encrypted backup so the right key can be picked from a keyring.
16
+ * Domain-separated hash: it reveals nothing usable about the key itself.
17
+ */
18
+ export declare function getBackupKeyId(key: Uint8Array): string;
19
+ /** Base64 form of a key, for storage (secure store, keyring file). */
20
+ export declare function encodeBackupKey(key: Uint8Array): string;
21
+ /** Reverse of {@link encodeBackupKey}. Throws `corrupted` on anything else than a 256-bit key. */
22
+ export declare function decodeBackupKey(encoded: string): Uint8Array;
23
+ //# 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,iDAAiD;AACjD,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,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAW3D"}
@@ -0,0 +1,56 @@
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
+ /** Generates a new random 256-bit backup key. */
21
+ export function generateBackupKey(randomBytes = defaultRandomBytes) {
22
+ const key = randomBytes(BACKUP_KEY_LENGTH);
23
+ if (key.length !== BACKUP_KEY_LENGTH) {
24
+ throw new BackupCryptoError("no-random-source", `randomBytes returned ${key.length} bytes instead of ${BACKUP_KEY_LENGTH}.`);
25
+ }
26
+ return key;
27
+ }
28
+ /**
29
+ * A short, stable, non-secret identifier of a key (16 hex chars), stored next
30
+ * to each encrypted backup so the right key can be picked from a keyring.
31
+ * Domain-separated hash: it reveals nothing usable about the key itself.
32
+ */
33
+ export function getBackupKeyId(key) {
34
+ const input = new Uint8Array(KEY_ID_DOMAIN.length + key.length);
35
+ input.set(KEY_ID_DOMAIN);
36
+ input.set(key, KEY_ID_DOMAIN.length);
37
+ return hex.encode(sha256(input).subarray(0, 8));
38
+ }
39
+ /** Base64 form of a key, for storage (secure store, keyring file). */
40
+ export function encodeBackupKey(key) {
41
+ return base64.encode(key);
42
+ }
43
+ /** Reverse of {@link encodeBackupKey}. Throws `corrupted` on anything else than a 256-bit key. */
44
+ export function decodeBackupKey(encoded) {
45
+ let key;
46
+ try {
47
+ key = base64.decode(encoded);
48
+ }
49
+ catch {
50
+ throw new BackupCryptoError("corrupted", "Invalid backup key encoding.");
51
+ }
52
+ if (key.length !== BACKUP_KEY_LENGTH) {
53
+ throw new BackupCryptoError("corrupted", "Invalid backup key length.");
54
+ }
55
+ return key;
56
+ }
@@ -0,0 +1,47 @@
1
+ import { type RandomBytes } from "./backupKey.js";
2
+ export declare const ENCRYPTED_BACKUP_FORMAT = "forthtilliath.encrypted-backup";
3
+ export declare const ENCRYPTED_BACKUP_VERSION = 1;
4
+ declare const CIPHER = "xchacha20-poly1305";
5
+ /**
6
+ * What an encrypted backup file contains. Still JSON (so pickers filtering on
7
+ * `application/json` keep working), but only the header is readable: the
8
+ * app's data is in `ciphertext`.
9
+ */
10
+ export interface EncryptedBackupEnvelope {
11
+ format: typeof ENCRYPTED_BACKUP_FORMAT;
12
+ version: number;
13
+ /** Free label of the app that wrote it — authenticated, not secret. */
14
+ app: string;
15
+ /** {@link getBackupKeyId} of the key it was encrypted with. */
16
+ keyId: string;
17
+ cipher: typeof CIPHER;
18
+ nonce: string;
19
+ ciphertext: string;
20
+ }
21
+ export interface EncryptBackupOptions {
22
+ /** Stored in clear and authenticated, e.g. `"glucodose"`. */
23
+ app?: string;
24
+ randomBytes?: RandomBytes;
25
+ }
26
+ /**
27
+ * Encrypts a backup's text (typically the app's JSON export) with
28
+ * XChaCha20-Poly1305 and a fresh random 192-bit nonce — safe to reuse the
29
+ * same key for every backup. Returns the JSON envelope to write as is.
30
+ */
31
+ export declare function encryptBackup(plaintext: string, key: Uint8Array, { app, randomBytes }?: EncryptBackupOptions): string;
32
+ /** Whether `content` is an encrypted backup envelope (of any version). */
33
+ export declare function isEncryptedBackup(content: string): boolean;
34
+ /**
35
+ * Validates and returns an encrypted backup's header. Throws
36
+ * `not-encrypted` for anything that isn't an envelope, `unsupported-version`
37
+ * for a newer one, `corrupted` for a malformed one.
38
+ */
39
+ export declare function readEncryptedBackup(content: string): EncryptedBackupEnvelope;
40
+ /**
41
+ * Decrypts an envelope written by {@link encryptBackup}. Throws `unknown-key`
42
+ * when `key` isn't the one it was encrypted with, `corrupted` when the file
43
+ * was truncated or tampered with.
44
+ */
45
+ export declare function decryptBackup(content: string, key: Uint8Array): string;
46
+ export {};
47
+ //# 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,eAAO,MAAM,uBAAuB,mCAAmC,CAAC;AACxE,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,QAAA,MAAM,MAAM,uBAAuB,CAAC;AAGpC;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,OAAO,uBAAuB,CAAC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;IACZ,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,oBAAoB;IACnC,6DAA6D;IAC7D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,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,120 @@
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
+ export const ENCRYPTED_BACKUP_FORMAT = "forthtilliath.encrypted-backup";
7
+ export const ENCRYPTED_BACKUP_VERSION = 1;
8
+ const CIPHER = "xchacha20-poly1305";
9
+ const NONCE_LENGTH = 24;
10
+ // The header is bound to the ciphertext as associated data: editing the
11
+ // app/keyId/version of a file makes decryption fail instead of being trusted.
12
+ function associatedData(envelope) {
13
+ return utf8ToBytes(JSON.stringify([
14
+ envelope.format,
15
+ envelope.version,
16
+ envelope.app,
17
+ envelope.keyId,
18
+ envelope.cipher,
19
+ ]));
20
+ }
21
+ function assertKey(key) {
22
+ if (key.length !== BACKUP_KEY_LENGTH) {
23
+ throw new BackupCryptoError("corrupted", "Invalid backup key length.");
24
+ }
25
+ }
26
+ /**
27
+ * Encrypts a backup's text (typically the app's JSON export) with
28
+ * XChaCha20-Poly1305 and a fresh random 192-bit nonce — safe to reuse the
29
+ * same key for every backup. Returns the JSON envelope to write as is.
30
+ */
31
+ export function encryptBackup(plaintext, key, { app = "", randomBytes = defaultRandomBytes } = {}) {
32
+ assertKey(key);
33
+ const nonce = randomBytes(NONCE_LENGTH);
34
+ if (nonce.length !== NONCE_LENGTH) {
35
+ throw new BackupCryptoError("no-random-source");
36
+ }
37
+ const header = {
38
+ format: ENCRYPTED_BACKUP_FORMAT,
39
+ version: ENCRYPTED_BACKUP_VERSION,
40
+ app,
41
+ keyId: getBackupKeyId(key),
42
+ cipher: CIPHER,
43
+ };
44
+ const ciphertext = xchacha20poly1305(key, nonce, associatedData(header)).encrypt(utf8ToBytes(plaintext));
45
+ const envelope = {
46
+ ...header,
47
+ nonce: base64.encode(nonce),
48
+ ciphertext: base64.encode(ciphertext),
49
+ };
50
+ return JSON.stringify(envelope);
51
+ }
52
+ function tryParseJson(content) {
53
+ try {
54
+ return JSON.parse(content);
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ function isRecord(value) {
61
+ return typeof value === "object" && value !== null;
62
+ }
63
+ /** Whether `content` is an encrypted backup envelope (of any version). */
64
+ export function isEncryptedBackup(content) {
65
+ const parsed = tryParseJson(content);
66
+ return isRecord(parsed) && parsed.format === ENCRYPTED_BACKUP_FORMAT;
67
+ }
68
+ /**
69
+ * Validates and returns an encrypted backup's header. Throws
70
+ * `not-encrypted` for anything that isn't an envelope, `unsupported-version`
71
+ * for a newer one, `corrupted` for a malformed one.
72
+ */
73
+ export function readEncryptedBackup(content) {
74
+ const parsed = tryParseJson(content);
75
+ if (!isRecord(parsed) || parsed.format !== ENCRYPTED_BACKUP_FORMAT) {
76
+ throw new BackupCryptoError("not-encrypted");
77
+ }
78
+ if (typeof parsed.version !== "number" ||
79
+ parsed.version > ENCRYPTED_BACKUP_VERSION) {
80
+ throw new BackupCryptoError("unsupported-version");
81
+ }
82
+ const { app, keyId, cipher, nonce, ciphertext } = parsed;
83
+ if (typeof app !== "string" ||
84
+ typeof keyId !== "string" ||
85
+ cipher !== CIPHER ||
86
+ typeof nonce !== "string" ||
87
+ typeof ciphertext !== "string") {
88
+ throw new BackupCryptoError("corrupted", "Malformed encrypted backup.");
89
+ }
90
+ return {
91
+ format: ENCRYPTED_BACKUP_FORMAT,
92
+ version: parsed.version,
93
+ app,
94
+ keyId,
95
+ cipher,
96
+ nonce,
97
+ ciphertext,
98
+ };
99
+ }
100
+ /**
101
+ * Decrypts an envelope written by {@link encryptBackup}. Throws `unknown-key`
102
+ * when `key` isn't the one it was encrypted with, `corrupted` when the file
103
+ * was truncated or tampered with.
104
+ */
105
+ export function decryptBackup(content, key) {
106
+ assertKey(key);
107
+ const envelope = readEncryptedBackup(content);
108
+ if (envelope.keyId !== getBackupKeyId(key)) {
109
+ throw new BackupCryptoError("unknown-key");
110
+ }
111
+ try {
112
+ const nonce = base64.decode(envelope.nonce);
113
+ const ciphertext = base64.decode(envelope.ciphertext);
114
+ const plaintext = xchacha20poly1305(key, nonce, associatedData(envelope)).decrypt(ciphertext);
115
+ return bytesToUtf8(plaintext);
116
+ }
117
+ catch {
118
+ throw new BackupCryptoError("corrupted", "Encrypted backup failed authentication.");
119
+ }
120
+ }
@@ -0,0 +1,18 @@
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
+ export declare class BackupCryptoError extends Error {
15
+ readonly code: BackupCryptoErrorCode;
16
+ constructor(code: BackupCryptoErrorCode, message?: string);
17
+ }
18
+ //# 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,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,8 @@
1
+ export class BackupCryptoError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message ?? code);
5
+ this.name = "BackupCryptoError";
6
+ this.code = code;
7
+ }
8
+ }
@@ -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,50 @@
1
+ import { type RandomBytes } from "./backupKey.js";
2
+ export declare const BACKUP_KEYRING_FORMAT = "forthtilliath.backup-keyring";
3
+ export interface BackupKeyringEntry {
4
+ id: string;
5
+ key: Uint8Array;
6
+ /** Epoch ms. */
7
+ createdAt: number;
8
+ }
9
+ /**
10
+ * Every key an app has encrypted backups with, so a file written before a
11
+ * key change (another phone, a reinstall…) stays readable. New backups always
12
+ * use `currentId`. Immutable: every helper returns a new keyring.
13
+ */
14
+ export interface BackupKeyring {
15
+ currentId: string | null;
16
+ keys: readonly BackupKeyringEntry[];
17
+ }
18
+ export declare const emptyKeyring: BackupKeyring;
19
+ export declare function findKeyringKey(keyring: BackupKeyring, id: string): BackupKeyringEntry | undefined;
20
+ export declare function getCurrentKey(keyring: BackupKeyring): BackupKeyringEntry | undefined;
21
+ /**
22
+ * Adds `key` (a no-op if already there). It becomes the current key when
23
+ * `makeCurrent` is set or when the keyring had none.
24
+ */
25
+ export declare function addKeyToKeyring(keyring: BackupKeyring, key: Uint8Array, { makeCurrent, createdAt }?: {
26
+ makeCurrent?: boolean | undefined;
27
+ createdAt?: number | undefined;
28
+ }): BackupKeyring;
29
+ /** Returns the keyring with a current key, generating one if it had none. */
30
+ export declare function ensureCurrentKey(keyring: BackupKeyring, randomBytes?: RandomBytes): BackupKeyring;
31
+ /**
32
+ * Union of two keyrings. `preferred`'s current key wins (e.g. the one already
33
+ * on the cloud, so every device converges on it), falling back to `other`'s.
34
+ */
35
+ export declare function mergeKeyrings(preferred: BackupKeyring, other: BackupKeyring): BackupKeyring;
36
+ /** Same current key and same set of keys — i.e. nothing to save or upload. */
37
+ export declare function sameKeyring(a: BackupKeyring, b: BackupKeyring): boolean;
38
+ /**
39
+ * Decrypts with whichever key of the keyring the backup was written with.
40
+ * Throws `unknown-key` if none matches.
41
+ */
42
+ export declare function decryptBackupWithKeyring(content: string, keyring: BackupKeyring): string;
43
+ /** JSON form, for a secure store or a private cloud file. Contains the raw keys. */
44
+ export declare function serializeKeyring(keyring: BackupKeyring): string;
45
+ /**
46
+ * Reverse of {@link serializeKeyring}. Key ids are recomputed from the keys
47
+ * rather than trusted; a `currentId` that matches none of them is dropped.
48
+ */
49
+ export declare function parseKeyring(content: string): BackupKeyring;
50
+ //# 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,eAAO,MAAM,qBAAqB,iCAAiC,CAAC;AAGpE,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,UAAU,CAAC;IAChB,gBAAgB;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,SAAS,kBAAkB,EAAE,CAAC;CACrC;AAED,eAAO,MAAM,YAAY,EAAE,aAA6C,CAAC;AAEzE,wBAAgB,cAAc,CAC5B,OAAO,EAAE,aAAa,EACtB,EAAE,EAAE,MAAM,GACT,kBAAkB,GAAG,SAAS,CAEhC;AAED,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,oFAAoF;AACpF,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,114 @@
1
+ import { decodeBackupKey, defaultRandomBytes, encodeBackupKey, generateBackupKey, getBackupKeyId, } from "./backupKey.js";
2
+ import { decryptBackup, readEncryptedBackup } from "./envelope.js";
3
+ import { BackupCryptoError } from "./errors.js";
4
+ export const BACKUP_KEYRING_FORMAT = "forthtilliath.backup-keyring";
5
+ const BACKUP_KEYRING_VERSION = 1;
6
+ export const emptyKeyring = { currentId: null, keys: [] };
7
+ export function findKeyringKey(keyring, id) {
8
+ return keyring.keys.find((entry) => entry.id === id);
9
+ }
10
+ export function getCurrentKey(keyring) {
11
+ return keyring.currentId === null
12
+ ? undefined
13
+ : findKeyringKey(keyring, keyring.currentId);
14
+ }
15
+ /**
16
+ * Adds `key` (a no-op if already there). It becomes the current key when
17
+ * `makeCurrent` is set or when the keyring had none.
18
+ */
19
+ export function addKeyToKeyring(keyring, key, { makeCurrent = false, createdAt = Date.now() } = {}) {
20
+ const id = getBackupKeyId(key);
21
+ const keys = findKeyringKey(keyring, id)
22
+ ? keyring.keys
23
+ : [...keyring.keys, { id, key, createdAt }];
24
+ const currentId = makeCurrent || keyring.currentId === null ? id : keyring.currentId;
25
+ return { currentId, keys };
26
+ }
27
+ /** Returns the keyring with a current key, generating one if it had none. */
28
+ export function ensureCurrentKey(keyring, randomBytes = defaultRandomBytes) {
29
+ if (getCurrentKey(keyring))
30
+ return keyring;
31
+ return addKeyToKeyring(keyring, generateBackupKey(randomBytes), {
32
+ makeCurrent: true,
33
+ });
34
+ }
35
+ /**
36
+ * Union of two keyrings. `preferred`'s current key wins (e.g. the one already
37
+ * on the cloud, so every device converges on it), falling back to `other`'s.
38
+ */
39
+ export function mergeKeyrings(preferred, other) {
40
+ const keys = [...preferred.keys];
41
+ for (const entry of other.keys) {
42
+ if (!findKeyringKey(preferred, entry.id))
43
+ keys.push(entry);
44
+ }
45
+ return { currentId: preferred.currentId ?? other.currentId, keys };
46
+ }
47
+ /** Same current key and same set of keys — i.e. nothing to save or upload. */
48
+ export function sameKeyring(a, b) {
49
+ return (a.currentId === b.currentId &&
50
+ a.keys.length === b.keys.length &&
51
+ a.keys.every((entry) => findKeyringKey(b, entry.id)));
52
+ }
53
+ /**
54
+ * Decrypts with whichever key of the keyring the backup was written with.
55
+ * Throws `unknown-key` if none matches.
56
+ */
57
+ export function decryptBackupWithKeyring(content, keyring) {
58
+ const { keyId } = readEncryptedBackup(content);
59
+ const entry = findKeyringKey(keyring, keyId);
60
+ if (!entry)
61
+ throw new BackupCryptoError("unknown-key");
62
+ return decryptBackup(content, entry.key);
63
+ }
64
+ /** JSON form, for a secure store or a private cloud file. Contains the raw keys. */
65
+ export function serializeKeyring(keyring) {
66
+ const serialized = {
67
+ format: BACKUP_KEYRING_FORMAT,
68
+ version: BACKUP_KEYRING_VERSION,
69
+ currentId: keyring.currentId,
70
+ keys: keyring.keys.map(({ key, createdAt }) => ({
71
+ key: encodeBackupKey(key),
72
+ createdAt,
73
+ })),
74
+ };
75
+ return JSON.stringify(serialized);
76
+ }
77
+ function corrupted() {
78
+ return new BackupCryptoError("corrupted", "Malformed backup keyring.");
79
+ }
80
+ /**
81
+ * Reverse of {@link serializeKeyring}. Key ids are recomputed from the keys
82
+ * rather than trusted; a `currentId` that matches none of them is dropped.
83
+ */
84
+ export function parseKeyring(content) {
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(content);
88
+ }
89
+ catch {
90
+ throw corrupted();
91
+ }
92
+ const data = parsed;
93
+ if (data?.format !== BACKUP_KEYRING_FORMAT) {
94
+ throw corrupted();
95
+ }
96
+ if (typeof data.version !== "number" ||
97
+ data.version > BACKUP_KEYRING_VERSION) {
98
+ throw new BackupCryptoError("unsupported-version");
99
+ }
100
+ if (!Array.isArray(data.keys))
101
+ throw corrupted();
102
+ let keyring = emptyKeyring;
103
+ for (const entry of data.keys) {
104
+ const { key, createdAt } = (entry ?? {});
105
+ if (typeof key !== "string" || typeof createdAt !== "number")
106
+ throw corrupted();
107
+ keyring = addKeyToKeyring(keyring, decodeBackupKey(key), { createdAt });
108
+ }
109
+ const currentId = typeof data.currentId === "string" &&
110
+ findKeyringKey(keyring, data.currentId)
111
+ ? data.currentId
112
+ : null;
113
+ return { ...keyring, currentId };
114
+ }
@@ -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.0",
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.2",
31
+ "eslint": "10.7.0",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^5.0.2",
34
+ "@forthtilliath/eslint-config": "0.9.0",
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
  }