negotium 0.2.0 → 0.2.2
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/dist/agent-helpers.js +16 -6
- package/dist/agent-helpers.js.map +6 -5
- package/dist/browser-runtime.js +13 -5
- package/dist/browser-runtime.js.map +5 -4
- package/dist/hosted-agent.js +14 -6
- package/dist/hosted-agent.js.map +6 -5
- package/dist/main.js +185 -31
- package/dist/main.js.map +7 -6
- package/dist/mcp-factories.js +16 -6
- package/dist/mcp-factories.js.map +6 -5
- package/dist/runtime/cron/mcp-server.ts +1 -1
- package/dist/runtime/src/migration/single-user.ts +213 -29
- package/dist/runtime/src/storage/vault-crypto-core.ts +65 -0
- package/dist/runtime/src/storage/vault-crypto.ts +8 -48
- package/dist/runtime/src/version.ts +1 -1
- package/dist/types/packages/core/src/storage/vault-crypto-core.d.ts +6 -0
- package/dist/types/packages/core/src/storage/vault-crypto.d.ts +2 -1
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js +13 -5
- package/dist/vault.js.map +5 -4
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@ import { homedir } from "node:os";
|
|
|
15
15
|
import { basename, dirname, join, resolve } from "node:path";
|
|
16
16
|
import { sanitizeTopicName } from "#security/sanitize";
|
|
17
17
|
import { Database } from "#storage/sqlite";
|
|
18
|
-
import {
|
|
18
|
+
import { decryptVaultValueWithKey, encryptVaultValueWithKey } from "#storage/vault-crypto-core";
|
|
19
19
|
|
|
20
20
|
export const CANONICAL_LOCAL_USER_ID = "local";
|
|
21
21
|
export const SINGLE_USER_MIGRATION_ID = "negotium-0.2.0-single-user";
|
|
@@ -40,7 +40,9 @@ interface MoveOperation {
|
|
|
40
40
|
source: string;
|
|
41
41
|
staged: string;
|
|
42
42
|
destination?: string;
|
|
43
|
-
|
|
43
|
+
destinationStaged?: string;
|
|
44
|
+
collision?: "keep-destination" | "replace-destination" | "merge-jsonl" | "merge-tasks";
|
|
45
|
+
state: "pending" | "staged" | "source-staged" | "destination-staged" | "placed";
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
interface MigrationJournal {
|
|
@@ -70,7 +72,9 @@ function assertNoActiveNode(stateDir: string): void {
|
|
|
70
72
|
const infoPath = join(stateDir, runtimeName, "node-daemon.json");
|
|
71
73
|
if (!existsSync(infoPath)) continue;
|
|
72
74
|
try {
|
|
73
|
-
const value = JSON.parse(readFileSync(infoPath, "utf8")) as {
|
|
75
|
+
const value = JSON.parse(readFileSync(infoPath, "utf8")) as {
|
|
76
|
+
pid?: number;
|
|
77
|
+
};
|
|
74
78
|
if (typeof value.pid === "number" && processIsAlive(value.pid)) {
|
|
75
79
|
throw new Error(`Refusing migration while Negotium pid ${value.pid} is active.`);
|
|
76
80
|
}
|
|
@@ -121,17 +125,60 @@ function addDirectoryContents(
|
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
function
|
|
128
|
+
function pathWithin(path: string, root: string): boolean {
|
|
129
|
+
return path === root || path.startsWith(`${root}/`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function collisionPolicy(
|
|
133
|
+
operation: MoveOperation,
|
|
134
|
+
stateDir: string,
|
|
135
|
+
): NonNullable<MoveOperation["collision"]> | undefined {
|
|
136
|
+
const destination = operation.destination;
|
|
137
|
+
if (!destination || !existsSync(destination)) return undefined;
|
|
138
|
+
if (pathWithin(destination, join(stateDir, "runtime"))) return "keep-destination";
|
|
139
|
+
if (pathWithin(destination, join(stateDir, "binaries"))) return "keep-destination";
|
|
140
|
+
if (pathWithin(destination, join(stateDir, "secrets"))) return "replace-destination";
|
|
141
|
+
if (destination === join(stateDir, "data", "vault", "vault.db")) {
|
|
142
|
+
return "replace-destination";
|
|
143
|
+
}
|
|
144
|
+
if (pathWithin(destination, join(stateDir, "browser", "profiles"))) {
|
|
145
|
+
return "replace-destination";
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
pathWithin(destination, join(stateDir, "data", "conversations")) &&
|
|
149
|
+
destination.endsWith(".jsonl")
|
|
150
|
+
) {
|
|
151
|
+
return "merge-jsonl";
|
|
152
|
+
}
|
|
153
|
+
if (pathWithin(destination, join(stateDir, "data", "tasks")) && destination.endsWith(".json")) {
|
|
154
|
+
return "merge-tasks";
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
statSync(operation.source).isFile() &&
|
|
158
|
+
statSync(destination).isFile() &&
|
|
159
|
+
readFileSync(operation.source).equals(readFileSync(destination))
|
|
160
|
+
) {
|
|
161
|
+
return "keep-destination";
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`Migration collision at ${destination}.`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function configureCollisions(
|
|
167
|
+
operations: MoveOperation[],
|
|
168
|
+
stateDir: string,
|
|
169
|
+
stagingRoot: string,
|
|
170
|
+
): void {
|
|
125
171
|
const destinations = new Set<string>();
|
|
126
172
|
for (const operation of operations) {
|
|
127
173
|
if (!operation.destination) continue;
|
|
128
|
-
if (existsSync(operation.destination)) {
|
|
129
|
-
throw new Error(`Migration collision at ${operation.destination}.`);
|
|
130
|
-
}
|
|
131
174
|
if (destinations.has(operation.destination)) {
|
|
132
175
|
throw new Error(`Multiple legacy paths target ${operation.destination}.`);
|
|
133
176
|
}
|
|
134
177
|
destinations.add(operation.destination);
|
|
178
|
+
operation.collision = collisionPolicy(operation, stateDir);
|
|
179
|
+
if (operation.collision && operation.collision !== "keep-destination") {
|
|
180
|
+
operation.destinationStaged = join(stagingRoot, randomUUID());
|
|
181
|
+
}
|
|
135
182
|
}
|
|
136
183
|
}
|
|
137
184
|
|
|
@@ -141,16 +188,115 @@ function writeJournal(path: string, journal: MigrationJournal): void {
|
|
|
141
188
|
|
|
142
189
|
function rollbackFilesystem(journal: MigrationJournal, journalPath: string): void {
|
|
143
190
|
for (const operation of [...journal.operations].reverse()) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
191
|
+
if (operation.state === "placed") {
|
|
192
|
+
if (operation.collision === "keep-destination" || !operation.destination) {
|
|
193
|
+
if (existsSync(operation.staged) && !existsSync(operation.source)) {
|
|
194
|
+
mkdirSync(dirname(operation.source), { recursive: true });
|
|
195
|
+
renameSync(operation.staged, operation.source);
|
|
196
|
+
}
|
|
197
|
+
} else if (operation.destination && existsSync(operation.destination)) {
|
|
198
|
+
if (operation.collision === "merge-jsonl" || operation.collision === "merge-tasks") {
|
|
199
|
+
rmSync(operation.destination, { recursive: true, force: true });
|
|
200
|
+
if (existsSync(operation.staged) && !existsSync(operation.source)) {
|
|
201
|
+
mkdirSync(dirname(operation.source), { recursive: true });
|
|
202
|
+
renameSync(operation.staged, operation.source);
|
|
203
|
+
}
|
|
204
|
+
} else if (!existsSync(operation.source)) {
|
|
205
|
+
mkdirSync(dirname(operation.source), { recursive: true });
|
|
206
|
+
renameSync(operation.destination, operation.source);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else if (existsSync(operation.staged) && !existsSync(operation.source)) {
|
|
210
|
+
mkdirSync(dirname(operation.source), { recursive: true });
|
|
211
|
+
renameSync(operation.staged, operation.source);
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
operation.destination &&
|
|
215
|
+
operation.destinationStaged &&
|
|
216
|
+
existsSync(operation.destinationStaged) &&
|
|
217
|
+
!existsSync(operation.destination)
|
|
218
|
+
) {
|
|
219
|
+
mkdirSync(dirname(operation.destination), { recursive: true });
|
|
220
|
+
renameSync(operation.destinationStaged, operation.destination);
|
|
221
|
+
}
|
|
148
222
|
operation.state = "pending";
|
|
149
223
|
}
|
|
150
224
|
journal.phase = "filesystem";
|
|
151
225
|
writeJournal(journalPath, journal);
|
|
152
226
|
}
|
|
153
227
|
|
|
228
|
+
function mergedJsonl(source: string, destination: string): string {
|
|
229
|
+
const lines = new Set<string>();
|
|
230
|
+
for (const path of [source, destination]) {
|
|
231
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
232
|
+
if (line) lines.add(line);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return lines.size > 0 ? `${[...lines].join("\n")}\n` : "";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface TaskFileShape {
|
|
239
|
+
version: 1;
|
|
240
|
+
tasks: Array<{ id: string; blockedBy?: string[]; [key: string]: unknown }>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function readTaskFile(path: string): TaskFileShape {
|
|
244
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TaskFileShape>;
|
|
245
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.tasks)) {
|
|
246
|
+
throw new Error(`Invalid task file during migration: ${path}`);
|
|
247
|
+
}
|
|
248
|
+
return parsed as TaskFileShape;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function mergedTasks(source: string, destination: string): string {
|
|
252
|
+
const legacy = readTaskFile(source).tasks;
|
|
253
|
+
const current = readTaskFile(destination).tasks;
|
|
254
|
+
const merged = legacy.map((task) => ({ ...task }));
|
|
255
|
+
const usedIds = new Set(merged.map((task) => String(task.id)));
|
|
256
|
+
let nextId = Math.max(0, ...[...usedIds].map(Number).filter(Number.isInteger)) + 1;
|
|
257
|
+
const remapped = new Map<string, string>();
|
|
258
|
+
|
|
259
|
+
for (const task of current) {
|
|
260
|
+
const originalId = String(task.id);
|
|
261
|
+
const exact = merged.find(
|
|
262
|
+
(candidate) =>
|
|
263
|
+
candidate.id === originalId && JSON.stringify(candidate) === JSON.stringify(task),
|
|
264
|
+
);
|
|
265
|
+
if (exact) {
|
|
266
|
+
remapped.set(originalId, originalId);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const id = usedIds.has(originalId) ? String(nextId++) : originalId;
|
|
270
|
+
usedIds.add(id);
|
|
271
|
+
remapped.set(originalId, id);
|
|
272
|
+
merged.push({ ...task, id });
|
|
273
|
+
}
|
|
274
|
+
for (const task of merged.slice(legacy.length)) {
|
|
275
|
+
if (task.blockedBy) task.blockedBy = task.blockedBy.map((id) => remapped.get(id) ?? id);
|
|
276
|
+
}
|
|
277
|
+
return `${JSON.stringify({ version: 1, tasks: merged }, null, 2)}\n`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function placeOperation(operation: MoveOperation): void {
|
|
281
|
+
if (!operation.destination || operation.collision === "keep-destination") return;
|
|
282
|
+
mkdirSync(dirname(operation.destination), { recursive: true });
|
|
283
|
+
if (operation.collision === "merge-jsonl") {
|
|
284
|
+
writeFileSync(
|
|
285
|
+
operation.destination,
|
|
286
|
+
mergedJsonl(operation.staged, operation.destinationStaged!),
|
|
287
|
+
);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (operation.collision === "merge-tasks") {
|
|
291
|
+
writeFileSync(
|
|
292
|
+
operation.destination,
|
|
293
|
+
mergedTasks(operation.staged, operation.destinationStaged!),
|
|
294
|
+
);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
renameSync(operation.staged, operation.destination);
|
|
298
|
+
}
|
|
299
|
+
|
|
154
300
|
function tablesWithColumn(database: InstanceType<typeof Database>, columnName: string): string[] {
|
|
155
301
|
const tables = database
|
|
156
302
|
.query<{ name: string }, []>(
|
|
@@ -239,11 +385,11 @@ function migrateDatabase(
|
|
|
239
385
|
)
|
|
240
386
|
.all(source);
|
|
241
387
|
for (const row of rows) {
|
|
242
|
-
const plaintext =
|
|
388
|
+
const plaintext = decryptVaultValueWithKey(source, row.key, row.value, masterKey).value;
|
|
243
389
|
database
|
|
244
390
|
.query("UPDATE vault SET value = ? WHERE user_id = ? AND key = ?")
|
|
245
391
|
.run(
|
|
246
|
-
|
|
392
|
+
encryptVaultValueWithKey(CANONICAL_LOCAL_USER_ID, row.key, plaintext, masterKey),
|
|
247
393
|
source,
|
|
248
394
|
row.key,
|
|
249
395
|
);
|
|
@@ -512,7 +658,7 @@ export function migrateSingleUserState(
|
|
|
512
658
|
state: "pending",
|
|
513
659
|
});
|
|
514
660
|
}
|
|
515
|
-
|
|
661
|
+
configureCollisions(operations, stateDir, stagingRoot);
|
|
516
662
|
journal = {
|
|
517
663
|
id: SINGLE_USER_MIGRATION_ID,
|
|
518
664
|
sourcePrincipal: source,
|
|
@@ -529,30 +675,57 @@ export function migrateSingleUserState(
|
|
|
529
675
|
try {
|
|
530
676
|
for (const operation of journal.operations) {
|
|
531
677
|
// Reconcile a crash between rename(2) and the following journal write.
|
|
678
|
+
if (operation.state === "staged") operation.state = "source-staged";
|
|
679
|
+
if (operation.state === "pending" && !existsSync(operation.source)) {
|
|
680
|
+
if (existsSync(operation.staged)) operation.state = "source-staged";
|
|
681
|
+
else if (operation.destination && existsSync(operation.destination)) {
|
|
682
|
+
operation.state = "placed";
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (operation.state === "pending") {
|
|
686
|
+
mkdirSync(dirname(operation.staged), { recursive: true });
|
|
687
|
+
renameSync(operation.source, operation.staged);
|
|
688
|
+
operation.state = "source-staged";
|
|
689
|
+
writeJournal(journalPath, journal);
|
|
690
|
+
}
|
|
532
691
|
if (
|
|
533
|
-
operation.state === "
|
|
534
|
-
|
|
535
|
-
|
|
692
|
+
operation.state === "source-staged" &&
|
|
693
|
+
operation.collision === "replace-destination" &&
|
|
694
|
+
operation.destination &&
|
|
695
|
+
operation.destinationStaged &&
|
|
696
|
+
!existsSync(operation.staged) &&
|
|
697
|
+
existsSync(operation.destination) &&
|
|
698
|
+
existsSync(operation.destinationStaged)
|
|
536
699
|
) {
|
|
537
|
-
operation.state = "
|
|
700
|
+
operation.state = "placed";
|
|
701
|
+
writeJournal(journalPath, journal);
|
|
538
702
|
}
|
|
539
703
|
if (
|
|
540
|
-
operation.state === "staged" &&
|
|
704
|
+
operation.state === "destination-staged" &&
|
|
705
|
+
operation.collision !== "merge-jsonl" &&
|
|
706
|
+
operation.collision !== "merge-tasks" &&
|
|
707
|
+
operation.collision !== "keep-destination" &&
|
|
541
708
|
operation.destination &&
|
|
542
709
|
!existsSync(operation.staged) &&
|
|
543
710
|
existsSync(operation.destination)
|
|
544
711
|
) {
|
|
545
712
|
operation.state = "placed";
|
|
713
|
+
writeJournal(journalPath, journal);
|
|
546
714
|
}
|
|
547
|
-
if (operation.state === "
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
715
|
+
if (operation.state === "source-staged") {
|
|
716
|
+
if (operation.destination && operation.destinationStaged) {
|
|
717
|
+
if (existsSync(operation.destination) && !existsSync(operation.destinationStaged)) {
|
|
718
|
+
renameSync(operation.destination, operation.destinationStaged);
|
|
719
|
+
}
|
|
720
|
+
if (!existsSync(operation.destinationStaged)) {
|
|
721
|
+
throw new Error(`Missing collision backup for ${operation.destination}.`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
operation.state = "destination-staged";
|
|
551
725
|
writeJournal(journalPath, journal);
|
|
552
726
|
}
|
|
553
|
-
if (operation.state === "staged"
|
|
554
|
-
|
|
555
|
-
renameSync(operation.staged, operation.destination);
|
|
727
|
+
if (operation.state === "destination-staged") {
|
|
728
|
+
placeOperation(operation);
|
|
556
729
|
operation.state = "placed";
|
|
557
730
|
writeJournal(journalPath, journal);
|
|
558
731
|
}
|
|
@@ -598,10 +771,21 @@ export function migrateSingleUserState(
|
|
|
598
771
|
migratedTables,
|
|
599
772
|
};
|
|
600
773
|
for (const store of ["conversations", "tasks", "users"]) {
|
|
601
|
-
rmSync(join(stateDir, "data", store, source), {
|
|
774
|
+
rmSync(join(stateDir, "data", store, source), {
|
|
775
|
+
recursive: true,
|
|
776
|
+
force: true,
|
|
777
|
+
});
|
|
602
778
|
}
|
|
603
|
-
rmSync(join(stateDir, "
|
|
604
|
-
|
|
779
|
+
rmSync(join(stateDir, "run"), { recursive: true, force: true });
|
|
780
|
+
rmSync(join(stateDir, "bin"), { recursive: true, force: true });
|
|
781
|
+
rmSync(join(stateDir, "workspace", "browser-profiles"), {
|
|
782
|
+
recursive: true,
|
|
783
|
+
force: true,
|
|
784
|
+
});
|
|
785
|
+
writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
|
|
786
|
+
mode: 0o600,
|
|
787
|
+
flag: "wx",
|
|
788
|
+
});
|
|
605
789
|
journal.phase = "complete";
|
|
606
790
|
rmSync(stagingRoot, { recursive: true, force: true });
|
|
607
791
|
return {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const ENVELOPE_PREFIX = "otium-vault:v1:";
|
|
4
|
+
const IV_BYTES = 12;
|
|
5
|
+
const KEY_BYTES = 32;
|
|
6
|
+
|
|
7
|
+
function encryptionKey(masterKey: string): Buffer {
|
|
8
|
+
return createHash("sha256")
|
|
9
|
+
.update("otium-vault-value-v1\0", "utf8")
|
|
10
|
+
.update(masterKey, "utf8")
|
|
11
|
+
.digest()
|
|
12
|
+
.subarray(0, KEY_BYTES);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function aad(userId: string, key: string): Buffer {
|
|
16
|
+
return Buffer.from(`${userId}\0${key.toUpperCase()}`, "utf8");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isEncryptedVaultValue(value: string): boolean {
|
|
20
|
+
return value.startsWith(ENVELOPE_PREFIX);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function encryptVaultValueWithKey(
|
|
24
|
+
userId: string,
|
|
25
|
+
key: string,
|
|
26
|
+
value: string,
|
|
27
|
+
masterKey: string,
|
|
28
|
+
): string {
|
|
29
|
+
const iv = randomBytes(IV_BYTES);
|
|
30
|
+
const cipher = createCipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
|
|
31
|
+
cipher.setAAD(aad(userId, key));
|
|
32
|
+
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
33
|
+
const tag = cipher.getAuthTag();
|
|
34
|
+
return `${ENVELOPE_PREFIX}${iv.toString("base64url")}.${ciphertext.toString("base64url")}.${tag.toString("base64url")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function decryptVaultValueWithKey(
|
|
38
|
+
userId: string,
|
|
39
|
+
key: string,
|
|
40
|
+
storedValue: string,
|
|
41
|
+
masterKey: string,
|
|
42
|
+
): { value: string; legacyPlaintext: boolean } {
|
|
43
|
+
if (!isEncryptedVaultValue(storedValue)) {
|
|
44
|
+
return { value: storedValue, legacyPlaintext: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const encoded = storedValue.slice(ENVELOPE_PREFIX.length);
|
|
48
|
+
const [ivPart, ciphertextPart, tagPart, ...extra] = encoded.split(".");
|
|
49
|
+
if (!ivPart || ciphertextPart === undefined || !tagPart || extra.length > 0) {
|
|
50
|
+
throw new Error("Invalid encrypted vault value");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const iv = Buffer.from(ivPart, "base64url");
|
|
54
|
+
const ciphertext = Buffer.from(ciphertextPart, "base64url");
|
|
55
|
+
const tag = Buffer.from(tagPart, "base64url");
|
|
56
|
+
if (iv.length !== IV_BYTES || tag.length !== 16) {
|
|
57
|
+
throw new Error("Invalid encrypted vault value");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
|
|
61
|
+
decipher.setAAD(aad(userId, key));
|
|
62
|
+
decipher.setAuthTag(tag);
|
|
63
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
64
|
+
return { value: plaintext.toString("utf8"), legacyPlaintext: false };
|
|
65
|
+
}
|
|
@@ -1,25 +1,11 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
1
|
import { VAULT_MASTER_KEY } from "#platform/config";
|
|
2
|
+
import {
|
|
3
|
+
decryptVaultValueWithKey,
|
|
4
|
+
encryptVaultValueWithKey,
|
|
5
|
+
isEncryptedVaultValue,
|
|
6
|
+
} from "#storage/vault-crypto-core";
|
|
3
7
|
|
|
4
|
-
|
|
5
|
-
const IV_BYTES = 12;
|
|
6
|
-
const KEY_BYTES = 32;
|
|
7
|
-
|
|
8
|
-
function encryptionKey(masterKey = VAULT_MASTER_KEY): Buffer {
|
|
9
|
-
return createHash("sha256")
|
|
10
|
-
.update("otium-vault-value-v1\0", "utf8")
|
|
11
|
-
.update(masterKey, "utf8")
|
|
12
|
-
.digest()
|
|
13
|
-
.subarray(0, KEY_BYTES);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function aad(userId: string, key: string): Buffer {
|
|
17
|
-
return Buffer.from(`${userId}\0${key.toUpperCase()}`, "utf8");
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function isEncryptedVaultValue(value: string): boolean {
|
|
21
|
-
return value.startsWith(ENVELOPE_PREFIX);
|
|
22
|
-
}
|
|
8
|
+
export { isEncryptedVaultValue };
|
|
23
9
|
|
|
24
10
|
/** Encrypt one vault row. The user/key binding prevents ciphertext row swapping. */
|
|
25
11
|
export function encryptVaultValue(
|
|
@@ -28,12 +14,7 @@ export function encryptVaultValue(
|
|
|
28
14
|
value: string,
|
|
29
15
|
masterKey = VAULT_MASTER_KEY,
|
|
30
16
|
): string {
|
|
31
|
-
|
|
32
|
-
const cipher = createCipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
|
|
33
|
-
cipher.setAAD(aad(userId, key));
|
|
34
|
-
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
35
|
-
const tag = cipher.getAuthTag();
|
|
36
|
-
return `${ENVELOPE_PREFIX}${iv.toString("base64url")}.${ciphertext.toString("base64url")}.${tag.toString("base64url")}`;
|
|
17
|
+
return encryptVaultValueWithKey(userId, key, value, masterKey);
|
|
37
18
|
}
|
|
38
19
|
|
|
39
20
|
/**
|
|
@@ -46,26 +27,5 @@ export function decryptVaultValue(
|
|
|
46
27
|
storedValue: string,
|
|
47
28
|
masterKey = VAULT_MASTER_KEY,
|
|
48
29
|
): { value: string; legacyPlaintext: boolean } {
|
|
49
|
-
|
|
50
|
-
return { value: storedValue, legacyPlaintext: true };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const encoded = storedValue.slice(ENVELOPE_PREFIX.length);
|
|
54
|
-
const [ivPart, ciphertextPart, tagPart, ...extra] = encoded.split(".");
|
|
55
|
-
if (!ivPart || ciphertextPart === undefined || !tagPart || extra.length > 0) {
|
|
56
|
-
throw new Error("Invalid encrypted vault value");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const iv = Buffer.from(ivPart, "base64url");
|
|
60
|
-
const ciphertext = Buffer.from(ciphertextPart, "base64url");
|
|
61
|
-
const tag = Buffer.from(tagPart, "base64url");
|
|
62
|
-
if (iv.length !== IV_BYTES || tag.length !== 16) {
|
|
63
|
-
throw new Error("Invalid encrypted vault value");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
|
|
67
|
-
decipher.setAAD(aad(userId, key));
|
|
68
|
-
decipher.setAuthTag(tag);
|
|
69
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
70
|
-
return { value: plaintext.toString("utf8"), legacyPlaintext: false };
|
|
30
|
+
return decryptVaultValueWithKey(userId, key, storedValue, masterKey);
|
|
71
31
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const NEGOTIUM_VERSION = "0.2.
|
|
1
|
+
export const NEGOTIUM_VERSION = "0.2.2";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function isEncryptedVaultValue(value: string): boolean;
|
|
2
|
+
export declare function encryptVaultValueWithKey(userId: string, key: string, value: string, masterKey: string): string;
|
|
3
|
+
export declare function decryptVaultValueWithKey(userId: string, key: string, storedValue: string, masterKey: string): {
|
|
4
|
+
value: string;
|
|
5
|
+
legacyPlaintext: boolean;
|
|
6
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { isEncryptedVaultValue } from "./vault-crypto-core";
|
|
2
|
+
export { isEncryptedVaultValue };
|
|
2
3
|
/** Encrypt one vault row. The user/key binding prevents ciphertext row swapping. */
|
|
3
4
|
export declare function encryptVaultValue(userId: string, key: string, value: string, masterKey?: string): string;
|
|
4
5
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const NEGOTIUM_VERSION = "0.2.
|
|
1
|
+
export declare const NEGOTIUM_VERSION = "0.2.2";
|
package/dist/vault.js
CHANGED
|
@@ -329,12 +329,12 @@ if (isBun) {
|
|
|
329
329
|
Database = NodeDatabase;
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
// ../../packages/core/src/storage/vault-crypto.ts
|
|
332
|
+
// ../../packages/core/src/storage/vault-crypto-core.ts
|
|
333
333
|
import { createCipheriv, createDecipheriv, createHash, randomBytes as randomBytes2 } from "crypto";
|
|
334
334
|
var ENVELOPE_PREFIX = "otium-vault:v1:";
|
|
335
335
|
var IV_BYTES = 12;
|
|
336
336
|
var KEY_BYTES = 32;
|
|
337
|
-
function encryptionKey(masterKey
|
|
337
|
+
function encryptionKey(masterKey) {
|
|
338
338
|
return createHash("sha256").update("otium-vault-value-v1\x00", "utf8").update(masterKey, "utf8").digest().subarray(0, KEY_BYTES);
|
|
339
339
|
}
|
|
340
340
|
function aad(userId, key) {
|
|
@@ -343,7 +343,7 @@ function aad(userId, key) {
|
|
|
343
343
|
function isEncryptedVaultValue(value) {
|
|
344
344
|
return value.startsWith(ENVELOPE_PREFIX);
|
|
345
345
|
}
|
|
346
|
-
function
|
|
346
|
+
function encryptVaultValueWithKey(userId, key, value, masterKey) {
|
|
347
347
|
const iv = randomBytes2(IV_BYTES);
|
|
348
348
|
const cipher = createCipheriv("aes-256-gcm", encryptionKey(masterKey), iv);
|
|
349
349
|
cipher.setAAD(aad(userId, key));
|
|
@@ -351,7 +351,7 @@ function encryptVaultValue(userId, key, value, masterKey = VAULT_MASTER_KEY) {
|
|
|
351
351
|
const tag = cipher.getAuthTag();
|
|
352
352
|
return `${ENVELOPE_PREFIX}${iv.toString("base64url")}.${ciphertext.toString("base64url")}.${tag.toString("base64url")}`;
|
|
353
353
|
}
|
|
354
|
-
function
|
|
354
|
+
function decryptVaultValueWithKey(userId, key, storedValue, masterKey) {
|
|
355
355
|
if (!isEncryptedVaultValue(storedValue)) {
|
|
356
356
|
return { value: storedValue, legacyPlaintext: true };
|
|
357
357
|
}
|
|
@@ -373,6 +373,14 @@ function decryptVaultValue(userId, key, storedValue, masterKey = VAULT_MASTER_KE
|
|
|
373
373
|
return { value: plaintext.toString("utf8"), legacyPlaintext: false };
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
// ../../packages/core/src/storage/vault-crypto.ts
|
|
377
|
+
function encryptVaultValue(userId, key, value, masterKey = VAULT_MASTER_KEY) {
|
|
378
|
+
return encryptVaultValueWithKey(userId, key, value, masterKey);
|
|
379
|
+
}
|
|
380
|
+
function decryptVaultValue(userId, key, storedValue, masterKey = VAULT_MASTER_KEY) {
|
|
381
|
+
return decryptVaultValueWithKey(userId, key, storedValue, masterKey);
|
|
382
|
+
}
|
|
383
|
+
|
|
376
384
|
// ../../packages/core/src/storage/vault.ts
|
|
377
385
|
var vaultDb;
|
|
378
386
|
var vaultMasterKey = VAULT_MASTER_KEY;
|
|
@@ -591,4 +599,4 @@ export {
|
|
|
591
599
|
VAULT_DESCRIPTION_MAX_LENGTH
|
|
592
600
|
};
|
|
593
601
|
|
|
594
|
-
//# debugId=
|
|
602
|
+
//# debugId=D6C5CDDC478A1CCC64756E2164756E21
|