@muthuishere/vsync 0.3.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/src/backup.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Local rolling backup at ~/.config/vsync/backups/<name>-<ts>.zip.enc.
2
+ // Keeps only the 2 most recent backups per name; older ones are pruned.
3
+ //
4
+ // Backups are encrypted with the same key+salt as the S3 bundle so a
5
+ // stolen laptop / cloud-sync leak / Time Machine snapshot does not
6
+ // expose decrypted secrets sitting on disk.
7
+
8
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { zipPaths } from "./archive";
12
+ import { encrypt } from "./crypto";
13
+ import { vsyncBaseDir } from "./defaults";
14
+
15
+ export const BACKUP_EXT = ".zip.enc";
16
+ const KEEP = 2;
17
+
18
+ export function backupDir(): string {
19
+ const dir = process.env.VSYNC_BACKUP_DIR ?? join(vsyncBaseDir(), "backups");
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ export function timestamp(): string {
25
+ // YYYYMMDD-HHmmss UTC, e.g. 20260427-104530
26
+ return new Date().toISOString().slice(0, 19).replace(/[-:]/g, "").replace("T", "-");
27
+ }
28
+
29
+ // Zip the supplied paths (relative to baseDir), encrypt the zip with the
30
+ // given key+salt, and write the encrypted blob to backupDir() as
31
+ // <name>-<ts>.zip.enc. Returns the path to the encrypted backup, or null
32
+ // if none of the paths existed (nothing to back up).
33
+ export async function makeBackup(
34
+ name: string,
35
+ baseDir: string,
36
+ paths: string[],
37
+ encryption: { key: string; salt: string },
38
+ ): Promise<string | null> {
39
+ const existing = paths.filter((p) => existsSync(join(baseDir, p)));
40
+ if (existing.length === 0) return null;
41
+
42
+ const dir = backupDir();
43
+ const ts = timestamp();
44
+ const tmpZip = join(
45
+ tmpdir(),
46
+ `bk-${ts}-${Math.random().toString(36).slice(2)}.zip`,
47
+ );
48
+ const outPath = join(dir, `${name.toLowerCase()}-${ts}${BACKUP_EXT}`);
49
+
50
+ try {
51
+ await zipPaths(baseDir, existing, tmpZip);
52
+ const zipBytes = await Bun.file(tmpZip).bytes();
53
+ const encrypted = await encrypt(zipBytes, encryption.key, encryption.salt);
54
+ await Bun.write(outPath, encrypted);
55
+ } finally {
56
+ if (existsSync(tmpZip)) unlinkSync(tmpZip);
57
+ }
58
+
59
+ pruneBackups(name);
60
+ return outPath;
61
+ }
62
+
63
+ // Delete all but the KEEP most-recent <name>-*.zip.enc files in the backup dir.
64
+ export function pruneBackups(name: string): void {
65
+ const dir = backupDir();
66
+ const prefix = `${name.toLowerCase()}-`;
67
+ const matches = readdirSync(dir)
68
+ .filter((f) => f.startsWith(prefix) && f.endsWith(BACKUP_EXT))
69
+ .map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
70
+ .sort((a, b) => b.mtime - a.mtime);
71
+ for (const { f } of matches.slice(KEEP)) {
72
+ unlinkSync(join(dir, f));
73
+ }
74
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,21 @@
1
+ // JSON ⇄ gzip+base64 codec.
2
+ // Pure functions, easily unit-testable. CLIs in bin/ are thin wrappers.
3
+
4
+ export function encodeGzipBase64(json: string): string {
5
+ // Validate input is JSON before bothering to compress.
6
+ JSON.parse(json);
7
+ const gz = Bun.gzipSync(new TextEncoder().encode(json));
8
+ return Buffer.from(gz).toString("base64");
9
+ }
10
+
11
+ export function decodeGzipBase64(b64: string): string {
12
+ const gz = Buffer.from(b64.trim(), "base64");
13
+ if (gz.byteLength === 0) {
14
+ throw new Error("decoded base64 is empty");
15
+ }
16
+ const raw = Bun.gunzipSync(gz);
17
+ const json = new TextDecoder().decode(raw);
18
+ // Validate output is JSON before returning.
19
+ JSON.parse(json);
20
+ return json;
21
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,71 @@
1
+ // AES-256-GCM with PBKDF2-SHA256 key derivation from password+salt.
2
+ //
3
+ // Envelope format on disk:
4
+ // bytes 0..3 — magic "RQE1"
5
+ // bytes 4..15 — 12-byte IV (random per encryption)
6
+ // bytes 16..N — ciphertext (Web Crypto AES-GCM appends a 16-byte auth tag)
7
+
8
+ const MAGIC = new Uint8Array([0x52, 0x51, 0x45, 0x31]); // "RQE1"
9
+ const IV_LEN = 12;
10
+ const HEADER_LEN = MAGIC.length + IV_LEN; // 16
11
+ const PBKDF2_ITERATIONS = 600_000;
12
+
13
+ async function deriveKey(password: string, salt: string): Promise<CryptoKey> {
14
+ const enc = new TextEncoder();
15
+ const baseKey = await crypto.subtle.importKey(
16
+ "raw",
17
+ enc.encode(password),
18
+ { name: "PBKDF2" },
19
+ false,
20
+ ["deriveKey"],
21
+ );
22
+ return crypto.subtle.deriveKey(
23
+ {
24
+ name: "PBKDF2",
25
+ salt: enc.encode(salt),
26
+ iterations: PBKDF2_ITERATIONS,
27
+ hash: "SHA-256",
28
+ },
29
+ baseKey,
30
+ { name: "AES-GCM", length: 256 },
31
+ false,
32
+ ["encrypt", "decrypt"],
33
+ );
34
+ }
35
+
36
+ export async function encrypt(
37
+ data: Uint8Array,
38
+ password: string,
39
+ salt: string,
40
+ ): Promise<Uint8Array> {
41
+ const key = await deriveKey(password, salt);
42
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
43
+ const ct = new Uint8Array(
44
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data),
45
+ );
46
+ const out = new Uint8Array(HEADER_LEN + ct.byteLength);
47
+ out.set(MAGIC, 0);
48
+ out.set(iv, MAGIC.length);
49
+ out.set(ct, HEADER_LEN);
50
+ return out;
51
+ }
52
+
53
+ export async function decrypt(
54
+ blob: Uint8Array,
55
+ password: string,
56
+ salt: string,
57
+ ): Promise<Uint8Array> {
58
+ if (blob.byteLength < HEADER_LEN) {
59
+ throw new Error("envelope too short");
60
+ }
61
+ for (let i = 0; i < MAGIC.length; i++) {
62
+ if (blob[i] !== MAGIC[i]) {
63
+ throw new Error("invalid magic — not an RQE1 envelope");
64
+ }
65
+ }
66
+ const iv = blob.slice(MAGIC.length, HEADER_LEN);
67
+ const ct = blob.slice(HEADER_LEN);
68
+ const key = await deriveKey(password, salt);
69
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
70
+ return new Uint8Array(pt);
71
+ }
@@ -0,0 +1,91 @@
1
+ // defaults.ts — optional defaults template at ~/.config/vsync/defaults.
2
+ //
3
+ // Read by `vsync init` only — pre-fills prompts on subsequent setups
4
+ // after the first-ever init writes it. Never consulted by push / pull /
5
+ // sync — those resolve everything from the per-repo file (see
6
+ // repoconfig.ts) plus the keychain.
7
+ //
8
+ // Conventions: gzip(JSON), file 0600, parent dir 0700, honours
9
+ // XDG_CONFIG_HOME. Same security envelope as the per-repo file.
10
+
11
+ import { gunzipSync, gzipSync } from "node:zlib";
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import * as os from "node:os";
15
+
16
+ export type Defaults = {
17
+ version: 1;
18
+ s3?: {
19
+ endpoint?: string;
20
+ region?: string;
21
+ bucket?: string;
22
+ accessKeyId?: string;
23
+ secretAccessKey?: string;
24
+ useSsl?: boolean;
25
+ };
26
+ };
27
+
28
+ const ROOT_DIRNAME = "vsync";
29
+ const FILE_NAME = "defaults";
30
+
31
+ /** Base directory for vsync state. Honours XDG_CONFIG_HOME. */
32
+ export function vsyncBaseDir(): string {
33
+ const xdg = process.env.XDG_CONFIG_HOME;
34
+ const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), ".config");
35
+ return path.join(base, ROOT_DIRNAME);
36
+ }
37
+
38
+ /** Path to the defaults template. */
39
+ export function defaultsFilePath(): string {
40
+ return path.join(vsyncBaseDir(), FILE_NAME);
41
+ }
42
+
43
+ /** Persist the defaults template (creates the dir tree if needed). */
44
+ export async function saveDefaults(d: Defaults): Promise<string> {
45
+ validateDefaults(d);
46
+ const file = defaultsFilePath();
47
+ const dir = path.dirname(file);
48
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
49
+ try {
50
+ await fs.chmod(dir, 0o700);
51
+ } catch {
52
+ // Non-fatal; some filesystems don't honour chmod.
53
+ }
54
+ const json = JSON.stringify(d);
55
+ const gz = gzipSync(Buffer.from(json, "utf8"));
56
+ await fs.writeFile(file, gz, { mode: 0o600 });
57
+ try {
58
+ await fs.chmod(file, 0o600);
59
+ } catch {
60
+ // Same caveat as above.
61
+ }
62
+ return file;
63
+ }
64
+
65
+ /** Read the defaults template; returns null if absent. Throws on corruption. */
66
+ export async function loadDefaults(): Promise<Defaults | null> {
67
+ const file = defaultsFilePath();
68
+ let buf: Buffer;
69
+ try {
70
+ buf = await fs.readFile(file);
71
+ } catch (err: any) {
72
+ if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return null;
73
+ throw err;
74
+ }
75
+ const json = gunzipSync(buf).toString("utf8");
76
+ const parsed = JSON.parse(json);
77
+ validateDefaults(parsed);
78
+ return parsed;
79
+ }
80
+
81
+ /** Defensive shape check. */
82
+ export function validateDefaults(d: unknown): asserts d is Defaults {
83
+ const x = d as Partial<Defaults> | null;
84
+ if (!x || typeof x !== "object") throw new Error("defaults: not an object");
85
+ if (x.version !== 1) {
86
+ throw new Error(`defaults: unsupported version ${x.version} (expected 1)`);
87
+ }
88
+ if (x.s3 !== undefined && (typeof x.s3 !== "object" || x.s3 === null)) {
89
+ throw new Error("defaults: s3 field must be an object if present");
90
+ }
91
+ }
@@ -0,0 +1,179 @@
1
+ // envconfig.ts — the in-memory composite used by push / pull / sync.
2
+ // Glues two on-disk sources together:
3
+ //
4
+ // 1. `~/.config/vsync/<repo>/env_<env>` — self-contained per-(repo, env)
5
+ // config (S3 creds + salt + paths + sync routing). gzipped JSON,
6
+ // chmod 0600. See repoconfig.ts.
7
+ // 2. OS keychain (macOS Keychain / Linux libsecret / Windows Credential
8
+ // Manager) — the AES encryption key. See keychain.ts.
9
+ //
10
+ // `loadEnvConfig(repo, env)` reads both and returns the combined config.
11
+ // Missing file → returns null. Missing key → throws a "key not found"
12
+ // error so push/pull can surface it cleanly.
13
+ //
14
+ // Export/import blob:
15
+ // `buildExportBlob` zips config+key+metadata into a single string the
16
+ // user can share with teammates. `parseExportBlob` is the inverse.
17
+ // Both go through codec.ts for gzip+base64 framing.
18
+
19
+ import type { S3Credentials } from "./s3";
20
+ import { encodeGzipBase64, decodeGzipBase64 } from "./codec";
21
+ import type { ConfigFile } from "./repoconfig";
22
+ import { loadConfigFile, validateConfigFile } from "./repoconfig";
23
+ import { getKey } from "./keychain";
24
+
25
+ export const MIN_KEY_LEN = 20;
26
+ export const MIN_SALT_LEN = 16;
27
+ export const EXPORT_BLOB_VERSION = 2;
28
+
29
+ /**
30
+ * Runtime composite used by every push / pull / sync code path. Same
31
+ * shape as ConfigFile, with the keychain key spliced in.
32
+ */
33
+ export type EnvConfig = {
34
+ s3: S3Credentials;
35
+ encryption: { key: string; salt: string };
36
+ files?: { vaultFolder?: string };
37
+ sync?: ConfigFile["sync"];
38
+ };
39
+
40
+ /**
41
+ * Resolve the effective vault folder for a (repo, env). Defaults to
42
+ * `infra/vault/<env>` when the per-repo file doesn't override.
43
+ */
44
+ export function resolveVaultFolder(cfg: EnvConfig | ConfigFile, env: string): string {
45
+ return cfg.files?.vaultFolder ?? `infra/vault/${env.toLowerCase()}`;
46
+ }
47
+
48
+ /** Wire shape of the share blob exchanged between teammates. */
49
+ export type ExportPayload = {
50
+ version: number;
51
+ repo: string;
52
+ env: string;
53
+ config: ConfigFile; // file contents (no key)
54
+ key: string; // base64-encoded AES key
55
+ };
56
+
57
+ export class ConfigFileMissingError extends Error {
58
+ constructor(repo: string, env: string, filePath: string) {
59
+ super(
60
+ `no config file for ${repo}/${env} at ${filePath}.\n` +
61
+ `Run 'vsync init ${env} --repo=${repo}' to create one, or 'vsync import ${env} <share-file>' if a teammate sent you one.`,
62
+ );
63
+ this.name = "ConfigFileMissingError";
64
+ }
65
+ }
66
+
67
+ export class KeyMissingError extends Error {
68
+ constructor(repo: string, env: string) {
69
+ super(
70
+ `encryption key for ${repo}/${env} not found in OS keychain.\n` +
71
+ `Run 'vsync import ${env} <share-file>' if a teammate sent you the share file (it carries the key),\n` +
72
+ `or 'vsync init ${env} --repo=${repo}' to generate a fresh one.`,
73
+ );
74
+ this.name = "KeyMissingError";
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Load and assemble the full EnvConfig for a (repo, env). Throws
80
+ * ConfigFileMissingError if the on-disk file is absent, KeyMissingError
81
+ * if the file exists but the keychain entry is gone.
82
+ */
83
+ export async function loadEnvConfig(
84
+ repo: string,
85
+ env: string,
86
+ ): Promise<EnvConfig> {
87
+ const { configFilePath } = await import("./repoconfig");
88
+ const cfg = await loadConfigFile(repo, env);
89
+ if (!cfg) {
90
+ throw new ConfigFileMissingError(repo, env, configFilePath(repo, env));
91
+ }
92
+ const key = await getKey(repo, env);
93
+ if (!key) {
94
+ throw new KeyMissingError(repo, env);
95
+ }
96
+ const composite: EnvConfig = {
97
+ s3: cfg.s3,
98
+ encryption: { key, salt: cfg.encryption.salt },
99
+ ...(cfg.files !== undefined ? { files: cfg.files } : {}),
100
+ ...(cfg.sync !== undefined ? { sync: cfg.sync } : {}),
101
+ };
102
+ validate(composite);
103
+ return composite;
104
+ }
105
+
106
+ /** Build a share-blob string (the inverse of parseExportBlob). */
107
+ export function buildExportBlob(payload: ExportPayload): string {
108
+ validateExportPayload(payload);
109
+ return encodeGzipBase64(JSON.stringify(payload));
110
+ }
111
+
112
+ /** Parse a share-blob string. Throws on bad gzip / JSON / shape. */
113
+ export function parseExportBlob(blob: string): ExportPayload {
114
+ const json = decodeGzipBase64(blob.trim());
115
+ const parsed = JSON.parse(json);
116
+ validateExportPayload(parsed);
117
+ return parsed;
118
+ }
119
+
120
+ /** Validate a runtime composite EnvConfig (key included). */
121
+ export function validate(cfg: unknown): asserts cfg is EnvConfig {
122
+ const c = cfg as Partial<EnvConfig> | null;
123
+ const s3 = c?.s3;
124
+ for (const k of [
125
+ "endpoint",
126
+ "region",
127
+ "accessKeyId",
128
+ "secretAccessKey",
129
+ "bucket",
130
+ ] as const) {
131
+ if (!s3?.[k]) throw new Error(`s3.${k} missing`);
132
+ }
133
+ if (typeof s3?.useSsl !== "boolean") {
134
+ throw new Error("s3.useSsl missing or not a boolean");
135
+ }
136
+ const enc = c?.encryption;
137
+ for (const k of ["key", "salt"] as const) {
138
+ if (!enc?.[k]) throw new Error(`encryption.${k} missing`);
139
+ }
140
+ if (typeof enc.key !== "string" || enc.key.length < MIN_KEY_LEN) {
141
+ throw new Error(
142
+ `encryption.key must be at least ${MIN_KEY_LEN} characters (got ${enc.key?.length ?? 0}).`,
143
+ );
144
+ }
145
+ if (typeof enc.salt !== "string" || enc.salt.length < MIN_SALT_LEN) {
146
+ throw new Error(
147
+ `encryption.salt must be at least ${MIN_SALT_LEN} characters (got ${enc.salt?.length ?? 0}).`,
148
+ );
149
+ }
150
+ if (c.files !== undefined) {
151
+ if (typeof c.files !== "object" || c.files === null) {
152
+ throw new Error("files must be an object if present");
153
+ }
154
+ if (
155
+ c.files.vaultFolder !== undefined &&
156
+ typeof c.files.vaultFolder !== "string"
157
+ ) {
158
+ throw new Error("files.vaultFolder must be a string if present");
159
+ }
160
+ }
161
+ }
162
+
163
+ function validateExportPayload(p: unknown): asserts p is ExportPayload {
164
+ const x = p as Partial<ExportPayload> | null;
165
+ if (!x || typeof x !== "object") throw new Error("export blob is not an object");
166
+ if (x.version !== EXPORT_BLOB_VERSION) {
167
+ throw new Error(
168
+ `unsupported export blob version: ${x.version} (this CLI handles ${EXPORT_BLOB_VERSION})`,
169
+ );
170
+ }
171
+ if (!x.repo || typeof x.repo !== "string") throw new Error("export blob: repo missing");
172
+ if (!x.env || typeof x.env !== "string") throw new Error("export blob: env missing");
173
+ if (!x.key || typeof x.key !== "string" || x.key.length < MIN_KEY_LEN) {
174
+ throw new Error(
175
+ `export blob: key missing or shorter than ${MIN_KEY_LEN} chars`,
176
+ );
177
+ }
178
+ validateConfigFile(x.config);
179
+ }
package/src/envfile.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Parse a `.env.<ENV>` file into push-ready secret tasks.
2
+ //
3
+ // Mirrors the parsing behavior of reqsume/secrets.go:
4
+ // - skip blank lines + `#` comments
5
+ // - first `=` splits key/value, both trimmed
6
+ // - strip a single pair of surrounding `"` or `'` from the value
7
+ // - skip GITHUB_TOKEN / GOOGLE_APPLICATION_CREDENTIALS (local-only)
8
+ // - GCP_SA_KEY_FILE_PATH=<path> → reads file, pushes as GCP_SA_KEY (must look like JSON)
9
+ // - SSH_KEY_PATH=<path> → reads file, pushes as SSH_PRIVATE_KEY
10
+ //
11
+ // GITHUB_REPO and GCP_PROJECT_ID are pulled out into `meta` and never pushed
12
+ // as secrets — they're routing config consumed by sync-secrets itself.
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ export type SecretTask = { key: string; value: string };
19
+
20
+ export type ParsedEnv = {
21
+ tasks: SecretTask[];
22
+ meta: { GITHUB_REPO?: string; GCP_PROJECT_ID?: string };
23
+ };
24
+
25
+ const LOCAL_ONLY = new Set(["GITHUB_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS"]);
26
+ const ROUTING = new Set(["GITHUB_REPO", "GCP_PROJECT_ID"]);
27
+
28
+ export function parseEnvFile(path: string): ParsedEnv {
29
+ if (!existsSync(path)) {
30
+ throw new Error(`.env file not found: ${path}`);
31
+ }
32
+ const raw = readFileSync(path, "utf8");
33
+ const tasks: SecretTask[] = [];
34
+ const meta: ParsedEnv["meta"] = {};
35
+
36
+ for (const rawLine of raw.split(/\r?\n/)) {
37
+ const line = rawLine.trim();
38
+ if (!line || line.startsWith("#")) continue;
39
+
40
+ const eq = line.indexOf("=");
41
+ if (eq === -1) continue;
42
+
43
+ const key = line.slice(0, eq).trim();
44
+ let value = stripQuotes(line.slice(eq + 1).trim());
45
+
46
+ if (LOCAL_ONLY.has(key)) {
47
+ console.log(`Skipping ${key} (local use only)`);
48
+ continue;
49
+ }
50
+
51
+ if (ROUTING.has(key)) {
52
+ meta[key as keyof ParsedEnv["meta"]] = value;
53
+ continue;
54
+ }
55
+
56
+ if (key === "GCP_SA_KEY_FILE_PATH") {
57
+ const content = readFileExpandTilde(value).trim();
58
+ if (!content.startsWith("{")) {
59
+ throw new Error(`GCP key file does not look like JSON: ${value}`);
60
+ }
61
+ tasks.push({ key: "GCP_SA_KEY", value: content });
62
+ continue;
63
+ }
64
+
65
+ if (key === "SSH_KEY_PATH") {
66
+ try {
67
+ tasks.push({ key: "SSH_PRIVATE_KEY", value: readFileExpandTilde(value) });
68
+ } catch (e) {
69
+ console.warn(
70
+ `Warning: error reading SSH private key from ${value}: ${(e as Error).message}`,
71
+ );
72
+ }
73
+ continue;
74
+ }
75
+
76
+ tasks.push({ key, value });
77
+ }
78
+
79
+ return { tasks, meta };
80
+ }
81
+
82
+ function stripQuotes(value: string): string {
83
+ if (value.length < 2) return value;
84
+ const first = value[0];
85
+ const last = value[value.length - 1];
86
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
87
+ return value.slice(1, -1);
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function readFileExpandTilde(path: string): string {
93
+ if (path.startsWith("~/")) path = join(homedir(), path.slice(2));
94
+ return readFileSync(path, "utf8");
95
+ }
@@ -0,0 +1,65 @@
1
+ // keychain.ts — thin wrapper over Bun.secrets so the rest of the codebase
2
+ // doesn't have to know whether we're talking to macOS Keychain, Linux
3
+ // libsecret, or Windows Credential Manager. All three are abstracted away
4
+ // by Bun's API; we just pick a consistent service+name pair.
5
+ //
6
+ // Service name follows the UTI convention Bun recommends (`com.org.tool`).
7
+ // Account name is `<repo>/<env>` so a single Keychain entry per repo+env.
8
+
9
+ import { secrets } from "bun";
10
+
11
+ export const KEYCHAIN_SERVICE = "tools.vsync";
12
+
13
+ function accountName(repo: string, env: string): string {
14
+ if (!repo) throw new Error("repo is required for keychain operations");
15
+ if (!env) throw new Error("env is required for keychain operations");
16
+ return `${repo}/${env}`;
17
+ }
18
+
19
+ /** Save (or overwrite) the encryption key for a (repo, env) pair. */
20
+ export async function setKey(
21
+ repo: string,
22
+ env: string,
23
+ key: string,
24
+ ): Promise<void> {
25
+ if (!key) throw new Error("key value cannot be empty");
26
+ await secrets.set({
27
+ service: KEYCHAIN_SERVICE,
28
+ name: accountName(repo, env),
29
+ value: key,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Look up the encryption key. Returns null if not set (rather than throwing)
35
+ * so callers can produce friendly "key not found, run import" errors.
36
+ */
37
+ export async function getKey(
38
+ repo: string,
39
+ env: string,
40
+ ): Promise<string | null> {
41
+ return await secrets.get({
42
+ service: KEYCHAIN_SERVICE,
43
+ name: accountName(repo, env),
44
+ });
45
+ }
46
+
47
+ /** Remove the encryption key. Idempotent — no-op if it didn't exist. */
48
+ export async function deleteKey(repo: string, env: string): Promise<void> {
49
+ try {
50
+ await secrets.delete({
51
+ service: KEYCHAIN_SERVICE,
52
+ name: accountName(repo, env),
53
+ });
54
+ } catch {
55
+ // Bun.secrets.delete throws on macOS for missing entries; swallow so
56
+ // delete-key is idempotent across platforms.
57
+ }
58
+ }
59
+
60
+ /** Generate a fresh 32-byte AES key, base64-encoded (~44 chars). */
61
+ export function generateKey(): string {
62
+ const bytes = new Uint8Array(32);
63
+ crypto.getRandomValues(bytes);
64
+ return Buffer.from(bytes).toString("base64");
65
+ }
@@ -0,0 +1,38 @@
1
+ // Plaintext manifest bound into the encrypted bundle. Lets the pull side
2
+ // verify that the bundle was actually sealed at the timestamp `latest`
3
+ // claims, so an attacker with bucket-write but no encryption key can't
4
+ // repoint `latest` at a renamed copy of an older version.
5
+ //
6
+ // Layout (before encryption):
7
+ // bytes 0..7 — magic "RQEM0001"
8
+ // bytes 8..22 — 15-byte timestamp string "YYYYMMDD-HHmmss"
9
+ // bytes 23..N — original payload (the zip bytes)
10
+
11
+ const MAGIC = new TextEncoder().encode("RQEM0001");
12
+ const TS_LEN = 15;
13
+ const HEADER_LEN = MAGIC.length + TS_LEN; // 23
14
+
15
+ export function wrap(ts: string, payload: Uint8Array): Uint8Array {
16
+ if (ts.length !== TS_LEN) {
17
+ throw new Error(`manifest ts must be ${TS_LEN} chars (got ${ts.length})`);
18
+ }
19
+ const out = new Uint8Array(HEADER_LEN + payload.byteLength);
20
+ out.set(MAGIC, 0);
21
+ out.set(new TextEncoder().encode(ts), MAGIC.length);
22
+ out.set(payload, HEADER_LEN);
23
+ return out;
24
+ }
25
+
26
+ export function unwrap(blob: Uint8Array): { ts: string; payload: Uint8Array } {
27
+ if (blob.byteLength < HEADER_LEN) {
28
+ throw new Error("manifest too short");
29
+ }
30
+ for (let i = 0; i < MAGIC.length; i++) {
31
+ if (blob[i] !== MAGIC[i]) {
32
+ throw new Error("invalid manifest magic — not an RQEM0001 bundle");
33
+ }
34
+ }
35
+ const ts = new TextDecoder().decode(blob.slice(MAGIC.length, HEADER_LEN));
36
+ const payload = blob.slice(HEADER_LEN);
37
+ return { ts, payload };
38
+ }
@@ -0,0 +1,39 @@
1
+ // passphrase.ts — generate short, readable passphrases for the share file
2
+ // wrapper. Goals: easy to type, easy to copy, no visually-confusable
3
+ // characters (no 0/O/1/l/I), three hyphen-separated groups so eyes can
4
+ // pair them with confidence on a Slack DM.
5
+ //
6
+ // Default shape: XXXX-XXXX-XXXX (12 chars + 2 hyphens = 14 total).
7
+
8
+ const ALPHABET = "23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ";
9
+
10
+ export const PASSPHRASE_MIN_LEN = 8;
11
+
12
+ export function generatePassphrase(groups = 3, perGroup = 4): string {
13
+ if (groups < 1 || perGroup < 2) {
14
+ throw new Error("generatePassphrase: at least 1 group of 2 chars");
15
+ }
16
+ const total = groups * perGroup;
17
+ const bytes = new Uint8Array(total);
18
+ crypto.getRandomValues(bytes);
19
+ const chars: string[] = [];
20
+ for (let i = 0; i < total; i++) {
21
+ chars.push(ALPHABET[bytes[i]! % ALPHABET.length]);
22
+ }
23
+ const out: string[] = [];
24
+ for (let g = 0; g < groups; g++) {
25
+ out.push(chars.slice(g * perGroup, (g + 1) * perGroup).join(""));
26
+ }
27
+ return out.join("-");
28
+ }
29
+
30
+ /** Strip surrounding whitespace + reject obvious typos before decrypting. */
31
+ export function normalizePassphrase(input: string): string {
32
+ const trimmed = (input ?? "").trim();
33
+ if (trimmed.length < PASSPHRASE_MIN_LEN) {
34
+ throw new Error(
35
+ `passphrase must be at least ${PASSPHRASE_MIN_LEN} characters (got ${trimmed.length})`,
36
+ );
37
+ }
38
+ return trimmed;
39
+ }