@openparachute/vault 0.1.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/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOTP 2FA + single-use backup codes for the OAuth consent page.
|
|
3
|
+
*
|
|
4
|
+
* Layers on top of the owner password in `owner-auth.ts`: when 2FA is
|
|
5
|
+
* enrolled, consent requires password + (TOTP code OR one backup code).
|
|
6
|
+
*
|
|
7
|
+
* - Secret: 20 random bytes, base32-encoded. Stored as a string in
|
|
8
|
+
* `config.yaml` under `totp_secret`.
|
|
9
|
+
* - TOTP: SHA-1, 6 digits, 30s period. Validation accepts ±1 window
|
|
10
|
+
* (≈90s effective tolerance) to account for clock drift.
|
|
11
|
+
* - Backup codes: 6 codes, 8 characters each (alphanumeric, lowercased).
|
|
12
|
+
* Stored bcrypt-hashed (cost 10). Each code is single-use: on successful
|
|
13
|
+
* verification, its hash is removed from the list.
|
|
14
|
+
*/
|
|
15
|
+
import * as OTPAuth from "otpauth";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
|
|
18
|
+
|
|
19
|
+
const BCRYPT_COST = 10;
|
|
20
|
+
const BACKUP_CODE_COUNT = 6;
|
|
21
|
+
const BACKUP_CODE_LENGTH = 8;
|
|
22
|
+
const TOTP_ISSUER = "Parachute Vault";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// TOTP
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeTotp(secretBase32: string, label: string): OTPAuth.TOTP {
|
|
29
|
+
return new OTPAuth.TOTP({
|
|
30
|
+
issuer: TOTP_ISSUER,
|
|
31
|
+
label,
|
|
32
|
+
algorithm: "SHA1",
|
|
33
|
+
digits: 6,
|
|
34
|
+
period: 30,
|
|
35
|
+
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the stored TOTP secret (base32), or null if 2FA is not enrolled. */
|
|
40
|
+
export function getTotpSecret(): string | null {
|
|
41
|
+
const s = readGlobalConfig().totp_secret;
|
|
42
|
+
if (typeof s !== "string" || s.length === 0) return null;
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hasTotpEnrolled(): boolean {
|
|
47
|
+
return getTotpSecret() !== null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface EnrollmentResult {
|
|
51
|
+
/** Base32-encoded secret (show to user for manual entry). */
|
|
52
|
+
secret: string;
|
|
53
|
+
/** otpauth:// URL (encode as QR code for authenticator apps). */
|
|
54
|
+
otpauthUrl: string;
|
|
55
|
+
/** One-time backup codes in plaintext. Show once; never retrievable. */
|
|
56
|
+
backupCodes: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate a fresh TOTP secret + backup codes and persist them.
|
|
61
|
+
* Overwrites any existing enrollment.
|
|
62
|
+
*/
|
|
63
|
+
export async function enrollTotp(label = "owner"): Promise<EnrollmentResult> {
|
|
64
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
65
|
+
const totp = makeTotp(secret, label);
|
|
66
|
+
const { codes, hashes } = await generateBackupCodes();
|
|
67
|
+
|
|
68
|
+
const config = readGlobalConfig();
|
|
69
|
+
config.totp_secret = secret;
|
|
70
|
+
config.backup_codes = hashes;
|
|
71
|
+
writeGlobalConfig(config);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
secret,
|
|
75
|
+
otpauthUrl: totp.toString(),
|
|
76
|
+
backupCodes: codes,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Remove the TOTP enrollment and all backup codes. */
|
|
81
|
+
export function disableTotp(): void {
|
|
82
|
+
const config = readGlobalConfig();
|
|
83
|
+
delete config.totp_secret;
|
|
84
|
+
delete config.backup_codes;
|
|
85
|
+
writeGlobalConfig(config);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* In-memory cache of recently-used TOTP codes to prevent replay within
|
|
90
|
+
* the ±1 acceptance window. Key = "secret:counter"; value = expiry timestamp.
|
|
91
|
+
* Bounded: entries auto-expire ~2 minutes after the code's window closes.
|
|
92
|
+
*/
|
|
93
|
+
const usedTotpCounters = new Map<string, number>();
|
|
94
|
+
|
|
95
|
+
/** Drop entries whose window has passed. Called on every verify. */
|
|
96
|
+
function gcUsedTotp(now: number): void {
|
|
97
|
+
for (const [k, exp] of usedTotpCounters) {
|
|
98
|
+
if (exp < now) usedTotpCounters.delete(k);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Verify a TOTP code against the given secret.
|
|
104
|
+
* Accepts ±1 window (prev/current/next 30s period). A given (secret, counter)
|
|
105
|
+
* is single-use within its acceptance lifetime — replays are rejected.
|
|
106
|
+
*
|
|
107
|
+
* `markUsed`: set false in tests that want to verify the same code twice.
|
|
108
|
+
* Defaults to true in production.
|
|
109
|
+
*/
|
|
110
|
+
export function verifyTotpCode(secretBase32: string, code: string, markUsed = true): boolean {
|
|
111
|
+
const trimmed = code.trim();
|
|
112
|
+
if (!/^\d{6}$/.test(trimmed)) return false;
|
|
113
|
+
try {
|
|
114
|
+
const totp = makeTotp(secretBase32, "owner");
|
|
115
|
+
const delta = totp.validate({ token: trimmed, window: 1 });
|
|
116
|
+
if (delta === null) return false;
|
|
117
|
+
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
gcUsedTotp(now);
|
|
120
|
+
const counter = Math.floor(now / 30_000) + delta;
|
|
121
|
+
// Hash the secret so the in-memory replay cache never holds the plaintext
|
|
122
|
+
// TOTP secret as a map key (defense in depth against heap dumps / logs).
|
|
123
|
+
const secretHash = createHash("sha256").update(secretBase32).digest("hex");
|
|
124
|
+
const key = `${secretHash}:${counter}`;
|
|
125
|
+
if (usedTotpCounters.has(key)) return false;
|
|
126
|
+
if (markUsed) {
|
|
127
|
+
// Expire the entry a bit after the outer edge of the acceptance window.
|
|
128
|
+
usedTotpCounters.set(key, now + 120_000);
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Test-only: reset the replay-protection cache. */
|
|
137
|
+
export function _resetTotpReplayCache(): void {
|
|
138
|
+
usedTotpCounters.clear();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Backup codes
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function randomBackupCode(): string {
|
|
146
|
+
// Lowercase alphanumeric minus ambiguous (0,o,1,l). Read-aloud friendly.
|
|
147
|
+
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
|
148
|
+
const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
|
|
149
|
+
let out = "";
|
|
150
|
+
for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
|
|
151
|
+
out += alphabet[bytes[i] % alphabet.length];
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Generate a fresh set of backup codes + their bcrypt hashes. */
|
|
157
|
+
export async function generateBackupCodes(): Promise<{ codes: string[]; hashes: string[] }> {
|
|
158
|
+
const codes: string[] = [];
|
|
159
|
+
const hashes: string[] = [];
|
|
160
|
+
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
|
161
|
+
const code = randomBackupCode();
|
|
162
|
+
codes.push(code);
|
|
163
|
+
hashes.push(await Bun.password.hash(code, { algorithm: "bcrypt", cost: BCRYPT_COST }));
|
|
164
|
+
}
|
|
165
|
+
return { codes, hashes };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Rotate: replace stored backup codes with a fresh set. Returns the plaintext codes. */
|
|
169
|
+
export async function regenerateBackupCodes(): Promise<string[]> {
|
|
170
|
+
const { codes, hashes } = await generateBackupCodes();
|
|
171
|
+
const config = readGlobalConfig();
|
|
172
|
+
config.backup_codes = hashes;
|
|
173
|
+
writeGlobalConfig(config);
|
|
174
|
+
return codes;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getBackupCodeCount(): number {
|
|
178
|
+
return readGlobalConfig().backup_codes?.length ?? 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Serialize backup-code verification so two concurrent consent POSTs can't
|
|
183
|
+
* both consume the same code (TOCTOU between verify-await and config write).
|
|
184
|
+
* Bun is single-threaded but bcrypt verify yields the event loop.
|
|
185
|
+
*/
|
|
186
|
+
let backupCodeMutex: Promise<unknown> = Promise.resolve();
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Verify a backup code; if it matches, consume it (remove from the stored
|
|
190
|
+
* list) and return true. Single-use, and safe against concurrent requests.
|
|
191
|
+
*/
|
|
192
|
+
export async function verifyAndConsumeBackupCode(code: string): Promise<boolean> {
|
|
193
|
+
const normalized = code.trim().toLowerCase().replace(/\s+/g, "");
|
|
194
|
+
if (!normalized) return false;
|
|
195
|
+
|
|
196
|
+
// Chain behind any in-flight verification.
|
|
197
|
+
const run = backupCodeMutex.then(() => doVerifyAndConsume(normalized));
|
|
198
|
+
backupCodeMutex = run.catch(() => {}); // keep chain alive even if one throws
|
|
199
|
+
return run;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function doVerifyAndConsume(normalized: string): Promise<boolean> {
|
|
203
|
+
// Re-read hashes at the start of this critical section so we see consumes
|
|
204
|
+
// from prior mutex holders.
|
|
205
|
+
const config = readGlobalConfig();
|
|
206
|
+
const hashes = config.backup_codes;
|
|
207
|
+
if (!hashes || hashes.length === 0) return false;
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
210
|
+
try {
|
|
211
|
+
if (await Bun.password.verify(normalized, hashes[i])) {
|
|
212
|
+
// Consume: splice from the snapshot we verified against and persist.
|
|
213
|
+
config.backup_codes = hashes.filter((_, j) => j !== i);
|
|
214
|
+
writeGlobalConfig(config);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// Corrupt hash — skip.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault store management — opens and caches per-vault SQLite stores.
|
|
3
|
+
*
|
|
4
|
+
* BunStore is an alias for SqliteStore from core. They share the same
|
|
5
|
+
* implementation to avoid duplicating wikilink hooks and other behavior.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
9
|
+
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
10
|
+
import { openVaultDb } from "./db.ts";
|
|
11
|
+
|
|
12
|
+
export { SqliteStore as BunStore };
|
|
13
|
+
export { defaultHookRegistry };
|
|
14
|
+
|
|
15
|
+
/** Cache of open vault stores. */
|
|
16
|
+
const stores = new Map<string, SqliteStore>();
|
|
17
|
+
|
|
18
|
+
/** Reverse lookup: store instance → vault name. Used by hooks that need to
|
|
19
|
+
* resolve the vault's assets directory from the store handle. */
|
|
20
|
+
const storeToVault = new WeakMap<SqliteStore, string>();
|
|
21
|
+
|
|
22
|
+
/** Get or create a store for a vault. */
|
|
23
|
+
export function getVaultStore(name: string): SqliteStore {
|
|
24
|
+
let store = stores.get(name);
|
|
25
|
+
if (!store) {
|
|
26
|
+
const db = openVaultDb(name);
|
|
27
|
+
// Share the process-wide hook registry so features can register
|
|
28
|
+
// handlers once at startup and have them fire for every vault.
|
|
29
|
+
store = new SqliteStore(db, { hooks: defaultHookRegistry });
|
|
30
|
+
stores.set(name, store);
|
|
31
|
+
storeToVault.set(store, name);
|
|
32
|
+
}
|
|
33
|
+
return store;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Look up the vault name for a previously-opened store. */
|
|
37
|
+
export function getVaultNameForStore(store: SqliteStore): string | undefined {
|
|
38
|
+
return storeToVault.get(store);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Close all open stores. */
|
|
42
|
+
export function closeAllStores(): void {
|
|
43
|
+
for (const [, store] of stores) {
|
|
44
|
+
store.db.close();
|
|
45
|
+
}
|
|
46
|
+
stores.clear();
|
|
47
|
+
}
|