owlmem 0.3.0 → 0.4.1

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 +99 -6
  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";
31
- const BASE = (process.env.OWLMEM_URL || "https://app.owlmem.com").replace(/\/$/, "");
30
+ export const VERSION = "0.4.1";
31
+ const BASE = (process.env.OWLMEM_URL || "https://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,76 @@ 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
+ // ── passkey unlock (browser only: navigator.credentials) ──
115
+ // A passkey's PRF extension returns 32 bytes only that passkey can compute (the server never sees them). They're stretched (HKDF) into a
116
+ // wrapping key that seals the vault key; OwlMem stores only the sealed box (vault_key_wraps). The page that calls these runs on owlmem.com.
117
+ const b64url = (u8) => b64(u8).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
118
+ const unb64url = (s) => { s = String(s).replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; return unb64(s); };
119
+ async function prfWrapKey(prf) {
120
+ const k = await crypto.subtle.importKey("raw", prf, "HKDF", false, ["deriveKey"]);
121
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: enc.encode("owlmem-vault-wrap-v1") }, k, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
122
+ }
123
+ const wrapAad = (space, user, credId) => enc.encode(space + "\n" + user + "\n" + credId);
124
+ // challenge(可选):服务器发的先验挑战。给了的话这一次刷 Passkey 同时就是「验明正身」——返回的 credential 交给服务器验签(10-24:导出要一步到位)
125
+ export async function passkeyPrf(creds, challenge) { // creds: [{cred_id, salt}] → {cred_id, prf, credential}
126
+ if (!globalThis.PublicKeyCredential) throw new Fail("This browser doesn't support passkeys.");
127
+ const map = Object.fromEntries(creds.map((c) => [c.cred_id, { first: unb64(c.salt) }]));
128
+ const prf = creds.length === 1 ? { eval: { first: unb64(creds[0].salt) } } : { evalByCredential: map };
129
+ const cred = await navigator.credentials.get({ publicKey: { challenge: challenge ? unb64url(challenge) : crypto.getRandomValues(new Uint8Array(32)), rpId: location.hostname, userVerification: "required",
130
+ timeout: 60000, allowCredentials: creds.map((c) => ({ type: "public-key", id: unb64url(c.cred_id) })), extensions: { prf } } });
131
+ const out = cred.getClientExtensionResults()?.prf?.results?.first;
132
+ if (!out) throw new Fail("This passkey can't unlock the vault (its device doesn't support the PRF feature). Use another passkey or the recovery code.");
133
+ const u = (b) => b64url(new Uint8Array(b));
134
+ return { cred_id: u(cred.rawId), prf: out, credential: { id: cred.id, type: cred.type, response: { clientDataJSON: u(cred.response.clientDataJSON),
135
+ authenticatorData: u(cred.response.authenticatorData), signature: u(cred.response.signature) } } };
136
+ }
137
+ export async function wrapVaultKey(prf, rawKey, space, user, credId) {
138
+ const iv = crypto.getRandomValues(new Uint8Array(12));
139
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: wrapAad(space, user, credId) }, await prfWrapKey(prf), rawKey);
140
+ return { nonce: b64(iv), wrapped: b64(new Uint8Array(ct)) };
141
+ }
142
+ export async function unlockWithPasskey(wraps, space, user, keyCheck, challenge) { // → {raw, cred_id, credential}
143
+ const r = await passkeyPrf(wraps, challenge);
144
+ const w = wraps.find((x) => x.cred_id === r.cred_id);
145
+ const raw = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(w.nonce), additionalData: wrapAad(space, user, w.cred_id) }, await prfWrapKey(r.prf), unb64(w.wrapped)));
146
+ if (!(await verifyKeyCheck(raw, space, keyCheck))) throw new Fail("Passkey unlock is out of date (the vault was reset). Use the recovery code and set the passkey up again.");
147
+ return { raw, cred_id: w.cred_id, credential: r.credential };
148
+ }
149
+ export function newSalt() { return b64(crypto.getRandomValues(new Uint8Array(32))); }
150
+
151
+ export async function openFromBrowser(privateKey, space, box) {
152
+ const epk = await crypto.subtle.importKey("raw", unb64(box.epk), ECDH, false, []);
153
+ 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));
154
+ return new Uint8Array(pt);
155
+ }
156
+ // <<< vault core
157
+
87
158
  // ── config ──
88
159
  function cfg() { try { return JSON.parse(readFileSync(join(DIR, "config.json"), "utf8")); } catch { return {}; } }
89
160
  function saveFile(path, text) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, text, { mode: 0o600 }); chmodSync(path, 0o600); }
@@ -135,6 +206,7 @@ async function approved(method, path, body, kind = "access") {
135
206
  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
207
  const itemPath = (name) => "items/" + name.split("/").map(encodeURIComponent).join("/");
137
208
 
209
+ // >>> vault core
138
210
  // ── import / export formats ──
139
211
  export function parseCSV(text) {
140
212
  const rows = []; let row = [], f = "", q = false;
@@ -237,6 +309,26 @@ export function guessCSV(text) {
237
309
  if (head.includes('"group"') || head.startsWith("group,") || head.includes("last modified")) return "keepass-csv";
238
310
  return "1password-csv";
239
311
  }
312
+ // <<< vault core
313
+
314
+ // ── unlock via the console ──
315
+ async function unlockInBrowser(space) {
316
+ const kp = await unlockKeypair();
317
+ const r = await call("POST", "unlock", { pubkey: kp.pubkey });
318
+ if (r.status >= 400) throw new Fail(r.data.detail || r.data.error);
319
+ const fp = await unlockFingerprint(kp.pubkey);
320
+ 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)…");
321
+ try { spawn(process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open", [r.data.url], { stdio: "ignore", detached: true }).on("error", () => {}).unref(); } catch {}
322
+ const wait = Number(process.env.OWLMEM_POLL_MS || 2000), until = Date.now() + 10 * 60 * 1000;
323
+ while (Date.now() < until) {
324
+ await new Promise((res) => setTimeout(res, wait));
325
+ const p = await call("GET", "unlock/" + r.data.id);
326
+ if (p.status >= 400) throw new Fail(p.data.detail || p.data.error);
327
+ if (p.data.status === "done") { say("Key received from " + p.data.by + "."); return openFromBrowser(kp.privateKey, space, p.data); }
328
+ if (p.data.status === "expired") break;
329
+ }
330
+ throw new Fail("Nobody approved the unlock in time. Run `owlmem vault unlock` again.");
331
+ }
240
332
 
241
333
  // ── prompts ──
242
334
  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 +472,9 @@ export async function main(argv) {
380
472
  }
381
473
  if (sub === "unlock") {
382
474
  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.");
475
+ 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();
476
+ const raw = typed ? parseRecovery(typed) : await unlockInBrowser(sp.space);
477
+ 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
478
  saveFile(join(DIR, "keys", sp.space + ".key"), b64(raw));
386
479
  say("Unlocked: this machine can now decrypt the vault of space " + sp.space + ".");
387
480
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "owlmem",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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" },