backdot 1.7.0 → 1.8.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/README.md CHANGED
@@ -49,6 +49,12 @@ Prefix a pattern with `!` to exclude matching files:
49
49
  }
50
50
  ```
51
51
 
52
+ ## Encryption
53
+
54
+ To encrypt files before they are pushed to the remote repo, add `"encrypt": true` to your config.
55
+
56
+ On first backup you'll be prompted for a password and offered to save it to `~/.backdot.key` so that future backups do not prompt for a password.
57
+
52
58
  ## Commands
53
59
 
54
60
  | Command | Description |
package/dist/cli.js CHANGED
@@ -51,7 +51,8 @@ cli.command("", "").action(() => {
51
51
  console.log(` No config found. Run ${chalk.bold("backdot init")} to get started.\n`);
52
52
  }
53
53
  });
54
- cli.help();
54
+ // cac auto-adds a --help flag; remove its redundant section from help output
55
+ cli.help((sections) => sections.filter((s) => !s.body?.includes("--help")));
55
56
  cli.version(getVersion());
56
57
  async function main() {
57
58
  try {
@@ -62,7 +63,8 @@ async function main() {
62
63
  const msg = errorMessage(err);
63
64
  logger.error(msg);
64
65
  console.error(`\n Error: ${msg}\n`);
65
- if (!process.stdout.isTTY) {
66
+ const isRunningInBackground = !process.stdout.isTTY;
67
+ if (isRunningInBackground) {
66
68
  sendNotification("Backdot", `Backup failed: ${msg}`);
67
69
  }
68
70
  process.exit(1);
@@ -1,17 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import ora from "ora";
2
4
  import { loadConfig, CONFIG_PATH } from "../config.js";
3
5
  import { resolveFiles } from "../resolveFiles.js";
4
- import { cleanStaging, copyToStaging, writeRepoReadme } from "../staging.js";
6
+ import { cleanStaging, copyToStaging, writeRepoReadme, machineDir } from "../staging.js";
5
7
  import { gitPull, gitCommitAndPush } from "../git.js";
6
8
  import { logger } from "../log.js";
7
9
  import { pluralize } from "../utils.js";
10
+ import { checkRepoVisibility } from "../repoVisibility.js";
11
+ import { decrypt, deriveKey } from "../crypto/encryption.js";
12
+ import { resolvePassword, offerToSaveKeyFile, confirmPassword, ENC_SUFFIX, } from "../crypto/password.js";
13
+ function findEncryptedFile(dir) {
14
+ if (!fs.existsSync(dir)) {
15
+ return null;
16
+ }
17
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
18
+ const fullPath = path.join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ const encryptedFile = findEncryptedFile(fullPath);
21
+ if (encryptedFile) {
22
+ return encryptedFile;
23
+ }
24
+ }
25
+ else if (entry.name.endsWith(ENC_SUFFIX)) {
26
+ return fullPath;
27
+ }
28
+ }
29
+ return null;
30
+ }
8
31
  export async function backup() {
9
32
  logger.info("Starting backup");
10
33
  const config = loadConfig();
11
34
  logger.info(`Repository: ${config.repository}`);
12
35
  logger.info(`Machine: ${config.machine}`);
13
- const spinner = ora("Resolving files").start();
36
+ let password;
37
+ let derivedKey;
38
+ let passwordWasInteractive = false;
39
+ if (config.encrypt) {
40
+ const result = await resolvePassword();
41
+ password = result.password;
42
+ passwordWasInteractive = result.interactive;
43
+ derivedKey = deriveKey(password);
44
+ }
45
+ const spinner = ora("Checking repository visibility").start();
14
46
  try {
47
+ const visibility = await checkRepoVisibility(config.repository);
48
+ if (visibility === "public") {
49
+ spinner.fail("Backup refused — repository is public");
50
+ throw new Error(`Repository "${config.repository}" is publicly accessible.\n` +
51
+ "Backing up to a public repo would expose sensitive files.\n" +
52
+ "Make the repository private, then try again.");
53
+ }
54
+ spinner.text = "Resolving files";
15
55
  const userFiles = resolveFiles(config);
16
56
  logger.info(`Resolved ${pluralize(userFiles.length, "file")}`);
17
57
  if (userFiles.length === 0) {
@@ -21,10 +61,29 @@ export async function backup() {
21
61
  const files = [...userFiles, CONFIG_PATH];
22
62
  spinner.text = "Syncing with remote";
23
63
  await gitPull(config.repository);
64
+ if (derivedKey) {
65
+ const existingEncryptedFile = findEncryptedFile(machineDir(config.machine));
66
+ if (existingEncryptedFile) {
67
+ spinner.text = "Verifying encryption password";
68
+ const encryptedContent = fs.readFileSync(existingEncryptedFile);
69
+ try {
70
+ decrypt(encryptedContent, derivedKey);
71
+ }
72
+ catch {
73
+ spinner.fail("Backup failed");
74
+ throw new Error("Password does not match the existing backup.");
75
+ }
76
+ }
77
+ else if (passwordWasInteractive) {
78
+ spinner.stop();
79
+ await confirmPassword(password);
80
+ spinner.start();
81
+ }
82
+ }
24
83
  spinner.text = `Copying ${pluralize(files.length, "file")} to staging`;
25
84
  cleanStaging(config.machine);
26
- copyToStaging(files, config.machine);
27
- writeRepoReadme(config.repository);
85
+ copyToStaging(files, config.machine, derivedKey);
86
+ writeRepoReadme(config.repository, config.encrypt);
28
87
  spinner.text = "Pushing to remote";
29
88
  const result = await gitCommitAndPush();
30
89
  const successMsg = result?.commitUrl
@@ -37,5 +96,8 @@ export async function backup() {
37
96
  spinner.fail("Backup failed");
38
97
  throw err;
39
98
  }
99
+ if (password) {
100
+ await offerToSaveKeyFile(password);
101
+ }
40
102
  logger.info("Backup complete");
41
103
  }
@@ -22,14 +22,17 @@ export async function history(repoUrl) {
22
22
  console.log("\n No backup history found.\n");
23
23
  return;
24
24
  }
25
- const selectedCommit = await select({
25
+ const selectedCommitHash = await select({
26
26
  message: "Select a backup to restore from:",
27
27
  loop: false,
28
- choices: commits.map((c) => ({
29
- name: `${c.hash.slice(0, 7)} ${c.date.split("T")[0]} ${c.message}`,
30
- value: c.hash,
31
- })),
28
+ choices: commits.map((commit) => {
29
+ const dateOnly = commit.date.split("T")[0];
30
+ return {
31
+ name: `${commit.hash.slice(0, 7)} ${dateOnly} ${commit.message}`,
32
+ value: commit.hash,
33
+ };
34
+ }),
32
35
  });
33
36
  console.log();
34
- await restore({ repoUrl, commit: selectedCommit });
37
+ await restore({ repoUrl, commit: selectedCommitHash });
35
38
  }
@@ -17,7 +17,7 @@ function getMachineName() {
17
17
  }
18
18
  return os.hostname().replace(/\.(local|localdomain)$/, "");
19
19
  }
20
- const TEMPLATE = {
20
+ const DEFAULT_CONFIG = {
21
21
  repository: "git@github.com:USERNAME/backdot-backup.git",
22
22
  machine: getMachineName(),
23
23
  paths: ["~/.zshrc", "~/.gitconfig"],
@@ -42,10 +42,11 @@ export function init() {
42
42
  console.log(` ${chalk.yellow(`${CONFIG_PATH} already exists — skipping creation.`)}`);
43
43
  }
44
44
  else {
45
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2) + "\n");
45
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n");
46
46
  console.log(` Created ${CONFIG_PATH} with defaults.`);
47
47
  }
48
48
  console.log(" Open it and set your repository URL and files to back up.");
49
+ console.log(` To encrypt backups, add ${chalk.bold('"encrypt": true')} to the config.`);
49
50
  console.log();
50
51
  // Step 3
51
52
  console.log(chalk.bold(" Step 3 — Run your first backup"));
@@ -8,14 +8,16 @@ import { gitPull } from "../git.js";
8
8
  import { STAGING_DIR, machineDir } from "../staging.js";
9
9
  import { logger } from "../log.js";
10
10
  import { pluralize } from "../utils.js";
11
+ import { decrypt, deriveKey } from "../crypto/encryption.js";
12
+ import { resolvePassword, offerToSaveKeyFile, ENC_SUFFIX } from "../crypto/password.js";
11
13
  const HOME = os.homedir();
12
- function walkDir(dir) {
14
+ function listFilesRecursively(dir) {
13
15
  return fs
14
16
  .readdirSync(dir, { withFileTypes: true })
15
17
  .filter((entry) => entry.name !== ".git")
16
18
  .flatMap((entry) => {
17
- const full = path.join(dir, entry.name);
18
- return entry.isDirectory() ? walkDir(full) : [full];
19
+ const fullPath = path.join(dir, entry.name);
20
+ return entry.isDirectory() ? listFilesRecursively(fullPath) : [fullPath];
19
21
  });
20
22
  }
21
23
  function listMachines() {
@@ -24,8 +26,8 @@ function listMachines() {
24
26
  }
25
27
  return fs
26
28
  .readdirSync(STAGING_DIR, { withFileTypes: true })
27
- .filter((e) => e.isDirectory() && e.name !== ".git")
28
- .map((e) => e.name);
29
+ .filter((entry) => entry.isDirectory() && entry.name !== ".git")
30
+ .map((entry) => entry.name);
29
31
  }
30
32
  async function resolveRepoAndMachine(repoUrl, commit) {
31
33
  if (!repoUrl) {
@@ -85,61 +87,80 @@ export async function restore({ repoUrl, commit, yes, } = {}) {
85
87
  }
86
88
  return;
87
89
  }
88
- const stagedFiles = walkDir(baseDir);
90
+ const stagedFiles = listFilesRecursively(baseDir);
89
91
  logger.info(`Found ${pluralize(stagedFiles.length, "file")} in backup repository`);
90
92
  if (stagedFiles.length === 0) {
91
93
  spinner.stop();
92
94
  console.log("No files found in backup repository.");
93
95
  return;
94
96
  }
95
- const fileMappings = stagedFiles.map((src) => {
96
- const rel = path.relative(baseDir, src);
97
- return { src, dest: path.join(HOME, rel), rel };
97
+ const fileMappings = stagedFiles.map((stagedFilePath) => {
98
+ let relativePath = path.relative(baseDir, stagedFilePath);
99
+ if (relativePath.endsWith(ENC_SUFFIX)) {
100
+ relativePath = relativePath.slice(0, -ENC_SUFFIX.length);
101
+ }
102
+ return { src: stagedFilePath, dest: path.join(HOME, relativePath), rel: relativePath };
98
103
  });
99
- const existing = fileMappings.filter((f) => fs.existsSync(f.dest));
100
- const fresh = fileMappings.filter((f) => !fs.existsSync(f.dest));
101
- logger.info(`${fresh.length} new, ${existing.length} already exist`);
104
+ const filesAlreadyOnDisk = fileMappings.filter((file) => fs.existsSync(file.dest));
105
+ const newFiles = fileMappings.filter((file) => !fs.existsSync(file.dest));
106
+ logger.info(`${newFiles.length} new, ${filesAlreadyOnDisk.length} already exist`);
102
107
  spinner.stop();
103
108
  console.log();
104
- let toRestore;
109
+ let filesToRestore;
105
110
  if (yes) {
106
- toRestore = fresh;
107
- if (existing.length > 0) {
108
- console.log(` Skipped ${pluralize(existing.length, "existing file")}. Run without --yes to select them.`);
111
+ filesToRestore = newFiles;
112
+ if (filesAlreadyOnDisk.length > 0) {
113
+ console.log(` Skipped ${pluralize(filesAlreadyOnDisk.length, "existing file")}. Run without --yes to select them.`);
109
114
  console.log();
110
115
  }
111
116
  }
112
117
  else {
113
118
  const choices = [];
114
- if (fresh.length > 0) {
115
- choices.push(new Separator(`── New files (${fresh.length}) ──`));
116
- for (const f of fresh) {
117
- choices.push({ name: f.rel, value: f, checked: true });
119
+ if (newFiles.length > 0) {
120
+ choices.push(new Separator(`── New files (${newFiles.length}) ──`));
121
+ for (const file of newFiles) {
122
+ choices.push({ name: file.rel, value: file, checked: true });
118
123
  }
119
124
  }
120
- if (existing.length > 0) {
121
- choices.push(new Separator(`── Existing files — will overwrite (${existing.length}) ──`));
122
- for (const f of existing) {
123
- choices.push({ name: f.rel, value: f, checked: false });
125
+ if (filesAlreadyOnDisk.length > 0) {
126
+ choices.push(new Separator(`── Existing files — will overwrite (${filesAlreadyOnDisk.length}) ──`));
127
+ for (const file of filesAlreadyOnDisk) {
128
+ choices.push({ name: file.rel, value: file, checked: false });
124
129
  }
125
130
  }
126
- toRestore = await checkbox({
131
+ filesToRestore = await checkbox({
127
132
  message: "Select files to restore:",
128
133
  loop: false,
129
134
  choices,
130
135
  });
131
136
  console.log();
132
137
  }
133
- if (toRestore.length === 0) {
138
+ if (filesToRestore.length === 0) {
134
139
  console.log("No files selected for restore.");
135
140
  return;
136
141
  }
137
- const copySpinner = ora("Restoring files").start();
138
- for (const { src, dest } of toRestore) {
142
+ let password;
143
+ const hasEncryptedFiles = filesToRestore.some(({ src }) => src.endsWith(ENC_SUFFIX));
144
+ if (hasEncryptedFiles) {
145
+ const result = await resolvePassword();
146
+ password = result.password;
147
+ }
148
+ const derivedKey = password ? deriveKey(password) : undefined;
149
+ const restoreSpinner = ora("Restoring files").start();
150
+ for (const { src, dest } of filesToRestore) {
139
151
  fs.mkdirSync(path.dirname(dest), { recursive: true });
140
- fs.copyFileSync(src, dest);
152
+ if (derivedKey && src.endsWith(ENC_SUFFIX)) {
153
+ const content = fs.readFileSync(src);
154
+ fs.writeFileSync(dest, decrypt(content, derivedKey));
155
+ }
156
+ else {
157
+ fs.copyFileSync(src, dest);
158
+ }
141
159
  }
142
- copySpinner.succeed(`Restored ${pluralize(toRestore.length, "file")}`);
160
+ restoreSpinner.succeed(`Restored ${pluralize(filesToRestore.length, "file")}`);
143
161
  console.log();
144
- logger.info(`Restored ${pluralize(toRestore.length, "file")}`);
162
+ if (password) {
163
+ await offerToSaveKeyFile(password);
164
+ }
165
+ logger.info(`Restored ${pluralize(filesToRestore.length, "file")}`);
145
166
  }
@@ -1,4 +1,8 @@
1
+ import fs from "node:fs";
2
+ import chalk from "chalk";
1
3
  import { setupLaunchd, uninstallLaunchd } from "../launchd.js";
4
+ import { loadConfig } from "../config.js";
5
+ import { KEY_FILE_PATH } from "../crypto/password.js";
2
6
  function requireMacOS() {
3
7
  if (process.platform !== "darwin") {
4
8
  throw new Error("Scheduling is only supported on macOS (launchd). Use cron or systemd on Linux.");
@@ -7,6 +11,16 @@ function requireMacOS() {
7
11
  export function schedule() {
8
12
  requireMacOS();
9
13
  setupLaunchd();
14
+ try {
15
+ const config = loadConfig();
16
+ if (config.encrypt && !fs.existsSync(KEY_FILE_PATH)) {
17
+ console.log(chalk.yellow(` Encryption is enabled. Run ${chalk.bold("backdot backup")} once to create ~/.backdot.key,\n` +
18
+ ` or set ${chalk.bold("BACKDOT_PASSWORD")} in your environment.\n`));
19
+ }
20
+ }
21
+ catch {
22
+ // Config may not exist yet; ignore
23
+ }
10
24
  }
11
25
  export function unschedule() {
12
26
  requireMacOS();
@@ -6,17 +6,47 @@ import { resolveFiles } from "../resolveFiles.js";
6
6
  import { compareFiles } from "../staging.js";
7
7
  import { isScheduled } from "../launchd.js";
8
8
  import { pluralize } from "../utils.js";
9
- function tildePath(filePath) {
9
+ import { checkRepoVisibility } from "../repoVisibility.js";
10
+ import { deriveKey } from "../crypto/encryption.js";
11
+ import { resolvePassword } from "../crypto/password.js";
12
+ function abbreviateHomePath(filePath) {
10
13
  const home = os.homedir();
11
14
  return filePath.startsWith(home) ? "~" + filePath.slice(home.length) : filePath;
12
15
  }
16
+ function formatVisibility(visibility) {
17
+ switch (visibility) {
18
+ case "public":
19
+ return chalk.red.bold("public (backup disabled)");
20
+ case "private":
21
+ return chalk.green("private");
22
+ case "unknown":
23
+ return chalk.yellow("unknown (could not verify)");
24
+ }
25
+ }
13
26
  export async function status() {
14
27
  const scheduled = isScheduled();
15
28
  console.log();
16
- console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
29
+ console.log(` Schedule: ${scheduled ? "active (daily at 02:00)" : `not active (run ${chalk.bold("backdot schedule")} to enable)`}`);
17
30
  const config = loadConfig();
18
- console.log(` Repo: ${config.repository}`);
19
- console.log(` Machine: ${config.machine}`);
31
+ console.log(` Repo: ${config.repository}`);
32
+ console.log(` Machine: ${config.machine}`);
33
+ if (config.encrypt) {
34
+ console.log(` Encryption: ${chalk.green("enabled")}`);
35
+ }
36
+ const visibilitySpinner = ora("Checking repository visibility").start();
37
+ const visibility = await checkRepoVisibility(config.repository);
38
+ visibilitySpinner.stop();
39
+ if (visibility !== "private") {
40
+ console.log(` Visibility: ${formatVisibility(visibility)}`);
41
+ }
42
+ if (visibility === "public") {
43
+ console.log();
44
+ console.log(chalk.red(" ⚠ Repository is public. Backup is disabled to prevent leaking sensitive files.\n" +
45
+ " Make the repository private, then try again."));
46
+ console.log();
47
+ return;
48
+ }
49
+ const derivedKey = config.encrypt ? deriveKey((await resolvePassword()).password) : undefined;
20
50
  console.log();
21
51
  const spinner = ora("Resolving files").start();
22
52
  try {
@@ -28,7 +58,12 @@ export async function status() {
28
58
  }
29
59
  const files = [...userFiles, CONFIG_PATH];
30
60
  spinner.text = "Comparing with remote backup";
31
- const { backedUp, modified, notBackedUp, error } = await compareFiles(files, config.machine, config.repository);
61
+ const { backedUp, modified, notBackedUp, error } = await compareFiles({
62
+ files,
63
+ machine: config.machine,
64
+ repository: config.repository,
65
+ derivedKey,
66
+ });
32
67
  spinner.stop();
33
68
  if (error) {
34
69
  console.log(chalk.yellow(` Could not fetch status: ${error}`));
@@ -40,22 +75,22 @@ export async function status() {
40
75
  else {
41
76
  if (modified.length > 0) {
42
77
  console.log(chalk.yellow(` Modified since last backup (${modified.length}):`));
43
- for (const f of modified) {
44
- console.log(` ${tildePath(f)}`);
78
+ for (const filePath of modified) {
79
+ console.log(` ${abbreviateHomePath(filePath)}`);
45
80
  }
46
81
  console.log();
47
82
  }
48
83
  if (notBackedUp.length > 0) {
49
84
  console.log(chalk.red(` Not yet backed up (${notBackedUp.length}):`));
50
- for (const f of notBackedUp) {
51
- console.log(` ${tildePath(f)}`);
85
+ for (const filePath of notBackedUp) {
86
+ console.log(` ${abbreviateHomePath(filePath)}`);
52
87
  }
53
88
  console.log();
54
89
  }
55
90
  if (backedUp.length > 0) {
56
91
  console.log(chalk.green(` Backed up (${backedUp.length}):`));
57
- for (const f of backedUp) {
58
- console.log(` ${tildePath(f)}`);
92
+ for (const filePath of backedUp) {
93
+ console.log(` ${abbreviateHomePath(filePath)}`);
59
94
  }
60
95
  console.log();
61
96
  }
package/dist/commitUrl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const PROVIDERS = {
2
- "github.com": (p, sha) => `https://github.com/${p}/commit/${sha}`,
3
- "gitlab.com": (p, sha) => `https://gitlab.com/${p}/-/commit/${sha}`,
4
- "bitbucket.org": (p, sha) => `https://bitbucket.org/${p}/commits/${sha}`,
2
+ "github.com": (repoPath, sha) => `https://github.com/${repoPath}/commit/${sha}`,
3
+ "gitlab.com": (repoPath, sha) => `https://gitlab.com/${repoPath}/-/commit/${sha}`,
4
+ "bitbucket.org": (repoPath, sha) => `https://bitbucket.org/${repoPath}/commits/${sha}`,
5
5
  };
6
6
  export function getCommitUrl(remoteUrl, sha) {
7
7
  for (const [host, buildUrl] of Object.entries(PROVIDERS)) {
@@ -9,7 +9,8 @@ export function getCommitUrl(remoteUrl, sha) {
9
9
  if (idx === -1) {
10
10
  continue;
11
11
  }
12
- let repoPath = remoteUrl.slice(idx + host.length + 1).trim();
12
+ const separatorLength = 1; // skip the ":" (SSH) or "/" (HTTPS) after the hostname
13
+ let repoPath = remoteUrl.slice(idx + host.length + separatorLength).trim();
13
14
  if (repoPath.endsWith(".git")) {
14
15
  repoPath = repoPath.slice(0, -4);
15
16
  }
package/dist/config.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { z } from "zod";
2
2
  export declare const CONFIG_PATH: string;
3
- export declare function expandTilde(p: string): string;
3
+ export declare function expandTilde(pattern: string): string;
4
4
  declare const ConfigSchema: z.ZodObject<{
5
5
  repository: z.ZodString;
6
6
  machine: z.ZodString;
7
7
  paths: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>>;
8
+ encrypt: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
8
9
  }, z.core.$strip>;
9
10
  export type Config = z.infer<typeof ConfigSchema>;
10
11
  export declare function loadConfig(): Config;
package/dist/config.js CHANGED
@@ -3,14 +3,15 @@ import path from "node:path";
3
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
5
  export const CONFIG_PATH = path.join(os.homedir(), ".backdot.json");
6
- export function expandTilde(p) {
7
- if (p.startsWith("!")) {
8
- return "!" + expandTilde(p.slice(1));
6
+ export function expandTilde(pattern) {
7
+ // fast-glob uses "!" for negation patterns — preserve the prefix, expand the rest
8
+ if (pattern.startsWith("!")) {
9
+ return "!" + expandTilde(pattern.slice(1));
9
10
  }
10
- if (p.startsWith("~/") || p === "~") {
11
- return path.join(os.homedir(), p.slice(1));
11
+ if (pattern.startsWith("~/") || pattern === "~") {
12
+ return path.join(os.homedir(), pattern.slice(1));
12
13
  }
13
- return p;
14
+ return pattern;
14
15
  }
15
16
  const ConfigSchema = z.object({
16
17
  repository: z.string().min(1),
@@ -18,30 +19,31 @@ const ConfigSchema = z.object({
18
19
  paths: z
19
20
  .array(z.string().min(1).transform(expandTilde))
20
21
  .min(1, '"paths" must be a non-empty array'),
22
+ encrypt: z.boolean().optional().default(false),
21
23
  });
22
24
  export function loadConfig() {
23
25
  if (!fs.existsSync(CONFIG_PATH)) {
24
26
  throw new Error(`Config file not found: ${CONFIG_PATH}\n Run "backdot init" to create it.`);
25
27
  }
26
- let raw;
28
+ let rawJson;
27
29
  try {
28
- raw = fs.readFileSync(CONFIG_PATH, "utf-8");
30
+ rawJson = fs.readFileSync(CONFIG_PATH, "utf-8");
29
31
  }
30
32
  catch {
31
33
  throw new Error(`Failed to read config file: ${CONFIG_PATH}`);
32
34
  }
33
35
  let parsed;
34
36
  try {
35
- parsed = JSON.parse(raw);
37
+ parsed = JSON.parse(rawJson);
36
38
  }
37
39
  catch {
38
40
  throw new Error(`Invalid JSON in config file: ${CONFIG_PATH}`);
39
41
  }
40
42
  const result = ConfigSchema.safeParse(parsed);
41
43
  if (!result.success) {
42
- const messages = result.error.issues.map((i) => {
43
- const path = i.path.length > 0 ? `"${i.path.join(".")}"` : "config";
44
- return ` - ${path}: ${i.message}`;
44
+ const messages = result.error.issues.map((issue) => {
45
+ const field = issue.path.length > 0 ? `"${issue.path.join(".")}"` : "config";
46
+ return ` - ${field}: ${issue.message}`;
45
47
  });
46
48
  throw new Error(`Invalid config in ${CONFIG_PATH}:\n${messages.join("\n")}`);
47
49
  }
@@ -0,0 +1,8 @@
1
+ export interface DerivedKey {
2
+ password: string;
3
+ salt: Buffer;
4
+ key: Buffer;
5
+ }
6
+ export declare function deriveKey(password: string, salt?: Buffer): DerivedKey;
7
+ export declare function encrypt(plaintext: Buffer, derivedKey: DerivedKey): Buffer;
8
+ export declare function decrypt(encrypted: Buffer, derivedKey: DerivedKey): Buffer;
@@ -0,0 +1,54 @@
1
+ import crypto from "node:crypto";
2
+ const ALGORITHM = "aes-256-gcm";
3
+ const SALT_LENGTH = 32;
4
+ const IV_LENGTH = 12;
5
+ const AUTH_TAG_LENGTH = 16;
6
+ const KEY_LENGTH = 32;
7
+ // Higher N = harder to brute-force but slower to encrypt/decrypt (~300ms).
8
+ // 2^17 is the OWASP minimum recommendation for password-based key derivation.
9
+ const SCRYPT_N = 2 ** 17;
10
+ const SCRYPT_R = 8;
11
+ const SCRYPT_PARAMS = {
12
+ N: SCRYPT_N,
13
+ r: SCRYPT_R,
14
+ p: 1,
15
+ maxmem: 128 * SCRYPT_N * SCRYPT_R * 2, // must exceed 128*N*r
16
+ };
17
+ const OVERHEAD = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH;
18
+ export function deriveKey(password, salt) {
19
+ const resolvedSalt = salt ?? crypto.randomBytes(SALT_LENGTH);
20
+ const key = crypto.scryptSync(password, resolvedSalt, KEY_LENGTH, SCRYPT_PARAMS);
21
+ return { password, salt: resolvedSalt, key };
22
+ }
23
+ function deriveKeyForSalt(derivedKey, salt) {
24
+ if (derivedKey.salt.equals(salt)) {
25
+ return derivedKey.key;
26
+ }
27
+ return crypto.scryptSync(derivedKey.password, salt, KEY_LENGTH, SCRYPT_PARAMS);
28
+ }
29
+ export function encrypt(plaintext, derivedKey) {
30
+ const iv = crypto.randomBytes(IV_LENGTH);
31
+ const cipher = crypto.createCipheriv(ALGORITHM, derivedKey.key, iv);
32
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
33
+ const authTag = cipher.getAuthTag();
34
+ return Buffer.concat([derivedKey.salt, iv, authTag, ciphertext]);
35
+ }
36
+ export function decrypt(encrypted, derivedKey) {
37
+ if (encrypted.length < OVERHEAD) {
38
+ throw new Error("Data is too short to be encrypted content.");
39
+ }
40
+ let offset = 0;
41
+ const salt = encrypted.subarray(offset, (offset += SALT_LENGTH));
42
+ const iv = encrypted.subarray(offset, (offset += IV_LENGTH));
43
+ const authTag = encrypted.subarray(offset, (offset += AUTH_TAG_LENGTH));
44
+ const ciphertext = encrypted.subarray(offset);
45
+ const key = deriveKeyForSalt(derivedKey, salt);
46
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
47
+ decipher.setAuthTag(authTag);
48
+ try {
49
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
50
+ }
51
+ catch {
52
+ throw new Error("Decryption failed — wrong password or corrupted data.");
53
+ }
54
+ }
@@ -0,0 +1,11 @@
1
+ export declare const KEY_FILE_PATH: string;
2
+ export declare const ENC_SUFFIX = ".encrypted";
3
+ export declare function checkKeyFilePermissions(): void;
4
+ export declare function saveKeyFile(password: string): void;
5
+ export interface PasswordResult {
6
+ password: string;
7
+ interactive: boolean;
8
+ }
9
+ export declare function resolvePassword(): Promise<PasswordResult>;
10
+ export declare function confirmPassword(password: string): Promise<void>;
11
+ export declare function offerToSaveKeyFile(password: string): Promise<void>;