Skip to content

Keep session passwords in an end-to-end encrypted vault - #111

Merged
Alexgodoroja merged 3 commits into
mainfrom
session-vault
Sep 11, 2026
Merged

Alexgodoroja merged 3 commits into
mainfrom
session-vault

Conversation

@Alexgodoroja

@Alexgodoroja Alexgodoroja commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Why

A session's password lived only in the browser that chose or was told it. Clearing that browser, opening the session on another device, or closing the terminal that printed it could lose the session for good, and a frame that failed to decrypt deleted the only stored copy.

What

Each account gets a session vault: a P-256 key pair whose private half the service stores only encrypted under a random vault key, which is itself wrapped by a 160-bit recovery key shown once and never sent. Session passwords are sealed to the account key (ephemeral ECDH, HKDF-SHA256, AES-256-GCM bound to the session id and recipient), so any browser that unlocks the vault opens them and the service opens none of them.

Service (app/server)

  • 007_account_keys.sql (additive, CREATE TABLE IF NOT EXISTS).
  • GET/POST /api/vault: create-only; reset needs a sign-in within 10 minutes and the current version, applied in one conditional statement.
  • GET /api/account/key for the CLI.
  • POST /api/sessions accepts the CLI's owner_share. It is shape-checked and never fails a registration.
  • Members carry accountKey. Non-owners may store a share only for themselves. Owners see sharedWith.

Web (app/src)

  • Required one-time setup with a confirmed recovery key, and unlock in any new browser.
  • The unlocked key is a non-extractable CryptoKey in IndexedDB. Sign-out locks it.
  • Reset carries running sessions across.
  • Every saved source is tried before the gate, and a proven password goes first.
  • Nothing is deleted on a decryption failure; only an unproven guess is.
  • Sharing seals to vault keys and asks before sealing to a key that changed.
  • Browser-started sessions use 128-bit passwords and are refused on machines that cannot receive one.

CLI (internal/account, cmd/shell)

  • Seals each encrypted session's password to the vault at registration, using the key handed over on the shell login loopback callback. Machines linked earlier pin the key on first use.
  • Refuses a key that changed since it was pinned.

Accounts and sessions from before the vault

Migration is idempotent and runs in each browser until done:

  • Cached passwords and shares sealed to old browser keys move into the vault once.
  • Each step compares with what the vault holds and writes only when that is missing or known wrong. A guess never overwrites a copy that may be right.
  • Old CLIs keep working without sealing. Old web tabs keep working until reloaded.

Security review

A focused review of this diff found nothing exploitable above the 0.8 confidence bar. One hardening point it raised is applied here: a verified cached password is tried before a vault share, since a share is sealed with an ephemeral key and cannot be authenticated. .github/SECURITY.md describes the vault's scope and its trust-on-first-use points.

Verification

  • Go: go vet, gofmt, and go test ./internal/... ./cmd/... pass.
  • App: typecheck and 734 vitest tests pass, including cross-language vectors in both directions: Go-sealed shares open in the browser and browser-sealed shares open in Go.
  • Store conformance and Postgres migration tests pass against Postgres 16.
  • Driven in a real browser against the QA harness:
    • setup
    • reload stays unlocked
    • a new browser asks for the recovery key and rejects a wrong one
    • the fingerprint appears on Account

Deploy order

Migrate first (007 is additive, so the current Worker is unaffected), then deploy the Worker. The CLI half ships with the next CLI release; until then new sessions from older CLIs are saved to the vault the first time they open in a browser.

Since review

Assignees can open what they are assigned.

  • When the owner assigns people from their own browser, they're added to the session's audience and the password is sealed to them right away. A failed seal is retried on the next poll.
  • The assignee list on its own never seals a password. Owners and admins can assign anyone to any session, themselves included, so doing that would let them read sessions nobody shared with them.
  • Someone assigned by an admin, or from another browser, appears on the session page as "assigned but cannot open it yet", with a one-click "Let them open it" for the owner.
  • A second security review caught an earlier version that sealed automatically to any assignee whose key the owner's browser had used before. It's removed.

Fixed from the code review:

  • A password that opened a session is sealed into the vault only when the vault doesn't already hold it, and share changes no longer rebuild an open terminal. Together these ended a reconnect on every poll.
  • Typed passwords are written only after they work, and a guess never replaces a proven password.
  • A reset carries finished sessions across too.
  • Every holder can be shared with again.

Phone: consent and vault buttons no longer collapse, and the recovery key field shows the whole key.

Secrets:

  • gitleaks is clean over every commit, with an allowlist limited to the two test-vector fixtures.
  • No .env or browser state file is in any commit.

A session's password lived only in the browser that chose or was told it,
so clearing that browser, opening the session elsewhere, or closing the
terminal that printed it could lose the session for good. A frame that
failed to decrypt also deleted the only stored copy.

Each account now has a session vault: a P-256 key pair whose private half
the service stores only encrypted under a random vault key, itself wrapped
by a 160-bit recovery key shown once and never sent. Session passwords are
sealed to the account key (ECDH, HKDF-SHA256, AES-256-GCM bound to the
session and recipient), so any browser that unlocks the vault opens them and
the service opens none of them.

- Web: required one-time setup with a confirmed recovery key; unlock in a
  new browser; the unlocked key is a non-extractable CryptoKey in IndexedDB;
  sign-out locks it; reset needs a fresh sign-in and carries running
  sessions across.
- CLI: seals each encrypted session's password to the vault at registration,
  pinning the key the browser hands over at `shell login` (or on first use
  for machines linked earlier) and refusing a key that changed.
- Sharing seals to colleagues' vault keys and asks before sealing to a key
  that changed since it was last used.
- Migration from before the vault is idempotent: cached passwords and shares
  sealed to old browser keys move into the vault once, never overwriting a
  copy that may be right with one that is only a guess.
- A password is no longer deleted on a decryption failure; only an unproven
  guess is, and every saved source is tried before asking.
- Browser-started sessions use 128-bit passwords and are refused on machines
  that cannot receive one.
Alexgodoroja added 2 commits September 11, 2026 14:08
Assigning a session from the owner's browser now seals its password to the
people added, so whoever is responsible opens it without being told the
password. An assignee made elsewhere is sealed to automatically only if this
browser already trusts their key; otherwise the session page offers it to the
owner in one click, so the service's assignee list alone never decides who can
read a session.

From review:
- A password that opened a session is sealed into the vault only when the
  vault does not already hold exactly it, and a changed share no longer
  rebuilds an open terminal. Together these ended a reconnect every poll.
- A typed password is written only once it has opened the session, and a
  guess can never replace a password known to work.
- A vault reset carries finished sessions across too.
- Everyone holding a copy can be shared with again, for copies sealed to an
  old browser key or a vault since reset.
- A proven password is tried before a vault share, which anyone holding the
  public key could have made.

On phones, the consent and vault buttons no longer collapse to the height of
their text, and the recovery key field shows the whole key.

A gitleaks allowlist covers the two fixed test vectors, whose keys exist only
for those tests.
Sealing a session's password to any assignee whose key this browser had used
before let an owner or admin, who can assign anyone to any session including
themselves, read every session of someone who had once shared one with them,
with no action from that person.

The assignee list now never seals a password on its own. The owner assigning
from their own browser records the people as chosen and seals to them; a seal
that fails is retried from that record. Anyone assigned by someone else is
offered to the owner on the session page, in one click.
@Alexgodoroja
Alexgodoroja merged commit 3561390 into main Sep 11, 2026
15 checks passed
@Alexgodoroja
Alexgodoroja deleted the session-vault branch September 11, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant