rumb
← logbook

A vault, and the rest of the shell

The half that had not moved

For most of this year the logbook has been about tramuntana, because that is where the work was. The shell stopped at the tab groups of v0.2.0, on 2026-06-29, and stayed there while the engine learned to play video and to hand its decisions to the embedder.

Between then and 2026-10-19 the shell took 71 commits. One of them alone is 17 720 lines: a password vault with passkeys, one-time codes and an SSH agent, temporary profiles, bookmarks, and layouts of several tabs at once. This entry is about those, and mostly about the vault, because it is the part where getting it wrong costs the most.

A vault that opens one item at a time

The vault is an encrypted inro database in $XDG_DATA_HOME/rumb/vault.inro, opened with a master password that is never stored. Argon2id stretches the password, with the salt and cost kept beside the database in vault.kdf, into a 32-byte master key.

Nothing is encrypted with that key. HKDF derives from it one key for the pages of the database and one key per kind of item, and the master key is wiped as soon as those exist. Each item is then sealed on its own, under a random key that belongs to it alone and is stored wrapped under the key of its kind. The id and kind of the item are authenticated with both, so a sealed secret does not open as any other item.

What follows is the point of the design. Listing the vault reads what each item is for — site, username, key name — and decrypts no secret. Vault::secret opens one item, and there is no call that opens them all. The key of one kind opens nothing of another kind. An unlocked vault holds the per-kind keys and no secret in the clear, which bounds what a bug or a read of the process's memory exposes.

It does not bound what the master password exposes: whoever has it opens every item. The box that asks for it is drawn by the shell's own chrome, never by a page, and what is typed goes into buffers of a fixed size that are wiped when dropped — a String that grows leaves its old copy behind, unwiped.

Filling, and where it refuses to

A login is offered only on https, or on http to this machine. It is typed into the first visible password field of the page and the text field before it, but only if the page is still at the site the offer was made for: a navigation that lands in between gets no password. The script runs in the top document only, so a frame from somewhere else is never filled. One-time codes go the same way, into the field the user is in or the one the page marks as taking a code.

The add box has a password generator — length, kinds of character, no lookalikes, excluded characters, unbiased draws from getrandom — and every password gets a verdict on how hard it is to guess. The verdict is an estimate made on the spot, and it asks no one.

Even the icons say something. The vault shows each item with the icon of its site, and those icons are kept inside the vault and served to its page from rumb://vault/icon with a token. A folder of them would be the list of sites in the clear.

Passkeys, bound by the shell

A passkey is an ES256 key pair made for one site, the relying party. The private key is a vault item of its own kind, sealed like the rest; the site keeps the public key and sends a challenge to sign. There is nothing to type and nothing a look-alike site can ask for, as long as one rule holds: a passkey signs only for the relying party it was made for.

The whole protection is in deciding which relying parties a page may speak for, and from what. The authenticator takes the page's origin as an argument, and the shell gives it the origin Servo knows for the document — the delegate the engine gained earlier this autumn — never one the page states.

FOUR CHECKS, ON AN ORIGIN THE PAGE CANNOT STATE origin from Servo + rp id asked https, or http localhost a domain, not an address rp id is host or its parent registrable (public suffix) refused: the page learns only that it was All four pass: the shell's own box names the site by that relying party and origin, and asks the user. example.org may ask for example.org · login.example.org may ask for example.org · nobody may ask for co.uk

From that origin, relying_party works out the rest. The scheme must be https, or http to localhost. The host must be a domain name, because an address has no parent to share a passkey with and no certificate to prove it. The relying party id the page asks for must be the host itself or a parent of it, and registrable by the public suffix list Servo carries, so no page can claim co.uk. A page that fails any of this is refused, and learns only that it was.

The box that asks the user names the site by that relying party and origin, never by a name the page chose. Saying yes there is the user-present flag; the vault being open, which took the master password, is user-verified.

Codes and keys

The vault keeps the seeds of time-based one-time codes, the second factor most sites call an authenticator app. It takes what the site hands out — a base32 seed in any case, with or without spaces and dashes, or an otpauth:// link — and makes the code of the moment by RFC 6238.

And it keeps SSH keys. rumb runs an SSH agent over them: ssh, git and torii reach it through SSH_AUTH_SOCK and have rumb sign for them. The keys are ed25519. The private half never leaves the vault: a signature opens that one key, signs and wipes it. While the vault is locked the agent has no keys and signs nothing.

Of the agent protocol it speaks what ssh needs: listing keys and signing. Everything else is refused, adding and removing keys among it, which is done in rumb://vault. Like any SSH agent, it answers every program of this user that can reach the socket; the socket is in $XDG_RUNTIME_DIR and readable by the user alone, and that is the whole of the boundary.

Getting in, and getting back

A vault nobody can fill is a vault nobody uses, so logins come in from a CSV export, from Chromium browsers — their Login Data, with the key kept in the desktop keyring — and from Firefox profiles, through logins.json and key4.db, AES-256-CBC or 3DES. An import never writes over a login the vault already has.

Two things are kept outside the vault for the day it does not open, and they are told apart because they fail apart. A recovery key is the master key itself, written to a file. It opens the vault with no password and no vault.kdf, so it is for a forgotten password or a lost salt, and it is as secret as the password. Setting a new password wraps that same master key again: nothing in the database is encrypted twice, and the recovery key goes on working.

A backup is vault.kdf and the database as they are on disk, in one file. It is ciphertext, opens only with the password or the recovery key, and is only put back where there is no vault. Unlike history and bookmarks, nothing about the vault is best-effort: a vault that cannot be read is an error the user sees, never an empty vault, because an empty vault invites saving over the real one.

Profiles, bookmarks and layouts

A temporary profile lives in memory, on the temporary sessions tramuntana gained, and is gone when it ends: its name, its settings and what it visited are never written to disk. One can be granted the vault for the session or until a date, for everything or for chosen categories. Inside the grant it can fill, but not copy or show a password, and it cannot create a passkey. Up to twelve named presets wait in the main profile's settings.

Bookmarks are one list for the whole process, in folders one level deep, with a star in the bar and a rumb://bookmarks page. History keeps one entry per address, with the times of the visits before the last. And the start page's icons are cached only for the sites on it, because an icon for every site visited would be a second history, one that clearing the history leaves behind.

A layout shows several tabs at once, as a tree of splits: any panel can be split again, side by side or one above the other, at a ratio the user drags. Around them: Lucide icons in place of glyphs that depended on the installed fonts, a scrollbar the shell draws because Servo paints none, and the memory of each tab when the pointer rests on it.

Underneath, and what is left

A script that reads screen.width waits with the page stopped for the shell's answer, and the shell only answered once a frame, so each read cost 16.67 ms. It now runs Servo's event loop when Servo asks, and a YouTube video change reaches playing in 1.35 s instead of 3.0 s. SpiderMonkey now collects after 8 MB of malloc rather than 38 MB, which left a tab playing video about 60 MB smaller at the same CPU. And SIGTERM — logout, shutdown — closes the windows the way the close button does, so the engine shuts down cleanly.

RUMB_MULTIPROCESS=1 runs each page's script and layout in a child process, and RUMB_SANDBOX=1 puts those children in a Landlock and seccomp sandbox. Both are off by default until site isolation works, and until then the vault shares a process with the pages.

That is the honest limit of everything above. The vault bounds what a bug exposes, not what the master password does, and inro keeps its copy of the page key until the database closes and does not wipe it then. Next in the backlog: downloads with quarantine, a lockdown mode, an RSS manager, collaborative sessions and sync between devices on the local network.

FROM ONE PASSWORD TO ONE SECRET master password Argon2id master key wiped after HKDF salt, cost: vault.kdf HKDF page key (inro) key: logins key: SSH, passkey… item key item key random, wrapped under the key of its kind Listing decrypts no secret. Opening one item unwraps one key.
Listing the vault decrypts no secret; opening one item unwraps one key.