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.
- package/owlmem.mjs +61 -5
- 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.
|
|
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) =>
|
|
51
|
-
const unb64 = (s) =>
|
|
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
|
|
384
|
-
|
|
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;
|