imprint-mcp 0.2.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/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal browser-compatible cookie jar for replay.
|
|
3
|
+
*
|
|
4
|
+
* This is the minimal compatible wrapper for the v1 state model. It keeps the
|
|
5
|
+
* needed surface small: Set-Cookie ingestion, request-url matching, path
|
|
6
|
+
* ordering, deletion, and ambiguity detection for scalar `${cookie.*}` lookups.
|
|
7
|
+
* `tough-cookie` can replace this after an audit of browser-compatibility,
|
|
8
|
+
* ESM/Bun support, public suffix behavior, license, and security history.
|
|
9
|
+
* CHIPS/partitioned cookies and full SameSite context enforcement are out of
|
|
10
|
+
* scope for v1.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface RuntimeCookie {
|
|
14
|
+
name: string;
|
|
15
|
+
value: string;
|
|
16
|
+
domain: string;
|
|
17
|
+
path: string;
|
|
18
|
+
expires?: number;
|
|
19
|
+
httpOnly?: boolean;
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
sameSite?: string;
|
|
22
|
+
hostOnly?: boolean;
|
|
23
|
+
creationIndex?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CookieLookupConstraints {
|
|
27
|
+
url?: string;
|
|
28
|
+
domain?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
sameSite?: string;
|
|
31
|
+
allowHttpOnlyProjection?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type CookieLookupResult =
|
|
35
|
+
| { ok: true; cookie: RuntimeCookie }
|
|
36
|
+
| { ok: false; reason: 'missing' | 'ambiguous' | 'httponly'; matches: RuntimeCookie[] };
|
|
37
|
+
|
|
38
|
+
let globalCreationIndex = 0;
|
|
39
|
+
|
|
40
|
+
export class RuntimeCookieJar {
|
|
41
|
+
private cookies: RuntimeCookie[] = [];
|
|
42
|
+
|
|
43
|
+
constructor(cookies: RuntimeCookie[] = []) {
|
|
44
|
+
for (const c of cookies) this.setCookie(c);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clone(): RuntimeCookieJar {
|
|
48
|
+
return new RuntimeCookieJar(this.cookies.map((c) => ({ ...c })));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
toJSON(): RuntimeCookie[] {
|
|
52
|
+
return this.cookies.map((c) => ({ ...c }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setCookie(cookie: RuntimeCookie): void {
|
|
56
|
+
const normalized = normalizeCookie(cookie);
|
|
57
|
+
const idx = this.cookies.findIndex(
|
|
58
|
+
(c) =>
|
|
59
|
+
c.name === normalized.name && c.domain === normalized.domain && c.path === normalized.path,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (isExpired(normalized)) {
|
|
63
|
+
if (idx >= 0) this.cookies.splice(idx, 1);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (idx >= 0) {
|
|
68
|
+
const previous = this.cookies[idx];
|
|
69
|
+
this.cookies[idx] = {
|
|
70
|
+
...normalized,
|
|
71
|
+
creationIndex: previous?.creationIndex ?? normalized.creationIndex,
|
|
72
|
+
};
|
|
73
|
+
} else {
|
|
74
|
+
this.cookies.push(normalized);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setCookieFromHeader(setCookie: string, requestUrl: string): void {
|
|
79
|
+
const parsed = parseSetCookie(setCookie, requestUrl);
|
|
80
|
+
if (parsed) this.setCookie(parsed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getCookieHeader(url: string): string | null {
|
|
84
|
+
const matching = this.matchingCookies(url).sort(cookieHeaderSort);
|
|
85
|
+
if (!matching.length) return null;
|
|
86
|
+
return matching.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lookup(
|
|
90
|
+
name: string,
|
|
91
|
+
requestUrl: string,
|
|
92
|
+
constraints: CookieLookupConstraints = {},
|
|
93
|
+
): CookieLookupResult {
|
|
94
|
+
const url = constraints.url ?? requestUrl;
|
|
95
|
+
let matching = this.matchingCookies(url).filter((c) => c.name === name);
|
|
96
|
+
if (constraints.domain) {
|
|
97
|
+
const domain = normalizeDomain(constraints.domain);
|
|
98
|
+
matching = matching.filter((c) => normalizeDomain(c.domain) === domain);
|
|
99
|
+
}
|
|
100
|
+
if (constraints.path) matching = matching.filter((c) => c.path === constraints.path);
|
|
101
|
+
if (constraints.sameSite) {
|
|
102
|
+
matching = matching.filter(
|
|
103
|
+
(c) => (c.sameSite ?? '').toLowerCase() === constraints.sameSite?.toLowerCase(),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!matching.length) return { ok: false, reason: 'missing', matches: [] };
|
|
108
|
+
if (matching.length > 1) {
|
|
109
|
+
return { ok: false, reason: 'ambiguous', matches: matching.sort(cookieHeaderSort) };
|
|
110
|
+
}
|
|
111
|
+
const top = matching[0];
|
|
112
|
+
if (!top) return { ok: false, reason: 'missing', matches: [] };
|
|
113
|
+
if (top.httpOnly && constraints.allowHttpOnlyProjection !== true) {
|
|
114
|
+
return { ok: false, reason: 'httponly', matches: [top] };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, cookie: top };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private matchingCookies(url: string): RuntimeCookie[] {
|
|
120
|
+
let parsed: URL;
|
|
121
|
+
try {
|
|
122
|
+
parsed = new URL(url);
|
|
123
|
+
} catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const host = parsed.hostname.toLowerCase();
|
|
127
|
+
const path = parsed.pathname || '/';
|
|
128
|
+
const secure = parsed.protocol === 'https:';
|
|
129
|
+
const nowSeconds = Date.now() / 1000;
|
|
130
|
+
|
|
131
|
+
return this.cookies.filter((c) => {
|
|
132
|
+
if (c.expires !== undefined && c.expires <= nowSeconds) return false;
|
|
133
|
+
if (c.secure && !secure) return false;
|
|
134
|
+
if (!domainMatches(c, host)) return false;
|
|
135
|
+
return pathMatches(c.path, path);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function extractSetCookieHeaders(headers: Headers): string[] {
|
|
141
|
+
const h = headers as Headers & { getSetCookie?: () => string[] };
|
|
142
|
+
if (typeof h.getSetCookie === 'function') return h.getSetCookie();
|
|
143
|
+
const sc = headers.get('set-cookie');
|
|
144
|
+
return sc ? splitSetCookieHeader(sc) : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Split a concatenated Set-Cookie header without splitting inside Expires. */
|
|
148
|
+
export function splitSetCookieHeader(joined: string): string[] {
|
|
149
|
+
return joined.split(/,\s*(?=[A-Za-z0-9!#$%&'*+\-.^_`|~]+=)/);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseSetCookie(setCookie: string, requestUrl: string): RuntimeCookie | null {
|
|
153
|
+
const parts = setCookie.split(';').map((s) => s.trim());
|
|
154
|
+
const first = parts[0] ?? '';
|
|
155
|
+
const eq = first.indexOf('=');
|
|
156
|
+
if (eq <= 0) return null;
|
|
157
|
+
|
|
158
|
+
let url: URL;
|
|
159
|
+
try {
|
|
160
|
+
url = new URL(requestUrl);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cookie: RuntimeCookie = {
|
|
166
|
+
name: first.slice(0, eq),
|
|
167
|
+
value: first.slice(eq + 1),
|
|
168
|
+
domain: url.hostname,
|
|
169
|
+
path: defaultPath(url.pathname),
|
|
170
|
+
hostOnly: true,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (const attr of parts.slice(1)) {
|
|
174
|
+
const attrEq = attr.indexOf('=');
|
|
175
|
+
const rawName = attrEq === -1 ? attr : attr.slice(0, attrEq);
|
|
176
|
+
const rawValue = attrEq === -1 ? '' : attr.slice(attrEq + 1);
|
|
177
|
+
const name = rawName.toLowerCase();
|
|
178
|
+
|
|
179
|
+
if (name === 'domain') {
|
|
180
|
+
cookie.domain = normalizeDomain(rawValue);
|
|
181
|
+
cookie.hostOnly = false;
|
|
182
|
+
} else if (name === 'path') {
|
|
183
|
+
cookie.path = rawValue.startsWith('/') ? rawValue : '/';
|
|
184
|
+
} else if (name === 'expires') {
|
|
185
|
+
const ms = Date.parse(rawValue);
|
|
186
|
+
if (!Number.isNaN(ms)) cookie.expires = Math.floor(ms / 1000);
|
|
187
|
+
} else if (name === 'max-age') {
|
|
188
|
+
const seconds = Number.parseInt(rawValue, 10);
|
|
189
|
+
if (Number.isFinite(seconds)) cookie.expires = Math.floor(Date.now() / 1000) + seconds;
|
|
190
|
+
} else if (name === 'httponly') {
|
|
191
|
+
cookie.httpOnly = true;
|
|
192
|
+
} else if (name === 'secure') {
|
|
193
|
+
cookie.secure = true;
|
|
194
|
+
} else if (name === 'samesite') {
|
|
195
|
+
cookie.sameSite = rawValue;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return normalizeCookie(cookie);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeCookie(cookie: RuntimeCookie): RuntimeCookie {
|
|
203
|
+
const expires = cookie.expires === -1 ? undefined : cookie.expires;
|
|
204
|
+
return {
|
|
205
|
+
...cookie,
|
|
206
|
+
expires,
|
|
207
|
+
domain: normalizeDomain(cookie.domain),
|
|
208
|
+
path: cookie.path || '/',
|
|
209
|
+
hostOnly: cookie.hostOnly ?? !cookie.domain.startsWith('.'),
|
|
210
|
+
creationIndex: cookie.creationIndex ?? globalCreationIndex++,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function normalizeDomain(domain: string): string {
|
|
215
|
+
return domain.replace(/^\./, '').toLowerCase();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isExpired(cookie: RuntimeCookie): boolean {
|
|
219
|
+
return cookie.expires !== undefined && cookie.expires <= Date.now() / 1000;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function domainMatches(cookie: RuntimeCookie, host: string): boolean {
|
|
223
|
+
const dom = normalizeDomain(cookie.domain);
|
|
224
|
+
return cookie.hostOnly ? host === dom : host === dom || host.endsWith(`.${dom}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function pathMatches(cookiePath: string, requestPath: string): boolean {
|
|
228
|
+
if (requestPath === cookiePath) return true;
|
|
229
|
+
if (!requestPath.startsWith(cookiePath)) return false;
|
|
230
|
+
return cookiePath.endsWith('/') || requestPath.charAt(cookiePath.length) === '/';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function defaultPath(pathname: string): string {
|
|
234
|
+
if (!pathname || pathname[0] !== '/') return '/';
|
|
235
|
+
if (pathname === '/') return '/';
|
|
236
|
+
const idx = pathname.lastIndexOf('/');
|
|
237
|
+
return idx <= 0 ? '/' : pathname.slice(0, idx);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function cookieHeaderSort(a: RuntimeCookie, b: RuntimeCookie): number {
|
|
241
|
+
return (
|
|
242
|
+
b.path.length - a.path.length ||
|
|
243
|
+
Number(b.hostOnly === true) - Number(a.hostOnly === true) ||
|
|
244
|
+
(a.creationIndex ?? 0) - (b.creationIndex ?? 0)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted credential bundle export/import.
|
|
3
|
+
*
|
|
4
|
+
* Use case: a user teaches a skill on their laptop and ships both the skill
|
|
5
|
+
* folder AND the credentials to a remote OpenClaw/Hermes agent. The skill
|
|
6
|
+
* folder lives in git (no plaintext), and credentials travel as a passphrase
|
|
7
|
+
* -encrypted bundle file the user can transport via any channel.
|
|
8
|
+
*
|
|
9
|
+
* Bundle wire format (JSON envelope; bytes never touch disk unencrypted):
|
|
10
|
+
* ```
|
|
11
|
+
* {
|
|
12
|
+
* "version": 1,
|
|
13
|
+
* "site": "<site>",
|
|
14
|
+
* "createdAt": "<iso>",
|
|
15
|
+
* "kdf": { "alg": "argon2id", "t": 3, "m": 65536, "p": 4, "saltB64": "..." },
|
|
16
|
+
* "cipher": { "alg": "xsalsa20poly1305", "nonceB64": "...", "ctB64": "..." }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
* Plaintext (after decrypt) = `{ secrets: Record<string,string>, cookies: CookieRecord[], storage?: StorageRecord[], manifest: ManifestEntry[] }`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { argon2id } from '@noble/hashes/argon2.js';
|
|
23
|
+
import {
|
|
24
|
+
type CookieRecord,
|
|
25
|
+
type CredentialBackend,
|
|
26
|
+
type ManifestEntry,
|
|
27
|
+
type StorageRecord,
|
|
28
|
+
readSiteManifest,
|
|
29
|
+
writeSiteManifest,
|
|
30
|
+
} from './credential-store.ts';
|
|
31
|
+
|
|
32
|
+
interface BundlePlaintext {
|
|
33
|
+
secrets: Record<string, string>;
|
|
34
|
+
cookies: CookieRecord[];
|
|
35
|
+
storage?: StorageRecord[];
|
|
36
|
+
manifest: ManifestEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BundleEnvelope {
|
|
40
|
+
version: 1;
|
|
41
|
+
site: string;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
kdf: { alg: 'argon2id'; t: number; m: number; p: number; saltB64: string };
|
|
44
|
+
cipher: { alg: 'xsalsa20poly1305'; nonceB64: string; ctB64: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const KDF_T = 3;
|
|
48
|
+
const KDF_M = 64 * 1024;
|
|
49
|
+
const KDF_P = 4;
|
|
50
|
+
|
|
51
|
+
function deriveKey(passphrase: string, salt: Uint8Array): Uint8Array {
|
|
52
|
+
return argon2id(new TextEncoder().encode(passphrase), salt, {
|
|
53
|
+
t: KDF_T,
|
|
54
|
+
m: KDF_M,
|
|
55
|
+
p: KDF_P,
|
|
56
|
+
dkLen: 32,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function b64encode(bytes: Uint8Array): string {
|
|
61
|
+
return Buffer.from(bytes).toString('base64');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function b64decode(s: string): Uint8Array {
|
|
65
|
+
return new Uint8Array(Buffer.from(s, 'base64'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// biome-ignore lint/suspicious/noExplicitAny: lazy libsodium ref
|
|
69
|
+
let sodiumPromise: Promise<any> | null = null;
|
|
70
|
+
// biome-ignore lint/suspicious/noExplicitAny: lazy libsodium ref
|
|
71
|
+
async function getSodium(): Promise<any> {
|
|
72
|
+
if (!sodiumPromise) {
|
|
73
|
+
sodiumPromise = import('libsodium-wrappers').then(async (m) => {
|
|
74
|
+
await m.default.ready;
|
|
75
|
+
return m.default;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return sodiumPromise;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function exportBundle(opts: {
|
|
82
|
+
backend: CredentialBackend;
|
|
83
|
+
site: string;
|
|
84
|
+
passphrase: string;
|
|
85
|
+
}): Promise<BundleEnvelope> {
|
|
86
|
+
if (opts.passphrase.length < 8) {
|
|
87
|
+
throw new Error('Passphrase must be at least 8 characters.');
|
|
88
|
+
}
|
|
89
|
+
const sodium = await getSodium();
|
|
90
|
+
|
|
91
|
+
const names = await opts.backend.listSecrets(opts.site);
|
|
92
|
+
const secrets: Record<string, string> = {};
|
|
93
|
+
for (const n of names) {
|
|
94
|
+
const v = await opts.backend.getSecret(opts.site, n);
|
|
95
|
+
if (v !== null) secrets[n] = v;
|
|
96
|
+
}
|
|
97
|
+
const cookies = await opts.backend.getCookies(opts.site);
|
|
98
|
+
const storage = (await opts.backend.getStorage?.(opts.site)) ?? [];
|
|
99
|
+
const manifest = readSiteManifest(opts.site)?.secrets ?? [];
|
|
100
|
+
|
|
101
|
+
const plaintext: BundlePlaintext = { secrets, cookies, storage, manifest };
|
|
102
|
+
const text = new TextEncoder().encode(JSON.stringify(plaintext));
|
|
103
|
+
|
|
104
|
+
const salt = sodium.randombytes_buf(16);
|
|
105
|
+
const key = deriveKey(opts.passphrase, salt);
|
|
106
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
107
|
+
const ct = sodium.crypto_secretbox_easy(text, nonce, key);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
version: 1,
|
|
111
|
+
site: opts.site,
|
|
112
|
+
createdAt: new Date().toISOString(),
|
|
113
|
+
kdf: { alg: 'argon2id', t: KDF_T, m: KDF_M, p: KDF_P, saltB64: b64encode(salt) },
|
|
114
|
+
cipher: { alg: 'xsalsa20poly1305', nonceB64: b64encode(nonce), ctB64: b64encode(ct) },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function decryptBundle(opts: {
|
|
119
|
+
envelope: BundleEnvelope;
|
|
120
|
+
passphrase: string;
|
|
121
|
+
}): Promise<BundlePlaintext> {
|
|
122
|
+
const sodium = await getSodium();
|
|
123
|
+
if (opts.envelope.version !== 1) {
|
|
124
|
+
throw new Error(`Unknown bundle version ${opts.envelope.version}.`);
|
|
125
|
+
}
|
|
126
|
+
if (opts.envelope.kdf.alg !== 'argon2id') {
|
|
127
|
+
throw new Error(`Unsupported KDF "${opts.envelope.kdf.alg}".`);
|
|
128
|
+
}
|
|
129
|
+
if (opts.envelope.cipher.alg !== 'xsalsa20poly1305') {
|
|
130
|
+
throw new Error(`Unsupported cipher "${opts.envelope.cipher.alg}".`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const salt = b64decode(opts.envelope.kdf.saltB64);
|
|
134
|
+
const nonce = b64decode(opts.envelope.cipher.nonceB64);
|
|
135
|
+
const ct = b64decode(opts.envelope.cipher.ctB64);
|
|
136
|
+
const key = argon2id(new TextEncoder().encode(opts.passphrase), salt, {
|
|
137
|
+
t: opts.envelope.kdf.t,
|
|
138
|
+
m: opts.envelope.kdf.m,
|
|
139
|
+
p: opts.envelope.kdf.p,
|
|
140
|
+
dkLen: 32,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let plain: Uint8Array;
|
|
144
|
+
try {
|
|
145
|
+
plain = sodium.crypto_secretbox_open_easy(ct, nonce, key);
|
|
146
|
+
} catch {
|
|
147
|
+
throw new Error('Wrong passphrase, or the bundle has been tampered with.');
|
|
148
|
+
}
|
|
149
|
+
return JSON.parse(new TextDecoder().decode(plain)) as BundlePlaintext;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function importBundle(opts: {
|
|
153
|
+
backend: CredentialBackend;
|
|
154
|
+
envelope: BundleEnvelope;
|
|
155
|
+
passphrase: string;
|
|
156
|
+
/** When true, abort if any secret already exists for this site. Default false (overwrite). */
|
|
157
|
+
failOnConflict?: boolean;
|
|
158
|
+
}): Promise<{ imported: string[]; cookieCount: number; storageCount: number }> {
|
|
159
|
+
const data = await decryptBundle({ envelope: opts.envelope, passphrase: opts.passphrase });
|
|
160
|
+
const site = opts.envelope.site;
|
|
161
|
+
|
|
162
|
+
if (opts.failOnConflict) {
|
|
163
|
+
const existing = await opts.backend.listSecrets(site);
|
|
164
|
+
const conflicts = existing.filter((n) => n in data.secrets);
|
|
165
|
+
if (conflicts.length > 0) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Refusing to overwrite existing secrets for "${site}": ${conflicts.join(', ')}\n→ delete them first or rerun without --fail-on-conflict.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const imported: string[] = [];
|
|
173
|
+
for (const [name, value] of Object.entries(data.secrets)) {
|
|
174
|
+
await opts.backend.setSecret(site, name, value);
|
|
175
|
+
imported.push(name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (data.cookies.length > 0) {
|
|
179
|
+
await opts.backend.setCookies(site, data.cookies);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (data.storage && data.storage.length > 0 && opts.backend.setStorage) {
|
|
183
|
+
await opts.backend.setStorage(site, data.storage);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (data.manifest.length > 0) {
|
|
187
|
+
writeSiteManifest({
|
|
188
|
+
site,
|
|
189
|
+
secrets: data.manifest,
|
|
190
|
+
updatedAt: new Date().toISOString(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { imported, cookieCount: data.cookies.length, storageCount: data.storage?.length ?? 0 };
|
|
195
|
+
}
|