Specfork

@specfork/password-manager

Offline desktop password manager. One encrypted vault file in the open KDBX 4 format; entries, generator, TOTP, auto-type, auto-lock, clipboard clearing, history, import, export, merge.

desktopsecurity Version 0.1.1 113 requirements Licence CC-BY-4.0 Specfork/password-manager

Password manager

One person keeps logins, notes and secrets in a single encrypted vault file on their own computer. The file uses the open KDBX 4 format, so other compatible apps can open it. The vault is unlocked with a password, a key file, or both; it locks itself when idle, clears copied secrets from the clipboard, types logins into other windows, generates passwords and one-time codes, keeps a history of every change, and merges two copies of the same vault. Behaviour derived from KeePassXC's open-source code (GPL-2.0 or GPL-3.0), written fresh and stack-free. Not affiliated with or endorsed by the KeePassXC Team. The name is used only to credit the source.

Area Prefix Covers
vault.md VLT KDBX 4 file layout, KDF, ciphers, integrity, saving, reload, vault settings
keys.md KEY Composite key: password, key file; unlock, change key, quick unlock
entries.md ENT Entries, groups, tags, custom fields, attachments, references, search
history.md HIS Entry history, recycle bin, deleted objects, merge
generator.md GEN Password and passphrase generator, entropy, quality, health report, breach check
totp.md OTP Time-based one-time codes
security.md SEC No telemetry, network policy, memory hygiene, clipboard, auto-lock
autotype.md ATY Typing into the focused window: matching, sequences, timing
transfer.md XFR Import (CSV, other managers' formats), export with warnings
browser.md BRW Local IPC contract for browser extensions

Single user, no account, no server: everything runs on one computer (Windows, macOS, Linux) with no network unless the user turns on a feature in SEC-3. Shortcuts are defaults and can be changed; Mod = Ctrl on Windows and Linux, Cmd on macOS. Instants are UTC; byte layouts are little-endian unless stated.

Auto-type

Status: Draft · Prefix: ATY

Purpose

Type an entry's login into whatever window has focus, so secrets reach other apps without the clipboard.

Requirements

ID Requirement Verified by
ATY-1 Auto-type sends synthetic key input to the window that has focus, never through the clipboard; characters without a key on the current layout are sent as Unicode input. Where the OS gives no input permission, the feature explains what to grant (or that it is unavailable) and does nothing. type ä€ into a text editor with a US layout → both appear
ATY-2 Triggers: a global hotkey (none by default; a taken hotkey is reported), "auto-type selected entry" Mod+Shift+V (the app hides and types into the window focused before it), and the IPC request BRW-8. A locked vault prompts to unlock first. press hotkey on a locked vault → unlock prompt, then typing
ATY-3 Per entry: autotype_enabled (inherit from group), sequence (empty = inherit), and window associations [{window pattern, sequence?}]. Expired entries and the recycle bin are skipped unless a setting allows expired. entry auto-type off → never matched
ATY-4 Global hotkey matching over all unlocked vaults: an association pattern matches the focused window title as a whole, ignoring case, with * = any characters, or as a regular expression when written //re//; optionally (both default on) a title containing the entry title, or the entry URL's host, also matches. pattern *Mail - Browser vs title Inbox - Mail - Browser → match
ATY-5 Results: one match → typed at once unless "always ask" (default on) shows the chooser; none or several → chooser with search over all entries. The chooser preselects the entry chosen for the same window within the last 15 s. 2 matches → chooser shows both
ATY-6 Sequence: the first defined of association sequence, entry sequence, nearest group default, vault default {USERNAME}{TAB}{PASSWORD}{ENTER}. entry with no sequence → default typed
ATY-7 Tokens: fields {TITLE} {USERNAME} {PASSWORD} {URL} {NOTES} {TOTP} {S:<field>} and references (ENT-8); keys {TAB} {ENTER} {SPACE} {BACKSPACE} {DELETE} {INSERT} {HOME} {END} {PGUP} {PGDN} {UP} {DOWN} {LEFT} {RIGHT} {ESC} {F1}..{F16} and literal {+} {^} {%} {~} {#} {{} {}}; {KEY n} repeats a key n times (n ≤ 100); prefixes + Shift, ^ Ctrl, % Alt, # Super apply to the next key. {TAB 3} → three tabs
ATY-8 Control tokens: {DELAY n} pauses n ms (≤ 10000), {DELAY=n} sets the gap between keys, {CLEARFIELD} selects all and deletes. An unknown token or bad number stops before typing anything and names the token. {TABB} → error naming {TABB}, nothing typed
ATY-9 Timing: start delay 500 ms (setting 0..10000), gap between keys 25 ms (setting 0..1000). Typing stops at once if the focused window changes. switch window mid-sequence → remaining keys not sent

Out of scope

  • Two-channel obfuscation tricks; per-application keyboard layouts.

Decisions

  • One generic rule ("type into the focused window") instead of per-OS behaviour: each OS supplies its input method; the matching and sequence rules are the same everywhere.

Browser IPC

Status: Draft · Prefix: BRW

Purpose

A local, encrypted message contract that lets a browser extension (or any local client) ask the app for matching logins, with the user approving every client and site.

Requirements

ID Requirement Verified by
BRW-1 Off by default. When on, the app listens on a per-user local endpoint only (a Unix domain socket in the user's runtime folder with owner-only permissions, or a named pipe restricted to the current user); never a network port. A relay helper bridges the browser's native messaging to it. setting on → no TCP listener; another OS user cannot connect
BRW-2 Messages are UTF-8 JSON objects of at most 1 MiB: {action, client_id, nonce, message} where message is the base64 ciphertext of an inner JSON object; replies echo action and carry nonce = request nonce + 1 (little-endian increment). Key exchange change_public_keys {public_key, nonce, client_id} (unencrypted): the app answers with a fresh X25519 public key for that connection. All later messages use X25519 + XSalsa20-Poly1305 boxes with 24-byte nonces; a reused nonce or failed decryption → error cannot_decrypt. reply nonce ≠ request + 1 → client rejects; replay a captured request → cannot_decrypt
BRW-3 Pairing associate {key, id_key} asks the user in the app to accept and name the client; accepted → {id, hash} stored in the vault's custom data with created and last-used times. test_associate {id, key} must succeed per vault before other requests; unknown → not_associated. The user can list and remove paired clients per vault. remove pairing → next request not_associated
BRW-4 Actions after pairing: get_logins, set_login, generate_password, get_totp {uuid}, get_vault_groups, create_group {path}, lock_vault, request_autotype {search}, get_vault_hash (SHA-256 of the root group uuid, so clients can tell vaults apart). Unknown action → unknown_action. lock_vault → vault locks
BRW-5 get_logins {url, submit_url?, keys:[{id,key}]} returns entries whose url or url_* fields match: same host or a subdomain of it, same port, and same scheme (setting, default on); recycle bin, expired (setting) and entries marked hidden from clients excluded. Sorted best first: exact URL, then path prefix, then host, then subdomain; setting "best match only" (default off). site https://a.b.test/login, entry https://b.test → returned
BRW-6 The first request from a site for an entry not yet decided shows the user the entries and asks allow or deny, with "remember"; remembered decisions are stored per entry as allowed and denied hosts. Setting "always allow" (default off). deny + remember → later requests omit that entry silently
BRW-7 A returned login: {uuid, title, username, password, otp?, fields} where fields holds only custom fields the user marked for clients. Placeholders and references are resolved (ENT-9). entry with unmarked custom field → not in reply
BRW-8 set_login {url, username, password, group?, uuid?}: without uuid creates an entry in the given or default client group; with uuid updates it after a confirmation (setting "always allow updates", default off), keeping history. request_autotype runs ATY-2 with the site host as search after user confirmation. update existing → confirmation shown, history +1
BRW-9 Locked vault: when "unlock on request" (default on) the unlock form appears and the request waits up to 60 s; otherwise or on timeout → vault_locked. The app pushes vault_locked and vault_unlocked events to connected clients. lock vault → connected client receives vault_locked
BRW-10 Error reply: {action, error, error_code}, codes: 1 vault_locked, 2 not_associated, 3 cannot_decrypt, 4 no_logins_found, 5 unknown_action, 6 denied, 7 bad_request, 8 too_large. Messages and their contents are never logged. 2 MiB message → too_large

Out of scope

  • The browser extension itself, form detection and filling.
  • Installing native messaging manifests per browser; passkeys.

Decisions

  • Action and error names are this spec's own; a client written against them works with any implementation of it.
  • Pairing lives in the vault, so moving the file keeps paired clients; removing a vault's pairing never affects another.

Entries

Status: Draft · Prefix: ENT

Purpose

What a vault holds: entries in a tree of groups, with tags, custom fields, attachments, references and expiry, and how to find them.

Requirements

ID Requirement Verified by
ENT-1 Entry: uuid (16 random bytes), title, username, password, url, notes (multi-line), tags (set), icon (built-in index or custom icon uuid), fg_color, bg_color, expires (bool) + expiry_time, created, modified, accessed, location_changed, custom fields, attachments, auto-type settings (ATY-3), history (HIS-1). Text is UTF-8 with no length limit. new entry → all times equal now, expires false
ENT-2 password is always protected (masked in the UI, protected in the file per VLT-11). Per-vault flags may also protect title, username, url, notes (default off). New entries get the vault's default_username. read file raw → password not in clear text
ENT-3 Custom field: non-empty name, unique within the entry (case-sensitive), not one of the standard names Title, UserName, Password, URL, Notes; text value; protected flag (default off) masks it and protects it in the file. add field Password → refused
ENT-4 Attachment: name unique within the entry (adding a clash asks replace or rename to name (1)), bytes. Identical contents are stored once per vault. Adding a file over 10 MiB asks for confirmation. same 1 MB file on 2 entries → vault grows by ≈ 1 MB once
ENT-5 Groups form a tree under one root. Group: uuid, name, notes, icon, expires/expiry_time, default_autotype_sequence (inherit), autotype_enabled and search_enabled (inherit, on, off; root inherits = on). Every entry is in exactly one group. Entries and groups move by drag or cut/paste; a group cannot move into itself or a descendant. Moving sets location_changed. group search off → its entries absent from search; drag group A into its child → refused
ENT-6 Tags: trimmed, non-empty, no , or ;; duplicates within an entry dropped ignoring case. A tag list shows every tag with its entry count; choosing a tag filters the list. Renaming or deleting a tag there changes every entry that has it. rename tag work → job on 5 entries → 5 entries changed, each with history
ENT-7 Expiry: an entry with expires and expiry_time ≤ now is expired: shown struck through, excluded from auto-type and browser results unless a setting allows. On unlock, entries expired or expiring within N days (default 3, setting, on by default) are listed. entry expiring tomorrow → listed on unlock
ENT-8 Field reference {REF:<w>@<s>:<text>}: w ∈ T U P A N I (title, username, password, url, notes, uuid) to return; s ∈ T U P A N I O (O = any custom field) to search; resolves to the first matching entry outside the recycle bin. Nesting up to 10 levels; deeper or circular references stay as literal text. {REF:P@I:<uuid of B>} in A's password → copying A's password gives B's
ENT-9 Placeholders {TITLE}, {USERNAME}, {PASSWORD}, {URL}, {NOTES}, {S:<field name>} and references are resolved when copying, opening a URL, auto-typing and answering the browser IPC; the stored value stays unresolved. url https://x.test/?u={USERNAME} opens with the username filled in
ENT-10 Clone: new uuid, title suffix - Clone (optional), options: replace username and password with references to the original, include history (default off). clone with references → change original password → clone's resolves to new one
ENT-11 Open URL uses the system browser. A cmd:// URL runs a local command only after a confirmation that shows the command, each time unless the user ticks "remember for this entry". open cmd://notepad → confirmation first
ENT-12 Default shortcuts: new entry Mod+N, edit Enter, delete Del, copy username Mod+B, copy password Mod+C, copy URL Mod+U, copy one-time code Mod+T, open URL Mod+Shift+U, auto-type Mod+Shift+V, search Mod+F, lock Mod+L, save Mod+S. press Mod+C on an entry → password on clipboard
ENT-13 Edit form: changes apply on OK or Apply; Cancel with changes asks to discard. Applying with no field changed does not update modified nor add history. open, OK without change → modified unchanged
ENT-14 Search runs as the user types (or on Enter, setting) over all groups (default) or the current group; excludes the recycle bin and groups with search off; Esc clears it. Search syntax: space-separated terms, all must match; a bare term matches a substring, ignoring case, of title, username, url, notes, tags or custom field values (not passwords). Prefixes: title:, user:, pass:, url:, notes:, field:<name>=, attach:, group:, tag:, uuid:, is:expired, is:expired-<days>, is:weak, has:otp. Modifiers: - excludes, + matches the whole value exactly, * treats the word as a regular expression; "..." keeps spaces. A case-sensitive toggle applies to all terms. type bank → matching entries within 300 ms; tag:work -url:intranet → work-tagged entries whose url lacks "intranet"

Out of scope

  • SSH agent integration, passkeys, secret-service style OS APIs, group sharing between vaults.
  • Printing.

Decisions

  • Search syntax is the app's own (inspired by the source): prefixes are short English words; implementations may add aliases.
  • Tag rename edits every entry and so creates history on each: tags live on entries, not as separate records.

Generator

Status: Draft · Prefix: GEN

Purpose

Generate strong passwords and passphrases, show how strong any password is, and report weak, reused, expiring or breached ones.

Requirements

ID Requirement Verified by
GEN-1 Two modes, password and passphrase, available standalone and from any password field; the last-used options are remembered (not the output). The generator keeps no history of outputs. generate, close, reopen → same options, new value
GEN-2 Password options: length 1..999 (default 32); classes lower a–z, upper A–Z, digits 0–9, brackets ()[]{}<>, punctuation .,:;, quotes "' and backtick, dashes -_/|, math +*=!?%^~, symbols #$&@, extended printable U+00A1..U+00FF; default lower + upper + digits. Plus also_include (extra characters) and exclude (characters removed). defaults → 32 chars from a–z A–Z 0–9 only
GEN-3 avoid_lookalikes (default on) removes 0 O o 1 l I | 5 S 8 B 6 G from the pool. on, 1000 outputs → none contains 0 or O
GEN-4 every_class (default on): each output holds at least one character from every selected class (and from also_include when set). Length below the class count, or an empty pool, disables generating and says why. 3 classes, length 2 → disabled with reason
GEN-5 Randomness comes from the OS cryptographic generator; each character is picked uniformly from its pool without modulo bias, and the result is shuffled uniformly. chi-square over 100,000 single-char outputs → uniform at p > 0.01
GEN-6 Passphrase options: word count 1..40 (default 7); word list = bundled list of 7776 words (default) or a user file with one word per line (blanks and duplicates dropped; under 1000 words → warning); separator any string (default one space); case lower (default), upper, title, mixed (each word randomly lower or title). 7 words, space → 6 spaces
GEN-7 The generator shows entropy in bits: password = length × log2(pool size); passphrase = words × log2(list size). 32 chars from 56-char pool → ≈ 185.8 bits
GEN-8 Every password field (entry, vault key) shows an estimated entropy for the typed value from a pattern-aware estimator (dictionary words, keyboard runs, repeats, dates, common substitutions), not the naive formula. Password1! → < 40 bits
GEN-9 Quality bands by bits: ≤ 0 bad, < 40 poor, < 75 weak, < 100 good, ≥ 100 excellent; shown as a coloured meter with the band name. 80 bits → good
GEN-10 Health report per entry (not in the recycle bin, not excluded by its own flag): start from GEN-8 bits; if the same password is in N > 1 entries subtract 15 × (N − 1) and cap at 74; expired → 0; expiring in d ≤ 30 days → cap at 70 then subtract 2 × (30 − d). Entries scoring < 75 are listed lowest first with reasons. 2 entries share a 120-bit password → both 74, listed as reused
GEN-11 Breach check (opt-in, off): for each password compute uppercase hex SHA-1, send only its first 5 characters to the user-configured range endpoint, compare the returned suffixes locally, and show the count. A local hash-list file can be checked instead with no network. Usernames, URLs and full hashes are never sent. capture traffic → only 5-hex-char prefixes leave the device

Out of scope

  • Password rotation on websites; storing generator history.

Decisions

  • Two entropy figures: the generator's is exact for its own output; the estimator's is honest about human-chosen text.
  • Health penalties follow the source's numbers so reports compare across implementations.

History

Status: Draft · Prefix: HIS

Purpose

Nothing is lost by accident: every change keeps the previous version of an entry, deletions go to a recycle bin, and two copies of a vault can be merged.

Requirements

ID Requirement Verified by
HIS-1 Applying a change to an entry first stores a snapshot of its previous state (all fields, tags, custom fields, attachment references, icon, colours, times; not its own history) at the end of the entry's history. No snapshot when no stored field changed, or only accessed changed. edit password → history has 1 snapshot with the old password; open entry, copy password → history unchanged
HIS-2 After each snapshot, the oldest are dropped until count ≤ history_max_items and total size ≤ history_max_size (VLT-16); size counts snapshot text plus attachment bytes not also used by the current version. −1 means no limit for that rule. limit 10, 11th edit → 10 snapshots, oldest gone
HIS-3 History view lists snapshots newest first with their modified time; a snapshot can be viewed read-only, restored, or deleted; "delete all history" asks to confirm. Restore makes the snapshot current and stores the replaced state as a new snapshot. restore 3rd snapshot → current = it, history count +1
HIS-4 Recycle bin (when recycle_bin_enabled): deleting an entry or group outside it moves it into a "Recycle Bin" group created on first use, identified by uuid in vault settings (not by name), and sets location_changed; no confirmation by default (setting). delete entry → it appears in the bin
HIS-5 Deleting inside the bin, with the bin disabled, or with Shift+Del deletes permanently after confirmation that names the item and count; each removed uuid is added to the vault's deleted-objects list with the time. "Empty recycle bin" asks to confirm with the number of items, then deletes them permanently (HIS-5). delete inside bin → gone, deleted-objects has its uuid; bin with 4 items → confirm "4" → bin empty
HIS-6 Restore from the bin moves the item back to its previous parent group if it still exists, else to the root. delete entry from group G, restore → back in G
HIS-7 The bin's contents are excluded from search, auto-type, browser IPC, health report and expiry lists. entry in bin matches search text → not shown
HIS-8 Merge: choose another vault file and its key; its content is merged into the open vault, which is then saved per VLT-15. A preview lists every change (added, updated, moved, deleted, metadata) before the user confirms. Merging a vault with itself or an identical copy reports "no changes" and does not save. merge → preview shows 2 added, 1 updated; cancel → no change; merge same file → "no changes"
HIS-9 Matching is by uuid. An item only in the other vault is added under its parent group (by uuid; missing parents are recreated along its path), unless this vault's deleted-objects list has it with a time later than its modified. entry new in B → added to A in the same group
HIS-10 An entry in both with different content: the one with the later modified becomes current; the other's current state joins history; both histories are combined by modified time with equal snapshots removed, then trimmed (HIS-2). Equal modified and content → nothing. A edited Monday, B edited Tuesday → result = B's version, A's in history
HIS-11 Location: the copy with the later location_changed decides the parent group. Groups merge like entries using their own modified and location_changed. B moved entry later → moved in A
HIS-12 Deletions: an item deleted in either vault is removed from the result when its deletion time is later than its modified in the other; otherwise it is kept and the deletion record dropped. Deleted-objects lists are combined. B deleted X after A last edited X → X removed
HIS-13 Vault settings merge: custom icons combined by uuid; name, description and other settings taken from the vault whose settings changed later; the recycle bin uuid of the open vault is kept. B renamed vault later → name from B

Out of scope

  • Three-way merge with a common ancestor; per-field conflict resolution by the user.
  • Restoring permanently deleted items.

Decisions

  • Last-modified-wins with the loser kept in history: simple, deterministic, and never loses data that history limits allow.
  • Merge always previews first; the source can merge without preview.

Keys

Status: Draft · Prefix: KEY

Purpose

The composite key that unlocks a vault: a password, a key file, or both; unlocking, changing the key, and optional quick unlock.

Requirements

ID Requirement Verified by
KEY-1 A composite key has a password, a key file, or both; at least one is required. A blank password is allowed only after an explicit "use no password" confirmation and is then omitted, which differs from an empty-string password. create vault with key file only → opens with key file alone
KEY-2 Password component = SHA-256(UTF-8 bytes of the password), with no trimming or normalisation. Key file component = 32 bytes (KEY-3). Composite key = SHA-256(password component ‖ key file component), omitting absent components, password first. password pässword typed decomposed vs precomposed → different keys
KEY-3 Key file reading, first match wins: XML version 2.0 (hex data plus a 4-byte hash checked against the first 4 bytes of SHA-256(data); mismatch → refuse); XML version 1.0 (base64 of 32 bytes); exactly 32 bytes → used as is; exactly 64 hex characters → decoded; any other non-empty file → SHA-256(file). Empty file → refuse. 64-hex-char file → its 32 decoded bytes; 1 KB photo → its SHA-256
KEY-4 "Generate key file" writes 32 random bytes as XML version 2.0 with its hash. Using a legacy key file kind (XML 1.0, raw, hex, hashed) works but shows a suggestion to replace it. The vault file itself can never be its own key file. The app never modifies a key file. Creating a vault with a key file warns that losing the file means losing the vault. choose the vault as key file → refused; open vault with key file → key file mtime unchanged
KEY-5 Wrong key → "invalid credentials" only, never which component was wrong; the password field is cleared and refocused. There is no attempt limit; the KDF cost is the brake. wrong password, right key file → same message as both wrong
KEY-6 The unlock screen remembers the last key file path per vault (setting remember_key_files, default on) and never the password. Off → stored paths are deleted. turn off → reopen app → key file field empty
KEY-7 Change key: while unlocked, set a new password (typed twice, shown matching or not) and/or key file; takes effect on the next save. Earlier backups and copies keep the old key; a notice says so. change password, save, reopen → only new password works
KEY-8 New and changed vault passwords show estimated entropy and quality (GEN-9); a setting min_vault_password_quality (none default, poor, weak, good, excellent) blocks weaker ones. minimum good, enter summer2024 → refused
KEY-9 The password may be supplied on standard input when opening from a command line, never as a command-line argument. pass password as argument → refused with usage message
KEY-10 Quick unlock (optional, offered only where the OS provides biometric or device-PIN protected key storage, default on there): after a full unlock, a locked vault reopens with that OS prompt. The stored secret is removed on app exit, vault close, key change, a failed OS prompt 3 times, or turning it off. quick unlock, exit app, restart → full key required

Out of scope

  • Hardware challenge-response tokens and smart cards as key components.
  • Key escrow or recovery; a lost key cannot be recovered.

Decisions

  • No unlock attempt limit: the file can be copied and attacked offline anyway, so only the KDF cost protects it.
  • Quick unlock never survives an app restart: the OS store holds a secret only for the running session.

Security

Status: Draft · Prefix: SEC

Purpose

Rules that keep secrets on the device and out of sight: no telemetry, no network by default, memory hygiene, clipboard clearing and automatic locking.

Requirements

ID Requirement Verified by
SEC-1 No telemetry, analytics, usage pings or crash uploads, ever, in any setting. capture traffic during a full session with defaults → zero outbound connections
SEC-2 No network by default: a fresh install makes no outbound connection in any workflow. Only the features in SEC-3 may connect, each only while its own setting is on. every SEC-3 setting off → zero connections while using every feature
SEC-3 Opt-in network features, each off by default: icon_download (fetch a site icon for one entry on the user's request: GET the URL host's icon over HTTPS, no cookies, 10 s timeout, no third-party icon service unless a separate setting is on); breach_check (GEN-11); update_check (asked once at first start; sends only a version request). icon download off → icon button hidden
SEC-4 Memory hygiene (goal, verified by review): passwords, protected fields, keys and derived keys live in buffers that are overwritten with zeros before release, locked against swapping where the OS allows, and kept XORed with a per-session random key while not in use. manual: review secret buffer handling; memory dump after lock → no known test password
SEC-5 Process hardening where the OS allows: core and crash dumps of process memory off; other same-user processes cannot attach a debugger or read process memory. manual: attach debugger as same user → refused on supporting OS
SEC-6 Logs, error messages and the settings file never contain passwords, field values, one-time codes, key file contents or key material. Settings hold only preferences and, when allowed, recent vault and key file paths. grep settings and logs for a test password → none
SEC-7 Copying a secret starts a clear timer (setting clear_clipboard, default on, 10 s, range 1..999 s). When it ends the clipboard is cleared only if it still holds the copied value. Locking and quitting also clear it. Copies are flagged to the OS as sensitive so clipboard history and cloud sync skip them where supported. After copying, the window minimises (default), hides, or stays (setting). copy password, wait 10 s → clipboard empty; copy, then copy other text → that text kept; default, copy username → window minimised
SEC-8 Auto-lock: after idle_lock_seconds with no input to the app (setting on, default 900 s, range 10..86400), when the OS screen locks (default on), when the OS user session switches (default on), on minimise (default off), after auto-type (default off). idle 900 s → vault locked
SEC-9 Lock discards all decrypted data and keys from memory, closes open dialogs of that vault and shows only the file name and unlock form. Unsaved changes: saved first when auto-save is on; otherwise the user is asked and an idle lock waits rather than discard. auto-save off, edit, idle → prompt, data not lost
SEC-10 Opening an attachment writes it to a temporary file readable only by the current user; it is deleted on close of the viewer, lock or exit, and any left over are deleted at next start. No other decrypted temporary files are written. open attachment, lock → temp file gone
SEC-11 Passwords are masked with a fixed count of dots that hides their length, in lists, preview and edit form (setting, default masked); each field has a reveal toggle. Notes can be masked too (default off). 4-char and 40-char passwords → same mask
SEC-12 Window content is excluded from screen capture and screen sharing where the OS supports it (setting allow_screen_capture, default off). Only one app instance runs per user; a second launch passes its file argument to the first over a per-user local channel and exits. screenshot tool → window blank; launch twice → one window

Out of scope

  • Protection against malware already running as the user or as an administrator.
  • Secure input against keyloggers.

Decisions

  • Network is a short opt-in list instead of a general toggle: each connection has one stated purpose and payload.
  • Memory hygiene is a stated goal checked by review: managed runtimes cannot always prove zeroing, but must try.

One-time codes

Status: Draft · Prefix: OTP

Purpose

Entries can hold a time-based one-time code secret and show the current code, compatible with standard authenticator setups.

Requirements

ID Requirement Verified by
OTP-1 Stored in the entry's protected custom field otp as an otpauth://totp/<label>?secret=<base32>&period=<s>&digits=<n>&algorithm=<SHA1|SHA256|SHA512>&issuer=<text> URI; defaults period 30, digits 6, SHA1 (omitted from the URI when default). Optional encoder=steam. set up with defaults → URI has only secret and issuer
OTP-2 Setup accepts a pasted URI or a Base32 secret plus period (1..86400 s), digits (6..10) and algorithm. Base32 ignores case, spaces and =; invalid characters or an empty secret → refused with a message. secret jbsw y3dp ehpk 3pxp → accepted
OTP-3 Older storage read on open and kept as found: a TOTP Seed field with a TOTP Settings field <period>;<digits> (or <period>;S for the Steam encoder), and TimeOtp-Secret-Base32 with TimeOtp-Length, TimeOtp-Period, TimeOtp-Algorithm fields. entry with TOTP Settings 60;8 → 8-digit code every 60 s
OTP-4 Code (RFC 6238): T = floor(unix_seconds / period) as 8-byte big-endian, HMAC with the algorithm and secret, dynamic truncation, modulo 10^digits, left-padded with zeros. Uses the system clock; never network time. RFC 6238 test vectors → all match
OTP-5 Steam encoder: 5 characters, each = alphabet 23456789BCDFGHJKMNPQRTVWXY[v mod 26] with v divided by 26 after each step, from the truncated 31-bit value. known Steam vector → expected 5 chars
OTP-6 Display: the current code and seconds left with a countdown bar, refreshed at each period boundary; entries with a code show a marker in the list; Mod+T copies the code (SEC-7 clearing applies); {TOTP} in auto-type and IPC gives the current code. copy at 29 s left → code valid for that window
OTP-7 The setup URI can be shown as a QR code rendered locally to move the secret to another authenticator; the secret is masked until revealed. show QR → scanning with an authenticator gives the same codes

Out of scope

  • Counter-based codes (HOTP), push approvals, hardware tokens.

Decisions

  • One field holds the whole URI: the standard format, readable by other apps sharing the file; older field pairs are read but never written.

Transfer

Status: Draft · Prefix: XFR

Purpose

Bring passwords in from CSV files and other managers' export formats, and take them out, with clear warnings when the output is not encrypted.

Requirements

ID Requirement Verified by
XFR-1 Import sources: CSV; KDBX 1 (.kdb, needs its key); unencrypted or password-encrypted JSON item exports (folders, items with login, card, identity and note types); 1pux zip archives; opvault folders. The target is a new vault (asks for location and composite key) or a new group in the open vault. import JSON into open vault → new group with all items
XFR-2 CSV import: preview of the first 20 rows; choose encoding (UTF-8 default; legacy code pages), delimiter (auto-detected among , ; tab |), quote ", comment prefix #, and whether row 1 is a header. Each column maps to group, title, username, password, url, notes, otp, icon, modified, created, a custom field named after its header, or ignore. ;-delimited file → detected, preview correct
XFR-3 CSV values: group is a /-separated path (created as needed; a leading root name is dropped); times accept ISO 8601 or Unix seconds (invalid → import time). Bad rows are listed with row numbers and skipped; a summary gives counts imported and skipped. row with 3 cols in a 5-col file → reported, others imported
XFR-4 JSON and archive imports: folders → groups; login items → entries (first URI → url, further URIs → custom fields url_2, url_3…), hidden fields → protected custom fields, one-time code → otp (OTP-1), other item types → entries with their fields as custom fields; attachments imported when present; tags kept. item with 3 URIs → url, url_2, url_3
XFR-5 Export formats: KDBX (save a copy with the same or a new key), CSV, HTML (printable tables per group), XML (the decrypted vault document). export each → 4 files open in their viewers
XFR-6 Before any unencrypted export the user confirms a warning that the file holds every password in plain text and anyone who gets it can read them; there is no "don't ask again". export CSV → warning every time
XFR-7 Export files are created readable only by the current user where the OS supports it, at a path the user chose; nothing is exported implicitly. export CSV → file mode owner-only
XFR-8 CSV export: UTF-8 without BOM, RFC 4180 quoting, \n line ends, header Group, Title, Username, Password, URL, Notes, TOTP, Icon, Last Modified, Created (ISO 8601 UTC); group = path below the root; recycle bin and history excluded; a note says custom fields and attachments are not included. entry in Root/Work/Mail → Group Work/Mail
XFR-9 XML export holds everything, history and recycle bin included, with protected values in plain text and attachments inline as base64. Re-importing it into a new vault gives the same content. export XML, import → entry count and fields equal

Out of scope

  • Importing from browsers' built-in password stores directly; scheduled or automatic exports.
  • Product-specific formats beyond the generic ones in XFR-1.

Decisions

  • Import never writes into existing entries: new vault or new group, so a bad import is one delete away.
  • @specfork/privacy is not required: its export must exclude secrets, and here the secrets are the data; local export lives in this spec.

Vault

Status: Draft · Prefix: VLT

Purpose

The vault file: its on-disk layout in the open KDBX 4 format, key derivation, encryption, integrity checks, and how it is saved, backed up and reloaded.

Requirements

ID Requirement Verified by
VLT-1 A vault is one file (default extension .kdbx) holding every group, entry, attachment, custom icon and vault setting. No vault content is written anywhere else except attachments the user opens (SEC-10) and exports (XFR-7). create vault, add 3 entries, copy only the file to another computer → all 3 entries open there
VLT-2 File start: uint32 0x9AA2D903, uint32 0xB54BFB67, uint32 version (major in the high 16 bits, minor in the low). Writes 4.0 (0x00040000), or 4.1 (0x00040001) when the vault uses 4.1-only data: tags, custom icon names or times, previous parent group, entry health-check exclusion. Reads 3.1, 4.0, 4.1. Wrong signatures → "not a vault file"; major version above 4 → "made by a newer app", nothing opened; KDBX 1 files only through import (XFR-1). vault with a tag → saved version 4.1; without → 4.0; random file → "not a vault file"; version 5.0 → refused
VLT-3 Outer header: repeated {id uint8, length uint32, value}. Fields: 2 cipher id (16-byte UUID), 3 compression (uint32: 0 none, 1 gzip), 4 master seed (32 bytes), 7 encryption IV (16 bytes for AES and Twofish, 12 for ChaCha20), 11 KDF parameters (variant map), 12 public custom data (variant map, optional, never secret), 0 end. A missing required field, unknown id or wrong length → refuse as corrupted. header without field 4 → "corrupted"
VLT-4 Variant map: uint16 version 0x0100 (high byte must be 0x01), then items {type uint8, name_len uint32, name UTF-8, value_len uint32, value} until type 0. Types: 0x04 uint32, 0x05 uint64, 0x08 bool, 0x0C int32, 0x0D int64, 0x18 UTF-8 string, 0x42 bytes. map version 0x0200 → refused
VLT-5 The header bytes are followed by SHA-256(header) and HMAC-SHA-256(header) keyed with the block key for index 2^64−1 (VLT-8). Hash mismatch → "corrupted"; HMAC mismatch → "invalid credentials" (KEY-5). flip one header byte → "corrupted"; wrong password → "invalid credentials"
VLT-6 KDF parameters: $UUID (16 bytes) selects the KDF. Argon2 (id 9e298b19-56db-4773-b23d-fc3ec6f0a1e6 = Argon2id, ef636ddf-8c29-444b-91f7-a9a403e30a0c = Argon2d) reads S salt (32 bytes), P parallelism uint32, M memory in bytes uint64, I iterations uint64, V version uint32 = 0x13, optional K secret and A associated data. AES-KDF (c9d9f39a-628a-4460-bf74-0d08c18a4fea) reads R rounds uint64 and S seed (32 bytes). All three open; any other → refuse. vault with Argon2d → opens; unknown KDF id → refused
VLT-7 New vaults use Argon2id: memory 64 MiB, parallelism min(logical CPUs, 4), iterations calibrated so one derivation takes ≈ the target time (default 1000 ms, settable 100..5000 ms), at least 2. Manual limits: memory 8 KiB..4 GiB, parallelism 1..256, iterations 1..2^32−1. Settings warn, without blocking, when Argon2 memory < 16 MiB or estimated unlock < 100 ms ("weak"), or memory > 2 GiB or estimated unlock > 10 s ("may not open on smaller devices"). Changing KDF settings shows the new estimated unlock time and applies on the next save. new vault on a 2-core machine → P = 2, M = 67108864; set memory 4 MiB → weak warning shown, save allowed
VLT-8 Keys: transformed = KDF(composite key); enc_key = SHA-256(master_seed ‖ transformed); hmac_base = SHA-512(master_seed ‖ transformed ‖ 0x01); block key i = SHA-512(uint64 i ‖ hmac_base). known test vector vault → derived keys match
VLT-9 Ciphers: AES-256-CBC (31c1f2e6-bf71-4350-be58-05216afc5aff), ChaCha20 (d6038a2b-8b6f-4cb5-a524-339a31dbb59a), Twofish-256-CBC (ad68f29f-576f-4bb9-a36a-d47af965346c); CBC uses PKCS#7 padding. Default AES-256; selectable per vault. switch to ChaCha20, save, reopen → header cipher id is ChaCha20, IV 12 bytes
VLT-10 Payload after the header: blocks {hmac 32 bytes, size int32, data} with hmac = HMAC-SHA-256(block key i, uint64 i ‖ int32 size ‖ data), i from 0, data ≤ 1 MiB, ending with an empty block. Any block mismatch → refuse; never show partial data. flip one byte in block 2 → open refused, nothing shown
VLT-11 The joined blocks are decrypted, then gunzipped when compression = 1 (default on), giving the inner header {id uint8, length uint32, value}: 1 inner stream id (3 = ChaCha20; 2 = Salsa20, read only), 2 inner stream key (64 random bytes), 3 attachment (1 flag byte, bit 0x01 = protected, then content; order gives index 0..n), 0 end. The XML document follows. Protected values (passwords, protected custom fields) in the XML are XORed with the inner stream (ChaCha20, key = first 32 bytes and nonce = next 12 bytes of SHA-512(inner key)) in document order, then base64-encoded. vault with 2 attachments → inner header holds 2 binaries; read password element raw → not the plain value
VLT-12 XML: root KeePassFile with Meta (VLT-16 settings, custom icons, custom data) and Root (one root group, DeletedObjects list of {UUID, DeletionTime}). UUIDs are base64 of 16 bytes; times are base64 of int64 seconds since 0001-01-01T00:00:00Z. Custom data on vault, group and entry is kept unchanged on save; other unknown elements are ignored. custom data key set by another app → still present after save
VLT-13 Every save makes a new master seed, IV, KDF salt and inner stream key. Saving is atomic: write a temporary file in the same folder, flush to disk, then replace the vault. On failure the old file stays intact, an error is shown and the vault stays marked modified. A setting direct_write (default off) writes in place for folders that cannot rename. save twice with no change → files differ, both open; disk full during save → old file opens, * still shown
VLT-14 Backup before save (default off): copy the current file to a path from a pattern (default {DB_FILENAME}.old.kdbx; {TIME:fmt} inserts the local time) before writing; a failed backup aborts the save. backup on, save → .old.kdbx holds the previous version
VLT-15 Auto-save after every change (default on) and on lock and exit (default on). With auto-save off the tab shows *; closing, locking or quitting asks save / discard / cancel. auto-save off, edit, quit → prompt
VLT-16 Vault settings: name, description, default_username, color (tab marker), recycle_bin_enabled (default true), history_max_items (default 10, −1 = no limit), history_max_size (default 6 MiB, −1 = no limit), compression (default gzip), cipher and KDF. new vault → history limits 10 and 6 MiB
VLT-17 External change: the open file is watched. Changed on disk with no unsaved changes → reloaded silently (setting auto_reload, default on); with unsaved changes → ask "merge mine into the new file" (HIS-10) or "keep mine and overwrite on next save". Changed while locked → the new file is read on unlock. edit file from a second app → first app shows the change within 5 s
VLT-18 Several vaults can be open at once, each in its own tab with its own lock state. Recently opened vault paths are remembered (setting, default on) and reopened at start (default on). open 2 vaults, lock one → other stays unlocked

Out of scope

  • Writing KDBX 3.1 or older; hardware challenge-response key components.
  • Sharing or syncing folders of vaults between people; cloud storage clients.
  • The detailed XML element list beyond VLT-12; implementers follow the published KDBX 4 format.

Decisions

  • Argon2id is the default KDF for new vaults (the source defaults to Argon2d): it resists side channels and is the current general recommendation; Argon2d and AES-KDF still open.
  • Always write KDBX 4: one write path, and 4.0 stays readable by older compatible apps unless 4.1 data is present.
  • KDF warnings never block: the user may need weak settings for a slow phone app that shares the file.

From Specfork/password-manager at b1aa78a, licence CC-BY-4.0.