vault: a lock that lets go, and seals that say what they broke

The idle timeout was checked only when the key was next asked for, so a
vault nobody touched kept its key in memory indefinitely while status
reported it locked. A ticker now drops the key once it is idle, and
status() expires it too. Vault passwords are Zeroizing from the request
body to Argon2.

Re-sealing a file rewrote links with a case-sensitive replace, and only
in documents linked through node_media. Links are now matched by regex,
whatever their case, in every document and card body that names the id.
A folder re-seal carries on past a file that fails and reports how many
failed. Two re-seals of one file no longer both commit.

:share reports how many sealed files the document embeds, since a share
link never serves them, and :encrypt reports how many shared documents
embed what it just sealed. Vault status counts sealed files, so :export!
asks for the password first rather than navigating into a 423 page. A
sealed embed that fails to load while the vault is shut becomes a button
that unlocks it and puts the embed back.
This commit is contained in:
nvrl committed 2026-10-20 01:43:45 +02:00
1 parent aa34be8663
commit 336df03ee2
11 files changed
+248 -36

No files matched your search

+45
View File
@@ -40,6 +40,38 @@
}
})
/*
* A sealed file embedded here will not load while the vault is shut: the
* server answers 423 and the browser shows a broken image. Errors do not
* bubble, so they are caught on the way down, and the broken embed is
* swapped for a button that opens the vault and puts it back.
*/
$effect(() => {
const root = article
if (!root) return
const sealed = async (event: Event) => {
const element = event.target as HTMLElement
const media = element.closest('img, video, audio') as HTMLImageElement | HTMLMediaElement | null
if (!media || !(media.getAttribute('src') ?? media.querySelector('source')?.getAttribute('src') ?? '').includes('/api/media/')) return
await workspace.loadVault()
if (!workspace.vault?.configured || workspace.vault.unlocked || !media.isConnected) return
const button = document.createElement('button')
button.className = 'sealed-embed'
button.textContent = 'sealed — unlock to show'
button.onclick = async (click) => {
click.preventDefault()
click.stopPropagation()
if (!(await workspace.ensureUnlocked())) return
button.replaceWith(media)
if (media instanceof HTMLImageElement) media.src = media.src
else media.load()
}
media.replaceWith(button)
}
root.addEventListener('error', sealed, true)
return () => root.removeEventListener('error', sealed, true)
})
/**
* `/n/<space>/<slug>` links — the ones the importer writes between imported
* documents — are followed in place rather than reloading the application.
@@ -152,6 +184,19 @@
</section>
<style>
.pane :global(.sealed-embed) {
display: block;
margin: 0.5rem 0;
padding: 1.5rem;
width: 100%;
border: 1px dashed currentColor;
background: none;
color: inherit;
opacity: 0.7;
font: inherit;
cursor: pointer;
}
.pane {
display: flex;
flex-direction: column;
+6 -2
View File
@@ -145,6 +145,8 @@ export interface VaultStatus {
unlocked: boolean
/** Seconds until it locks itself if nothing uses it. */
locks_in: number | null
/** How many files are sealed: what an export needs the vault open for. */
sealed: number
}
/** The Proton Drive mirror, as `/api/backup` reports it. */
@@ -261,7 +263,9 @@ export const api = {
/** Publishes the document, or mints a fresh key for one already published. */
share: (id: number) =>
request<{ token: string; key: string; path: string }>(`/nodes/${id}/share`, { method: 'POST' }),
request<{ token: string; key: string; path: string; sealed: number }>(`/nodes/${id}/share`, {
method: 'POST',
}),
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
@@ -351,7 +355,7 @@ export const api = {
/** 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'}`, {
request<{ files: number; unchanged: number; failed: number; shared: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
method: 'POST',
}),
+10 -3
View File
@@ -1,6 +1,6 @@
import { api, isContainer, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { formatDay, parseDay, parseDue, type ScrollAmount } from './util'
import { formatDay, parseDay, parseDue, sealedNote, type ScrollAmount } from './util'
export type { ScrollAmount }
@@ -596,8 +596,8 @@ export const commands: CommandSpec[] = [
if (!id) return workspace.say('nothing selected', 'error')
if (!node?.shared) return workspace.say('not shared — :share first', 'error')
await workspace.guard(async () => {
const url = await workspace.mintShareLink(id)
workspace.say(`new link → ${url} (copied) — the previous one is dead`)
const { url, sealed } = await workspace.mintShareLink(id)
workspace.say(`new link → ${url} (copied) — the previous one is dead${sealedNote(sealed)}`)
})
},
},
@@ -636,6 +636,13 @@ export const commands: CommandSpec[] = [
// A plain navigation, so the browser streams the archive straight to
// disk instead of the tab holding all of it in memory first.
await workspace.flush()
// Sealed files go into the archive opened, which needs the vault:
// asked here, because a refusal after the navigation would replace
// the whole app with an error page.
await workspace.loadVault()
if ((workspace.vault?.sealed ?? 0) > 0 && !(await workspace.ensureUnlocked())) {
return workspace.say('the export holds sealed files — it needs the vault open', 'error')
}
workspace.say('building the archive — the download starts on its own')
location.href = '/api/export'
},
+13 -7
View File
@@ -13,7 +13,7 @@ import {
type SpaceOverview,
type VaultStatus,
} from './api'
import { formatBytes } from './util'
import { formatBytes, sealedNote } from './util'
export type Pane = 'list' | 'document'
/** The tree of documents, or the board of the space being browsed. */
@@ -242,10 +242,16 @@ class Workspace {
/** Seal what is under the cursor, or take it back out, and show the result. */
async seal(id: number, encrypt: boolean) {
const { files, unchanged } = await api.seal(id, encrypt)
const { files, unchanged, failed, shared } = await api.seal(id, encrypt)
const verb = encrypt ? 'encrypted' : 'decrypted'
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
this.say(`${verb} ${files} file${files === 1 ? '' : 's'}${already}`)
const broken = failed > 0 ? `, ${failed} could not be` : ''
const readers =
shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : ''
this.say(
`${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${readers}`,
failed > 0 || shared > 0 ? 'error' : undefined,
)
await this.refresh()
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id)
}
@@ -1096,8 +1102,8 @@ class Workspace {
await api.unshare(id)
this.say(`#${id} is private again`)
} else {
const url = await this.mintShareLink(id)
this.say(`shared → ${url} (copied)`)
const { url, sealed } = await this.mintShareLink(id)
this.say(`shared → ${url} (copied)${sealedNote(sealed)}`, sealed > 0 ? 'error' : undefined)
}
if (this.open?.id === id) this.open = await api.getNode(id)
await this.refresh()
@@ -1110,10 +1116,10 @@ class Workspace {
* retires the old one.
*/
async mintShareLink(id: number) {
const { path } = await api.share(id)
const { path, sealed } = await api.share(id)
const url = `${location.origin}${path}`
await this.copy(url)
return url
return { url, sealed }
}
async copy(text: string) {
+4
View File
@@ -130,3 +130,7 @@ export function scrollPage(el: HTMLElement, amount: ScrollAmount) {
el.scrollBy({ top: amount * line * 3 })
}
}
/** What a share link's reader will miss: a share never serves a sealed file. */
export const sealedNote = (sealed: number) =>
sealed > 0 ? ` — ${sealed} sealed file${sealed === 1 ? '' : 's'} in it stay hidden from readers` : ''