docs/superpowers/specs/2026-09-17-keycask-design.md
304 lines · 13206 bytes
1# keycask design
2
3Swift password manager: a shared core library, a command-line tool for
4macOS, Linux, and Windows, and later an iOS/macOS app and an AutoFill
5extension. This document covers the core and the CLI. The app and the
6extension get their own specs on top of a working core.
7
8The repository history before this commit was a Rust program of the same
9name. Nothing here is constrained by it: not the vault format, not the
10command set, not the tests.
11
12## Goals
13
14- One vault file, encrypted with a passphrase, readable by every target
15 including a sandboxed iOS app.
16- A core library with no platform dependencies beyond Foundation and
17 swift-crypto, so the CLI and the apps share all logic.
18- A CLI that covers daily use and is scriptable: `--json` on reads,
19 stable exit codes, a non-interactive passphrase path.
20- Identical behavior on macOS, Linux, and Windows, verified by one test
21 suite.
22
23## Non-goals for this slice
24
25- The app and the AutoFill extension.
26- Sync of any kind.
27- TOTP, import/export, audit, password history, multiple recipients,
28 changing the passphrase, QR output, man pages, shell completions.
29- Reading vaults from the Rust version.
30- A config file.
31
32## Repository and package
33
34One SwiftPM package at the repository root. `swift-tools-version:6.4`,
35Swift 6 language mode.
36
37```
38Package.swift
39Sources/KeycaskCore/ library: model, envelope, generator, resolution, wordlist
40Sources/keycask/ executable: ArgumentParser commands, paths, prompt, clipboard, atomic write
41Tests/KeycaskCoreTests/ Swift Testing, in-process
42Tests/KeycaskCLITests/ Swift Testing, spawns the built binary
43docs/superpowers/specs/
44.gitbay/ci.yml
45LICENSE (0BSD), NOTICE, README.md, .gitignore
46```
47
48Dependencies: `apple/swift-crypto` and `apple/swift-argument-parser`.
49Nothing else. `Package.resolved` is committed.
50
51`KeycaskCore` imports Foundation and Crypto only. It never imports
52ArgumentParser, AppKit, UIKit, or spawns a process. Everything that
53touches a terminal, a path convention, the clipboard, or a subprocess
54lives in the executable target. A future app target links `KeycaskCore`
55and supplies its own versions of those pieces.
56
57`platforms:` in the manifest lists `.macOS(.v14)` and `.iOS(.v17)`. That
58sets Apple minimums only and has no effect on Linux or Windows.
59
60## Vault file format
61
62The file is a JSON envelope. It is inspectable with any JSON tool and
63versioned without a hand-written binary parser.
64
65```json
66{
67 "format": 1,
68 "kdf": {
69 "name": "pbkdf2-hmac-sha256",
70 "iterations": 600000,
71 "salt": "<base64, 16 random bytes>"
72 },
73 "box": "<base64, ChaCha20-Poly1305 combined nonce || ciphertext || tag>"
74}
75```
76
77- Key: 32 bytes from PBKDF2-HMAC-SHA256 over the passphrase, encoded as
78 UTF-8 after NFC normalization, with the salt and iteration count from
79 the envelope. PBKDF2 comes from swift-crypto's `_CryptoExtras`.
80- Salt: 16 random bytes generated by `init`, kept unchanged across saves.
81- Nonce: 12 random bytes, fresh on every save. `box` is the
82 `ChaChaPoly.SealedBox.combined` bytes.
83- Plaintext: the JSON-encoded vault from the next section.
84- No additional authenticated data.
85
86Decryption outcomes:
87
88- Authentication failure, for any reason including a wrong passphrase,
89 is "cannot decrypt". The two cases are never distinguished.
90- A file that is not valid JSON, does not match the envelope shape, has
91 an unknown `kdf.name`, or has a `format` other than 1 is "corrupt".
92- Plaintext that decrypts but does not decode as a vault is "corrupt".
93
94Writing an envelope reuses the salt and iteration count of the envelope
95that was read, so a vault created with one KDF setting keeps it until a
96future explicit rekey command exists.
97
98## Data model
99
100```swift
101public struct Vault: Codable, Sendable {
102 public var entries: [Entry]
103}
104
105public struct Entry: Codable, Sendable {
106 public let id: EntryID
107 public var name: String
108 public var username: String?
109 public var password: String
110 public var url: String?
111 public var notes: String?
112 public var tags: [String]
113 public let created: Date
114 public var updated: Date
115}
116```
117
118- `EntryID` is eight characters from the alphabet `a-z` without `l` and
119 `o`, plus digits `2-9`: 32 symbols, 40 bits. IDs are generated with
120 `SystemRandomNumberGenerator` and checked for uniqueness against the
121 vault on insert. They are shown in every listing. Names are labels and
122 may repeat; the ID is the identity.
123- `tags` is kept sorted and free of duplicates, with case preserved, by
124 the mutation methods on `Vault`. Comparison for `--tag` filters is
125 case-insensitive.
126- Dates are ISO 8601 in UTC with whole-second precision.
127- Encoding uses sorted keys and a fixed date strategy so the same vault
128 always produces the same plaintext bytes. Foundation's `UUID` is not
129 used anywhere.
130
131`Vault` exposes: `add(_:)`, `remove(id:)`, `update(id:_:)`,
132`resolve(_ ref: String) throws -> Entry`, `filter(tag:)`,
133`search(_ query: String)`.
134
135## Resolution
136
137Every command that names an entry takes a `REF`.
138
1391. If `REF` equals an entry's ID, that entry.
1402. Otherwise, if exactly one entry has `name == REF`, that entry.
1413. Otherwise, if several entries have that name, error "ambiguous",
142 listing each candidate's ID, username, and URL. Exit 5.
1434. Otherwise, error "not found". Exit 3.
144
145There is no prefix matching. IDs are short by design so they are typed
146whole. A name that happens to equal another entry's ID resolves to the
147ID; the listing shows both, so the user can see it.
148
149## CLI
150
151Built with swift-argument-parser. Root command `keycask`, global option
152`--vault PATH`.
153
154| Command | Behavior |
155|---|---|
156| `init` | Prompts for the passphrase twice, creates an empty vault. Fails with exit 1 if the file exists. |
157| `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. |
158| `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. |
159| `ls [--tag T] [--json]` | One row per entry: id, name, username, url. Sorted by name, then id. |
160| `find QUERY [--json]` | Case-insensitive substring match over name, username, url, notes, and tags. Same output shape as `ls`. |
161| `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. |
162| `rm REF [--yes]` | Asks for confirmation on a TTY unless `--yes`. Without a TTY and without `--yes`, exit 2. |
163| `generate [--length N] [--words N] [--copy]` | Prints a password. Does not open the vault. `--copy` goes to the clipboard instead of stdout. |
164| `clip REF [--field NAME]` | Copies the field (default `password`) and clears it after 45 seconds if the clipboard still holds it. |
165| `clipboard-daemon` | Hidden. The detached child that performs the delayed clear. |
166
167Output rules:
168
169- `--json` emits one object (`show`) or an array (`ls`, `find`) of
170 entries with the same field names as the model. `password` is the
171 string `"********"` unless `--reveal`.
172- Text output goes to stdout. Errors go to stderr as one line.
173- `--length` defaults to 24. The character alphabet is ASCII upper,
174 lower, digits, and the symbols `!@#$%^&*()-_=+[]{};:,.<>?`. Every
175 character is drawn uniformly with `SystemRandomNumberGenerator`.
176- `--words N` takes an explicit count. `generate` with neither `--length`
177 nor `--words` produces a 24-character password. Words come from the EFF long list,
178 joined with `-`. The list is embedded as one multi-line string literal
179 in `KeycaskCore` and split on first use. Attribution goes in `NOTICE`.
180- `--generate` and `--words` together is a usage error.
181
182Passphrase input: if `KEYCASK_PASSPHRASE` is set, its value is used. It
183exists so tests and scripts run unattended. Otherwise the CLI prompts on
184the terminal with echo off. No TTY and no variable is exit 2.
185
186Password input for `add` and `edit --password`: on a terminal, a hidden
187prompt. Without a terminal, the first line of stdin. Neither available is
188exit 2.
189
190Exit codes:
191
192| Code | Meaning |
193|---|---|
194| 0 | ok |
195| 1 | failure (I/O, vault exists, clipboard tool missing, and anything else) |
196| 2 | usage, including ArgumentParser validation errors and missing passphrase |
197| 3 | not found (entry, or vault file) |
198| 4 | cannot decrypt |
199| 5 | ambiguous name |
200
201ArgumentParser exits with 64 on validation errors by default. `main`
202calls `parseAsRoot()` inside `do/catch`, prints
203`fullMessage(for:)` to stderr, and exits 2 instead.
204
205## Platform layer
206
207All in the executable target, each behind a small function with a Unix
208and a Windows body where they differ.
209
210- **Vault path.** `--vault`, else `$KEYCASK_VAULT`, else
211 `$XDG_DATA_HOME/keycask/vault.kc`, else
212 `$HOME/.local/share/keycask/vault.kc`. On Windows:
213 `%LOCALAPPDATA%\keycask\vault.kc`. The directory is created on write.
214- **Atomic write.** Write to `<name>.tmp` beside the target, fsync,
215 rename over the target. Unix opens the temp file with mode 0600.
216 Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` because
217 `rename` fails on an existing target there. The temp file is removed
218 on any failure.
219- **Prompt.** Reads a line from the terminal with echo off: termios on
220 Unix, `SetConsoleMode` without `ENABLE_ECHO_INPUT` on Windows.
221 Restores the mode afterwards, including on error.
222- **Clipboard.** Shell out to the first tool found: `pbcopy`/`pbpaste`
223 on macOS; `wl-copy`/`wl-paste`, then `xclip -selection clipboard`, on
224 Linux; `clip.exe` to write and `powershell -command Get-Clipboard` to
225 read on Windows. No tool found is exit 1 with a message naming the
226 tools. `clip` spawns `keycask clipboard-daemon` detached with stdout
227 and stderr to null and exits without waiting. `clip` writes the
228 clipboard itself, then spawns the daemon with `{"secret": ...}` on its
229 stdin. The daemon sleeps 45 seconds, reads the clipboard, and clears
230 it if it still equals the secret. It never restores earlier contents,
231 so a second `clip` inside the window cannot bring an earlier secret
232 back.
233
234## Errors
235
236One `KeycaskError` enum in `KeycaskCore` with a case per situation and an
237`exitCode` and `message` property. Foundation and Crypto errors are
238wrapped into it at the call site that produced them; nothing outside
239`KeycaskCore` sees a raw `CryptoKitError` or `CocoaError`. The
240executable's `main` catches `KeycaskError`, prints `message` to stderr,
241and exits with `exitCode`. Any other error is a bug and exits 1 with its
242description.
243
244## Testing
245
246Swift Testing on all targets.
247
248Core, in process:
249
250- Envelope: encrypt then decrypt round-trips; wrong passphrase is
251 `cannotDecrypt`; one flipped byte in `box` is `cannotDecrypt`;
252 non-JSON, wrong `format`, and unknown `kdf.name` are `corrupt`; the
253 salt is preserved across a save and the nonce is not; a PBKDF2 vector
254 from a published source matches.
255- Model: encoding the same vault twice yields identical bytes; tags are
256 sorted and unique after every mutation; `updated` changes on update.
257- ID: length 8, alphabet as specified, insert rejects a duplicate.
258- Resolution: ID beats name; unique name resolves; repeated name is
259 `ambiguous` with all candidates; unknown is `notFound`.
260- Generator: requested length; only alphabet characters; word count and
261 separator; every word is in the list.
262- Search: case-insensitive, matches each searchable field, no match
263 returns empty.
264
265CLI, black box:
266
267- Locates the built `keycask` binary from the test bundle's products
268 directory.
269- Each test gets its own temp directory and runs the binary with
270 `KEYCASK_VAULT` pointing into it and `KEYCASK_PASSPHRASE` set. Tests
271 are independent and may run in parallel.
272- Covers every command's happy path, every exit code, `--json` shape,
273 masking versus `--reveal`, `--field` raw output, the ambiguous-name
274 listing, `rm` without `--yes` and without a TTY, `edit` with no flags,
275 `--generate` with `--words`, and `init` on an existing vault.
276- The clipboard daemon's clear decision is unit-tested in process;
277 the tests do not touch the real clipboard.
278
279The CLI suite is the conformance suite. When the app exists, its
280behavior is checked against the same expectations.
281
282## CI and release
283
284`.gitbay/ci.yml`, Linux runner:
285
2861. Install swiftly into `$HOME` if missing, then the Swift 6.4
287 toolchain. `$HOME` persists across builds so this is a one-time cost.
2882. `swift format lint --strict --recursive Sources Tests`
2893. `swift build`
2904. `swift test`
291
292macOS and Windows are built and tested by hand until runners exist for
293them.
294
295Releases are annotated git tags `vX.Y.Z`. The Homebrew formula lives in
296a separate tap repository and builds the tagged source with
297`swift build -c release --product keycask`. Prebuilt Linux and Windows
298binaries are a later addition.
299
300## Workflow
301
302The root commit of this history is the wipe: this spec, the license,
303`.gitignore`, and a README skeleton, pushed directly to `main`. Every
304change after it goes through a branch and a merge request.