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

+32 -2
View File
@@ -10,6 +10,8 @@ export interface FileInfo {
mime: string
size: number
url: string
/** Sealed in the vault: it opens only while the vault is unlocked. */
encrypted: boolean
}
export interface NodeSummary {
@@ -125,10 +127,19 @@ export interface MediaFile {
size: number
created_at: number
url: string
encrypted: boolean
/** Every document that embeds this file — a file may be used by several. */
node_ids: number[]
}
/** The vault, as `/api/vault` reports it. */
export interface VaultStatus {
configured: boolean
unlocked: boolean
/** Seconds until it locks itself if nothing uses it. */
locks_in: number | null
}
/** The Proton Drive mirror, as `/api/backup` reports it. */
export interface BackupStatus {
configured: boolean
@@ -285,12 +296,12 @@ export const api = {
* Upload files into a space or folder as file nodes. XHR rather than fetch,
* because a gigabyte going up needs to say how far along it is.
*/
putFiles: (parentId: number, files: File[], progress?: (fraction: number) => void) =>
putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) =>
new Promise<WorkspaceNode[]>((resolve, reject) => {
const form = new FormData()
for (const file of files) form.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/nodes/${parentId}/files`)
xhr.open('POST', `/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`)
xhr.responseType = 'json'
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress?.(event.loaded / event.total)
@@ -311,6 +322,25 @@ export const api = {
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
vaultStatus: () => request<VaultStatus>('/vault'),
setupVault: (password: string) =>
request<VaultStatus>('/vault', { method: 'POST', body: body({ password }) }),
changeVaultPassword: (current: string, password: string) =>
request<VaultStatus>('/vault', { method: 'PUT', body: body({ current, password }) }),
unlockVault: (password: string) =>
request<VaultStatus>('/vault/unlock', { method: 'POST', body: body({ password }) }),
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
seal: (id: number, encrypt: boolean) =>
request<{ files: number; unchanged: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
method: 'POST',
}),
backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),