Threat model
docs/THREAT_MODEL.md in the repository. Do not edit. Open the source file.This document tells who Lasbolt defends against, what it protects, and what is out of scope. GOAL.md has the decisions. docs/DESIGN.md has the mechanisms. If a design change weakens a statement here, change this document in the same commit.
1. Assets
| Asset | Where it lives | Who can read it |
|---|---|---|
| Master password | The user’s head, the client during login | The user only. The server never gets it. |
| Encryption key (from the master password) | Client memory | The user’s clients only |
| User private keys (box, sign) | Server, encrypted with the encryption key, and a second copy (recovery kit) encrypted with the enc key of the recovery code. After a rotation (DESIGN 12.10) the old keys are only where a client or a thief kept them; the chain of signed rotation records is public | The user’s unlocked clients; the holder of the recovery code |
| Server-readable values | Server DB, encrypted with the data key | The running server, the user, agents in scope |
| E2E values | Server DB, encrypted with item keys | Members of the E2E folder and principals with grant keys |
| E2E names and metadata | Server DB, encrypted with meta keys (@mkey, DESIGN 10.18) | Members of the E2E folder, and principals with an item, folder or meta grant |
| Data key, KEK | Server memory (data key), operator (KEK) | The running server |
| Session cookies, device tokens, sessions, scoped keys | Clients, server (hash only) | The holder |
Scoped key string (lasbolt_sk_…) | The user’s CI or agent. Never the server: the server has only the hash of the token part | The holder. It opens the key’s private keys |
| E2E keys in the CLI | Memory of the agent (or of one direct-mode process). Never on disk | The CLI of the user |
enc for Touch ID unlock (DESIGN 11.13) | A data protection keychain item of the macOS app (this Mac only, no iCloud), with Touch ID access control. For a short time: the memory of the app, and the agent socket | The app (team signature and its keychain access group), after a Touch ID match |
| Local store key, in the app (DESIGN 11.15, Touch ID build, opt-in) | A data protection keychain item of the macOS app (this Mac only, no iCloud, no biometry). Memory of the agent while the store is open. For a short time: the memory of the app, and the agent socket | The app (team signature and its keychain access group). The app sends it only to an agent with the code signature of the bundled helper |
| Local store key on Android (DESIGN 13.3) | 32 random bytes made by the app, wrapped with an AES-256-GCM key of the Android Keystore (no user authentication, StrongBox if present, the key never leaves the device). The wrapped blob is a private file of the app. The base64 text of the bytes is the passphrase of the file key store (keys.json, Argon2id). In memory: the app, the agent, and the environment of a login helper command for its time | The app UID. A rooted device, or a debugger on a debuggable build, reads it |
| Grant rows (DESIGN 10.17) | Server DB, sealed to the public key of a session or a scoped key | The principal with that private key |
| Backups | Backup dir, off-site copies | The operator (KEK or backup identity needed) |
| Recovery code (DESIGN 8.1) | Paper or a file of the user, offline. Never the server: it has HashToken of the derived auth key and the kit sealed with the derived enc key | The holder. With the username it opens the account and all E2E data |
| Operator reset token (DESIGN 8.1) | The link that the operator gives the user. The server has only its hash. 1 hour, single use | The holder can set a new master password (new keys; old E2E data stays locked) |
Tab blob and wrap key W (DESIGN 7.4) | Blob: sessionStorage of one tab. W: server memory only, never the database, a log or a backup | The blob alone is useless. W is read by the cookie session that stored it, for at most the idle time (15 min) |
| Invitation link (DESIGN 17.16) | The person who got it. The server has only HashToken of the token | The holder can make one account with the role of the invitation, once, until it ends |
Control socket of lasboltd (DESIGN 17.16) | A Unix socket with mode 0600 on the host of the server | The service user and root on that host |
| Metadata of the vault | Server DB | The running server: entity counts, tree shape, sizes, times, E2E root names |
2. Adversaries and what Lasbolt promises
2.1 Network attacker
- Promise: cannot read or change traffic.
- How: TLS in front of
lasboltd(the operator’s proxy or the server). Cookies are__Host-, Secure, HttpOnly, SameSite=Strict. Tokens are bearer secrets and need TLS. - Limit: the operator must set up TLS. Dev mode on localhost has no TLS.
2.2 Theft of the server disk or a backup (server not running)
- Promise: server-readable data stays encrypted. E2E data stays encrypted.
- How: the data key is wrapped by the KEK. With the passphrase provider, the KEK comes from Argon2id. With the key-file provider, the KEK is a file that must not be on the same disk.
- Limit: with the passphrase provider, the thief can guess the passphrase offline at Argon2id cost. A sealed backup (
--backup-recipient) also needs the offline backup identity. A long random passphrase is the defense.
2.3 Running server compromised, passive (reads memory and DB)
- Promise: E2E data stays secret. The master password and the encryption keys stay secret. The secret of a scoped key stays secret: the server stores only the hash of its token, and the token cannot open the key’s
keys_enc. Grant rows are sealed to the principal. - Lost: all server-readable data, because the data key is in memory. This is by design (GOAL.md: server-readable by default).
- Recovery kit (DESIGN 8.1): the server holds
recovery_keys_encandHashTokenof the auth key of the code. The code has 130 random bits, so neither can be guessed offline. The server never sees the code or its enc key. - Tab unlock (DESIGN 7.4): while a browser tab is unlocked, server memory holds the wrap key
Wof that tab (at most 8 for each session, gone after 15 minutes without use, at log out and at restart). WithWand the sealed blob a reader could open the user keys. The blob is only in the tab (sessionStorage), so memory alone is not enough, and the data key is in memory anyway. A copy of the database or of a backup has noW. The E2E promise of this section is the same as before: a passive server that reads memory and the database, and never sees a tab blob, gets no E2E data.
2.4 Running server compromised, active (changes code or answers)
- Web client: no promise for E2E. The server sends the JavaScript and the WASM, so it can send code that reads E2E data after unlock. This is true for every web-based E2E product.
- CLI and native clients: E2E values stay secret, and the client detects changed or forged E2E ops (signatures, bundle checks, trust rules in DESIGN 4.7). This is the main reason for the CLI (GOAL.md, Phase 3). This promise holds fully for
lasbolt login --password(the CLI derives the keys itself, trust rule 1). - Device-code sessions and scoped keys get their keys from the web app, so they have the web caveat. An active server can serve page code that seals keys of its own choice. In addition, HPKE base mode does not prove who sealed a grant, and the server knows the client’s public key. A session pins the user’s sign key at its first login (DESIGN 10.17), so a later change is detected, but an active server at the first login is not. A scoped key takes the owner’s sign key from its own encrypted bundle, which the server cannot change. Ops of a key count only with a valid cert of the user and only inside the cert scope.
- Recovery: an active server can serve a bad
/recoverpage or kit card that sends the code away (the web caveat). A user who doubts the server must not type the code there. - Limits for all clients:
- Rollback and withholding (DESIGN 12.2, 12.8). Clients that can sign write signed checkpoints of each E2E folder, and every client checks the checkpoints of the server and the checkpoint that it remembers. A client finds that the server hides, changes or adds an op below a checkpoint that it knows, shows the problem
rollbackand refuses writes to that folder (CLI exit 10). Limits:- Forks. A server that shows two devices two different histories, and never forwards a checkpoint of one device to the other, is not found. A user can compare “checkpoint of
, - New device. A device with no memory trusts the checkpoints that the server gives. A server can hide all of them (and the newest ops) from a new device.
- Ops above the newest checkpoint. Changes made after the last checkpoint that a device knows can be hidden from it. The cadence (one each hour, and one after each own write) makes the window short.
- Restore. A server can change
db_epochto look like a restore. Clients then say “the server was restored”, but writes stay off until the user accepts (lasbolt e2e accept-rollback, Accept in the web app with the master password). Accept refuses a folder whose state does not agree with the checkpoints of the server either. A user who accepts a false restore loses the hidden changes from that device’s view. - Web memory. The web app keeps its memory in
localStorage, which the server’s page code can change (the web caveat). - Key rotation (DESIGN 12.10). A scoped key or a pinned session made before a rotation finds the new user key by a forward walk of the signed chain from the old key. A bad server with a leaked old key can show it a forked chain at its first walk. Two records from one old key stop the client (
key-fork); a client with a local store keeps the newest key that it reached. Making the key again closes this. - Purges (DESIGN 12.3, 12.9). A purge of an E2E entity needs a record that the user (or a key with write access) signed. The server checks it in the transaction of the purge and keeps it for ever, and clients accept a missing op below a checkpoint only if a verified record names it. So a bad server cannot “purge” E2E items as an excuse: ops missing without a record are a
rollback. What remains: a server can withhold the records from a new device (it then sees arollbackfor an honest purge: a denial of service, as with checkpoints); a leaked key with write access can sign purges of the items in its scope until it is revoked; the record proves who signed, not that all devices wanted it.
- Forks. A server that shows two devices two different histories, and never forwards a checkpoint of one device to the other, is not found. A user can compare “checkpoint of
- The server can see and change metadata (tree shape, plaintext E2E root names, timing).
- The server can deny service.
- Rollback and withholding (DESIGN 12.2, 12.8). Clients that can sign write signed checkpoints of each E2E folder, and every client checks the checkpoints of the server and the checkpoint that it remembers. A client finds that the server hides, changes or adds an op below a checkpoint that it knows, shows the problem
2.5 Online attacker against login
- Promise: guessing is slow, and a valid password alone is not enough when TOTP is on.
- How: rate limits per IP and per user, TOTP, Argon2id on the client and a hash of the auth key on the server. Prelogin gives fake KDF parameters for unknown usernames.
- Limit: behind a proxy, rate limits per IP need
--trusted-proxy. Without it, all clients share the proxy’s IP. - Recovery is a login path (DESIGN 8.1).
/api/recover/startandfinishtake tokens of the same per-user and per-IP buckets as the login, and give the same 401 for a wrong code and an unknown user./api/recover/idgives a stable fake user ID for an unknown name. The code has 130 random bits, so online guessing is hopeless. A recovery needs no TOTP code (decision of the user): the kit replaces both factors. A new kit needs the auth key of the master password (same limits), so a stolen session cannot plant a kit. - Reset links (
/api/reset/check|finish) take a token of the per-IP limit for each failure. The token has 256 random bits and lives 1 hour.
2.6 Stolen credential
| Credential | Damage | Limit in time | Response |
|---|---|---|---|
| Session cookie | Full web access, no E2E without the master password | Session life | Log out, Settings › Devices |
| Device token | Full API access as the user, no E2E keys | 30 days idle | Revoke in Settings › Devices |
| Device-code session | Its scope, and E2E keys granted to it: folder keys (read), or the user keys (write, admin). The keys live in the agent memory | 1 h token, 24 h session | Revoke; the refresh token is single-use; rotate the user keys (DESIGN 12.10): this ends every session with a grant and gives new items new folder keys |
| Scoped key | Its scope and its grant keys. With write access, it can sign E2E ops in its cert scope | Its expiry (also in its v2 cert, so clients drop its later ops) | Revoke with a signed record; rotate E2E folders it could read |
| Recovery kit (code and username) | The account and all E2E data: the holder sets a new master password. TOTP is not asked | Until the user makes a new kit, rotates the keys or recovers (the code is single use) | Make a new kit at once (the old code stops working), or rotate the keys (a rotation replaces the kit); check Settings › Devices and the audit log |
LASBOLT_NEW_PASSWORD (for lasbolt recover) | The new master password, in the environment of the process: a program of the same user can read it (ps eww on macOS, /proc/<pid>/environ on Linux), and a shell history can keep it if it is typed in a command line | The life of the process, or of the shell history | Use the prompt (the default). Use the variable only for tests and automation, set it for one command, and change the password on a trusted device later |
Kit file of lasbolt recover --kit-file | The recovery code in clear text on disk: the same damage as the recovery kit | Until the file is deleted | Move it to offline storage, delete the file. The file has mode 0600 and is never replaced |
| Operator reset link | A new master password and new keys. No old E2E data (it stays locked for ever) | 1 hour, single use | The user sees the reset in the audit log and loses access; the operator makes a new link for the real user |
enc of the user (from the Touch ID item of the app, or from memory) | With keys_enc (each login of the user can fetch it, and an old copy also works) it opens the private keys of the user: all E2E data, and it can sign as the user. No login: enc does not give the auth key | Until the user keys are rotated (DESIGN 12.10): a rotation derives new master keys with a fresh salt, so the old enc opens only the old keys_enc and the old keys, which the key chain limits to the time before the cutoff | Rotate the keys (Settings › Security); Touch ID asks for the master password once after it |
Session or scoped key with values: meta | Names and metadata in its scope, also of the E2E items it has meta grants for. No secret value and no item key. With write access: metadata fields only | As above | Revoke |
- A principal that has read a value or an E2E key can keep a copy. Revocation stops future access only.
- Signed revocation (DESIGN 10.20). The owner signs a revocation record with a cutoff HLC. Clients drop ops of the key above the cutoff (
revoked-signer) and above the expiry of a v2 cert (expired-signer). So a leaked key secret and a bad server together cannot add new ops after the revocation. Limits:- A client with a local store (the CLI and its agent) also drops a backdated op that it did not hold when it first verified the record (the sticky rule). A client that sees the key’s ops only after the revocation (a new device, the web app) cannot tell a backdated op from a real old one.
- A bad server can withhold the record, as it can withhold ops (2.4).
- A revocation while E2E is locked ends access at once, but the record is signed only at the next unlock on the Keys page (or
lasbolt keys revokeagain afterlasbolt unlock). Until then, clients do not know. - The CLI takes the sticky snapshot only while its E2E keys are unlocked (it must verify the record first). Ops that arrive while it is locked count as held.
- A
LASBOLT_KEYrun keeps nothing on disk, so it applies the cutoffs only, as the web app does. - A write or admin device-code session with a
usergrant signs as the user. Signed revocation cannot stop it. A rotation of the user keys (DESIGN 12.10) does: it ends the session, and clients drop ops of the old key above the cutoff (rotated-signer; the sticky rule also drops backdated ones on clients with a local store). - After a rotation, old items stay readable to a holder of an old folder key, also their later edits: an item key does not change (
@keyis set once). New items use the new folder key. To lock an old key holder out of one item, move it into a new E2E folder, or make it again and purge the old one (DESIGN 10.17, 12.9).
- A device-code session gets
keys_encand the KDF parameters only throughGET /api/v1/account/keys?unlock=1, for the master password unlock on the phone (DESIGN 13.10). A stolen session token can therefore be used to guess the master password offline. Limits: the Argon2id cost, 5 fetches per 10 minutes per user, and an audit row (keys_fetch) with the IP for each fetch. Scoped keys never get them. A device token already had this access. - A refresh token used a second time revokes its session at once. The CLI refreshes under the store lock (or only in the agent), so a normal client never does this by mistake.
2.7 Malicious or tricked agent
- Promise: an agent gets only its scope. With
lasbolt run,lasbolt injectand the MCP tools, secret values do not enter the agent’s context. - Limits:
- “Inject only” and output masking are done by the client. An agent that runs commands as the user can work around them. They reduce accidents; they are not a boundary.
- Prompt injection: notes and metadata are written by other principals. Clients show the author, and the MCP tools mark them as untrusted data. An agent can still be tricked.
- Metadata is untrusted input (DESIGN 10.18). Values have types and length limits, and the server checks every server-readable value. The server cannot check E2E values: clients check them when they decrypt and drop a bad one. The web UI makes links only for
httpandhttpsURLs. A client that uses metadata (for example an auth header for a base URL) must treat it as input from another principal: a badbase_urlcan send a secret to another host. The UI shows the author and the device of each value. metagrants. A principal with only a meta grant gets the meta key of an E2E entity. That key opens the name and the metadata fields only. Clients never use it for another field, also if a writer sealed one with it (core/e2etests this). A meta principal that can write is limited to metadata fields by the server and by the clients (trust rule 2).- Per-use approval (DESIGN 10.21). For server-readable items the server withholds the secret values from sessions and keys and gives one value for each approval. For E2E items it is a boundary only for a principal without the item key (a just-in-time grant). A principal with a folder or user grant can read without asking: the check is in the honest client. A principal can keep a value or a key that it got, and values synced before
@approvewas set. The prompt reaches only an open web app. A user who approves without reading the prompt gives the value. - Fill and copy with approval (DESIGN 11.14). The browser extension and the macOS app ask through the agent, so the prompt names the login of the agent (the session or the key), not the front end. The front end is only in the purpose (“fill on github.com via Lasbolt extension in Chrome”, “copy in the Lasbolt app”), and the purpose comes from the client: any process of the same user can talk to the agent and write any purpose (section 3). Read the item, the field and the host before you approve. Cancel in a client does not take back the request: it stays pending in the web app until its 5 minutes end, and an approve after the Cancel gives the value to the next fill or copy of the same login, item and field. An approval ID is no secret that grants a value: the server gives the value only to the principal that asked.
- Device-code phishing: an attacker can send the user an approval link. The approval page shows the code, the client, the host, the IP and the scope, and says “Did you not start this? Deny it.” The user can lower the access, never raise it. Lasbolt accepts this risk for ease of use, like AWS. The audit log shows each approval and the IP of the client.
2.7a Browser extensions and the native messaging host
- Promise: a web page cannot get a secret from Lasbolt. Only the extension of an allowed ID can start
lasbolt nm-host(the browser enforcesallowed_originsandallowed_extensions). The host relays five ops only (fill.match,fill.get,search,status, andlock, which drops the end-to-end keys and gives no data).fill.matchandsearchgive names, usernames and URLs, never a secret value.fill.getgives only username, password and TOTP of an item whose URL fits the page, checked again in the agent, with scope and the audit as forlasbolt read(the audit names the extension, set by the host). - Limits:
- Any process of the same user can talk to the agent socket and so skip the host (THREAT_MODEL 3). The host adds no boundary there. The extension ID is a check of the browser, not a secret.
- The URL comes from the extension. A hostile or buggy extension build can send any URL, so the extension ID list in the manifest is the control. The agent still refuses a URL that does not fit the item (look-alike hosts,
http, other registrable domains). - An extension user action is needed for a fill in the extension (DESIGN 11.4), but the agent cannot see it. The host cannot tell a click from a script of the extension.
- Matching by registrable domain gives a login to a sibling subdomain of the same site (for example
a.example.comandb.example.com). Hosts that share a public suffix entry (github.io) stay apart.
- In the extension (DESIGN 11.11):
- Only the background talks to the host. The URL that it sends is the URL of the tab from the browser, never a value from a page or a content script. A web page cannot send a message to the extension (no
externally_connectable, noweb_accessible_resources). - The values go to one frame: the top frame, or a frame whose origin and the origin of each frame above it are the origin of the top page. A cross-origin frame, and an opaque frame (
about:blank,srcdoc,data:,blob:), never gets a value. The background checks it with the frame data of the browser, and the content script checks it again. - A fill needs a user action: Fill in the popup, the shortcut, or a pick in the inline menu. The content script acts only on events with
isTrusted, so a page script cannot open the menu or pick an item. - The inline icon and menu are in a closed shadow root. The page sees the element, not its content. The menu has names and usernames only. A secret enters the page only at the fill. The icon shows on each login field, so the page does not learn from it if Lasbolt has a login for the site.
- Approval waits (DESIGN 11.14). The background keeps the approval ID; the popup, the content scripts and the page never see it. A fill after an approval goes only to the tab and the frame of the click that started it, and only if the full tab URL and the document of the frame did not change; a navigation stops the wait. The click is the user action, also when the approval comes minutes later.
- Limits. After the fill, the page (and each script on it) can read the values in the fields: this is true for each password manager. The page sees the host element of the menu and can hide it, cover it, or stop its key events (a denial of service, or a trick to make the user click). A page can learn that the extension is installed. The clipboard is cleared after 30 seconds without a check, so a copy that the user made in that time is cleared too.
- Only the background talks to the host. The URL that it sends is the URL of the tab from the browser, never a value from a page or a content script. A web page cannot send a message to the extension (no
2.7b The macOS app and Touch ID unlock (DESIGN 11.13)
- Promise: the Touch ID item gives
enconly to the Lasbolt app (code of the team with the keychain group<team>.se.pengstrom.lasbolt.keys; the Safari appex and the helper do not have it), only on this Mac, and only after a match of a finger that was enrolled when the item was written (.biometryCurrentSet). The Mac login password does not open it. The app sendsenconly to an agent whose code signature is the bundled helper of the same team (checked with the audit token of the socket peer), so a process that replaces the socket or theagent-pathfile does not get it. No agent op givesencor a private key out: the agent givesenconly in the reply to an unlock that sent the password. - Limits:
- After a Touch ID unlock the agent holds the E2E keys, as after a password unlock. Each process of the same user can then read E2E data through the socket (section 3). Touch ID does not make this wider or narrower.
- A person whose finger was enrolled before the item was written can unlock.
- The app cannot tell who caused the unlock window to show. The prompt names Lasbolt. A user who touches the sensor on a prompt that they did not expect gives the unlock.
- The keychain group needs a free-team provisioning profile, which lives 7 days. After that, the app does not start until it is built again (DESIGN 11.13). This is a limit of use, not of security.
- A Debug build (
make apple) hasget-task-allow, and its helper has no hardened runtime. A debugger of the same user can readencin the app and the keys in the agent. The release build (make apple-release, hardened runtime, noget-task-allow) is for daily use. - When the item is older than 14 days, the app asks for the master password again.
2.7c The local store key in the app (DESIGN 11.15)
Not built: the user accepted the gap (2026-10-17). The promise and limits below describe the design for when it is built, after a caller check in the agent.
- Promise (Touch ID build, when the user turns on “Keep the local store key in the app”): the local store key is only in a data protection keychain item of the app’s group, on this Mac. A program of the same user without that entitlement cannot read, change or delete it, also when no agent runs. The move encrypts the store again with a new key, so a key that a program read from the
securityitem before the move does not open the store after it. The app sends the key only to an agent with the code signature of the bundled helper, and the agent uses a key from any key store only if it opens the check record of the store. No agent op gives out a key that is only in the app. The promise is for a move that the app ran. - Limits:
- While the agent runs, each process of the same user can read the vault through the socket (section 3). The move does not change this.
- A program of the same user can start the app, or run a
lasboltcommand, so that the app unlocks the store. The app does not ask the user first (no biometry, so that a launch at login works). - A program of the same user can delete
state.dbor change itskeyinforow: a denial of service, as today. - The default build cannot have the item (no keychain group without a profile), and the legacy login keychain gave no protection in the probe. The default build keeps the
securitylimit. - When the 7-day profile of the Touch ID build ends, the CLI cannot open its store until the user builds the app again or signs out.
- A Debug build (
make apple) lets a debugger of the same user read the key in the app and in the agent. - A peer of the same user can run the move ops itself before the app does. It then knows the key, and the app shows
bad_key.
2.7d The Android app (DESIGN 13)
- Promise: the app and its helper run in the app sandbox (one UID). The agent socket (
<filesDir>/agent/a.sock, directory mode 0700) is reachable by the app UID only, and the agent checks the peer UID (SO_PEERCRED) as on Linux. Another app cannot connect, read the local store (state.db,keys.json) or the wrapped key. Cloud backup and device transfer are off (allowBackup=false,dataExtractionRulesexclude all), so the store and the wrapped blob never leave the phone (a copy without the Keystore key would be useless, but it is not made). A copied value is marked sensitive and is cleared after 30 s if it is still the clip of the app. The sign-in and the item view useFLAG_SECUREwhen “Block screenshots” is on (the default of a release build; a debug build has it off, so a debug build on a real phone shows values in screenshots and in the recent apps view). - Limits:
- Root, or a debugger on a debuggable build, reads the memory of the helper and the app and the files, and can use the Keystore key through the app. The Keystore key of the local store (DESIGN 13.3) has no user authentication, so an unlocked phone in other hands gives the vault (the signed-in app opens it with no check). It is the same trust as the agent on a desktop (section 3). Biometric unlock (below) adds a check for the E2E keys only: it does not protect the local store.
- The wrapped key and the store belong together. If the Keystore key is definitely lost (entry missing, GCM tag fails, malformed blob), the app cannot open the store: it wipes the local data and asks for a new login. A transient Keystore error wipes nothing: the app shows it with “Try again”. The server data is not affected. An uninstall deletes both.
- The login helper commands get the store passphrase in the environment (
LASBOLT_KEY_PASSPHRASE), because there is no terminal. A process of the same UID can read it from/proc/<pid>/environfor the time of the command. Other apps cannot. The agent gets it with thevault.unlockop and does not keep it. - DNS (DESIGN 13.2). The helper sends plain DNS (UDP port 53) to the servers of the active network, from
$LASBOLT_HOME/dns. Private DNS (DNS over TLS) is not used, so a network operator or a hostile Wi-Fi sees the server name and can answer with a wrong address. Login and sync use TLS with the certificate check, so a wrong address gives a failed connection, not a leak of data. A server over plain http (the “Allow plain http” switch, meant for a VPN) has no such protection. The app writes the file and the helper reads it, both with the app UID. - The agent is a child process of the app. Android can stop it (memory, battery). The app starts it again. The agent holds the E2E keys in memory only, so a stop locks the E2E folders.
- Biometric unlock (DESIGN 13.16). The app keeps
encof the user (it opens the E2E keys, and it can sign as the user, section 2.6) wrapped with an AES-GCM key of the Android Keystore. The key needs a freshBIOMETRIC_STRONGmatch for each use (aCryptoObjectprompt, timeout 0), works only on an unlocked phone, goes invalid when a fingerprint or face is added (setInvalidatedByBiometricEnrollment), and sits in StrongBox when the phone has it. The wrapped blob is in the private files of the app and is not in a backup.- What it defends. A person who holds the unlocked phone, or who holds the phone and knows its screen lock PIN, cannot open the E2E folders with the PIN alone: the PIN does not open this key (only a strong biometric does; there is no screen lock fall back). A person who adds a fingerprint with the PIN makes the key invalid, so they cannot use it either, and the app asks for the master password.
- What it does not defend. A match of the owner is needed for each unlock, but while the folders are open (the agent holds the keys, as on a desktop) a same-UID process reads them. Root or a debugger on a debuggable build can call the Keystore through the app after one match. A fingerprint that is enrolled before the user turns the feature on can unlock it (a person who is already enrolled on the phone is trusted, as for the lock screen). The app clears its byte copy of
encafter the send, but the JVM keeps the hex text until the garbage collector runs. - The 14-day rule. After 14 days without the master password, the item is not used and the app asks for the password, so the user does not forget it. It is a rule of the app only: the clock can be moved back (a creation time in the future counts as old, but a user who moves the clock forward and back can keep the item). It is not a security boundary.
- Only for a password login. A browser sign-in has no
enc(DESIGN 11.13), so it has no biometric unlock.
- Approvals on the phone (DESIGN 13.17). A password login can list and decide approvals (
/api/v1/approvals, device token only; a session or a scoped key gets 403). This gives no new read power: the device token reads every value of the user already. The approve in the app needs the owner check of the phone (a strong biometric or the screen lock), but it has no CryptoObject: it is a gate of the screen. Root, or any process that has the agent socket, can sendapprovals.decidewith no check. A notification has no Approve button, so a tap on the lock screen cannot approve. The notification shows names of items, so the lock screen shows a public text only. A push (FCM) is not built: a request shows while the app process lives (poll every 15 s in front and 60 s in the background, and at each sync). The phone cannot approve an E2E request that needs a sealed key of the browser (DESIGN 10.21); the web app does it. - Autofill (DESIGN 13.4, 13.11). Another app, or a web page in another app, can try to get a password by faking the page address.
Trust in
webDomain. The service readswebDomainonly from a package that is a pinned browser (Chrome, Chrome Beta, Firefox, Firefox Beta, Firefox Nightly) with a pinned signing certificate (SHA-256, checked by the system withhasSigningCertificate). A WebView in another app can setwebDomainto any text, so for any other package the service offers nothing. The pin list comes from Google’s published list of privileged browsers; a new browser needs a digest from a source like that. We trust that the pinned browser setswebDomainfrom the real page address (a flaw in the browser breaks this), and that the signing keys of those vendors stay safe. A debug build also trusts its own package (test only; no release build has it).Fields of different sites. A page that has the user name and the password fields under different domains (frames) gets no fill.
No secret before the pick. A
FillResponsehas names and user names only; every dataset has an authentication intent.fill.getruns only after the user picks a dataset, in an activity that is not exported. That activity takes the URL and the field IDs from the structure that the system gives it (not from its intent) and checks the package against the pin list again. An item with a per-use approval waits for the web app (DESIGN 11.14).Names are visible. The drop-down and the keyboard strip show the item names and user names on the screen (a shoulder-surfing and a screen-recording risk, as a list in the app). After the fill, the browser page holds the value, as if the user had typed it. The app keeps the value in memory only while the pick activity lives.
Save from the browser (DESIGN 13.12). A page that the user fills in goes to the agent as a new login or as a changed password.
- Trust.
onSaveRequestruns the same browser trust check as fill (pinned package and signing certificate) and readswebDomainfrom the same place. An untrusted package gets noSaveInfoand no save. A pinned browser that lies about the page address is the same risk as for fill. The saved item has the domain of the page as its URL, so a wrongwebDomainmakes a login for a wrong site; the value is never sent to another site. - Values go to the agent only. The typed user name and password are in the
SaveRequest(the system holds them for the save sheet, as for every service), then in the service in memory, then initem.createoritem.updateon the agent socket. The service keeps no copy after the call. Deviation: when the save needs the screen (an item of this user name exists, or the agent must start first), the values wait in one process-memory slot (LasboltApp.holdSave). The slot is cleared whenSaveActivitytakes the values, or after 2 minutes. The values are not in the intent, in a file or in a log. The activity is not exported. - The system save sheet is the consent for a new login. A page cannot make a save without the user: the sheet is the system’s.
- No
SaveInfofor a read session or a signed-out app. The agent also refuses the write (outside_scope). - The agent ops.
item.create,item.updateanditem.deleteare not in the allow-list ofnm-hostor of the Safari relay, so a browser extension cannot write. On the socket they need the app UID like all ops. They log the item ID and the caller, never a value. The old password stays in the history of the item, so a wrong update can be restored in the web app.
- Trust.
The rest of the autofill API (DESIGN 13.15).
- Cards. A card has no URL, so
card.matchandcard.getdo not bind a card to a site. Any page of a pinned browser can offer the card items, and a hostile page can show a payment form. The control is the same as for logins: the user must pick a dataset, the pick may need the web approval (11.14), and the page never receives a value before the pick. The difference is that a wrong site is not refused by the agent, so the user must see where the card goes: the dataset subtitle has the host (webDomain), next to the cardholder. A pick with no approval fills at once and shows no further screen. The ops are not in the allow-list ofnm-hostand not in the Safari relay, so an extension cannot call them. A dataset shows the name and the cardholder, never digits. - Strong password.
gen.passwordruns in the agent (pwcrypto). The password goes through the result ofAutofillActivityto the page, as a login does. If the user submits the form without a change, the system may show no save sheet (13.13), and the password exists only in the form; this is a loss risk, not a leak. - One-time codes. The agent makes the code at the pick. A code is good for seconds. The same pick rules apply.
- Response authentication. The activity returns a
FillResponsethat has datasets with their own authentication, so it holds no secret value. A cancelled unlock returns nothing. - Never list and pick history.
NeverSites(hosts) andPickHistory(host, kind, item ID, time) are in the private preferences of the app (no backup). They show which sites the user visits and which items they used, to a process with the app UID or root, like the local store does. The history is cleared at sign-out. “Don’t offer on” is a dataset whose intent opens NeverActivity(not exported); it reads the host from the structure, not from the intent, and asks before it adds the host. - More browsers. Samsung Internet, Brave, Edge, Vivaldi and DuckDuckGo are pinned from the same published list (digests in 13.15). Compatibility mode is not used: the system puts the page address (the text of the URL bar) on the URL-bar node only (
Helper.sanitizeUrlBarin AOSP), not on an ancestor of the form fields, and the text of a URL bar is a weaker source thanwebDomain. The finder offers nothing there. These browsers work only where they call the autofill API themselves.
- Cards. A card has no URL, so
2.7e Pairing a phone (DESIGN 13.9, 13.10)
| Asset or step | Risk | Control |
|---|---|---|
Pairing offer (pair_id) | It is not a credential. Whoever has it can start a device code that appears on the page of the offer’s user | 128-bit ID, only its hash is stored, 10 minutes, single use, 3 open offers per user, rate limits per user and per IP. The user compares the code and approves; only the offer’s user can approve a paired code. A copied QR code can at most show a foreign device code with its name and IP, and the user denies it |
The /pair and /app pages | They are open pages. An attacker can send a person a link to a fake page | The pages hold no secret and no ID in the HTML (the ID is in the fragment, so not in logs or referrers). The script only redirects to the lasbolt:// or intent URL of the same origin. The CSP is unchanged |
Session e2e.unlock with the password | See 2.6: offline guessing of the master password with a stolen session token | Rate limit, audit, Argon2id. Trust rule 1 pins the user sign key |
| E2E grants for a paired phone without the password (DESIGN 7.5) | A script in an unlocked tab, or a person at it, gives a phone the user’s E2E keys, and the grant lasts after the tab locks | Only for an offer that this web session made (the server marks it); the tab must restore its unlock (7.4) with a live W; the user clicks Approve with the device and fingerprint on screen; the audit event has e2e_source. A typed device code still needs the master password (phishing). An unlocked tab can already read all E2E items, and can already seal the user bundle to a key of its choice through e2eGrantUser (2.9) |
| Test hooks of pwharness | They approve codes without a browser | Only with --test-hooks; the production server has no such route |
2.7f The iOS app (DESIGN 14)
- Promise: the agent runs in the app process, in the app sandbox. The agent socket (
<Library>/agent/a.sock, directory mode 0700) and the local store are in the app container, which other apps cannot open. The store key is a Keychain item that only this app can read,AfterFirstUnlockThisDeviceOnlyand not synchronized, so it does not go to iCloud Keychain or to a backup, andLASBOLT_HOMEis excluded from backups. A copied value stays on the device (localOnly, no Universal Clipboard) and expires after 30 s. The app covers its window when it is not active, so the app switcher does not show a value. - Limits:
- The cgo exception. The Go core is a static library in the app (DESIGN 14.1). The Go code and the crypto are the same as on the other platforms, but the process now also holds the Swift UI and the system frameworks: a flaw in the app process has the agent keys in the same memory. On macOS and Android the agent is a separate process.
- iOS cannot block screenshots or screen recording. A value on screen can be captured.
- The Keychain item of the store key has no user authentication, so an unlocked phone in other hands gives the vault (also the server-readable items). The same trust as the agent on a desktop (section 3). Face ID (below) protects only the E2E keys, the extension and the approvals, not the store key.
- Face ID unlock (DESIGN 14.9). Promise:
encis in a Keychain item of the app only (.biometryCurrentSet,WhenUnlockedThisDeviceOnly, not synchronized, the default group of the app, not the App Group), and it is read only after a Face ID match. A face that was enrolled later does not open it. The phone passcode does not open it. After 14 days, the app and the extension ask for the master password. Limits: a face that was enrolled before the item was written can unlock;encgives all E2E keys, as the password does (but it cannot log in); after a Face ID unlock the agent holds the E2E keys in the process of the app until the user locks them; iOS has no peer check of the agent (it is in the app process); a session may now get and useenc(DESIGN 14.9), and each unlock fetcheskeys_encunder the limit of 5 in 10 minutes with an audit row. - The gate of the extension (DESIGN 14.9). The extension reads a second item in the App Group (random bytes,
.biometryCurrentSet) and takes a Face ID match as the password check. It is a gate, not encryption: code in the extension process can read the snapshot key without a match, as without the master password (DESIGN 14.8). The gate holds no key and notenc, so the extension never holds the key of the E2E data. A good password renews it. The app deletes the gate with its own item. - Approvals on the phone (DESIGN 14.9, 10.21). Only a password login (a device token) can decide. A session and a scoped key get 403
device_only: they are the principals that get asked. The API is the one of DESIGN 13.17, shared with Android. A phone that signed in with a QR code cannot approve. “Approve” asks for a Face ID match in the app. The server cannot see that match: a copy of the device token can decide with no match. It is the same trust as the web user, because a device token may also set and clear@approve(DESIGN 10.21). The phone gives no key: it cannot approve an E2E item that the asking program cannot open (409needs_grants: it needs a key sealed in the web app). A local notification has the name of the program and the item, and no value. It shows on a locked screen, unless the user hides previews. The poll and the wake are best effort: iOS can delay or skip a wake, a request ends after 5 minutes, and there is no push. A missed request is a denied one (it ends), so a late alert is a loss of use, not of safety. - The environment of the process holds the store passphrase (
LASBOLT_KEY_PASSPHRASE, DESIGN 14.1) for the life of the process. Only the app process can read it. - iOS suspends the app and can stop it at any time. The agent holds the E2E keys in memory only, so a stop locks the E2E folders.
- AutoFill snapshot (DESIGN 14.8). The extension reads a copy of the server-readable logins in the App Group container, sealed with its own random key. That key is a Keychain item in the App Group access group with the same protection as the store key (
AfterFirstUnlockThisDeviceOnly, not synchronized), so only the app and its extension can read it, and it does not leave the device. The snapshot never has E2E items, items that ask for approval, a TOTP secret or the trash. The master password in the extension is a gate for a person with the unlocked phone, not a second encryption: code in the extension process can read the key without it. The snapshot also haskeys_encand the KDF parameters, to check the password offline; a person who copies the container and the Keychain item gets the logins anyway, and a copy of the container alone allows offline guessing of the master password at the full Argon2id cost (the same data is on the server). Sign-out deletes the snapshot, its key and the identities. A changed password can show in the extension up to the next refresh of the app (DESIGN 14.8).
2.7g Peer sync (DESIGN 15, draft; the server part is built, 15.15; the transport is built, 15.17; the agent and CLI are built, 15.19)
| Asset or step | Risk | Control |
|---|---|---|
| The peer list from the server | A server adds a false peer | The false peer gets only what the server has (plain ops, E2E ciphertext); it cannot make E2E ops (user signature) and peers never send keys. A user can turn on “Pinned peers” (lasbolt peer set pinned on, built in 15.19): the pin check then also needs the key in the local list of confirmed keys, and lasbolt peer confirm adds a key after the user compares its fingerprint with the one that lasbolt peer status shows on the other device. The check fails closed, runs for every handshake, and closes open sessions on a peers event. Limit: the fingerprint is the 64-bit pwcrypto.Fingerprint; a server that can search 2^64 keys could make a key with the same fingerprint (open question of 15.19) |
| A revoked or stolen device | It stays a peer until the other devices next get the peer list from the server | peer_list_max_age (default 7 days) ends all peering after that time; the server refuses its ops with an HLC after revoked_at, also by relay |
| The UDP port on any network | Scans, floods, attacks on the QUIC and TLS code | Datagrams without the mac1 tag of the receiver key get no answer (the port looks closed); TLS with both sides pinned to peer keys; no data before that. A user can limit peering to the LAN or to address ranges |
| The relay push on the server | A device with a stolen token pushes ops in the name of another device of the user, or outside a scope | The server accepts a relay group only when the author device is a device of the user with write access, every op has a wall time at or before revoked_at of that device, and every op is in the scope of both devices (oprules.CheckRelay, DESIGN 15.15). An @e2e root is refused (no folder key by relay). E2E ops need the user signature as always. A scoped key never relays, but it can be the author of a relayed op when it is a key of the user, not revoked at the op HLC, and the op is in its scope; the ops are then checked with the sign key of the key (15.16). Limit: a plain op has no signature, so a stolen full device token can write any plain op, as before; the op is then attributed to the device that the thief names. Revoking the stolen device stops its own pushes |
The reflexive UDP port (--peer-udp) | A scan finds the port; a flood; the port is used to amplify traffic to a spoofed address or as a free STUN server | The port answers only a datagram that opens with the AEAD key of a device (pwcrypto.Seal, a key per device from a server secret) and has a time within 2 minutes. Every other datagram gets no answer, and no database query runs for it. A revoked, expired or read-only device gets no answer. The reply is never longer than the probe. An administrator can turn the port off (--peer-udp off). A holder of a probe key can learn only its own public address and port |
The peer registry (PUT /peers/self, GET /peers, punch) | A device floods the registry or the punch call; a device reads the endpoints of the user | Only devices of the user with write access can call them; a peer change sends one event to the devices of that user only; punch is limited to one call a second for each pair of devices; at most 16 endpoints of a checked form. The endpoints go only to the user’s own write devices, and the server already sees these addresses |
| Endpoints and NAT traversal | The server, and STUN servers if an administrator lists any, see the addresses of the user’s devices | The server is already trusted with them (it sees the client IPs); public STUN is off by default |
| LAN beacons | A device on the same LAN sees that a peer exists; a forged or replayed beacon makes a device dial an address | The beacon has a nonce and a MAC keyed by the user ID; it does not name the user to others. It holds the device ID and the peer port in the clear, so a LAN observer sees that a device exists and can group the beacons of one device. A forged beacon needs the user ID. A replayed beacon only costs a dial that the pin and the mac1 filter end. A user can turn beacons off |
| The transport code (DESIGN 15.17) | A flaw in quic-go or TLS reached from any network | Datagrams without a good mac1 tag are dropped before QUIC sees them (core/peernet/filter.go). The TLS config is TLS 1.3 only, with no tickets, and pins the key of the other side on every handshake (pwcrypto.PeerTLS); a key that is not in a fresh peer list ends the handshake. quic-go v0.64.1 is MIT, has no cgo, and links only x/crypto, x/net and x/sys. Limit: mac1 keys are derived from public keys, so anyone who knows the peer key of a device (the server and the user’s other devices) can send it datagrams; the tag is a filter against scans, not an authentication. mac2 cookies are not built |
| A session with a peer that was revoked later | It stays open until the next check | Dial and Reach check the pin first and close a session that the list no longer allows; the exchange checks the list again; the agent closes sessions on a peers event and when the pin check fails (built, 15.19; TestPeerLoopRevokedClosesTheSession) |
| The peer key, the settings and the confirmed keys on the device (DESIGN 15.19) | A local attacker reads the seed or changes the settings (turns the pin off, adds a confirmed key) | They are blobs of the encrypted local store (peer/key, peer/settings, peer/endpoints), so the store key protects them at rest, and a logout deletes them. Limit: any process of the same user can call the agent ops peer.set and peer.confirm, as it can call vault.state (10.16); the agent socket is the boundary |
| The event stream of the agent (15.19) | The agent now keeps one SSE connection open, so the server sees when a device is online; a stop of the server waited for the streams | The server already sees every sync from the device; the stream carries no data except events. lasboltd cancels the streams at a stop (15.19 item 12) |
A manual endpoint (peer add) | The device sends a datagram to an address that the user typed, for every peer | The datagram has the mac1 trailer of the peer key, so a wrong address gets no answer and learns nothing. The address is stored locally and not sent to the server |
| Background sync of the agent (15.19) | Ops of a peer reach the server through this device without a command | This is the relay push of 15.7 with the checks of oprules.CheckRelay; the user’s commands see the reports of the background sync (they are kept, not dropped) |
| Plain ops on the wire between peers | They are plaintext inside the QUIC channel | TLS 1.3; both ends are devices of the same user that already have the data |
| A scoped session as a peer | It gets ops outside its scope | The sender filters by the scope in the peer list with the core check of the server; the server checks the scope again at relay push |
| A scoped session writes as a peer | It sends ops outside its scope | The receiver refuses ops outside the write scope of the sender, with the tree rules of core/oprules (the same as the server for a push); the server checks again at relay push |
| A false peer sends a purge entry | The receiver drops a plain entity with no proof (the entry has only the entity ID) | The server can already purge any plain entity on its own stream, so a false peer has the power of the server and no more. An entry for an entity in an E2E scope needs a signed purge record (12.3) that the agent verifies. The receiver ignores entries of another database epoch. Pinned peers stop a false peer |
| A locked agent as a peer | It holds ops that it could not check (E2E and signed ops), and gives them to other peers | The ops wait apart from the log and do not show (15.8). Each receiver checks every op itself. They are ciphertext or signed ops. A purge of their entity removes them |
| A purge before the server is reached | A peer that has not seen the purge sends the purged item to another peer | Peers forward purge entries; the server refuses the ops; the item goes away from every device at its next server sync |
| Ops of a retired key after a rotation | An offline peer signs with the old key | The window rule of DESIGN 12.10 in core; the server and the peers refuse them |
2.7h Import files (DESIGN 16)
| Asset or step | Risk | Control |
|---|---|---|
| A file from Bitwarden or from a person | A password-protected Bitwarden file with attacker-chosen KDF values (a huge Argon2id memory or PBKDF2 count) stalls or crashes the client; a ZIP bomb or a huge CSV/JSON fills memory; a crafted file makes the parser crash or write outside the vault; an unencrypted export lies on disk in plaintext | KDF values are checked against fixed bounds before any derivation (pwcrypto.BitwardenKDF.Check); the MAC is checked before decryption; limits on file size, item count, ZIP entries, attachment sizes and data.json size, counted while reading (DESIGN 16.5); the parser does no file I/O and is fuzzed. A plain or CSV export is the user’s own plaintext: the CLI and web page must say so and the user must delete the file after the import (Bitwarden’s “Password protected” JSON is the safer export) |
| The import in the CLI (DESIGN 16.6) | The file password or an imported secret reaches a log, the report or the agent audit; the import writes plain items into an end-to-end folder | The password comes from a prompt, LASBOLT_IMPORT_PASSWORD or stdin, never from a flag, and is not kept after the parse. The report and --json hold counts and item names only, never a value. The writes use commitNew, as lasbolt new does, so an item in an E2E folder is sealed by the key holder. A re-import makes duplicates unless --skip-existing is used |
| The import in the web page, E2E target (DESIGN 16.6) | The server sees the file, its password or the items of an end-to-end folder | The crypto worker parses the file (importParse takes a Uint8Array) and keeps the items in the worker. The page gets a report (counts and names) and signed ciphertext ops only. The file password is typed in the page and goes to the worker only. The page opens the folder with the checkpoint checks (e2eOpenScopeChecked, the memory in localStorage), so a rolled-back folder gets no import, and it signs a checkpoint after the write. Test: the browser test checks that no marker text of the file is in a request or in the database files |
| The import in the web page, plain target (DESIGN 16.6) | The server gets the items of a plain folder as plain text | This is by design: the server reads plain items anyway. It does not get the file or its password: the worker parses in both cases, so the page sends the server only the items in their write form (POST /api/import/plain, 100 items at a time, 8 MiB at most). The page says so before the import. The route checks the target folder (live, plain, in the user’s space) and uses the same vault code as a form, so each item passes the same checks. This differs from DESIGN 16.3, which let the server parse a plain target: the password then would reach the server |
| Memory of the worker during an import | The parsed items stay in the worker after the import; a script in the page asks for them | The worker never returns item values to the page for an E2E target. For a plain target the page holds the items for one page at a time. The worker forgets the parsed file after the import (importDrop) and when the page leaves (kit.stop). A script in an unlocked page is a limit of 2.9 |
2.8 Other users of the same server
- Promise: a user reads only spaces where they are a member. IDs of other spaces stay hidden (“not found”).
- For E2E: membership is also enforced by keys. A server bug cannot give E2E plaintext to a non-member.
- Shared spaces and teams: 2.12.
2.9 Attacks on the web app
- Promise: no script injection, no cross-site requests.
- How: strict CSP with no inline script,
textContentfor decrypted text, CSRF tokens, SameSite=Strict, all assets embedded (no CDN). - Limit: an injected script in an unlocked tab has the keys in effect. The worker never gives a private key to the page, but its page-callable list has
e2eGrantUser,e2eGrantFolderande2eGrantItem: the page names the box key, and the worker seals the user bundle or a folder key to it. So a script that runs in an unlocked tab can seal the keys to its own key and send them out. The controls are the ones above (CSP, no inline script, no remote assets); the worker list is not a boundary against a script in the page. - Tab unlock changes nothing here. A script in an unlocked page could always ask the worker for data. It can also ask the worker to restore a tab, which gives it nothing more. The page never holds
W: the worker sends and reads it. The tab-key calls need the CSRF header and a same-origin fetch, also the GET.
2.10 Supply chain
- How: no remote assets, pinned Go modules,
govulncheckinmake lint, no cgo, few dependencies. - Limit: a compromised Go module in the build is not detected by the build itself.
2.11 Organisation: instance admins, invitations, the control socket, disable and delete (DESIGN 17.16)
| Asset or step | Risk | Control |
|---|---|---|
| An instance admin or owner | A bad or tricked admin reads vault content, or makes a user or a key that does | An instance role gives no access to space content: the admin views have no content field, OpenSpace needs a membership, and E2E keys never reach the server. An admin can still disable, delete or lock out a user (an availability attack), and can invite an account that joins shared spaces later (milestone 3 needs a member to add it). Every admin action is in the audit log of the target and of the actor. Limit: until milestone 4 there is no step-up, so a stolen admin web session can act at once |
| A stolen admin session or token | The attacker uses the admin calls | The gate needs a password login without a scope in a web session or an app device. A scoped session, a scoped key, and the device tokens of cli, extension and agent get 403. Only an owner can grant owner, open the registration, or touch an owner. The last enabled owner cannot be removed. Limit: a full device token of kind app and a web session pass (the step-up of 17.8 is not built) |
| Owner lock-out | A lost or disabled last owner leaves nobody to administrate | The store refuses to demote, disable or delete the last enabled owner (inside the transaction). lasboltctl owner add is the break-glass for a person with the host |
| Invitation link | A link that leaks gives an account on this server with the role of the invitation | 256 bits, only the hash is stored, single use, 7 days by default and 30 at most, revocable. The link is shown once to the admin, in the answer of a POST (never in an address of the admin). An invitation for admin or owner is as strong as the role: only an owner can make an owner invitation. The register page sets no referrer and no cache. The first use wins; a second use fails with one generic error. Limit: the person who opens the link makes a GET of /register?invite=<token>, so the token is in the access log of a reverse proxy and in the history of that browser, and it works until it is used or ends. The controls are single use, a short life (7 days by default) and revocation; do not keep proxy logs longer than the life of an invitation, and revoke a link that was sent to a wrong place |
| Guessing invitation tokens | Online guessing on the register page and call | 256-bit tokens, one error for every failure, a rate limit per IP address on each check. The checks do not reveal if a token ever existed |
Registration open | Anyone who reaches the server can make an account (also an attacker who wants to store data or probe the server) | Only an owner (or the host) can set it. The default after the first user is invite. The old flag only seeds the setting once, and the server logs a warning while the registration is open |
| The control socket | A local user or program uses it | It is a Unix socket with the mode 0600 owned by the service user; on Linux the server also checks the peer user ID (only its own user and root). It has no network listener. A local root or the service user can already read the database and the process memory, so the socket adds no power they do not have. A peer with the right user ID on macOS is checked by the file mode only. The commands can change users and invitations but give no access to content or keys; the host principal never passes the key-admin and password-login rules. A directory with a mode that lets another user replace the socket path (for example a world-writable /tmp) is a mistake of the deployment: use RuntimeDirectory= |
| A stale or foreign socket path | lasboltd takes over a path of another program, or deletes a file | Listen refuses a path that is not a socket, refuses a socket where a process answers, and removes only a socket that refuses the connection |
| Disable | A disabled user keeps a credential | One transaction revokes the web sessions, device tokens, session tokens, refresh tokens, scoped keys, grant rows, peer registrations, open approvals and device codes, pairing offers and reset links. Every credential check also refuses a disabled user. A disabled user gets the same answer as a wrong password, before any TOTP step, so login does not tell that the account exists or is disabled. Limit: E2E data that a revoked device or key already holds stays with it; rotate the user keys if a device was stolen (DESIGN 12.10) |
| Delete | An admin deletes data by mistake, or a deleted account stays | A grace period (30 days by default, the setting delete_grace) in which an admin can cancel. The account is disabled at once. After the grace period the job deletes the spaces that have no other member; a space with other members stays. Limit: backups keep the data until they expire; a restore brings back a deleted account (see “Restore brings back revoked devices”) |
| A disabled or deleted user as an author | Ops of the user stay in shared spaces | The ops stay and stay verifiable with the public keys of the user. The empty account row keeps the foreign key. No credential or key of the user is kept |
2.12 Shared spaces and teams (DESIGN 17.17, 17.19, 17.22, 17.23; shared spaces and teams are built, in the server and in the web app)
| Asset or step | Risk | Control |
|---|---|---|
| The key of a user that another user adds | An active server gives the admin a key of its own (a man in the middle), and so gets the folder keys sealed to it | The add dialog shows the safety number and its QR code; the admin can compare it in person or on a call and mark the pin verified. A pin is signed by its owner, versioned, and remembered by each client, so a later different key without a rotation record stops the client. Limit: an admin who does not compare the number trusts the server at the first add (trust on first use). This is the same limit as other E2E messengers |
| The anchor of a space | A user who joins trusts the genesis author through a pin made on first use | The user’s own add entry must have the user’s own keys (trust rule 1), and the genesis author’s safety number is offered for comparison. Limit: as above, without a comparison the first view trusts the server |
| The membership log | The server adds a member, changes a role, hides a removal, or shows two orders to two clients | Every entry is signed by an admin or owner in the state before it and chained by hash; the server and the clients run one rule function. Clients remember the newest head and report a shorter log or another hash as member-fork; checkpoints carry the head (DESIGN 12.2). Limit: a fork that the server never shows to both clients is not found (as 2.4 “Forks”); a new device trusts the head that the server gives |
| Ops in a shared space | A removed or demoted member, or a key that the log never gave, signs ops that the server stores and other members see | The server checks every signed op against the log (DESIGN 17.19, point 1): the signer is the key of the author in the log at the HLC of the op, or a key that rotation records link to it inside its cutoff, and the role at that HLC is writer or higher. Clients run the same pure functions (State.CheckOp) and drop what fails. Limit: a scoped key’s cert chain, a plain op with no signature, and a shared space made before migration 18 have no log check on the server |
| The keys in an entry | An admin client signs a key that the server does not hold, or a stale key after a rotation | The server stores genesis, add and keys entries only if the keys are the current keys of the subject on this server, and the subject is not disabled or deleted. A bad server can still lie at the lookup: the contact pin and the safety number are the control |
| User lookup | A member finds the names of the accounts of the organisation | Exact match only, no list, one answer for a missing, disabled or deleted name, a rate limit for each user and each IP address, and an audit row for each call. Limit: a user can still test names one by one, up to the limit |
| Limited clients | A stolen token of the CLI, an extension or an agent, or a scoped key, changes members, pins or folder keys | Only a web session or an app device from a password login without a scope can write entries, pins and folder keys, and look users up. The others get 403 before the body is read, with an audit row. Reads of the log are for members |
| Server-readable shared spaces | The server or the operator reads or changes them, or gives them to a non-member | Not a promise: the operator is out of scope for server-readable data (section 3). The signed log lets clients show who is a member as the admins decided, so a member added only in the database is visible as a fault |
| An instance admin | Puts an account of their own into a space | An instance role gives no membership: only a space admin’s client can sign an add, and invitations have no spaces or teams (DESIGN 17.16, 17.17). An instance admin can still disable members (availability) and make teams, which a space admin must then add |
| Two admins act at the same time | Each removes or demotes the other; the result depends on order | The server accepts an entry only at the head (409 not_head); the second client rebuilds its entry on the new head, and the rules then refuse it if its author lost the role. The last owner cannot be removed |
| A removed member | Keeps what it read, and keeps old folder keys | Removal is in force from its at: later ops of the member are dropped by clients, and the server stops serving the space. Every E2E root of the space is marked for re-key; an admin’s client writes a new folder key version that the removed member does not get. Limit: items that existed before the re-key, and their later edits, stay readable to a holder of the old folder key and the ciphertext (DESIGN 12.10, decision 1); until an admin re-keys, a bad server can give new items to the removed member |
| A disabled, deleted or reset member | Its devices hold folder keys; a reset gives new keys with no rotation record | Disable and delete mark the member’s spaces for re-key. After a reset, other clients see a key change and drop its ops until an admin compares the safety number and writes a keys entry. The server refuses a folder key row for a reset member until then (keys_pending): a row would be sealed to the retired box key. A member that rotated gets its rows sealed to the new box key of its signed rotation record, not to the box key of the log. The reset keeps the rotation records (the logs need them) and marks the shared spaces of the member, also those it reaches through a team, for re-key. The reset deletes the team key rows of the member (they are sealed to the old box key): the member waits for a manager’s keys entry with a new row (DESIGN 17.22) |
The use role | A use member reads the values | Not cryptography: clients hide Reveal and refuse lasbolt read, but a use member of an E2E space has the folder key and a modified client can show values. The member dialog says so. Give use only to people you would trust with the values, to stop mistakes and casual reading |
| Team keys | A bad manager or a stolen manager device adds a member to a team and so to all its spaces | Only a manager writes the team log; space admins add a team at a named head; every team change is in the log and the audit. The genesis author must be an instance owner or admin (the server checks it). A team has at most the role writer in a space, so a team never gives the right to write the log or to re-key. Limit: a space admin who adds a team trusts its managers for that space |
| A member leaves a team | Keeps the old team key, which opens the folder keys of every space of the team | A new team key version for the remaining members (the server serves no team key row to a removed member), and every space of the team is marked for re-key. The order is fixed on the server: team-key (team log), then team-keys (space log, the head moves to the new key), then the re-key. The server refuses a team folder row that is not sealed to the key that the space log names, and refuses it while that head is not the newest team key, so a re-key cannot go to the key that the removed member holds. A mark clears only when every member and every team has a row of the newest folder version, sealed to the newest team key. The same limit as a removed member, for each space |
| A team key row | A row is sealed to a retired box key, or to a user that the log does not know | The server checks a team key row as a folder key row: the keys of the member in the team log, followed through the rotation records, must be its keys on the server (keys_pending). A team-key entry needs rows for exactly the members that the log lists as available. Limit: after a rotation of the user keys, the old team key rows stay sealed to the retired box key until a manager writes a new team-key entry (the rotation transaction does not reseal them). The member’s worker writes a request (its own rotation keys entry) at its next unlock, a manager’s worker derives “N members need the team key again” from the team log and makes the new key with one button (DESIGN 17.23 point 8); the mark shows only after that unlock |
| The team logs of a space (web worker) | A bad server shows a team log that is shorter, forked, or from another creator, or swaps the keys of a member, so that the worker seals a team key or a folder key to a key of the server | The worker verifies every team log that the space log names, also removed teams, before the space log: signatures, chain, rules, the anchor pin of the genesis author, the own keys, and the newest head that this browser saw (team-member-fork). A team log that fails freezes the space (writes off). It uses the team states from the same fetch as the space log. Limit: the pin of the team creator is made on first use, and the head memory is in localStorage (the web caveat of 2.4). A space admin adds a team without a safety number step and trusts its managers for that space |
| Who reads a team log | A user learns who is in a team | Any full login can read a team log by its ID: the log has user IDs and public keys only, and a client needs it to add the team and to verify a space. The user names in the answer are for team members and instance admins. A scoped session and a scoped key get nothing |
| The last owner of a space is lost | Nobody can change the members | Accepted: no break-glass, because a break-glass would give the host a signing key. The other members keep their access |
3. Out of scope
- A compromised client OS, browser or browser extension, and malware running as the same user. (On macOS, the
securitykey storage of the local store key does not stop programs of the same user. The Touch ID item of the app does stop them (2.7b), but the running agent does not. The store key item of 2.7c is not built.) - Physical access to an unlocked device.
- Side channels (timing on shared hardware, power, cache).
- Denial of service beyond basic rate limits.
- The operator as an adversary for server-readable data. Use E2E folders for data the operator must not read.
- The operator can reset any account (DESIGN 8.1).
lasboltd user reset-passwordgives a link that sets a new master password. This is not a new power: the operator always could read and change server-readable data, and could replace the auth verifier in the database. The reset cannot open E2E data: it makes new keys, and the old E2E folders stay locked for ever. The user sees it in the audit log (“Master password reset by the operator”), and all sessions, devices and scoped keys of the user end. - Traffic analysis.
- Quantum attackers: partly covered. HPKE uses the ML-KEM-768 + X25519 hybrid. Signatures (Ed25519) and symmetric keys are not post-quantum-specific.
4. Known gaps
| Gap | Effect | Plan |
|---|---|---|
| Rollback detection has limits | Signed checkpoints find a rollback or a hidden op below a known checkpoint (DESIGN 12.8). A fork that the server never shows to both devices, a new device without memory, and ops above the newest checkpoint are not covered. E2E purges are signed (DESIGN 12.9), so an honest purge is not a rollback and a purge without a record is | A channel outside the server for fork checks (later) |
lasbolt recover leaves a web session | The finish call sets a web session cookie. The CLI does not use it and does not log it out; it ends when it expires | Log out all sessions in Settings › Devices if needed; a later change can call /logout |
| A kit replaces TOTP | A stolen kit is enough without the second factor (decision of the user) | Keep the kit offline; WebAuthn later |
| A copied tab can restore the unlock | “Duplicate tab” and a page opened with window.open copy sessionStorage, so the copy has the blob and can restore while the wrap key W lives (DESIGN 7.4). It is the same user in the same browser, and the user can see the tab | Accept; Lock and the idle time end it |
| Restore brings back revoked devices and old credentials | A revoked token works again after a restore. A restore also brings back the recovery kit, the master password and the keys of the backup time: a replaced kit code, or a master password from before a recovery or a reset, works again | Re-revoke after restore (DESIGN 9.10); after a restore, ask users who recovered or were reset since the backup to recover or reset again |
| CLI key storage | The CLI keeps the local store key in the OS key store. On macOS the security tool gives no per-program access control, so other programs of the same user can read the item. Linux and Windows stores have the same limit for programs of the same user. Without an OS store, a passphrase-wrapped file is used. Windows has no agent yet (direct mode with a lock). An item owned by the app would make other programs prompt (the user saw that prompt in the probe), but while the agent runs any program of the user can ask the agent instead. | Accepted (2026-10-17). Next step: a caller check in the agent (only signed Lasbolt binaries get secrets, others get an approval prompt); after it, the app-owned key of DESIGN 11.15. Windows agent pipe |
| Debug build of the macOS app | make apple builds Debug: get-task-allow on the app and no hardened runtime on the helper. A debugger of the same user can read enc (Touch ID) and the E2E keys from memory. The release build (make apple-release: hardened runtime, no get-task-allow) closes this | Use the release build for daily use; Debug only for development |
| Passkeys and WebAuthn not built | TOTP is the only second factor | Later |
| Grants of sessions and keys come from the browser | The web caveat applies to them; a session trusts the user’s sign key on first use | Grants from a native app (later) |
| Backdated ops of a revoked key or a retired user key | Clients drop ops above the cutoff of a signed revocation (DESIGN 10.20) or of a rotation (DESIGN 12.10). A new device or the web app cannot tell a backdated op of a leaked key from a real old one. A bad server can withhold the record | A checkpoint after the record shows a later forged op as a rollback (DESIGN 12.8); key rotation (DESIGN 12.4); rotate the folder |
| Revocation while E2E is locked | The record is signed at the next unlock. Until then, clients do not drop new ops of the key | Unlock on the Keys page, or lasbolt keys revoke again after lasbolt unlock |
| A write session holds the user keys | The agent of that CLI can open every E2E folder of the user until it stops | Rotate the user keys (DESIGN 12.10): the session ends and new items get new folder keys. Per-folder grants with signing keys (later) |
| Old items stay readable to old key holders | After a rotation, a holder of the old keys or an old folder key reads the items that existed at the rotation, also their later edits (the item key does not change) | Move the item into a new E2E folder, or make it again and purge the old one (DESIGN 10.17, 12.9) |
| Meta grants are per entity | A meta principal does not see E2E items that are made after its approval, or older items without @mkey | A per-folder meta key (later) |
| Two metadata fields with one key | Two offline writers can make two fields for one key. The server cannot stop it in E2E | Readers show both; meta unset clears both |