8000 feat(security): lock a namespace to your passkeys by adeelahmad · Pull Request #110 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
7FFF
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Lock a namespace to your passkeys.** An owner turns a namespace into a vault (Admin › Namespaces › Vault): its
files open only after one of its passkeys unlocks it, for an hour by default (`encryption.vault_minutes`), and its
queued work waits while it's locked. The passkey's WebAuthn PRF secret makes the key; the server keeps nothing that
opens it, and there is no recovery code, so add a second passkey. Owners add and remove passkeys while it's open
(never the last one) and can make it ordinary again. See docs/encryption.md#vaults.
- **Hardening.** Sign-in throttles count each visitor behind the web app (Docker) instead of one bucket for everyone,
so one person's wrong tries can't lock others out and nobody gets unlimited tries. The web app's pages send
nosniff, referrer, permissions and frame policies, and HSTS when reached through Cloudflare. The containers run with
Expand Down
35 changes: 31 additions & 4 deletions docs/encryption.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ Lens encrypts with AES-256-GCM. Each namespace has its own random data key, and
| Wrapper | Where its key comes from | What it allows |
| --- | --- | --- |
| `server` | HKDF-SHA-256 of the server's secret (`ARCHIVE_SECRET_KEY`, or `data_dir/secret.key`) | Background work (transcription, embeddings, search, routines, the assistant) reads the namespace without anyone signed in |
| `passkey:<credential>` | The passkey's WebAuthn PRF output (coming with passkey vaults) | The namespace opens when its owner signs in with that passkey |
| `passkey:<credential>` | HKDF-SHA-256 of the passkey's WebAuthn PRF output for the namespace | A vault opens when one of its owners unlocks it with that passkey |

A namespace whose `server` wrapper is removed is a **vault**. Only one of its passkeys can open it, and its contents
are processed only while it is unlocked. Register more than one passkey for a vault: if every passkey that opens it
is lost, its data can't be recovered by anyone.
A namespace whose `server` wrapper is removed is a **vault** (see [Vaults](#vaults)).

Encrypted files are written in 64 KiB chunks, so audio and video can still seek. The last chunk is marked, so a file
that has been cut short, edited or reordered fails to open instead of reading as something else. Key rotation adds a
Expand Down Expand Up @@ -45,5 +43,34 @@ What isn't encrypted by Lens: the SurrealDB database itself, including transcrip
and semantic search need, and your own folders that Lens scans (it only reads them). Put the data volume on an
encrypted disk (LUKS, FileVault, BitLocker or an encrypted ZFS dataset) to cover these.

## Vaults

A vault is a namespace only its owners' passkeys open: the server keeps nothing that opens its files on its own.
Owners turn one on in Admin › Namespaces › the namespace › **Vault** with "Lock to my passkey".

* **How a passkey opens it.** Unlocking is a passkey prompt that also asks the passkey, through the WebAuthn PRF
extension, for a secret made from a salt for that namespace (`sha256("lens-vault:<id>")`). Only that passkey can
make the same secret again. The browser sends it beside the signed answer, Lens derives the key-encryption key from
it with HKDF-SHA-256, unwraps the data key, and keeps neither. Recent phones, computers, password managers and
security keys support PRF; a passkey that doesn't is refused with a message saying so.
* **Unlocked for a while.** A vault stays open in the API process for `encryption.vault_minutes` (60 by default)
after each unlock, or until an owner presses "Lock now". Locked, its files don't play or download (the API answers
423), its queued work waits (it runs once the vault is unlocked again), and its plain working copies are removed
as soon as no job is using them. Workers running in a separate process
never get the key, so they skip vault jobs.
* **More than one passkey.** While it's open, owners add their passkeys with "Add one of my passkeys". The last
passkey that opens a vault can't be removed, from the vault or from the account, and an admin removing someone's
passkeys (Admin › People) is refused while they are the only way into a vault.
* **No other way in.** There is no recovery code, password or admin override. If every passkey that opens a vault is
lost, its files can't be recovered by anyone. Add a second passkey (a phone or a security key) right away.
* **Its files are always encrypted.** Turning a namespace into a vault encrypts its files that aren't yet (when
`encryption.files` is off), in the background, and new ones are encrypted as they're stored. Files in folders Lens
scans stay as they are.
* **Turning it back.** "Make it ordinary again" (while it's open) wraps the key for the server again and drops the
passkey wrappers. Its files stay encrypted, now with a key the server can open.

A vault covers its files. Its transcripts, search indexes and other details are in the database like every other
namespace's (see above).

Keep `ARCHIVE_SECRET_KEY` (or `secret.key`) safe and backed up separately from the data. Without it, nothing
encrypted with the server key can be read.
4 changes: 3 additions & 1 deletion fastapi_backend/app/api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ def _plain_size(db: Any, cfg: dict[str, Any] | None, path: str | os.PathLike[str
try:
return keyring.plain_size(db, cfg, path)
except keyring.Locked:
raise HTTPException(423, "this namespace is locked; unlock it with your passkey") from None
raise HTTPException(
423, "this namespace is a locked vault: its owners unlock it with a passkey, on its page in Admin, Namespaces"
) from None
except keyring.Damaged:
raise HTTPException(500, "this file is damaged on the server and can't be opened") from None

Expand Down
2 changes: 2 additions & 0 deletions fastapi_backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
templates,
uploads,
users,
vaults,
video,
views,
voice,
Expand Down Expand Up @@ -99,5 +100,6 @@
routines,
sensors,
voice,
vaults,
):
api_router.include_router(module.router)
10 changes: 7 additions & 3 deletions fastapi_backend/app/api/v1/routes/passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,17 @@ def make_signin_link(uid: int, user: AdminWriter, request: Request, db: Db) -> S


@people.delete("/users/{uid}/passkeys")
def drop_passkeys(uid: int, user: AdminWriter, db: Db) -> Ok:
def drop_passkeys(uid: int, user: AdminWriter, db: Db, lose_vaults: bool = False) -> Ok:
"""Remove all of this person's passkeys and end their sessions (a lost or stolen device). Send them a sign-in link
to add a new one. Audited as `user.passkeys_remove`."""
to add a new one. Refused (409) when they are the only way into a vault, unless lose_vaults=true. Audited as
`user.passkeys_remove`."""
if uid == user.id:
raise HTTPException(400, "remove your own passkeys one by one in your account")
if not auth.get_account(db, uid):
raise HTTPException(404, "not found")
n = passkeys.remove_all(db, uid)
try:
n = passkeys.remove_all(db, uid, lose_vaults)
except ValueError as e:
raise HTTPException(409, str(e)) from None
auth.audit(db, user.as_audit(), "user.passkeys_remove", f"account:{uid}", [str(n)])
return Ok()
116 changes: 116 additions & 0 deletions fastapi_backend/app/api/v1/routes/vaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Vaults: namespaces only their people's passkeys open (app/domain/vaults.py, docs/encryption.md#vaults).

Owners make a namespace a vault, add and remove the passkeys that open it, and turn it back; anyone whose passkey opens
it can unlock it for a while, or lock it now. Every step that needs a passkey is two calls, like signing in: options
for the browser (asking for the PRF extension's secret for this vault), then its answer.
"""

from __future__ import annotations

from fastapi import APIRouter, HTTPException, Request

from app.api.deps import Acl, Cfg, CurrentUser, Db, Writer, web_origin
from app.domain import auth, keyring, passkeys, vaults
from app.schemas.auth import PasskeyOptions
from app.schemas.namespaces import VaultAnswer, VaultStart, VaultStatus

router = APIRouter(prefix="/namespaces", tags=["vaults"])


def _signed_in(user: CurrentUser) -> None:
if user.via != "access":
raise HTTPException(403, "sign in with a passkey for this; API tokens and apps can't")


def _space(acl: Acl, name: str, kind: str) -> int:
return acl.namespace(name, "owner") if kind in ("seal", "add") else acl.namespace(name)


def _run(fn, *args):
try:
return fn(*args)
except keyring.Locked:
raise HTTPException(423, "this vault is locked; unlock it with one of its passkeys first") from None
except RuntimeError: # its keys changed meanwhile (keyring._save)
raise HTTPException(409, "the vault changed just now; try again") from None
except (passkeys.PasskeyError, vaults.VaultError, ValueError) as e:
raise HTTPException(400, str(e)) from None
except KeyError:
raise HTTPException(404, "not found") from None


@router.get("/{name}/vault")
def get_vault(name: str, acl: Acl, db: Db) -> VaultStatus:
"""Whether the namespace is a vault, whether it's open now, and which passkeys open it (whose, for owners)."""
sid = acl.namespace(name)
out = vaults.status(db, sid)
try:
acl.need(sid, "owner")
except HTTPException:
out["passkeys"] = [{**p, "account": None, "email": None} for p in out["passkeys"]]
return VaultStatus(**out)


@router.post("/{name}/vault/options")
def vault_options(name: str, body: VaultStart, user: Writer, acl: Acl, request: Request, db: Db) -> PasskeyOptions:
"""Start making the namespace a vault (owners), unlocking it, or adding a passkey to it (owners): options for the
browser, which ask the passkey for this vault's PRF secret."""
_signed_in(user)
sid = _space(acl, name, body.kind)
return PasskeyOptions(**_run(vaults.options, db, web_origin(request), user.id, sid, body.kind))


def _answer(kind: str, name: str, body: VaultAnswer, user: Writer, acl: Acl, db: Db, cfg: dict) -> VaultStatus:
_signed_in(user)
sid = _space(acl, name, kind)
fn = {"seal": vaults.seal, "unlock": vaults.unlock, "add": vaults.add}[kind]
out = _run(fn, db, cfg, user.id, sid, body.flow, body.credential, body.prf)
auth.audit(db, user.as_audit(), f"vault.{kind}", f"space:{sid}")
return VaultStatus(**out)


@router.post("/{name}/vault")
def seal_vault(name: str, body: VaultAnswer, user: Writer, acl: Acl, db: Db, cfg: Cfg) -> VaultStatus:
"""Make the namespace a vault opened by the passkey that answered. From then on only its passkeys open its files;
lose every one and they're gone. Audited as `vault.seal`."""
return _answer("seal", name, body, user, acl, db, cfg)


@router.post("/{name}/vault/unlock")
def unlock_vault(name: str, body: VaultAnswer, user: Writer, acl: Acl, db: Db, cfg: Cfg) -> VaultStatus:
"""Open the vault on this server for encryption.vault_minutes; its waiting work runs. Audited as `vault.unlock`."""
return _answer("unlock", name, body, user, acl, db, cfg)


@router.post("/{name}/vault/passkeys")
def add_vault_passkey(name: str, body: VaultAnswer, user: Writer, acl: Acl, db: Db, cfg: Cfg) -> VaultStatus:
"""Let the passkey that answered open the vault too (it must be open). Audited as `vault.add`."""
return _answer("add", name, body, user, acl, db, cfg)


@router.delete("/{name}/vault/passkeys/{pid}")
def remove_vault_passkey(name: str, pid: str, user: Writer, acl: Acl, db: Db) -> VaultStatus:
"""Stop a passkey opening the vault; never the last one. Audited as `vault.remove`."""
sid = acl.namespace(name, "owner")
out = _run(vaults.remove, db, sid, pid)
auth.audit(db, user.as_audit(), "vault.remove", f"space:{sid}", [pid])
return VaultStatus(**out)


@router.post("/{name}/vault/lock")
def lock_vault(name: str, user: Writer, acl: Acl, db: Db) -> VaultStatus:
"""Close the vault on this server now (owners). Audited as `vault.lock`."""
sid = acl.namespace(name, "owner")
out = _run(vaults.lock, db, sid)
auth.audit(db, user.as_audit(), "vault.lock", f"space:{sid}")
return VaultStatus(**out)


@router.delete("/{name}/vault")
def unseal_vault(name: str, user: Writer, acl: Acl, db: Db, cfg: Cfg) -> VaultStatus:
"""Make the vault an ordinary namespace again, which the server can open (it must be unlocked). Audited as
`vault.unseal`."""
sid = acl.namespace(name, "owner")
out = _run(vaults.unseal, db, cfg, sid)
auth.audit(db, user.as_audit(), "vault.unseal", f"space:{sid}")
return VaultStatus(**out)
6 changes: 4 additions & 2 deletions fastapi_backend/app/domain/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,12 @@ def enqueue_pending(db, space=None, by=None):


def claim(db, worker, can):
locked = keyring.locked_vaults(db) # a vault's work waits until someone unlocks it here
for r in db.rows(
"SELECT record::id(id) AS id, priority, created_at, started_at FROM job WHERE status = 'queued' AND next_step IN $can "
"ORDER BY priority DESC, created_at ASC LIMIT 10",
"SELECT record::id(id) AS id, space, priority, created_at, started_at FROM job WHERE status = 'queued' AND next_step IN $can "
"AND space NOTINSIDE $locked ORDER BY priority DESC, created_at ASC LIMIT 10",
can=sorted(can),
locked=sorted(locked),
):
t = store.now()
try:
Expand Down
Loading
Loading
0