owlmem 0.3.0 → 0.4.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 (2) hide show
  1. package/owlmem.mjs +61 -5
  2. package/package.json +1 -1
package/owlmem.mjs CHANGED
@@ -27,13 +27,14 @@ import { homedir } from "node:os";
27
27
  import { join, dirname } from "node:path";
28
28
  import { spawn } from "node:child_process";
29
29
 
30
- export const VERSION = "0.3.0";
30
+ export const VERSION = "0.4.0";
31
31
  const BASE = (process.env.OWLMEM_URL || "https://app.owlmem.com").replace(/\/$/, "");
32
32
  const DIR = process.env.OWLMEM_HOME || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "owlmem");
33
33
  const enc = new TextEncoder(), dec = new TextDecoder();
34
34
  const say = (...a) => process.stderr.write(a.join(" ") + "\n");
35
35
  class Fail extends Error {}
36
36
 
37
+ // >>> vault core(浏览器也能跑:控制台的保险库导入导出经 /cli/vault-core.mjs 用同一份代码;这一段里别用 Node 专有的东西)
37
38
  // ── encoding ──
38
39
  const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
39
40
  export function b32encode(bytes) {
@@ -47,8 +48,8 @@ export function b32decode(s) {
47
48
  for (const ch of s) { const i = B32.indexOf(ch); if (i < 0) throw new Fail("That recovery code has a character that doesn't belong in it."); val = (val << 5) | i; bits += 5; if (bits >= 8) { out.push((val >>> (bits - 8)) & 255); bits -= 8; } }
48
49
  return new Uint8Array(out);
49
50
  }
50
- const b64 = (u8) => Buffer.from(u8).toString("base64");
51
- const unb64 = (s) => new Uint8Array(Buffer.from(s, "base64"));
51
+ const b64 = (u8) => { let s = ""; for (const x of u8) s += String.fromCharCode(x); return btoa(s); };
52
+ const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
52
53
 
53
54
  // ── keys ──
54
55
  export function recoveryCode(key) { return "OWL-" + b32encode(key).match(/.{1,4}/g).join("-"); }
@@ -84,6 +85,39 @@ export async function verifyKeyCheck(raw, space, kc) {
84
85
  try { return (await open(raw, space, KEY_CHECK, n, c)).owlmem === "key-check-v1"; } catch { return false; }
85
86
  }
86
87
 
88
+ // ── unlock in the browser (passkey) ──
89
+ // The command line makes a one-time ECDH (P-256) key pair and sends only the public key. Someone signed in to the console unlocks the
90
+ // vault there (passkey or recovery code) and seals the vault key to that public key; OwlMem relays the sealed box and can't open it.
91
+ // Both sides show the same fingerprint of the public key, so a swapped key would be noticed.
92
+ const UNLOCK_INFO = enc.encode("owlmem-unlock-v1");
93
+ const ECDH = { name: "ECDH", namedCurve: "P-256" };
94
+ async function unlockAesKey(priv, pub, space, usage) {
95
+ const bits = await crypto.subtle.deriveBits({ name: "ECDH", public: pub }, priv, 256);
96
+ const hk = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]);
97
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: enc.encode(space), info: UNLOCK_INFO }, hk, { name: "AES-GCM", length: 256 }, false, [usage]);
98
+ }
99
+ export async function unlockKeypair() {
100
+ const kp = await crypto.subtle.generateKey(ECDH, false, ["deriveBits"]);
101
+ return { privateKey: kp.privateKey, pubkey: b64(new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey))) };
102
+ }
103
+ export async function unlockFingerprint(pubkey) {
104
+ const h = new Uint8Array(await crypto.subtle.digest("SHA-256", unb64(pubkey)));
105
+ const s = b32encode(h).slice(0, 8); return s.slice(0, 4) + "-" + s.slice(4);
106
+ }
107
+ export async function sealForMachine(pubkey, rawKey, space) {
108
+ const eph = await crypto.subtle.generateKey(ECDH, true, ["deriveBits"]);
109
+ const pub = await crypto.subtle.importKey("raw", unb64(pubkey), ECDH, false, []);
110
+ const iv = crypto.getRandomValues(new Uint8Array(12));
111
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(space) }, await unlockAesKey(eph.privateKey, pub, space, "encrypt"), rawKey);
112
+ return { epk: b64(new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey))), nonce: b64(iv), ct: b64(new Uint8Array(ct)) };
113
+ }
114
+ export async function openFromBrowser(privateKey, space, box) {
115
+ const epk = await crypto.subtle.importKey("raw", unb64(box.epk), ECDH, false, []);
116
+ const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(box.nonce), additionalData: enc.encode(space) }, await unlockAesKey(privateKey, epk, space, "decrypt"), unb64(box.ct));
117
+ return new Uint8Array(pt);
118
+ }
119
+ // <<< vault core
120
+
87
121
  // ── config ──
88
122
  function cfg() { try { return JSON.parse(readFileSync(join(DIR, "config.json"), "utf8")); } catch { return {}; } }
89
123
  function saveFile(path, text) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, text, { mode: 0o600 }); chmodSync(path, 0o600); }
@@ -135,6 +169,7 @@ async function approved(method, path, body, kind = "access") {
135
169
  async function space() { const r = await call("GET", "key-check"); if (r.status >= 400) throw new Fail(r.data.detail || r.data.error); return r.data; }
136
170
  const itemPath = (name) => "items/" + name.split("/").map(encodeURIComponent).join("/");
137
171
 
172
+ // >>> vault core
138
173
  // ── import / export formats ──
139
174
  export function parseCSV(text) {
140
175
  const rows = []; let row = [], f = "", q = false;
@@ -237,6 +272,26 @@ export function guessCSV(text) {
237
272
  if (head.includes('"group"') || head.startsWith("group,") || head.includes("last modified")) return "keepass-csv";
238
273
  return "1password-csv";
239
274
  }
275
+ // <<< vault core
276
+
277
+ // ── unlock via the console ──
278
+ async function unlockInBrowser(space) {
279
+ const kp = await unlockKeypair();
280
+ const r = await call("POST", "unlock", { pubkey: kp.pubkey });
281
+ if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
282
+ const fp = await unlockFingerprint(kp.pubkey);
283
+ say("Open this link, sign in, and approve with your passkey:\n\n " + r.data.url + "\n\nThe console will show a code — check it matches: " + fp + "\nWaiting (up to 10 minutes)…");
284
+ try { spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open", [r.data.url], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {}
285
+ const wait = Number(process.env.OWLMEM_POLL_MS || 2000), until = Date.now() + 10 * 60 * 1000;
286
+ while (Date.now() < until) {
287
+ await new Promise((res) => setTimeout(res, wait));
288
+ const p = await call("GET", "unlock/" + r.data.id);
289
+ if (p.status >= 400) throw new Fail(p.data.detail || p.data.error);
290
+ if (p.data.status === "done") { say("Key received from " + p.data.by + "."); return openFromBrowser(kp.privateKey, space, p.data); }
291
+ if (p.data.status === "expired") break;
292
+ }
293
+ throw new Fail("Nobody approved the unlock in time. Run `owlmem vault unlock` again.");
294
+ }
240
295
 
241
296
  // ── prompts ──
242
297
  function readStdin() { return new Promise((res) => { let s = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (s += d)); process.stdin.on("end", () => res(s)); }); }
@@ -380,8 +435,9 @@ export async function main(argv) {
380
435
  }
381
436
  if (sub === "unlock") {
382
437
  const sp = await space();
383
- const raw = parseRecovery(await ask("Paste your recovery code (OWL-…, shown as dots), then Enter: "));
384
- if (!(await verifyKeyCheck(raw, sp.space, sp.key_check))) throw new Fail("That recovery code doesn't match this space's vault.");
438
+ const typed = (await ask("Paste your recovery code (OWL-…, shown as dots) — or just press Enter to unlock in your browser with a passkey: ")).trim();
439
+ const raw = typed ? parseRecovery(typed) : await unlockInBrowser(sp.space);
440
+ if (!(await verifyKeyCheck(raw, sp.space, sp.key_check))) throw new Fail(typed ? "That recovery code doesn't match this space's vault." : "The key from the browser doesn't match this vault.");
385
441
  saveFile(join(DIR, "keys", sp.space + ".key"), b64(raw));
386
442
  say("Unlocked: this machine can now decrypt the vault of space " + sp.space + ".");
387
443
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "OwlMem command line: end-to-end encrypted vault for your AI agents, and a local MCP server.",
5
5
  "type": "module",
6
6
  "bin": { "owlmem": "owlmem.mjs" },