krz/keycask

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

docs/superpowers/specs/2026-09-17-keycask-design.md

main
keycask/docs/superpowers/specs/2026-09-17-keycask-design.md rendered · source · history · blame · raw

304 lines · 13206 bytes

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.

{
  "format": 1,
  "kdf": {
    "name": "pbkdf2-hmac-sha256",
    "iterations": 600000,
    "salt": "<base64, 16 random bytes>"
  },
  "box": "<base64, ChaCha20-Poly1305 combined nonce || ciphertext || tag>"
}
  • 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

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 <name>.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.