vault: files sealed under a password that is never stored

Argon2id turns the vault password into a key that seals a random master
key; only the sealed copy is kept. Each file gets its own key, sealed
under the master key in its header with its media id, and its bytes are
AES-256-GCM STREAM records, so a swapped, cut or altered file refuses
to open. Uploads with ?encrypt=true are sealed as they stream in.

:vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive
it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on
logout and on restart; while locked, sealed files answer 423, share
links never serve them, and :export! refuses rather than leave them out.

Sealing an existing file rewrites it under a new id and retires the old
one: the mirror deletes the plaintext from Proton Drive outright,
trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from
the readable files/ view. The mirror now compares on-disk sizes, so
sealed files are not re-hashed every run.
This commit is contained in:
nvrl committed 2026-10-19 16:03:59 +02:00
1 parent 364ba61963
commit a641d9b6ba
33 files changed
+1573 -54

No files matched your search

+107 -3
View File
@@ -8,7 +8,7 @@ export type { ScrollAmount }
export const hooks = {
pickFiles: () => {},
/** Files for the folder being browsed, rather than for the document. */
pickTreeFiles: () => {},
pickTreeFiles: (_encrypt: boolean) => {},
pickFolder: () => {},
pickArchive: () => {},
focusEditor: () => {},
@@ -24,7 +24,7 @@ export const hooks = {
}
/** Help groups the command list under these headings, in this order. */
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'view'] as const
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'vault', 'view'] as const
export type Group = (typeof groups)[number]
export interface CommandSpec {
@@ -62,6 +62,29 @@ const findFolder = (target: string) => {
)
}
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
const sealTarget = (encrypt: boolean) =>
workspace.guard(async () => {
const node = workspace.target
if (!node || node.kind === 'document') {
return workspace.say('only files go into the vault — put the cursor on a file or a folder', 'error')
}
if (node.file && node.file.encrypted === encrypt) {
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
}
if (isContainer(node)) {
const verb = encrypt ? 'seal every file in' : 'take every file in'
const tail = encrypt ? 'into the vault' : 'out of the vault'
if (!(await workspace.ask(`${verb} ${node.kind} "${node.name || node.slug}" ${tail}?`))) return
} else if (!encrypt) {
const name = node.name || node.slug
if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return
}
if (!(await workspace.ensureUnlocked())) return
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
await workspace.seal(node.id, encrypt)
})
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
const ago = (at: number | null) => {
if (!at) return 'never'
@@ -622,7 +645,14 @@ export const commands: CommandSpec[] = [
aliases: ['up'],
group: 'editing',
help: 'while writing, attach files at the cursor — otherwise put files into this folder',
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()),
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles(false)),
bang: {
help: 'put files into this folder sealed in the vault — encrypted before they touch the disk',
run: () =>
workspace.guard(async () => {
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
}),
},
},
{
name: 'media',
@@ -753,6 +783,80 @@ export const commands: CommandSpec[] = [
workspace.say('usage: :sshkey [add <name> <key> | rm <name>]', 'error')
}),
},
{
name: 'vault',
group: 'vault',
help: 'whether the vault is open — or, the first time, set its password',
run: () =>
workspace.guard(async () => {
await workspace.loadVault()
const vault = workspace.vault
if (vault?.configured) {
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
return workspace.say(vault.unlocked ? `vault open${left} — :lock closes it` : 'vault locked — :unlock opens it')
}
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
workspace.vault = await api.setupVault(password)
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload! seals new files')
}),
},
{
name: 'unlock',
group: 'vault',
help: 'open the vault with its password, so sealed files can be read',
run: () =>
workspace.guard(async () => {
await workspace.loadVault()
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
await workspace.ensureUnlocked()
}),
},
{
name: 'lock',
group: 'vault',
help: 'close the vault now — it also closes itself when left alone, and on logout',
run: () =>
workspace.guard(async () => {
workspace.vault = await api.lockVault()
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
workspace.say('vault locked')
}),
},
{
name: 'passwd',
group: 'vault',
help: "change the vault's password — no file is rewritten",
run: () =>
workspace.guard(async () => {
const current = await workspace.askSecret('current vault password')
if (current === null) return
const password = await workspace.askSecret('new vault password, 10+ characters')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault = await api.changeVaultPassword(current, password)
workspace.say('vault password changed')
}),
},
{
name: 'encrypt',
aliases: ['seal'],
group: 'vault',
help: 'seal the file under the cursor in the vault — on a folder, every file inside it',
run: () => sealTarget(true),
},
{
name: 'decrypt',
aliases: ['unseal'],
group: 'vault',
help: 'take the file under the cursor back out of the vault — on a folder, every file inside it',
run: () => sealTarget(false),
},
{
name: 'help',
aliases: ['h'],