@mycadet/gateway 0.12.11 → 0.13.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.
Files changed (81) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  3. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  5. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/openapi.json +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  11. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  12. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  13. package/src/__tests__/credential-requests.test.ts +4 -4
  14. package/src/__tests__/devices-mine.test.ts +187 -0
  15. package/src/__tests__/edge-auth.test.ts +1 -20
  16. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  17. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  18. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  19. package/src/__tests__/feature-flags-route.test.ts +2 -191
  20. package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -25
  21. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  22. package/src/__tests__/guardian-integrity.test.ts +16 -20
  23. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  24. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  25. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  26. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  27. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  28. package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
  29. package/src/__tests__/runtime-client.test.ts +7 -7
  30. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  31. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  32. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  33. package/src/auth/actor-token-revocation.ts +34 -9
  34. package/src/cli/__tests__/pairing.test.ts +95 -0
  35. package/src/cli/index.ts +1 -1
  36. package/src/cli/pairing.ts +81 -6
  37. package/src/contacts-mirror-op-reporter.ts +19 -36
  38. package/src/db/connection.ts +0 -10
  39. package/src/feature-flag-resolver.ts +2 -7
  40. package/src/feature-flag-watcher.ts +1 -14
  41. package/src/guardian-integrity-reporter.ts +19 -47
  42. package/src/http/middleware/auth.ts +33 -27
  43. package/src/http/routes/auth-token.ts +1 -1
  44. package/src/http/routes/devices-mine.ts +103 -0
  45. package/src/http/routes/feature-flags.ts +1 -8
  46. package/src/http/routes/guardian-pin.ts +2 -11
  47. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  48. package/src/http/routes/live-voice-websocket.ts +1 -1
  49. package/src/http/routes/runtime-audio-stream.ts +1 -1
  50. package/src/http/routes/runtime-proxy.ts +2 -2
  51. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  52. package/src/index.ts +11 -198
  53. package/src/ipc/feature-flag-handlers.ts +2 -9
  54. package/src/remote-web/pairing-challenge-store.ts +15 -0
  55. package/src/risk/command-registry/commands/assistant.ts +0 -24
  56. package/src/risk/command-registry.test.ts +2 -4
  57. package/src/schema.ts +44 -372
  58. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  59. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  60. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  61. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  62. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  63. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  64. package/src/auth-fallback-count-tracker.ts +0 -113
  65. package/src/auth-fallback-reporter.ts +0 -127
  66. package/src/backup/backup-key.ts +0 -138
  67. package/src/backup/backup-routes.ts +0 -161
  68. package/src/backup/backup-worker.ts +0 -425
  69. package/src/backup/list-snapshots.ts +0 -97
  70. package/src/backup/local-writer.ts +0 -87
  71. package/src/backup/offsite-writer.ts +0 -182
  72. package/src/backup/paths.ts +0 -113
  73. package/src/backup/stream-crypt.ts +0 -258
  74. package/src/device-id.ts +0 -18
  75. package/src/feature-flag-remote-store.ts +0 -166
  76. package/src/http/routes/migration-proxy.ts +0 -636
  77. package/src/internal-telemetry-client.ts +0 -39
  78. package/src/ipc/debug-export-handlers.test.ts +0 -102
  79. package/src/ipc/debug-export-handlers.ts +0 -81
  80. package/src/remote-feature-flag-sync.ts +0 -440
  81. package/src/watchdog-reporter.ts +0 -110
@@ -1,182 +0,0 @@
1
- /**
2
- * Offsite snapshot writer with per-destination encryption.
3
- *
4
- * "Offsite" destinations are any location outside the local backup directory
5
- * where the user wants a redundant copy. Canonical examples: iCloud Drive,
6
- * an external SSD, a network share.
7
- *
8
- * Per-destination `encrypt` flag:
9
- * - `encrypt: true` → AES-256-GCM stream-encrypt into `.vbundle.enc`.
10
- * - `encrypt: false` → plaintext copy into `.vbundle`.
11
- *
12
- * Each destination is written independently and sequentially.
13
- */
14
-
15
- import { copyFile, mkdir, rename, stat, unlink } from "node:fs/promises";
16
- import { dirname, join } from "node:path";
17
-
18
- import type { SnapshotEntry } from "./list-snapshots.js";
19
- import { pruneDir } from "./list-snapshots.js";
20
- import { formatBackupFilename } from "./paths.js";
21
- import { encryptFile } from "./stream-crypt.js";
22
-
23
- export interface BackupDestination {
24
- path: string;
25
- encrypt: boolean;
26
- }
27
-
28
- export interface OffsiteWriteResult {
29
- destination: BackupDestination;
30
- entry: SnapshotEntry | null;
31
- skipped?: "parent-missing";
32
- error?: string;
33
- }
34
-
35
- /**
36
- * Derive the "safe ancestor" for an offsite destination — a directory that
37
- * must already exist on disk before we create intermediate directories
38
- * under it.
39
- */
40
- function deriveSafeAncestor(destinationPath: string): string {
41
- // iCloud Drive subtrees anchor on the iCloud root
42
- const home = process.env.HOME || "";
43
- if (home) {
44
- const iCloudRoot = join(home, "Library", "Mobile Documents", "com~apple~CloudDocs");
45
- if (
46
- destinationPath === iCloudRoot ||
47
- destinationPath.startsWith(`${iCloudRoot}/`)
48
- ) {
49
- return iCloudRoot;
50
- }
51
- }
52
- // /Volumes/<name>/... paths anchor on the volume mount point
53
- const volumesPrefix = "/Volumes/";
54
- if (destinationPath.startsWith(volumesPrefix)) {
55
- const rest = destinationPath.slice(volumesPrefix.length);
56
- const slash = rest.indexOf("/");
57
- const volumeName = slash === -1 ? rest : rest.slice(0, slash);
58
- if (volumeName.length > 0) {
59
- return `${volumesPrefix}${volumeName}`;
60
- }
61
- }
62
- return dirname(destinationPath);
63
- }
64
-
65
- /**
66
- * Write a local snapshot to a single offsite destination.
67
- */
68
- export async function writeOffsiteSnapshotToOne(
69
- localSnapshotPath: string,
70
- destination: BackupDestination,
71
- key: Buffer | null,
72
- now: Date,
73
- ): Promise<OffsiteWriteResult> {
74
- try {
75
- try {
76
- await stat(deriveSafeAncestor(destination.path));
77
- } catch (err) {
78
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
79
- return { destination, entry: null, skipped: "parent-missing" };
80
- }
81
- throw err;
82
- }
83
-
84
- await mkdir(destination.path, { recursive: true, mode: 0o700 });
85
-
86
- const filename = formatBackupFilename(now, {
87
- encrypted: destination.encrypt,
88
- });
89
- const outputPath = join(destination.path, filename);
90
-
91
- if (destination.encrypt) {
92
- if (key == null) {
93
- throw new Error(
94
- "Offsite destination requires encryption but no key was provided",
95
- );
96
- }
97
- await encryptFile(localSnapshotPath, outputPath, key);
98
- } else {
99
- const tempPath = `${outputPath}.tmp`;
100
- await copyFile(localSnapshotPath, tempPath);
101
- try {
102
- await rename(tempPath, outputPath);
103
- } catch (err) {
104
- if ((err as NodeJS.ErrnoException).code === "EXDEV") {
105
- await copyFile(tempPath, outputPath);
106
- await unlink(tempPath);
107
- } else {
108
- throw err;
109
- }
110
- }
111
- }
112
-
113
- const stats = await stat(outputPath);
114
- return {
115
- destination,
116
- entry: {
117
- path: outputPath,
118
- filename,
119
- createdAt: now,
120
- sizeBytes: stats.size,
121
- encrypted: destination.encrypt,
122
- },
123
- };
124
- } catch (err) {
125
- const message = err instanceof Error ? err.message : String(err);
126
- return { destination, entry: null, error: message };
127
- }
128
- }
129
-
130
- /**
131
- * Write a local snapshot to every configured offsite destination.
132
- */
133
- export async function writeOffsiteSnapshotToAll(
134
- localSnapshotPath: string,
135
- destinations: BackupDestination[],
136
- key: Buffer | null,
137
- now: Date,
138
- ): Promise<OffsiteWriteResult[]> {
139
- if (destinations.length === 0) return [];
140
-
141
- const results: OffsiteWriteResult[] = [];
142
- for (const destination of destinations) {
143
- const result = await writeOffsiteSnapshotToOne(
144
- localSnapshotPath,
145
- destination,
146
- key,
147
- now,
148
- );
149
- results.push(result);
150
- }
151
- return results;
152
- }
153
-
154
- /**
155
- * Apply retention to every configured offsite destination.
156
- */
157
- export async function pruneOffsiteSnapshotsInAll(
158
- destinations: BackupDestination[],
159
- retention: number,
160
- ): Promise<
161
- Array<{
162
- destination: BackupDestination;
163
- kept: SnapshotEntry[];
164
- deleted: SnapshotEntry[];
165
- skipped?: boolean;
166
- }>
167
- > {
168
- const results: Array<{
169
- destination: BackupDestination;
170
- kept: SnapshotEntry[];
171
- deleted: SnapshotEntry[];
172
- skipped?: boolean;
173
- }> = [];
174
- for (const destination of destinations) {
175
- const { kept, deleted, skipped } = await pruneDir(
176
- destination.path,
177
- retention,
178
- );
179
- results.push({ destination, kept, deleted, skipped });
180
- }
181
- return results;
182
- }
@@ -1,113 +0,0 @@
1
- /**
2
- * Path and filename helpers for the gateway backup module.
3
- *
4
- * The backup key lives in GATEWAY_SECURITY_DIR — outside the workspace
5
- * and outside the assistant sandbox boundary. The assistant process never
6
- * has access to this file.
7
- */
8
-
9
- import { join } from "node:path";
10
-
11
- import { getGatewaySecurityDir } from "../paths.js";
12
-
13
- // ---------------------------------------------------------------------------
14
- // Key path
15
- // ---------------------------------------------------------------------------
16
-
17
- /** Filename for the backup encryption key inside GATEWAY_SECURITY_DIR. */
18
- const BACKUP_KEY_FILENAME = "backup.key";
19
-
20
- /**
21
- * Returns the path to the backup encryption key file.
22
- *
23
- * The key lives in the gateway security directory, which is:
24
- * - In Docker: a dedicated volume at /gateway-security (GATEWAY_SECURITY_DIR)
25
- * - Locally: the XDG data home (`$XDG_DATA_HOME/cadet/gateway-security`)
26
- *
27
- * This keeps the key outside the workspace — the assistant's sandbox
28
- * boundary — so model-driven tools (file_read, shell) cannot access it.
29
- */
30
- export function getBackupKeyPath(): string {
31
- return join(getGatewaySecurityDir(), BACKUP_KEY_FILENAME);
32
- }
33
-
34
- // ---------------------------------------------------------------------------
35
- // Backup root + local directory
36
- // ---------------------------------------------------------------------------
37
-
38
- /**
39
- * Returns the backup root directory. Respects the `CADET_BACKUP_DIR`
40
- * environment variable override (used in containerized deployments where
41
- * backups must be on a persistent volume); falls back to `<cwd>/backups`
42
- * (the spawner picks the cwd — the XDG data home for CLI-hatched instances).
43
- */
44
- export function getBackupRootDir(): string {
45
- return process.env.CADET_BACKUP_DIR?.trim() || join(process.cwd(), "backups");
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // Backup filenames
50
- // ---------------------------------------------------------------------------
51
-
52
- /**
53
- * Formats a backup filename from a date. Encrypted backups get a `.vbundle.enc`
54
- * suffix; plaintext backups get `.vbundle`. Timestamp components are in UTC to
55
- * avoid timezone-induced filename collisions across devices. Milliseconds are
56
- * included so two backups started in the same UTC second produce distinct
57
- * filenames rather than silently overwriting each other.
58
- *
59
- * Example: `backup-20260411-153045-123.vbundle`
60
- */
61
- export function formatBackupFilename(
62
- date: Date,
63
- { encrypted }: { encrypted: boolean },
64
- ): string {
65
- const year = date.getUTCFullYear().toString().padStart(4, "0");
66
- const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
67
- const day = date.getUTCDate().toString().padStart(2, "0");
68
- const hour = date.getUTCHours().toString().padStart(2, "0");
69
- const minute = date.getUTCMinutes().toString().padStart(2, "0");
70
- const second = date.getUTCSeconds().toString().padStart(2, "0");
71
- const millis = date.getUTCMilliseconds().toString().padStart(3, "0");
72
- const ext = encrypted ? ".vbundle.enc" : ".vbundle";
73
- return `backup-${year}${month}${day}-${hour}${minute}${second}-${millis}${ext}`;
74
- }
75
-
76
- // Matches `backup-YYYYMMDD-HHMMSS` with an optional `-SSS` milliseconds
77
- // segment (legacy backups written before ms precision was added omit it) and
78
- // an optional `-<hex>` collision suffix that `writeLocalSnapshot` appends when
79
- // the canonical name is already taken. Followed by `.vbundle` or
80
- // `.vbundle.enc`. Kept as a module-level constant so repeated parsing doesn't
81
- // rebuild the RegExp.
82
- const BACKUP_FILENAME_RE =
83
- /^backup-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})(?:-(\d{3})(?:-[0-9a-f]+)?)?\.vbundle(?:\.enc)?$/;
84
-
85
- /**
86
- * Inverse of `formatBackupFilename`. Parses a backup filename (with either
87
- * `.vbundle` or `.vbundle.enc` suffix) and returns the encoded UTC timestamp.
88
- * Accepts legacy filenames without the `-SSS` milliseconds segment (treated
89
- * as `.000`). Returns `null` when the filename doesn't match the expected
90
- * pattern, when a component is out of range, or when the parsed date is
91
- * invalid.
92
- */
93
- export function parseBackupTimestamp(filename: string): Date | null {
94
- const match = BACKUP_FILENAME_RE.exec(filename);
95
- if (!match) return null;
96
- const [, year, month, day, hour, minute, second, millis] = match;
97
- const ms = millis ?? "000";
98
- const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`;
99
- const date = new Date(iso);
100
- if (Number.isNaN(date.getTime())) return null;
101
- if (
102
- date.getUTCFullYear() !== Number(year) ||
103
- date.getUTCMonth() !== Number(month) - 1 ||
104
- date.getUTCDate() !== Number(day) ||
105
- date.getUTCHours() !== Number(hour) ||
106
- date.getUTCMinutes() !== Number(minute) ||
107
- date.getUTCSeconds() !== Number(second) ||
108
- date.getUTCMilliseconds() !== Number(ms)
109
- ) {
110
- return null;
111
- }
112
- return date;
113
- }
@@ -1,258 +0,0 @@
1
- /**
2
- * Streaming AES-256-GCM file encryption/decryption for backup bundles.
3
- *
4
- * The on-disk format is:
5
- *
6
- * [12-byte IV][ciphertext...][16-byte GCM auth tag]
7
- *
8
- * Both encrypt and decrypt use Node streams so peak memory stays bounded
9
- * regardless of input size. This is important for backup archives which may
10
- * run to many gigabytes on larger workspaces.
11
- *
12
- * The key must be exactly 32 bytes (AES-256). The IV is randomly generated
13
- * per call, which is required for GCM semantic security — never reuse an
14
- * IV with the same key.
15
- */
16
-
17
- import {
18
- createCipheriv,
19
- createDecipheriv,
20
- randomBytes,
21
- } from "node:crypto";
22
- import {
23
- createReadStream,
24
- createWriteStream,
25
- } from "node:fs";
26
- import { open, rename, stat, unlink } from "node:fs/promises";
27
- import { Readable, Writable } from "node:stream";
28
- import { pipeline } from "node:stream/promises";
29
-
30
- // ---------------------------------------------------------------------------
31
- // Constants
32
- // ---------------------------------------------------------------------------
33
-
34
- /** Size of the AES-GCM initialization vector prefix, in bytes. */
35
- export const ENCRYPTED_HEADER_SIZE = 12;
36
-
37
- /** Size of the AES-GCM authentication tag suffix, in bytes. */
38
- export const GCM_TAG_SIZE = 16;
39
-
40
- const ALGORITHM = "aes-256-gcm";
41
- const KEY_LENGTH = 32;
42
-
43
- // ---------------------------------------------------------------------------
44
- // Helpers
45
- // ---------------------------------------------------------------------------
46
-
47
- function assertKey(key: Buffer): void {
48
- if (key.length !== KEY_LENGTH) {
49
- throw new Error("Backup encryption key must be 32 bytes");
50
- }
51
- }
52
-
53
- async function safeUnlink(path: string): Promise<void> {
54
- try {
55
- await unlink(path);
56
- } catch {
57
- // best-effort cleanup — swallow ENOENT and other errors
58
- }
59
- }
60
-
61
- function tempPath(outputPath: string): string {
62
- return `${outputPath}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Encrypt
67
- // ---------------------------------------------------------------------------
68
-
69
- /**
70
- * Stream-encrypt `inputPath` to `outputPath` using AES-256-GCM.
71
- *
72
- * Produces `[IV (12 bytes)][ciphertext][auth tag (16 bytes)]` in the output.
73
- * Writes to a temp file and atomically renames on success; unlinks the temp
74
- * file on any error so failed writes don't leave partial bundles behind.
75
- */
76
- export async function encryptFile(
77
- inputPath: string,
78
- outputPath: string,
79
- key: Buffer,
80
- ): Promise<void> {
81
- assertKey(key);
82
-
83
- const iv = randomBytes(ENCRYPTED_HEADER_SIZE);
84
- const cipher = createCipheriv(ALGORITHM, key, iv);
85
-
86
- const tmp = tempPath(outputPath);
87
- const writeStream = createWriteStream(tmp);
88
-
89
- try {
90
- // Write IV first so decrypt can read it without knowing the ciphertext size.
91
- await new Promise<void>((resolve, reject) => {
92
- writeStream.write(iv, (err) => (err ? reject(err) : resolve()));
93
- });
94
-
95
- // Stream plaintext through the cipher into the output.
96
- const readStream = createReadStream(inputPath);
97
- await pipeline(readStream, cipher, writeStream, { end: false });
98
-
99
- // Append the auth tag after the ciphertext body.
100
- const tag = cipher.getAuthTag();
101
- await new Promise<void>((resolve, reject) => {
102
- writeStream.write(tag, (err) => (err ? reject(err) : resolve()));
103
- });
104
-
105
- await new Promise<void>((resolve, reject) => {
106
- writeStream.end((err?: Error | null) => (err ? reject(err) : resolve()));
107
- });
108
-
109
- await rename(tmp, outputPath);
110
- } catch (err) {
111
- // Make sure the write stream is closed before we try to unlink the temp file.
112
- writeStream.destroy();
113
- await safeUnlink(tmp);
114
- throw err;
115
- }
116
- }
117
-
118
- // ---------------------------------------------------------------------------
119
- // Decrypt
120
- // ---------------------------------------------------------------------------
121
-
122
- /**
123
- * Stream-decrypt `inputPath` to `outputPath`. Expects the on-disk format
124
- * produced by `encryptFile`: `[IV][ciphertext][auth tag]`.
125
- *
126
- * Reads the IV and auth tag via positional reads, then streams only the
127
- * ciphertext body through the decipher. Atomic tmp + rename semantics.
128
- */
129
- export async function decryptFile(
130
- inputPath: string,
131
- outputPath: string,
132
- key: Buffer,
133
- ): Promise<void> {
134
- assertKey(key);
135
-
136
- const info = await stat(inputPath);
137
- const totalSize = info.size;
138
- const minSize = ENCRYPTED_HEADER_SIZE + GCM_TAG_SIZE;
139
- if (totalSize < minSize) {
140
- throw new Error(
141
- `Encrypted file is too small: ${totalSize} bytes (need at least ${minSize})`,
142
- );
143
- }
144
-
145
- // Read IV (first 12 bytes) and auth tag (last 16 bytes) via positional reads.
146
- const iv = Buffer.alloc(ENCRYPTED_HEADER_SIZE);
147
- const tag = Buffer.alloc(GCM_TAG_SIZE);
148
- const fh = await open(inputPath, "r");
149
- try {
150
- await fh.read(iv, 0, ENCRYPTED_HEADER_SIZE, 0);
151
- await fh.read(tag, 0, GCM_TAG_SIZE, totalSize - GCM_TAG_SIZE);
152
- } finally {
153
- await fh.close();
154
- }
155
-
156
- const decipher = createDecipheriv(ALGORITHM, key, iv);
157
- decipher.setAuthTag(tag);
158
-
159
- const ciphertextStart = ENCRYPTED_HEADER_SIZE;
160
- const ciphertextEnd = totalSize - GCM_TAG_SIZE - 1; // createReadStream end is inclusive
161
- const hasCiphertext = ciphertextEnd >= ciphertextStart;
162
-
163
- const tmp = tempPath(outputPath);
164
- const writeStream = createWriteStream(tmp);
165
-
166
- try {
167
- const ciphertextStream = hasCiphertext
168
- ? createReadStream(inputPath, {
169
- start: ciphertextStart,
170
- end: ciphertextEnd,
171
- })
172
- : Readable.from([]);
173
-
174
- // pipeline consumes the ciphertext, pushes it through the decipher, and
175
- // calls decipher.final() at the end — which is where auth tag verification
176
- // happens. A bad tag surfaces here as a thrown error.
177
- await pipeline(ciphertextStream, decipher, writeStream);
178
-
179
- await rename(tmp, outputPath);
180
- } catch (err) {
181
- writeStream.destroy();
182
- await safeUnlink(tmp);
183
- throw err;
184
- }
185
- }
186
-
187
- // ---------------------------------------------------------------------------
188
- // Verify
189
- // ---------------------------------------------------------------------------
190
-
191
- /**
192
- * Verify that `path` is a valid AES-256-GCM encrypted bundle for `key`.
193
- *
194
- * Streams the ciphertext through the decipher into a null sink and relies on
195
- * `decipher.final()` to either succeed (tag matches) or throw (tamper / wrong
196
- * key). No scratch file is written, so a full or read-only tmpdir cannot
197
- * cause a healthy backup to be reported as invalid.
198
- *
199
- * Returns `true` if the bundle authenticates, `false` on a cryptographic
200
- * failure (bad auth tag, wrong key, truncated/short input). Filesystem errors
201
- * on the *source* file (ENOENT, EACCES, EIO, …) are rethrown so callers can
202
- * distinguish tamper from transient I/O.
203
- */
204
- export async function verifyEncryptedFile(
205
- path: string,
206
- key: Buffer,
207
- ): Promise<boolean> {
208
- assertKey(key);
209
-
210
- const info = await stat(path);
211
- const totalSize = info.size;
212
- const minSize = ENCRYPTED_HEADER_SIZE + GCM_TAG_SIZE;
213
- if (totalSize < minSize) {
214
- return false;
215
- }
216
-
217
- const iv = Buffer.alloc(ENCRYPTED_HEADER_SIZE);
218
- const tag = Buffer.alloc(GCM_TAG_SIZE);
219
- const fh = await open(path, "r");
220
- try {
221
- await fh.read(iv, 0, ENCRYPTED_HEADER_SIZE, 0);
222
- await fh.read(tag, 0, GCM_TAG_SIZE, totalSize - GCM_TAG_SIZE);
223
- } finally {
224
- await fh.close();
225
- }
226
-
227
- const decipher = createDecipheriv(ALGORITHM, key, iv);
228
- decipher.setAuthTag(tag);
229
-
230
- const ciphertextStart = ENCRYPTED_HEADER_SIZE;
231
- const ciphertextEnd = totalSize - GCM_TAG_SIZE - 1;
232
- const hasCiphertext = ciphertextEnd >= ciphertextStart;
233
- const ciphertextStream = hasCiphertext
234
- ? createReadStream(path, { start: ciphertextStart, end: ciphertextEnd })
235
- : Readable.from([]);
236
-
237
- const nullSink = new Writable({
238
- write(_chunk, _encoding, cb) {
239
- cb();
240
- },
241
- });
242
-
243
- try {
244
- await pipeline(ciphertextStream, decipher, nullSink);
245
- return true;
246
- } catch (err) {
247
- if (isFilesystemError(err)) {
248
- throw err;
249
- }
250
- return false;
251
- }
252
- }
253
-
254
- function isFilesystemError(err: unknown): boolean {
255
- if (!err || typeof err !== "object") return false;
256
- const code = (err as { code?: unknown }).code;
257
- return typeof code === "string" && /^E[A-Z]+$/.test(code);
258
- }
package/src/device-id.ts DELETED
@@ -1,18 +0,0 @@
1
- let cached: string | null = null;
2
-
3
- /**
4
- * Read the device ID from the CADET_DEVICE_ID env var. The CLI always
5
- * injects it when spawning the gateway (local and containerized alike);
6
- * without it there is no device identity.
7
- */
8
- export function getDeviceId(): string | null {
9
- if (cached !== null) return cached;
10
-
11
- const envDeviceId = process.env.CADET_DEVICE_ID?.trim();
12
- if (envDeviceId) {
13
- cached = envDeviceId;
14
- return cached;
15
- }
16
-
17
- return null;
18
- }