# keycask design Swift password manager: a shared core library, a command-line tool for macOS, Linux, and Windows, and later an iOS/macOS app and an AutoFill extension. This document covers the core and the CLI. The app and the extension get their own specs on top of a working core. The repository history before this commit was a Rust program of the same name. Nothing here is constrained by it: not the vault format, not the command set, not the tests. ## Goals - One vault file, encrypted with a passphrase, readable by every target including a sandboxed iOS app. - A core library with no platform dependencies beyond Foundation and swift-crypto, so the CLI and the apps share all logic. - A CLI that covers daily use and is scriptable: `--json` on reads, stable exit codes, a non-interactive passphrase path. - Identical behavior on macOS, Linux, and Windows, verified by one test suite. ## Non-goals for this slice - The app and the AutoFill extension. - Sync of any kind. - TOTP, import/export, audit, password history, multiple recipients, changing the passphrase, QR output, man pages, shell completions. - Reading vaults from the Rust version. - A config file. ## Repository and package One SwiftPM package at the repository root. `swift-tools-version:6.4`, Swift 6 language mode. ``` Package.swift Sources/KeycaskCore/ library: model, envelope, generator, resolution, wordlist Sources/keycask/ executable: ArgumentParser commands, paths, prompt, clipboard, atomic write Tests/KeycaskCoreTests/ Swift Testing, in-process Tests/KeycaskCLITests/ Swift Testing, spawns the built binary docs/superpowers/specs/ .gitbay/ci.yml LICENSE (0BSD), NOTICE, README.md, .gitignore ``` Dependencies: `apple/swift-crypto` and `apple/swift-argument-parser`. Nothing else. `Package.resolved` is committed. `KeycaskCore` imports Foundation and Crypto only. It never imports ArgumentParser, AppKit, UIKit, or spawns a process. Everything that touches a terminal, a path convention, the clipboard, or a subprocess lives in the executable target. A future app target links `KeycaskCore` and supplies its own versions of those pieces. `platforms:` in the manifest lists `.macOS(.v14)` and `.iOS(.v17)`. That sets Apple minimums only and has no effect on Linux or Windows. ## Vault file format The file is a JSON envelope. It is inspectable with any JSON tool and versioned without a hand-written binary parser. ```json { "format": 1, "kdf": { "name": "pbkdf2-hmac-sha256", "iterations": 600000, "salt": "" }, "box": "" } ``` - Key: 32 bytes from PBKDF2-HMAC-SHA256 over the passphrase, encoded as UTF-8 after NFC normalization, with the salt and iteration count from the envelope. PBKDF2 comes from swift-crypto's `_CryptoExtras`. - Salt: 16 random bytes generated by `init`, kept unchanged across saves. - Nonce: 12 random bytes, fresh on every save. `box` is the `ChaChaPoly.SealedBox.combined` bytes. - Plaintext: the JSON-encoded vault from the next section. - No additional authenticated data. Decryption outcomes: - Authentication failure, for any reason including a wrong passphrase, is "cannot decrypt". The two cases are never distinguished. - A file that is not valid JSON, does not match the envelope shape, has an unknown `kdf.name`, or has a `format` other than 1 is "corrupt". - Plaintext that decrypts but does not decode as a vault is "corrupt". Writing an envelope reuses the salt and iteration count of the envelope that was read, so a vault created with one KDF setting keeps it until a future explicit rekey command exists. ## Data model ```swift public struct Vault: Codable, Sendable { public var entries: [Entry] } public struct Entry: Codable, Sendable { public let id: EntryID public var name: String public var username: String? public var password: String public var url: String? public var notes: String? public var tags: [String] public let created: Date public var updated: Date } ``` - `EntryID` is eight characters from the alphabet `a-z` without `l` and `o`, plus digits `2-9`: 32 symbols, 40 bits. IDs are generated with `SystemRandomNumberGenerator` and checked for uniqueness against the vault on insert. They are shown in every listing. Names are labels and may repeat; the ID is the identity. - `tags` is kept sorted and free of duplicates, with case preserved, by the mutation methods on `Vault`. Comparison for `--tag` filters is case-insensitive. - Dates are ISO 8601 in UTC with whole-second precision. - Encoding uses sorted keys and a fixed date strategy so the same vault always produces the same plaintext bytes. Foundation's `UUID` is not used anywhere. `Vault` exposes: `add(_:)`, `remove(id:)`, `update(id:_:)`, `resolve(_ ref: String) throws -> Entry`, `filter(tag:)`, `search(_ query: String)`. ## Resolution Every command that names an entry takes a `REF`. 1. If `REF` equals an entry's ID, that entry. 2. Otherwise, if exactly one entry has `name == REF`, that entry. 3. Otherwise, if several entries have that name, error "ambiguous", listing each candidate's ID, username, and URL. Exit 5. 4. Otherwise, error "not found". Exit 3. There is no prefix matching. IDs are short by design so they are typed whole. A name that happens to equal another entry's ID resolves to the ID; the listing shows both, so the user can see it. ## CLI Built with swift-argument-parser. Root command `keycask`, global option `--vault PATH`. | Command | Behavior | |---|---| | `init` | Prompts for the passphrase twice, creates an empty vault. Fails with exit 1 if the file exists. | | `add NAME [-u USER] [--url URL] [--notes TEXT] [--tag T]... [--generate] [--length N] [--words N]` | Prompts for the password unless `--generate` or `--words` is given. Prints the new ID. | | `show REF [--reveal] [--field NAME] [--json]` | Text: one field per line, password masked unless `--reveal`. `--field` prints that one value raw, with no masking, for scripts. | | `ls [--tag T] [--json]` | One row per entry: id, name, username, url. Sorted by name, then id. | | `find QUERY [--json]` | Case-insensitive substring match over name, username, url, notes, and tags. Same output shape as `ls`. | | `edit REF [--name N] [-u USER] [--url URL] [--notes TEXT] [--tag T]... [--untag T]... [--password] [--generate] [--length N] [--words N]` | Applies the given changes. `--password` prompts. Any change sets `updated`. No flags is a usage error. | | `rm REF [--yes]` | Asks for confirmation on a TTY unless `--yes`. Without a TTY and without `--yes`, exit 2. | | `generate [--length N] [--words N] [--copy]` | Prints a password. Does not open the vault. `--copy` goes to the clipboard instead of stdout. | | `clip REF [--field NAME]` | Copies the field (default `password`) and clears it after 45 seconds if the clipboard still holds it. | | `clipboard-daemon` | Hidden. The detached child that performs the delayed clear. | Output rules: - `--json` emits one object (`show`) or an array (`ls`, `find`) of entries with the same field names as the model. `password` is the string `"********"` unless `--reveal`. - Text output goes to stdout. Errors go to stderr as one line. - `--length` defaults to 24. The character alphabet is ASCII upper, lower, digits, and the symbols `!@#$%^&*()-_=+[]{};:,.<>?`. Every character is drawn uniformly with `SystemRandomNumberGenerator`. - `--words N` takes an explicit count. `generate` with neither `--length` nor `--words` produces a 24-character password. Words come from the EFF long list, joined with `-`. The list is embedded as one multi-line string literal in `KeycaskCore` and split on first use. Attribution goes in `NOTICE`. - `--generate` and `--words` together is a usage error. Passphrase input: if `KEYCASK_PASSPHRASE` is set, its value is used. It exists so tests and scripts run unattended. Otherwise the CLI prompts on the terminal with echo off. No TTY and no variable is exit 2. Password input for `add` and `edit --password`: on a terminal, a hidden prompt. Without a terminal, the first line of stdin. Neither available is exit 2. Exit codes: | Code | Meaning | |---|---| | 0 | ok | | 1 | failure (I/O, vault exists, clipboard tool missing, and anything else) | | 2 | usage, including ArgumentParser validation errors and missing passphrase | | 3 | not found (entry, or vault file) | | 4 | cannot decrypt | | 5 | ambiguous name | ArgumentParser exits with 64 on validation errors by default. `main` calls `parseAsRoot()` inside `do/catch`, prints `fullMessage(for:)` to stderr, and exits 2 instead. ## Platform layer All in the executable target, each behind a small function with a Unix and a Windows body where they differ. - **Vault path.** `--vault`, else `$KEYCASK_VAULT`, else `$XDG_DATA_HOME/keycask/vault.kc`, else `$HOME/.local/share/keycask/vault.kc`. On Windows: `%LOCALAPPDATA%\keycask\vault.kc`. The directory is created on write. - **Atomic write.** Write to `.tmp` beside the target, fsync, rename over the target. Unix opens the temp file with mode 0600. Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` because `rename` fails on an existing target there. The temp file is removed on any failure. - **Prompt.** Reads a line from the terminal with echo off: termios on Unix, `SetConsoleMode` without `ENABLE_ECHO_INPUT` on Windows. Restores the mode afterwards, including on error. - **Clipboard.** Shell out to the first tool found: `pbcopy`/`pbpaste` on macOS; `wl-copy`/`wl-paste`, then `xclip -selection clipboard`, on Linux; `clip.exe` to write and `powershell -command Get-Clipboard` to read on Windows. No tool found is exit 1 with a message naming the tools. `clip` spawns `keycask clipboard-daemon` detached with stdout and stderr to null and exits without waiting. `clip` writes the clipboard itself, then spawns the daemon with `{"secret": ...}` on its stdin. The daemon sleeps 45 seconds, reads the clipboard, and clears it if it still equals the secret. It never restores earlier contents, so a second `clip` inside the window cannot bring an earlier secret back. ## Errors One `KeycaskError` enum in `KeycaskCore` with a case per situation and an `exitCode` and `message` property. Foundation and Crypto errors are wrapped into it at the call site that produced them; nothing outside `KeycaskCore` sees a raw `CryptoKitError` or `CocoaError`. The executable's `main` catches `KeycaskError`, prints `message` to stderr, and exits with `exitCode`. Any other error is a bug and exits 1 with its description. ## Testing Swift Testing on all targets. Core, in process: - Envelope: encrypt then decrypt round-trips; wrong passphrase is `cannotDecrypt`; one flipped byte in `box` is `cannotDecrypt`; non-JSON, wrong `format`, and unknown `kdf.name` are `corrupt`; the salt is preserved across a save and the nonce is not; a PBKDF2 vector from a published source matches. - Model: encoding the same vault twice yields identical bytes; tags are sorted and unique after every mutation; `updated` changes on update. - ID: length 8, alphabet as specified, insert rejects a duplicate. - Resolution: ID beats name; unique name resolves; repeated name is `ambiguous` with all candidates; unknown is `notFound`. - Generator: requested length; only alphabet characters; word count and separator; every word is in the list. - Search: case-insensitive, matches each searchable field, no match returns empty. CLI, black box: - Locates the built `keycask` binary from the test bundle's products directory. - Each test gets its own temp directory and runs the binary with `KEYCASK_VAULT` pointing into it and `KEYCASK_PASSPHRASE` set. Tests are independent and may run in parallel. - Covers every command's happy path, every exit code, `--json` shape, masking versus `--reveal`, `--field` raw output, the ambiguous-name listing, `rm` without `--yes` and without a TTY, `edit` with no flags, `--generate` with `--words`, and `init` on an existing vault. - The clipboard daemon's clear decision is unit-tested in process; the tests do not touch the real clipboard. The CLI suite is the conformance suite. When the app exists, its behavior is checked against the same expectations. ## CI and release `.gitbay/ci.yml`, Linux runner: 1. Install swiftly into `$HOME` if missing, then the Swift 6.4 toolchain. `$HOME` persists across builds so this is a one-time cost. 2. `swift format lint --strict --recursive Sources Tests` 3. `swift build` 4. `swift test` macOS and Windows are built and tested by hand until runners exist for them. Releases are annotated git tags `vX.Y.Z`. The Homebrew formula lives in a separate tap repository and builds the tagged source with `swift build -c release --product keycask`. Prebuilt Linux and Windows binaries are a later addition. ## Workflow The root commit of this history is the wipe: this spec, the license, `.gitignore`, and a README skeleton, pushed directly to `main`. Every change after it goes through a branch and a merge request.