feat: encrypted git-backed vault with idle auto-lock
- Master password derives an AES-GCM-256 key via PBKDF2-SHA256; the key never leaves the device and the remote only sees ciphertext blobs. - One file per entry under entries/; conflicts produce a sidecar the user resolves in the UI. - Vault stays unlocked across background-script suspensions via storage.session; auto-locks after 5 min of inactivity.
This commit is contained in:
parent
8c05cffa67
commit
c965d0f4ad
|
|
@ -3,6 +3,11 @@
|
||||||
"name": "Passchmop",
|
"name": "Passchmop",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Git-backed, client-side-encrypted password manager.",
|
"description": "Git-backed, client-side-encrypted password manager.",
|
||||||
|
"background": {
|
||||||
|
"scripts": ["build/background.js"]
|
||||||
|
},
|
||||||
|
"permissions": ["storage", "clipboardWrite", "alarms"],
|
||||||
|
"host_permissions": ["https://*/*"],
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "passchmop@schmop",
|
"id": "passchmop@schmop",
|
||||||
|
|
|
||||||
103
src/background/crypto.ts
Normal file
103
src/background/crypto.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Crypto helpers — PBKDF2 key derivation + AES-GCM blob encryption.
|
||||||
|
// Uses only Web Crypto (SubtleCrypto); no dependencies.
|
||||||
|
|
||||||
|
const subtle = crypto.subtle;
|
||||||
|
const TE = new TextEncoder();
|
||||||
|
const TD = new TextDecoder();
|
||||||
|
|
||||||
|
const VERIFIER_PLAINTEXT = 'passchmop-v1';
|
||||||
|
|
||||||
|
// TS 6's lib.dom narrowed `BufferSource` to exclude SharedArrayBuffer-backed
|
||||||
|
// typed arrays. Our Uint8Arrays always sit on plain ArrayBuffers; this cast
|
||||||
|
// makes that explicit for Web Crypto APIs.
|
||||||
|
const bs = (u: Uint8Array): BufferSource => u as unknown as BufferSource;
|
||||||
|
|
||||||
|
export async function deriveKey(
|
||||||
|
password: string,
|
||||||
|
saltBytes: Uint8Array,
|
||||||
|
iterations: number,
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const material = await subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
TE.encode(password),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey'],
|
||||||
|
);
|
||||||
|
// extractable: true so we can export raw bytes into browser.storage.session
|
||||||
|
// (in-memory only) and re-import on event-page wake-up. Keeps the vault
|
||||||
|
// unlocked across Firefox MV3 background-script suspensions.
|
||||||
|
return subtle.deriveKey(
|
||||||
|
{ name: 'PBKDF2', salt: bs(saltBytes), iterations, hash: 'SHA-256' },
|
||||||
|
material,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encrypt(key: CryptoKey, plaintext: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: bs(iv) }, key, bs(plaintext)));
|
||||||
|
const out = new Uint8Array(iv.length + ct.length);
|
||||||
|
out.set(iv, 0);
|
||||||
|
out.set(ct, iv.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(key: CryptoKey, blob: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
||||||
|
const bytes = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
||||||
|
const iv = bytes.slice(0, 12);
|
||||||
|
const ct = bytes.slice(12);
|
||||||
|
const pt = await subtle.decrypt({ name: 'AES-GCM', iv: bs(iv) }, key, bs(ct));
|
||||||
|
return new Uint8Array(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptJson(key: CryptoKey, obj: unknown): Promise<Uint8Array> {
|
||||||
|
return encrypt(key, TE.encode(JSON.stringify(obj)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptJson<T = unknown>(key: CryptoKey, blob: Uint8Array): Promise<T> {
|
||||||
|
const pt = await decrypt(key, blob);
|
||||||
|
return JSON.parse(TD.decode(pt)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomSalt(n = 16): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuid(): string {
|
||||||
|
const b = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
b[6] = (b[6]! & 0x0f) | 0x40;
|
||||||
|
b[8] = (b[8]! & 0x3f) | 0x80;
|
||||||
|
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64encode(bytes: Uint8Array): string {
|
||||||
|
let s = '';
|
||||||
|
for (const byte of bytes) s += String.fromCharCode(byte);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64decode(str: string): Uint8Array {
|
||||||
|
const bin = atob(str);
|
||||||
|
const out = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeVerifier(key: CryptoKey): Promise<string> {
|
||||||
|
const blob = await encrypt(key, TE.encode(VERIFIER_PLAINTEXT));
|
||||||
|
return b64encode(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkVerifier(key: CryptoKey, b64verifier: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const blob = b64decode(b64verifier);
|
||||||
|
const pt = await decrypt(key, blob);
|
||||||
|
return TD.decode(pt) === VERIFIER_PLAINTEXT;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/background/fs.ts
Normal file
84
src/background/fs.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// Filesystem wrapper around lightning-fs (IndexedDB-backed). isomorphic-git
|
||||||
|
// needs the instance itself; our code uses the Promises-style helpers.
|
||||||
|
|
||||||
|
import LightningFS from '@isomorphic-git/lightning-fs';
|
||||||
|
|
||||||
|
const inst = new LightningFS('passchmop-repo');
|
||||||
|
|
||||||
|
export const fs = inst;
|
||||||
|
export const pfs = inst.promises;
|
||||||
|
export const dir = '/repo';
|
||||||
|
export const entriesDir = `${dir}/entries`;
|
||||||
|
|
||||||
|
interface FsError extends Error { code?: string }
|
||||||
|
|
||||||
|
export async function ensureDir(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pfs.mkdir(path);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as FsError).code !== 'EEXIST') throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEntryFiles(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const names = await pfs.readdir(entriesDir);
|
||||||
|
return names.filter(n => n.endsWith('.enc'));
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as FsError).code === 'ENOENT') return [];
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readEntryFile(filename: string): Promise<Uint8Array> {
|
||||||
|
const buf = await pfs.readFile(`${entriesDir}/${filename}`);
|
||||||
|
if (typeof buf === 'string') throw new Error('unexpected string read for entry file');
|
||||||
|
return new Uint8Array(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeEntryFile(filename: string, bytes: Uint8Array): Promise<void> {
|
||||||
|
await ensureDir(entriesDir);
|
||||||
|
await pfs.writeFile(`${entriesDir}/${filename}`, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEntryFile(filename: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pfs.unlink(`${entriesDir}/${filename}`);
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as FsError).code !== 'ENOENT') throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson<T = unknown>(path: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const buf = await pfs.readFile(`${dir}/${path}`, { encoding: 'utf8' });
|
||||||
|
const text = typeof buf === 'string' ? buf : new TextDecoder().decode(buf);
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as FsError).code === 'ENOENT') return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeJson(path: string, obj: unknown): Promise<void> {
|
||||||
|
await ensureDir(dir);
|
||||||
|
await pfs.writeFile(`${dir}/${path}`, JSON.stringify(obj, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wipeRepo(): Promise<void> {
|
||||||
|
async function rm(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stat = await pfs.stat(path);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const kids = await pfs.readdir(path);
|
||||||
|
for (const k of kids) await rm(`${path}/${k}`);
|
||||||
|
await pfs.rmdir(path);
|
||||||
|
} else {
|
||||||
|
await pfs.unlink(path);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as FsError).code !== 'ENOENT') throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await rm(dir);
|
||||||
|
}
|
||||||
84
src/background/index.ts
Normal file
84
src/background/index.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// Background entry point. Routes runtime messages to the vault API,
|
||||||
|
// runs periodic sync, and enforces the idle auto-lock.
|
||||||
|
|
||||||
|
import * as vault from './vault.js';
|
||||||
|
import type { Message, BackgroundResponse } from '../common/types.js';
|
||||||
|
|
||||||
|
const SYNC_ALARM = 'passchmop-sync';
|
||||||
|
const IDLE_ALARM = 'passchmop-idle';
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener(async (rawMsg: unknown): Promise<BackgroundResponse> => {
|
||||||
|
const msg = rawMsg as Message;
|
||||||
|
try {
|
||||||
|
let value: unknown = undefined;
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'isConfigured':
|
||||||
|
value = await vault.isConfigured();
|
||||||
|
break;
|
||||||
|
case 'isUnlocked':
|
||||||
|
value = await vault.isUnlocked();
|
||||||
|
break;
|
||||||
|
case 'setup':
|
||||||
|
await vault.setup(msg.args);
|
||||||
|
break;
|
||||||
|
case 'unlock':
|
||||||
|
await vault.unlock(msg.password);
|
||||||
|
break;
|
||||||
|
case 'lock':
|
||||||
|
await vault.lock();
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
value = await vault.list();
|
||||||
|
break;
|
||||||
|
case 'put':
|
||||||
|
value = await vault.put(msg.entry);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await vault.remove(msg.id, msg.file);
|
||||||
|
break;
|
||||||
|
case 'sync':
|
||||||
|
value = await vault.syncNow();
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
await vault.reset();
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = msg;
|
||||||
|
void _exhaustive;
|
||||||
|
return { ok: false, error: `unknown message type` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any interaction with an unlocked vault counts as activity and
|
||||||
|
// resets the idle-lock timer. Explicit lock/reset do not bump.
|
||||||
|
if (msg.type !== 'lock' && msg.type !== 'reset') {
|
||||||
|
await vault.touchActivity();
|
||||||
|
}
|
||||||
|
return { ok: true, value };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('background error:', e);
|
||||||
|
const error = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Periodic sync — fires only when the event page is alive AND unlocked.
|
||||||
|
// Otherwise the next popup open triggers a sync on unlock.
|
||||||
|
browser.alarms.create(SYNC_ALARM, { periodInMinutes: 2 });
|
||||||
|
// Idle auto-lock check — nukes the session key after the inactivity window.
|
||||||
|
browser.alarms.create(IDLE_ALARM, { periodInMinutes: 0.5 });
|
||||||
|
|
||||||
|
browser.alarms.onAlarm.addListener(async (alarm) => {
|
||||||
|
if (alarm.name === IDLE_ALARM) {
|
||||||
|
try { await vault.idleCheck(); }
|
||||||
|
catch (e) { console.warn('idle check failed:', e instanceof Error ? e.message : e); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (alarm.name === SYNC_ALARM) {
|
||||||
|
if (!(await vault.isUnlocked())) return;
|
||||||
|
try { await vault.syncNow(); }
|
||||||
|
catch (e) { console.warn('periodic sync failed:', e instanceof Error ? e.message : e); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('passchmop background ready');
|
||||||
322
src/background/sync.ts
Normal file
322
src/background/sync.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
// Git operations + per-entry conflict resolution.
|
||||||
|
//
|
||||||
|
// Strategy: keep a pending-ops queue locally. On sync:
|
||||||
|
// 1. fetch origin
|
||||||
|
// 2. hard-reset working tree to origin/main
|
||||||
|
// 3. replay each pending op, resolving conflicts per-entry
|
||||||
|
// 4. commit + push; on non-fast-forward, the caller retries
|
||||||
|
//
|
||||||
|
// Conflict rules:
|
||||||
|
// * add/add is impossible (UUIDs are random)
|
||||||
|
// * linear edit (remote.modified_at === op.baseModifiedAt) → overwrite
|
||||||
|
// * genuine edit/edit → keep newer as primary, loser as sidecar
|
||||||
|
// * edit/delete → edit wins (no silent data loss)
|
||||||
|
|
||||||
|
import * as git from 'isomorphic-git';
|
||||||
|
import http from 'isomorphic-git/http/web';
|
||||||
|
import {
|
||||||
|
fs, pfs, dir, entriesDir,
|
||||||
|
ensureDir, wipeRepo, writeJson, readJson,
|
||||||
|
listEntryFiles, readEntryFile, writeEntryFile, removeEntryFile,
|
||||||
|
} from './fs.js';
|
||||||
|
import {
|
||||||
|
encryptJson, decryptJson, makeVerifier, b64encode,
|
||||||
|
} from './crypto.js';
|
||||||
|
import type { Entry, VaultMeta, RepoConfig, PendingOp } from '../common/types.js';
|
||||||
|
|
||||||
|
interface InternalConfig {
|
||||||
|
repoUrl: string;
|
||||||
|
corsProxyUrl?: string;
|
||||||
|
auth: { username: string; token: string };
|
||||||
|
deviceId: string;
|
||||||
|
author: { name: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg: InternalConfig | null = null;
|
||||||
|
|
||||||
|
export function configure(input: RepoConfig): void {
|
||||||
|
cfg = {
|
||||||
|
repoUrl: input.repoUrl,
|
||||||
|
corsProxyUrl: input.corsProxyUrl || undefined,
|
||||||
|
auth: input.auth,
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
author: {
|
||||||
|
name: `passchmop-${input.deviceId.slice(0, 8)}`,
|
||||||
|
email: `${input.deviceId}@passchmop.local`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function must(): InternalConfig {
|
||||||
|
if (!cfg) throw new Error('sync not configured');
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opts common to every isomorphic-git call. Spread before your own fields.
|
||||||
|
function gitOpts<E extends object>(extra: E): E & {
|
||||||
|
fs: typeof fs; http: typeof http; dir: string;
|
||||||
|
corsProxy: string | undefined;
|
||||||
|
author: { name: string; email: string };
|
||||||
|
committer: { name: string; email: string };
|
||||||
|
onAuth: () => { username: string; password: string };
|
||||||
|
} {
|
||||||
|
const c = must();
|
||||||
|
return {
|
||||||
|
fs, http, dir,
|
||||||
|
corsProxy: c.corsProxyUrl,
|
||||||
|
author: c.author,
|
||||||
|
committer: c.author,
|
||||||
|
onAuth: () => ({ username: c.auth.username, password: c.auth.token }),
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a brand-new vault and push to the remote.
|
||||||
|
//
|
||||||
|
// Handles three remote states:
|
||||||
|
// 1. Empty repo (no refs) → init + commit + push.
|
||||||
|
// 2. Remote has main, no vault-meta → base our init commit on origin/main
|
||||||
|
// so our files are added on top of the existing content
|
||||||
|
// (e.g. an auto-created README).
|
||||||
|
// 3. Remote already has vault-meta → refuse; user should use "Join
|
||||||
|
// existing vault" instead.
|
||||||
|
export async function createVault(
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
saltBytes: Uint8Array,
|
||||||
|
iterations: number,
|
||||||
|
): Promise<VaultMeta> {
|
||||||
|
const c = must();
|
||||||
|
await wipeRepo();
|
||||||
|
await ensureDir(dir);
|
||||||
|
await git.init({ fs, dir, defaultBranch: 'main' });
|
||||||
|
await git.addRemote({ fs, dir, remote: 'origin', url: c.repoUrl });
|
||||||
|
|
||||||
|
let baseOid: string | null = null;
|
||||||
|
try {
|
||||||
|
await git.fetch(gitOpts({ ref: 'main', singleBranch: true, depth: 1 }));
|
||||||
|
baseOid = await git.resolveRef({ fs, dir, ref: 'refs/remotes/origin/main' });
|
||||||
|
} catch {
|
||||||
|
// Empty remote or no `main` yet — that's fine, we'll create it.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseOid) {
|
||||||
|
let vaultAlreadyExists = false;
|
||||||
|
try {
|
||||||
|
await git.readBlob({ fs, dir, oid: baseOid, filepath: 'vault-meta.json' });
|
||||||
|
vaultAlreadyExists = true;
|
||||||
|
} catch { /* not found → good */ }
|
||||||
|
if (vaultAlreadyExists) {
|
||||||
|
throw new Error('This repo already contains a vault. Use "Join existing vault" instead.');
|
||||||
|
}
|
||||||
|
// Fast-forward local main to origin/main so our commit is a proper child.
|
||||||
|
// IMPORTANT: writeRef main first, then checkout by *branch name*. Checking
|
||||||
|
// out by SHA leaves HEAD detached, after which commit() updates HEAD but
|
||||||
|
// not refs/heads/main, and push('main') silently sends a stale ref.
|
||||||
|
await git.writeRef({ fs, dir, ref: 'refs/heads/main', value: baseOid, force: true });
|
||||||
|
await git.checkout({ fs, dir, ref: 'main', force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: VaultMeta = {
|
||||||
|
version: 1,
|
||||||
|
kdf: {
|
||||||
|
name: 'PBKDF2',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
iterations,
|
||||||
|
salt: b64encode(saltBytes),
|
||||||
|
},
|
||||||
|
verifier: await makeVerifier(masterKey),
|
||||||
|
};
|
||||||
|
await writeJson('vault-meta.json', meta);
|
||||||
|
await ensureDir(entriesDir);
|
||||||
|
await pfs.writeFile(`${entriesDir}/.gitkeep`, '');
|
||||||
|
|
||||||
|
await git.add({ fs, dir, filepath: 'vault-meta.json' });
|
||||||
|
await git.add({ fs, dir, filepath: 'entries/.gitkeep' });
|
||||||
|
await git.commit({
|
||||||
|
fs, dir,
|
||||||
|
message: 'init vault',
|
||||||
|
author: c.author,
|
||||||
|
committer: c.author,
|
||||||
|
});
|
||||||
|
|
||||||
|
await git.push(gitOpts({ remote: 'origin', ref: 'main' }));
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone an existing vault repo. Caller is responsible for deriving the
|
||||||
|
// master key from the resulting `vault-meta.json`.
|
||||||
|
export async function cloneVault(): Promise<VaultMeta> {
|
||||||
|
const c = must();
|
||||||
|
await wipeRepo();
|
||||||
|
await git.clone(gitOpts({
|
||||||
|
url: c.repoUrl,
|
||||||
|
ref: 'main',
|
||||||
|
singleBranch: true,
|
||||||
|
depth: 1,
|
||||||
|
}));
|
||||||
|
const meta = await readJson<VaultMeta>('vault-meta.json');
|
||||||
|
if (!meta) throw new Error('repo has no vault-meta.json');
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readVaultMeta(): Promise<VaultMeta | null> {
|
||||||
|
return readJson<VaultMeta>('vault-meta.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch remote, reset to origin/main, apply pending ops, commit, push.
|
||||||
|
// Returns { changed }. Throws on push failure; caller may retry.
|
||||||
|
export async function sync(
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
pendingOps: readonly PendingOp[],
|
||||||
|
): Promise<{ changed: boolean }> {
|
||||||
|
const c = must();
|
||||||
|
await git.fetch(gitOpts({ ref: 'main', singleBranch: true, depth: 1 }));
|
||||||
|
|
||||||
|
let remoteOid: string;
|
||||||
|
try {
|
||||||
|
remoteOid = await git.resolveRef({ fs, dir, ref: 'refs/remotes/origin/main' });
|
||||||
|
} catch {
|
||||||
|
remoteOid = await git.resolveRef({ fs, dir, ref: 'HEAD' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRef *before* checkout, and check out by branch name, so HEAD stays
|
||||||
|
// symbolic → refs/heads/main. Checkout by SHA would leave HEAD detached,
|
||||||
|
// and subsequent commits wouldn't advance main; push becomes a silent no-op.
|
||||||
|
await git.writeRef({ fs, dir, ref: 'refs/heads/main', value: remoteOid, force: true });
|
||||||
|
await git.checkout({ fs, dir, ref: 'main', force: true });
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const op of pendingOps) {
|
||||||
|
if (await applyOp(op, masterKey, remoteOid)) changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await stageAll();
|
||||||
|
await git.commit({
|
||||||
|
fs, dir,
|
||||||
|
message: `sync from ${c.deviceId.slice(0, 8)}`,
|
||||||
|
author: c.author,
|
||||||
|
committer: c.author,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await git.push(gitOpts({ remote: 'origin', ref: 'main' }));
|
||||||
|
|
||||||
|
return { changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyOp(op: PendingOp, masterKey: CryptoKey, remoteOid: string): Promise<boolean> {
|
||||||
|
if (op.type === 'put') return applyPut(op, masterKey, remoteOid);
|
||||||
|
if (op.type === 'delete') return applyDelete(op, masterKey, remoteOid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read what origin/main actually holds for this entry. Do NOT read from the
|
||||||
|
// working tree: `vault.put()` pre-writes blobs there for immediate UI feedback,
|
||||||
|
// and `git.checkout -f` doesn't remove untracked files, so the workdir can
|
||||||
|
// contain our own fresh-IV encryption that would look like a divergent remote.
|
||||||
|
async function readRemoteEntryBlob(remoteOid: string, filename: string): Promise<Uint8Array | null> {
|
||||||
|
try {
|
||||||
|
const { blob } = await git.readBlob({
|
||||||
|
fs, dir, oid: remoteOid, filepath: `entries/${filename}`,
|
||||||
|
});
|
||||||
|
return blob;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyPut(
|
||||||
|
op: Extract<PendingOp, { type: 'put' }>,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
remoteOid: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const filename = `${op.id}.enc`;
|
||||||
|
const remoteBlob = await readRemoteEntryBlob(remoteOid, filename);
|
||||||
|
const localBlob = await encryptJson(masterKey, op.entry);
|
||||||
|
|
||||||
|
if (!remoteBlob) {
|
||||||
|
await writeEntryFile(filename, localBlob);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remoteModAt: string | null = null;
|
||||||
|
try {
|
||||||
|
const r = await decryptJson<Entry>(masterKey, remoteBlob);
|
||||||
|
remoteModAt = r.modified_at || null;
|
||||||
|
} catch { /* undecryptable — treat as divergent */ }
|
||||||
|
|
||||||
|
// Linear edit: remote is still at the version we based our edit on.
|
||||||
|
// Overwrite with no sidecar.
|
||||||
|
if (op.baseModifiedAt && remoteModAt && remoteModAt === op.baseModifiedAt) {
|
||||||
|
await writeEntryFile(filename, localBlob);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual divergence — another device edited the same entry. Keep the
|
||||||
|
// newer one as primary, save the loser as a `<uuid>.conflict-<ts>.enc`
|
||||||
|
// sidecar so the user can resolve in the UI.
|
||||||
|
const localTs = Date.parse(op.entry.modified_at) || 0;
|
||||||
|
const remoteTs = remoteModAt ? (Date.parse(remoteModAt) || 0) : 0;
|
||||||
|
if (localTs >= remoteTs) {
|
||||||
|
await writeEntryFile(filename, localBlob);
|
||||||
|
await writeEntryFile(`${op.id}.conflict-${remoteTs || Date.now()}.enc`, remoteBlob);
|
||||||
|
} else {
|
||||||
|
await writeEntryFile(`${op.id}.conflict-${localTs}.enc`, localBlob);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDelete(
|
||||||
|
op: Extract<PendingOp, { type: 'delete' }>,
|
||||||
|
masterKey: CryptoKey,
|
||||||
|
remoteOid: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const filename = op.file || `${op.id}.enc`;
|
||||||
|
const remoteBlob = await readRemoteEntryBlob(remoteOid, filename);
|
||||||
|
if (!remoteBlob) return false;
|
||||||
|
|
||||||
|
// Edit-beats-delete check only applies to the primary file; sidecar
|
||||||
|
// deletion is just "user resolved the conflict by picking the other one".
|
||||||
|
if (filename === `${op.id}.enc`) {
|
||||||
|
let remoteTs = 0;
|
||||||
|
try {
|
||||||
|
const e = await decryptJson<Entry>(masterKey, remoteBlob);
|
||||||
|
remoteTs = Date.parse(e.modified_at) || 0;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (remoteTs > (op.initiatedAt || 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeEntryFile(filename);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageAll(): Promise<void> {
|
||||||
|
const matrix = await git.statusMatrix({ fs, dir, filepaths: ['entries'] });
|
||||||
|
for (const [filepath, , workdir] of matrix) {
|
||||||
|
if (workdir === 0) {
|
||||||
|
await git.remove({ fs, dir, filepath });
|
||||||
|
} else {
|
||||||
|
await git.add({ fs, dir, filepath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBlobFiles(): Promise<string[]> {
|
||||||
|
return listEntryFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBlobFile(filename: string): Promise<Uint8Array> {
|
||||||
|
return readEntryFile(filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeBlobFile(filename: string, bytes: Uint8Array): Promise<void> {
|
||||||
|
return writeEntryFile(filename, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeBlobFile(filename: string): Promise<void> {
|
||||||
|
return removeEntryFile(filename);
|
||||||
|
}
|
||||||
324
src/background/vault.ts
Normal file
324
src/background/vault.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
// Vault — the background script's main state machine.
|
||||||
|
// Holds the in-memory master key, exposes CRUD, drives sync.js.
|
||||||
|
//
|
||||||
|
// Firefox MV3 suspends the background event page when idle, which
|
||||||
|
// wipes our in-memory state. To keep the vault unlocked across those
|
||||||
|
// suspensions we mirror the raw AES-GCM key to browser.storage.session
|
||||||
|
// (an in-memory store that survives event-page cycles but is cleared
|
||||||
|
// on browser restart). Each interaction bumps a lastActivityAt stamp,
|
||||||
|
// and a periodic alarm in index.ts calls idleCheck() to lock after
|
||||||
|
// the configured inactivity window.
|
||||||
|
|
||||||
|
import * as sync from './sync.js';
|
||||||
|
import * as c from './crypto.js';
|
||||||
|
import { wipeRepo } from './fs.js';
|
||||||
|
import type {
|
||||||
|
Entry, EntryInput, EntryWithMeta, Auth, RepoConfig,
|
||||||
|
VaultMeta, PendingOp, SetupArgs,
|
||||||
|
} from '../common/types.js';
|
||||||
|
|
||||||
|
const STORAGE = {
|
||||||
|
repoUrl: 'repoUrl',
|
||||||
|
authCiphertext: 'repoAuthCiphertext',
|
||||||
|
deviceId: 'deviceId',
|
||||||
|
corsProxyUrl: 'corsProxyUrl',
|
||||||
|
vaultMeta: 'vaultMeta',
|
||||||
|
pending: 'pendingOps',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SESSION = {
|
||||||
|
rawKey: 'rawMasterKey', // base64 AES-GCM raw bytes
|
||||||
|
lastActivity: 'lastActivity', // ms epoch
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
let masterKey: CryptoKey | null = null;
|
||||||
|
let meta: VaultMeta | null = null;
|
||||||
|
let repoConfig: RepoConfig | null = null;
|
||||||
|
let pending: PendingOp[] = [];
|
||||||
|
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let inflightSync: Promise<{ changed: boolean } | undefined> | null = null;
|
||||||
|
|
||||||
|
async function storageGet<T = Record<string, unknown>>(keys: string | string[]): Promise<T> {
|
||||||
|
return browser.storage.local.get(keys) as Promise<T>;
|
||||||
|
}
|
||||||
|
async function storageSet(obj: Record<string, unknown>): Promise<void> {
|
||||||
|
return browser.storage.local.set(obj);
|
||||||
|
}
|
||||||
|
async function storageRemove(keys: string | string[]): Promise<void> {
|
||||||
|
return browser.storage.local.remove(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePending(): Promise<void> {
|
||||||
|
await storageSet({ [STORAGE.pending]: pending });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistSession(): Promise<void> {
|
||||||
|
if (!masterKey) return;
|
||||||
|
const raw = new Uint8Array(await crypto.subtle.exportKey('raw', masterKey));
|
||||||
|
await browser.storage.session.set({
|
||||||
|
[SESSION.rawKey]: c.b64encode(raw),
|
||||||
|
[SESSION.lastActivity]: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSession(): Promise<void> {
|
||||||
|
await browser.storage.session.remove(Object.values(SESSION) as string[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreFromSession(): Promise<boolean> {
|
||||||
|
if (masterKey) return true;
|
||||||
|
const r = await browser.storage.session.get(Object.values(SESSION) as string[]) as {
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
const raw = r[SESSION.rawKey] as string | undefined;
|
||||||
|
const t = (r[SESSION.lastActivity] as number | undefined) || 0;
|
||||||
|
if (!raw) return false;
|
||||||
|
if (Date.now() - t > IDLE_TIMEOUT_MS) {
|
||||||
|
await clearSession();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bytes = c.b64decode(raw);
|
||||||
|
masterKey = await crypto.subtle.importKey(
|
||||||
|
'raw', bytes as unknown as BufferSource,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true, ['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const local = await storageGet<Record<string, unknown>>(Object.values(STORAGE) as string[]);
|
||||||
|
meta = (local[STORAGE.vaultMeta] as VaultMeta | undefined) || null;
|
||||||
|
|
||||||
|
let auth: Auth = { username: '', token: '' };
|
||||||
|
const authCt = local[STORAGE.authCiphertext] as string | undefined;
|
||||||
|
if (authCt) {
|
||||||
|
try {
|
||||||
|
auth = await c.decryptJson<Auth>(masterKey, c.b64decode(authCt));
|
||||||
|
} catch (e) { console.warn('failed to decrypt stored auth', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
repoConfig = {
|
||||||
|
repoUrl: local[STORAGE.repoUrl] as string,
|
||||||
|
corsProxyUrl: local[STORAGE.corsProxyUrl] as string | undefined,
|
||||||
|
deviceId: local[STORAGE.deviceId] as string,
|
||||||
|
auth,
|
||||||
|
};
|
||||||
|
sync.configure(repoConfig);
|
||||||
|
pending = (local[STORAGE.pending] as PendingOp[] | undefined) || [];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureUnlocked(): Promise<void> {
|
||||||
|
if (masterKey) return;
|
||||||
|
if (!(await restoreFromSession())) throw new Error('locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchActivity(): Promise<void> {
|
||||||
|
if (!masterKey) return;
|
||||||
|
await browser.storage.session.set({ [SESSION.lastActivity]: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function idleCheck(): Promise<void> {
|
||||||
|
const r = await browser.storage.session.get(SESSION.lastActivity) as { [k: string]: unknown };
|
||||||
|
const t = (r[SESSION.lastActivity] as number | undefined) || 0;
|
||||||
|
if (!t) return;
|
||||||
|
if (Date.now() - t > IDLE_TIMEOUT_MS) await lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isConfigured(): Promise<boolean> {
|
||||||
|
const r = await storageGet<Record<string, unknown>>([STORAGE.repoUrl, STORAGE.vaultMeta]);
|
||||||
|
return !!(r[STORAGE.repoUrl] && r[STORAGE.vaultMeta]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isUnlocked(): Promise<boolean> {
|
||||||
|
if (masterKey) return true;
|
||||||
|
return restoreFromSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setup(args: SetupArgs): Promise<void> {
|
||||||
|
const { repoUrl, username, token, masterPassword, existing, corsProxyUrl } = args;
|
||||||
|
if (!repoUrl || !masterPassword) throw new Error('repoUrl and masterPassword are required');
|
||||||
|
|
||||||
|
const deviceRec = await storageGet<{ [k: string]: unknown }>(STORAGE.deviceId);
|
||||||
|
let deviceId = deviceRec[STORAGE.deviceId] as string | undefined;
|
||||||
|
if (!deviceId) deviceId = c.uuid();
|
||||||
|
|
||||||
|
const auth: Auth = { username: username || '', token: token || '' };
|
||||||
|
sync.configure({ repoUrl, corsProxyUrl, auth, deviceId });
|
||||||
|
|
||||||
|
let derivedKey: CryptoKey;
|
||||||
|
let metaObj: VaultMeta;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
metaObj = await sync.cloneVault();
|
||||||
|
const salt = c.b64decode(metaObj.kdf.salt);
|
||||||
|
derivedKey = await c.deriveKey(masterPassword, salt, metaObj.kdf.iterations);
|
||||||
|
const ok = await c.checkVerifier(derivedKey, metaObj.verifier);
|
||||||
|
if (!ok) throw new Error('wrong master password for existing vault');
|
||||||
|
} else {
|
||||||
|
const salt = c.randomSalt(16);
|
||||||
|
const iterations = 600000;
|
||||||
|
derivedKey = await c.deriveKey(masterPassword, salt, iterations);
|
||||||
|
metaObj = await sync.createVault(derivedKey, salt, iterations);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authBlob = await c.encryptJson(derivedKey, auth);
|
||||||
|
await storageSet({
|
||||||
|
[STORAGE.repoUrl]: repoUrl,
|
||||||
|
[STORAGE.corsProxyUrl]: corsProxyUrl || '',
|
||||||
|
[STORAGE.deviceId]: deviceId,
|
||||||
|
[STORAGE.authCiphertext]: c.b64encode(authBlob),
|
||||||
|
[STORAGE.vaultMeta]: metaObj,
|
||||||
|
[STORAGE.pending]: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
masterKey = derivedKey;
|
||||||
|
meta = metaObj;
|
||||||
|
repoConfig = { repoUrl, corsProxyUrl, auth, deviceId };
|
||||||
|
pending = [];
|
||||||
|
await persistSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlock(password: string): Promise<void> {
|
||||||
|
const r = await storageGet<Record<string, unknown>>(Object.values(STORAGE) as string[]);
|
||||||
|
const storedMeta = r[STORAGE.vaultMeta] as VaultMeta | undefined;
|
||||||
|
const repoUrl = r[STORAGE.repoUrl] as string | undefined;
|
||||||
|
if (!storedMeta || !repoUrl) throw new Error('no vault configured');
|
||||||
|
|
||||||
|
meta = storedMeta;
|
||||||
|
const salt = c.b64decode(storedMeta.kdf.salt);
|
||||||
|
const key = await c.deriveKey(password, salt, storedMeta.kdf.iterations);
|
||||||
|
const ok = await c.checkVerifier(key, storedMeta.verifier);
|
||||||
|
if (!ok) throw new Error('wrong master password');
|
||||||
|
|
||||||
|
let auth: Auth = { username: '', token: '' };
|
||||||
|
const authCt = r[STORAGE.authCiphertext] as string | undefined;
|
||||||
|
if (authCt) {
|
||||||
|
try {
|
||||||
|
auth = await c.decryptJson<Auth>(key, c.b64decode(authCt));
|
||||||
|
} catch (e) { console.warn('failed to decrypt stored auth', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
masterKey = key;
|
||||||
|
repoConfig = {
|
||||||
|
repoUrl,
|
||||||
|
corsProxyUrl: r[STORAGE.corsProxyUrl] as string | undefined,
|
||||||
|
deviceId: r[STORAGE.deviceId] as string,
|
||||||
|
auth,
|
||||||
|
};
|
||||||
|
sync.configure(repoConfig);
|
||||||
|
pending = (r[STORAGE.pending] as PendingOp[] | undefined) || [];
|
||||||
|
await persistSession();
|
||||||
|
|
||||||
|
syncLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lock(): Promise<void> {
|
||||||
|
masterKey = null;
|
||||||
|
meta = null;
|
||||||
|
repoConfig = null;
|
||||||
|
pending = [];
|
||||||
|
if (syncTimer) { clearTimeout(syncTimer); syncTimer = null; }
|
||||||
|
await clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(): Promise<EntryWithMeta[]> {
|
||||||
|
await ensureUnlocked();
|
||||||
|
const files = await sync.listBlobFiles();
|
||||||
|
const entries: EntryWithMeta[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
const isConflict = /^(.+?)\.conflict-(.+)\.enc$/.test(f);
|
||||||
|
try {
|
||||||
|
const blob = await sync.readBlobFile(f);
|
||||||
|
const entry = await c.decryptJson<Entry>(masterKey!, blob);
|
||||||
|
entries.push({ ...entry, _file: f, _conflict: isConflict });
|
||||||
|
} catch {
|
||||||
|
// skip undecryptable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put(input: EntryInput): Promise<Entry> {
|
||||||
|
await ensureUnlocked();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const id = input.id || c.uuid();
|
||||||
|
|
||||||
|
// Record what version this edit is based on. On sync, if the remote's
|
||||||
|
// modified_at still matches this base, our edit is a linear update —
|
||||||
|
// no conflict, no sidecar. If someone else edited since, we diverge.
|
||||||
|
let baseModifiedAt: string | null = null;
|
||||||
|
if (input.id) {
|
||||||
|
try {
|
||||||
|
const existingBlob = await sync.readBlobFile(`${id}.enc`);
|
||||||
|
const existing = await c.decryptJson<Entry>(masterKey!, existingBlob);
|
||||||
|
baseModifiedAt = existing.modified_at || null;
|
||||||
|
} catch { /* not found — treat as new */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const full: Entry = {
|
||||||
|
id,
|
||||||
|
title: input.title || '',
|
||||||
|
url: input.url || '',
|
||||||
|
username: input.username || '',
|
||||||
|
password: input.password || '',
|
||||||
|
notes: input.notes || '',
|
||||||
|
created_at: input.created_at || now,
|
||||||
|
modified_at: now,
|
||||||
|
device_id: repoConfig!.deviceId,
|
||||||
|
};
|
||||||
|
const blob = await c.encryptJson(masterKey!, full);
|
||||||
|
await sync.writeBlobFile(`${id}.enc`, blob);
|
||||||
|
|
||||||
|
pending.push({ type: 'put', id, entry: full, baseModifiedAt });
|
||||||
|
await savePending();
|
||||||
|
syncLater();
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(id: string, file?: string): Promise<void> {
|
||||||
|
await ensureUnlocked();
|
||||||
|
const filename = file || `${id}.enc`;
|
||||||
|
await sync.removeBlobFile(filename);
|
||||||
|
pending.push({ type: 'delete', id, file: filename, initiatedAt: Date.now() });
|
||||||
|
await savePending();
|
||||||
|
syncLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncNow(retries = 3): Promise<{ changed: boolean } | undefined> {
|
||||||
|
await ensureUnlocked();
|
||||||
|
if (inflightSync) return inflightSync;
|
||||||
|
inflightSync = (async () => {
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
const { changed } = await sync.sync(masterKey!, pending);
|
||||||
|
pending = [];
|
||||||
|
await savePending();
|
||||||
|
return { changed };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e instanceof Error ? e.message : String(e)) || '';
|
||||||
|
const retryable = /not a fast-forward|non-fast|push|rejected/i.test(msg);
|
||||||
|
if (!retryable || i === retries - 1) throw e;
|
||||||
|
await new Promise(r => setTimeout(r, 500 * (i + 1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
try { return await inflightSync; }
|
||||||
|
finally { inflightSync = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLater(): void {
|
||||||
|
if (syncTimer) return;
|
||||||
|
syncTimer = setTimeout(async () => {
|
||||||
|
syncTimer = null;
|
||||||
|
try { await syncNow(); }
|
||||||
|
catch (e) { console.warn('background sync failed', e); }
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reset(): Promise<void> {
|
||||||
|
await lock();
|
||||||
|
await storageRemove(Object.values(STORAGE) as string[]);
|
||||||
|
await wipeRepo();
|
||||||
|
}
|
||||||
24
src/common/messages.ts
Normal file
24
src/common/messages.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type {
|
||||||
|
Message, BackgroundResponse, SetupArgs, EntryInput, Entry, EntryWithMeta,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
async function sendRaw<V = unknown>(message: Message): Promise<V | undefined> {
|
||||||
|
const res = await browser.runtime.sendMessage(message) as BackgroundResponse<V> | undefined;
|
||||||
|
if (!res || !res.ok) throw new Error(res?.error || 'background error');
|
||||||
|
return res.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed API the UI layers talk to. Centralises the message contract so
|
||||||
|
// background and UI don't drift out of sync on field names.
|
||||||
|
export const api = {
|
||||||
|
isConfigured: () => sendRaw<boolean>({ type: 'isConfigured' }) as Promise<boolean>,
|
||||||
|
isUnlocked: () => sendRaw<boolean>({ type: 'isUnlocked' }) as Promise<boolean>,
|
||||||
|
setup: (args: SetupArgs) => sendRaw<void>({ type: 'setup', args }),
|
||||||
|
unlock: (password: string) => sendRaw<void>({ type: 'unlock', password }),
|
||||||
|
lock: () => sendRaw<void>({ type: 'lock' }),
|
||||||
|
list: () => sendRaw<EntryWithMeta[]>({ type: 'list' }) as Promise<EntryWithMeta[]>,
|
||||||
|
put: (entry: EntryInput) => sendRaw<Entry>({ type: 'put', entry }) as Promise<Entry>,
|
||||||
|
delete: (id: string, file?: string) => sendRaw<void>({ type: 'delete', id, file }),
|
||||||
|
sync: () => sendRaw<{ changed: boolean }>({ type: 'sync' }),
|
||||||
|
reset: () => sendRaw<void>({ type: 'reset' }),
|
||||||
|
};
|
||||||
3
src/common/polyfills.ts
Normal file
3
src/common/polyfills.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// esbuild `inject` target: any bundle that references bare `Buffer`
|
||||||
|
// will have it resolved to this re-export of the browser polyfill.
|
||||||
|
export { Buffer } from 'buffer';
|
||||||
83
src/common/types.ts
Normal file
83
src/common/types.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Shared type definitions used across background / popup / options.
|
||||||
|
|
||||||
|
export interface Entry {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
modified_at: string;
|
||||||
|
device_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input shape when creating or updating an entry from the UI.
|
||||||
|
// All fields optional; the vault fills in id, timestamps, device_id.
|
||||||
|
export interface EntryInput {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry with filesystem metadata, as returned by vault.list().
|
||||||
|
export interface EntryWithMeta extends Entry {
|
||||||
|
_file: string;
|
||||||
|
_conflict: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Auth {
|
||||||
|
username: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoConfig {
|
||||||
|
repoUrl: string;
|
||||||
|
corsProxyUrl?: string;
|
||||||
|
auth: Auth;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultMeta {
|
||||||
|
version: 1;
|
||||||
|
kdf: {
|
||||||
|
name: 'PBKDF2';
|
||||||
|
hash: 'SHA-256';
|
||||||
|
iterations: number;
|
||||||
|
salt: string; // base64(raw 16B)
|
||||||
|
};
|
||||||
|
verifier: string; // base64(iv||ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PendingOp =
|
||||||
|
| { type: 'put'; id: string; entry: Entry; baseModifiedAt: string | null }
|
||||||
|
| { type: 'delete'; id: string; file: string; initiatedAt: number };
|
||||||
|
|
||||||
|
export interface SetupArgs {
|
||||||
|
repoUrl: string;
|
||||||
|
username: string;
|
||||||
|
token: string;
|
||||||
|
masterPassword: string;
|
||||||
|
existing: boolean;
|
||||||
|
corsProxyUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Message =
|
||||||
|
| { type: 'isConfigured' }
|
||||||
|
| { type: 'isUnlocked' }
|
||||||
|
| { type: 'setup'; args: SetupArgs }
|
||||||
|
| { type: 'unlock'; password: string }
|
||||||
|
| { type: 'lock' }
|
||||||
|
| { type: 'list' }
|
||||||
|
| { type: 'put'; entry: EntryInput }
|
||||||
|
| { type: 'delete'; id: string; file?: string }
|
||||||
|
| { type: 'sync' }
|
||||||
|
| { type: 'reset' };
|
||||||
|
|
||||||
|
export type BackgroundResponse<V = unknown> =
|
||||||
|
| { ok: true; value?: V }
|
||||||
|
| { ok: false; error: string };
|
||||||
44
src/types/modules.d.ts
vendored
Normal file
44
src/types/modules.d.ts
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Minimal type shim for @isomorphic-git/lightning-fs — the package ships
|
||||||
|
// no official types. We only use a small surface of its Promises-style API,
|
||||||
|
// plus hand the instance itself to isomorphic-git.
|
||||||
|
|
||||||
|
declare module '@isomorphic-git/lightning-fs' {
|
||||||
|
interface LightningFSOptions {
|
||||||
|
wipe?: boolean;
|
||||||
|
url?: string;
|
||||||
|
fileDbName?: string;
|
||||||
|
fileStoreName?: string;
|
||||||
|
lockDbName?: string;
|
||||||
|
lockStoreName?: string;
|
||||||
|
defer?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightningStats {
|
||||||
|
isDirectory(): boolean;
|
||||||
|
isFile(): boolean;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightningPromises {
|
||||||
|
mkdir(path: string, opts?: { mode?: number }): Promise<void>;
|
||||||
|
rmdir(path: string): Promise<void>;
|
||||||
|
readdir(path: string): Promise<string[]>;
|
||||||
|
readFile(path: string, opts?: { encoding?: 'utf8' } | 'utf8'): Promise<Uint8Array | string>;
|
||||||
|
writeFile(path: string, data: Uint8Array | string, opts?: { encoding?: 'utf8' } | 'utf8'): Promise<void>;
|
||||||
|
unlink(path: string): Promise<void>;
|
||||||
|
stat(path: string): Promise<LightningStats>;
|
||||||
|
lstat(path: string): Promise<LightningStats>;
|
||||||
|
readlink(path: string): Promise<string>;
|
||||||
|
symlink(target: string, path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LightningFS {
|
||||||
|
constructor(name?: string, options?: LightningFSOptions);
|
||||||
|
promises: LightningPromises;
|
||||||
|
// Callback-style API used by isomorphic-git internally — left untyped.
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LightningFS;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user