160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
|
|
import Database from 'better-sqlite3';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import path from 'node:path';
|
||
|
|
import { config } from './config.js';
|
||
|
|
|
||
|
|
fs.mkdirSync(config.dataDir, { recursive: true });
|
||
|
|
|
||
|
|
export const db = new Database(path.join(config.dataDir, 'keep.db'));
|
||
|
|
db.pragma('journal_mode = WAL');
|
||
|
|
|
||
|
|
db.exec(`
|
||
|
|
CREATE TABLE IF NOT EXISTS vaults (
|
||
|
|
vault_key TEXT PRIMARY KEY,
|
||
|
|
ciphertext BLOB NOT NULL,
|
||
|
|
nonce BLOB NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL,
|
||
|
|
updated_by TEXT NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS recipients (
|
||
|
|
recipient_id TEXT PRIMARY KEY,
|
||
|
|
label TEXT NOT NULL,
|
||
|
|
public_key TEXT NOT NULL,
|
||
|
|
created_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
-- Deleting a row IS the revocation for that (vault, recipient) pair.
|
||
|
|
CREATE TABLE IF NOT EXISTS vault_grants (
|
||
|
|
vault_key TEXT NOT NULL,
|
||
|
|
recipient_id TEXT NOT NULL,
|
||
|
|
wrapped_key TEXT NOT NULL, -- base64 crypto_box_seal(vault_symmetric_key, recipient_pubkey)
|
||
|
|
granted_at INTEGER NOT NULL,
|
||
|
|
PRIMARY KEY (vault_key, recipient_id)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS access_log (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
vault_key TEXT NOT NULL,
|
||
|
|
recipient_id TEXT NOT NULL,
|
||
|
|
action TEXT NOT NULL,
|
||
|
|
accessed_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
`);
|
||
|
|
|
||
|
|
export interface VaultRow {
|
||
|
|
vault_key: string;
|
||
|
|
ciphertext: Buffer;
|
||
|
|
nonce: Buffer;
|
||
|
|
updated_at: number;
|
||
|
|
updated_by: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RecipientRow {
|
||
|
|
recipient_id: string;
|
||
|
|
label: string;
|
||
|
|
public_key: string;
|
||
|
|
created_at: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface GrantRow {
|
||
|
|
vault_key: string;
|
||
|
|
recipient_id: string;
|
||
|
|
wrapped_key: string;
|
||
|
|
granted_at: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getVault(vaultKey: string): VaultRow | undefined {
|
||
|
|
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function upsertVault(vaultKey: string, ciphertext: Buffer, nonce: Buffer, updatedBy: string): void {
|
||
|
|
db.prepare(
|
||
|
|
`INSERT INTO vaults (vault_key, ciphertext, nonce, updated_at, updated_by)
|
||
|
|
VALUES (?, ?, ?, ?, ?)
|
||
|
|
ON CONFLICT(vault_key) DO UPDATE SET
|
||
|
|
ciphertext = excluded.ciphertext, nonce = excluded.nonce,
|
||
|
|
updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
||
|
|
).run(vaultKey, ciphertext, nonce, Math.floor(Date.now() / 1000), updatedBy);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getRecipient(recipientId: string): RecipientRow | undefined {
|
||
|
|
return db.prepare(`SELECT * FROM recipients WHERE recipient_id = ?`).get(recipientId) as RecipientRow | undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function listRecipients(): RecipientRow[] {
|
||
|
|
return db.prepare(`SELECT * FROM recipients ORDER BY created_at ASC`).all() as RecipientRow[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export function createRecipient(recipientId: string, label: string, publicKey: string): void {
|
||
|
|
db.prepare(
|
||
|
|
`INSERT INTO recipients (recipient_id, label, public_key, created_at) VALUES (?, ?, ?, ?)`,
|
||
|
|
).run(recipientId, label, publicKey, Math.floor(Date.now() / 1000));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function deleteRecipient(recipientId: string): void {
|
||
|
|
const tx = db.transaction(() => {
|
||
|
|
db.prepare(`DELETE FROM recipients WHERE recipient_id = ?`).run(recipientId);
|
||
|
|
db.prepare(`DELETE FROM vault_grants WHERE recipient_id = ?`).run(recipientId);
|
||
|
|
});
|
||
|
|
tx();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function hasGrant(vaultKey: string, recipientId: string): boolean {
|
||
|
|
const row = db.prepare(
|
||
|
|
`SELECT 1 FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`,
|
||
|
|
).get(vaultKey, recipientId);
|
||
|
|
return row != null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getGrant(vaultKey: string, recipientId: string): GrantRow | undefined {
|
||
|
|
return db.prepare(
|
||
|
|
`SELECT * FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`,
|
||
|
|
).get(vaultKey, recipientId) as GrantRow | undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function listGrantsForVault(vaultKey: string): GrantRow[] {
|
||
|
|
return db.prepare(`SELECT * FROM vault_grants WHERE vault_key = ?`).all(vaultKey) as GrantRow[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export function setGrant(vaultKey: string, recipientId: string, wrappedKeyB64: string): void {
|
||
|
|
db.prepare(
|
||
|
|
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at)
|
||
|
|
VALUES (?, ?, ?, ?)
|
||
|
|
ON CONFLICT(vault_key, recipient_id) DO UPDATE SET wrapped_key = excluded.wrapped_key, granted_at = excluded.granted_at`,
|
||
|
|
).run(vaultKey, recipientId, wrappedKeyB64, Math.floor(Date.now() / 1000));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Used by `push` (rotation): replaces every wrapped-key row for a vault
|
||
|
|
// in one transaction, so a concurrent grant/revoke can't interleave with
|
||
|
|
// a partial rewrap and leave the vault in a mixed old/new-key state.
|
||
|
|
export function replaceAllGrantsForVault(vaultKey: string, wrappedByRecipientId: Map<string, string>): void {
|
||
|
|
const tx = db.transaction(() => {
|
||
|
|
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ?`).run(vaultKey);
|
||
|
|
const insert = db.prepare(
|
||
|
|
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at) VALUES (?, ?, ?, ?)`,
|
||
|
|
);
|
||
|
|
const now = Math.floor(Date.now() / 1000);
|
||
|
|
for (const [recipientId, wrappedKeyB64] of wrappedByRecipientId) {
|
||
|
|
insert.run(vaultKey, recipientId, wrappedKeyB64, now);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
tx();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function deleteGrant(vaultKey: string, recipientId: string): void {
|
||
|
|
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
|
||
|
|
db.prepare(
|
||
|
|
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
|
||
|
|
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: string; action: string; accessed_at: number }[] {
|
||
|
|
return db.prepare(
|
||
|
|
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
|
||
|
|
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
|
||
|
|
}
|