krz/keycask

Password manager: Swift core library, CLI for macOS/Linux/Windows, iOS/macOS app. cli password-manager swift

Commit cf9e40ad0b

cf9e40ad0b45dc0066f63c68ce1888572e7596c3

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 06:24 UTC

Start over in Swift

Replace the Rust program with a Swift package: shared core library plus
a CLI for macOS, Linux, and Windows, with an iOS/macOS app to follow.
This commit holds the design spec, license, and README skeleton.

Layout: unified · split

.gitignore added +5
@@ -0,0 +1,5 @@
1.build/
2.swiftpm/
3DerivedData/
4.DS_Store
5*.kc
LICENSE added +12
@@ -0,0 +1,12 @@
1Copyright (C) 2026 krazy warez
2
3Permission to use, copy, modify, and/or distribute this software for any
4purpose with or without fee is hereby granted.
5
6THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
7WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
8MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
9ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
10WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
11ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
12OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
README.md added +13
@@ -0,0 +1,13 @@
1# keycask
2
3Command-line password manager. One passphrase-encrypted vault file.
4Swift, runs on macOS, Linux, and Windows.
5
6Work in progress. Design: `docs/superpowers/specs/2026-09-17-keycask-design.md`.
7
8## develop
9
10```sh
11swift build
12swift test
13```
docs/superpowers/specs/2026-09-17-keycask-design.md added +298
@@ -0,0 +1,298 @@
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
186Exit codes:
187
188| Code | Meaning |
189|---|---|
190| 0 | ok |
191| 1 | failure (I/O, vault exists, clipboard tool missing, and anything else) |
192| 2 | usage, including ArgumentParser validation errors and missing passphrase |
193| 3 | not found (entry, or vault file) |
194| 4 | cannot decrypt |
195| 5 | ambiguous name |
196
197ArgumentParser exits with 64 on validation errors by default. `main`
198calls `parseAsRoot()` inside `do/catch`, prints
199`fullMessage(for:)` to stderr, and exits 2 instead.
200
201## Platform layer
202
203All in the executable target, each behind a small function with a Unix
204and a Windows body where they differ.
205
206- **Vault path.** `--vault`, else `$KEYCASK_VAULT`, else
207 `$XDG_DATA_HOME/keycask/vault.kc`, else
208 `$HOME/.local/share/keycask/vault.kc`. On Windows:
209 `%LOCALAPPDATA%\keycask\vault.kc`. The directory is created on write.
210- **Atomic write.** Write to `<name>.tmp` beside the target, fsync,
211 rename over the target. Unix opens the temp file with mode 0600.
212 Windows uses `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` because
213 `rename` fails on an existing target there. The temp file is removed
214 on any failure.
215- **Prompt.** Reads a line from the terminal with echo off: termios on
216 Unix, `SetConsoleMode` without `ENABLE_ECHO_INPUT` on Windows.
217 Restores the mode afterwards, including on error.
218- **Clipboard.** Shell out to the first tool found: `pbcopy`/`pbpaste`
219 on macOS; `wl-copy`/`wl-paste`, then `xclip -selection clipboard`, on
220 Linux; `clip.exe` to write and `powershell -command Get-Clipboard` to
221 read on Windows. No tool found is exit 1 with a message naming the
222 tools. `clip` spawns `keycask clipboard-daemon` detached with stdout
223 and stderr to null, writes `{"secret": ..., "previous": ...}` to its
224 stdin, and exits without waiting. The daemon sets the clipboard,
225 sleeps 45 seconds, reads the clipboard, and if it still equals the
226 secret restores `previous` or clears when `previous` is empty.
227
228## Errors
229
230One `KeycaskError` enum in `KeycaskCore` with a case per situation and an
231`exitCode` and `message` property. Foundation and Crypto errors are
232wrapped into it at the call site that produced them; nothing outside
233`KeycaskCore` sees a raw `CryptoKitError` or `CocoaError`. The
234executable's `main` catches `KeycaskError`, prints `message` to stderr,
235and exits with `exitCode`. Any other error is a bug and exits 1 with its
236description.
237
238## Testing
239
240Swift Testing on all targets.
241
242Core, in process:
243
244- Envelope: encrypt then decrypt round-trips; wrong passphrase is
245 `cannotDecrypt`; one flipped byte in `box` is `cannotDecrypt`;
246 non-JSON, wrong `format`, and unknown `kdf.name` are `corrupt`; the
247 salt is preserved across a save and the nonce is not; a PBKDF2 vector
248 from a published source matches.
249- Model: encoding the same vault twice yields identical bytes; tags are
250 sorted and unique after every mutation; `updated` changes on update.
251- ID: length 8, alphabet as specified, insert rejects a duplicate.
252- Resolution: ID beats name; unique name resolves; repeated name is
253 `ambiguous` with all candidates; unknown is `notFound`.
254- Generator: requested length; only alphabet characters; word count and
255 separator; every word is in the list.
256- Search: case-insensitive, matches each searchable field, no match
257 returns empty.
258
259CLI, black box:
260
261- Locates the built `keycask` binary from the test bundle's products
262 directory.
263- Each test gets its own temp directory and runs the binary with
264 `KEYCASK_VAULT` pointing into it and `KEYCASK_PASSPHRASE` set. Tests
265 are independent and may run in parallel.
266- Covers every command's happy path, every exit code, `--json` shape,
267 masking versus `--reveal`, `--field` raw output, the ambiguous-name
268 listing, `rm` without `--yes` and without a TTY, `edit` with no flags,
269 `--generate` with `--words`, and `init` on an existing vault.
270- The clipboard daemon's restore decision is unit-tested in process;
271 the tests do not touch the real clipboard.
272
273The CLI suite is the conformance suite. When the app exists, its
274behavior is checked against the same expectations.
275
276## CI and release
277
278`.gitbay/ci.yml`, Linux runner:
279
2801. Install swiftly into `$HOME` if missing, then the Swift 6.4
281 toolchain. `$HOME` persists across builds so this is a one-time cost.
2822. `swift format lint --strict --recursive Sources Tests`
2833. `swift build`
2844. `swift test`
285
286macOS and Windows are built and tested by hand until runners exist for
287them.
288
289Releases are annotated git tags `vX.Y.Z`. The Homebrew formula lives in
290a separate tap repository and builds the tagged source with
291`swift build -c release --product keycask`. Prebuilt Linux and Windows
292binaries are a later addition.
293
294## Workflow
295
296The root commit of this history is the wipe: this spec, the license,
297`.gitignore`, and a README skeleton, pushed directly to `main`. Every
298change after it goes through a branch and a merge request.