krz/keycask

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

Commit 9a5889fafe

9a5889fafe80294ee796f2d7169e43f145455060

parent: 33770b09cc

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-17 15:59 UTC

Add CLI skeleton with init, paths, passphrase, atomic write

Layout: unified · split

Sources/keycask/AtomicFile.swift added +68
@@ -0,0 +1,68 @@
1import Foundation
2import KeycaskCore
3
4#if canImport(Darwin)
5 import Darwin
6#elseif canImport(Glibc)
7 import Glibc
8#elseif canImport(Musl)
9 import Musl
10#elseif os(Windows)
11 import WinSDK
12#endif
13
14enum AtomicFile {
15 static func write(_ data: Data, to url: URL) throws {
16 let directory = url.deletingLastPathComponent()
17 let temp = url.appendingPathExtension("tmp")
18 do {
19 try FileManager.default.createDirectory(
20 at: directory, withIntermediateDirectories: true)
21 try writePrivate(data, to: temp)
22 try replace(url, with: temp)
23 } catch let error as KeycaskError {
24 try? FileManager.default.removeItem(at: temp)
25 throw error
26 } catch {
27 try? FileManager.default.removeItem(at: temp)
28 throw KeycaskError.io("write \(url.path): \(error)")
29 }
30 }
31
32 #if os(Windows)
33 private static func writePrivate(_ data: Data, to url: URL) throws {
34 try data.write(to: url)
35 let handle = try FileHandle(forWritingTo: url)
36 try handle.synchronize()
37 try handle.close()
38 }
39
40 private static func replace(_ target: URL, with temp: URL) throws {
41 let ok = temp.path.withCString(encodedAs: UTF16.self) { src in
42 target.path.withCString(encodedAs: UTF16.self) { dst in
43 MoveFileExW(src, dst, DWORD(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
44 }
45 }
46 guard ok.boolValue else {
47 throw KeycaskError.io("rename \(temp.path): error \(GetLastError())")
48 }
49 }
50 #else
51 private static func writePrivate(_ data: Data, to url: URL) throws {
52 let fd = open(url.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600)
53 guard fd >= 0 else {
54 throw KeycaskError.io("open \(url.path): \(String(cString: strerror(errno)))")
55 }
56 let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
57 try handle.write(contentsOf: data)
58 try handle.synchronize()
59 try handle.close()
60 }
61
62 private static func replace(_ target: URL, with temp: URL) throws {
63 guard rename(temp.path, target.path) == 0 else {
64 throw KeycaskError.io("rename \(temp.path): \(String(cString: strerror(errno)))")
65 }
66 }
67 #endif
68}
Sources/keycask/Commands/Init.swift added +12
@@ -0,0 +1,12 @@
1import ArgumentParser
2
3struct Init: ParsableCommand {
4 static let configuration = CommandConfiguration(abstract: "Create an empty vault.")
5
6 @OptionGroup var global: GlobalOptions
7
8 func run() throws {
9 let url = try OpenVault.create(global)
10 print("created \(url.path)")
11 }
12}
Sources/keycask/Keycask.swift added +16
@@ -0,0 +1,16 @@
1import ArgumentParser
2
3struct GlobalOptions: ParsableArguments {
4 @Option(name: .long, help: "Path to the vault file.")
5 var vault: String?
6}
7
8struct Keycask: ParsableCommand {
9 static let configuration = CommandConfiguration(
10 commandName: "keycask",
11 abstract: "Command-line password manager. One passphrase-encrypted vault file.",
12 subcommands: [Init.self]
13 )
14
15 @OptionGroup var global: GlobalOptions
16}
Sources/keycask/OpenVault.swift added +46
@@ -0,0 +1,46 @@
1import Foundation
2import KeycaskCore
3
4struct OpenVault {
5 var vault: Vault
6 let kdf: Envelope.KDFParams
7 let url: URL
8 let passphrase: String
9
10 static func load(_ options: GlobalOptions) throws -> OpenVault {
11 let url = Paths.vaultURL(override: options.vault)
12 let data: Data
13 do {
14 data = try Data(contentsOf: url)
15 } catch let error as CocoaError where error.code == .fileReadNoSuchFile {
16 throw KeycaskError.noVault(url.path)
17 } catch {
18 if !FileManager.default.fileExists(atPath: url.path) {
19 throw KeycaskError.noVault(url.path)
20 }
21 throw KeycaskError.io("read \(url.path): \(error)")
22 }
23 let envelope = try Envelope(parsing: data)
24 let passphrase = try Passphrase.obtain(confirm: false)
25 let plaintext = try envelope.open(passphrase: passphrase)
26 let vault = try VaultCodec.decode(plaintext)
27 return OpenVault(vault: vault, kdf: envelope.kdf, url: url, passphrase: passphrase)
28 }
29
30 static func create(_ options: GlobalOptions) throws -> URL {
31 let url = Paths.vaultURL(override: options.vault)
32 guard !FileManager.default.fileExists(atPath: url.path) else {
33 throw KeycaskError.vaultExists(url.path)
34 }
35 let passphrase = try Passphrase.obtain(confirm: true)
36 let fresh = OpenVault(vault: Vault(), kdf: .fresh(), url: url, passphrase: passphrase)
37 try fresh.save()
38 return url
39 }
40
41 func save() throws {
42 let plaintext = try VaultCodec.encode(vault)
43 let envelope = try Envelope.seal(plaintext, passphrase: passphrase, kdf: kdf)
44 try AtomicFile.write(try envelope.encoded(), to: url)
45 }
46}
Sources/keycask/Passphrase.swift added +28
@@ -0,0 +1,28 @@
1import Foundation
2import KeycaskCore
3
4enum Passphrase {
5 static let variable = "KEYCASK_PASSPHRASE"
6
7 static func obtain(
8 confirm: Bool, environment: [String: String] = ProcessInfo.processInfo.environment
9 ) throws -> String {
10 if let fromEnv = environment[variable] {
11 return try validated(fromEnv)
12 }
13 guard Terminal.stdinIsTTY else {
14 throw KeycaskError.usage("no passphrase: set \(variable) or run on a terminal")
15 }
16 let first = try Terminal.readSecretLine(prompt: "Passphrase: ")
17 if confirm {
18 let second = try Terminal.readSecretLine(prompt: "Confirm passphrase: ")
19 guard first == second else { throw KeycaskError.failure("passphrases do not match") }
20 }
21 return try validated(first)
22 }
23
24 private static func validated(_ passphrase: String) throws -> String {
25 guard !passphrase.isEmpty else { throw KeycaskError.failure("passphrase is empty") }
26 return passphrase
27 }
28}
Sources/keycask/Paths.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2
3enum Paths {
4 static let variable = "KEYCASK_VAULT"
5
6 static func vaultURL(
7 override: String?, environment: [String: String] = ProcessInfo.processInfo.environment
8 ) -> URL {
9 if let override { return URL(fileURLWithPath: override) }
10 if let env = environment[variable], !env.isEmpty { return URL(fileURLWithPath: env) }
11 return defaultDirectory(environment: environment)
12 .appendingPathComponent("keycask").appendingPathComponent("vault.kc")
13 }
14
15 private static func defaultDirectory(environment: [String: String]) -> URL {
16 #if os(Windows)
17 let base = environment["LOCALAPPDATA"] ?? environment["USERPROFILE"] ?? "."
18 return URL(fileURLWithPath: base)
19 #else
20 if let xdg = environment["XDG_DATA_HOME"], !xdg.isEmpty {
21 return URL(fileURLWithPath: xdg)
22 }
23 let home = environment["HOME"] ?? "."
24 return URL(fileURLWithPath: home).appendingPathComponent(".local/share")
25 #endif
26 }
27}
Sources/keycask/Terminal.swift added +68
@@ -0,0 +1,68 @@
1import Foundation
2import KeycaskCore
3
4#if canImport(Darwin)
5 import Darwin
6#elseif canImport(Glibc)
7 import Glibc
8#elseif canImport(Musl)
9 import Musl
10#elseif os(Windows)
11 import CRT
12 import WinSDK
13#endif
14
15enum Terminal {
16 static var stdinIsTTY: Bool {
17 #if os(Windows)
18 return _isatty(_fileno(stdin)) != 0
19 #else
20 return isatty(STDIN_FILENO) != 0
21 #endif
22 }
23
24 static func write(_ text: String) {
25 FileHandle.standardError.write(Data(text.utf8))
26 }
27
28 static func readLine(prompt: String) -> String? {
29 write(prompt)
30 return Swift.readLine(strippingNewline: true)
31 }
32
33 static func confirm(_ question: String) -> Bool {
34 guard let answer = readLine(prompt: question + " [y/N] ") else { return false }
35 return answer.lowercased().hasPrefix("y")
36 }
37
38 static func readSecretLine(prompt: String) throws -> String {
39 write(prompt)
40 defer { write("\n") }
41 return try withEchoDisabled { Swift.readLine(strippingNewline: true) ?? "" }
42 }
43
44 #if os(Windows)
45 private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
46 let handle = GetStdHandle(DWORD(bitPattern: -10))
47 var mode: DWORD = 0
48 guard GetConsoleMode(handle, &mode).boolValue else {
49 throw KeycaskError.io("GetConsoleMode failed")
50 }
51 SetConsoleMode(handle, mode & ~DWORD(ENABLE_ECHO_INPUT))
52 defer { SetConsoleMode(handle, mode) }
53 return try body()
54 }
55 #else
56 private static func withEchoDisabled<T>(_ body: () throws -> T) throws -> T {
57 var original = termios()
58 guard tcgetattr(STDIN_FILENO, &original) == 0 else {
59 throw KeycaskError.io("tcgetattr failed")
60 }
61 var quiet = original
62 quiet.c_lflag &= ~tcflag_t(ECHO)
63 tcsetattr(STDIN_FILENO, TCSANOW, &quiet)
64 defer { tcsetattr(STDIN_FILENO, TCSANOW, &original) }
65 return try body()
66 }
67 #endif
68}
Sources/keycask/main.swift +20 −1
@@ -1,3 +1,22 @@
1import ArgumentParser
2import Foundation
13import KeycaskCore
24
3print("keycask")
5func fail(_ text: String, code: Int32) -> Never {
6 FileHandle.standardError.write(Data((text + "\n").utf8))
7 exit(code)
8}
9
10do {
11 var command = try Keycask.parseAsRoot()
12 try command.run()
13} catch let error as KeycaskError {
14 fail(error.message, code: error.exitCode)
15} catch {
16 let text = Keycask.fullMessage(for: error)
17 if Keycask.exitCode(for: error).isSuccess {
18 print(text)
19 exit(0)
20 }
21 fail(text, code: 2)
22}
Tests/KeycaskCLITests/CLI.swift +64 −2
@@ -27,6 +27,68 @@ enum Binary {
2727 }()
2828}
2929
30@Test func binaryIsBuilt() {
31 #expect(FileManager.default.isExecutableFile(atPath: Binary.url.path))
30struct CLI {
31 struct Result {
32 let status: Int32
33 let stdout: String
34 let stderr: String
35 var lines: [String] { stdout.split(separator: "\n").map(String.init) }
36 }
37
38 static let passphrase = "correct horse battery"
39
40 let dir: URL
41 let vault: URL
42
43 init() throws {
44 dir = FileManager.default.temporaryDirectory
45 .appendingPathComponent("keycask-tests-\(UUID().uuidString)")
46 try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
47 vault = dir.appendingPathComponent("vault.kc")
48 }
49
50 @discardableResult
51 func run(
52 _ args: [String],
53 stdin: String? = nil,
54 passphrase: String? = CLI.passphrase,
55 extraEnvironment: [String: String] = [:]
56 ) throws -> Result {
57 let process = Process()
58 process.executableURL = Binary.url
59 process.arguments = args
60 var env = ProcessInfo.processInfo.environment
61 env["KEYCASK_VAULT"] = vault.path
62 env.removeValue(forKey: "KEYCASK_PASSPHRASE")
63 if let passphrase { env["KEYCASK_PASSPHRASE"] = passphrase }
64 for (k, v) in extraEnvironment { env[k] = v }
65 process.environment = env
66
67 let out = Pipe()
68 let err = Pipe()
69 let input = Pipe()
70 process.standardOutput = out
71 process.standardError = err
72 process.standardInput = input
73 try process.run()
74 if let stdin {
75 input.fileHandleForWriting.write(Data(stdin.utf8))
76 }
77 try input.fileHandleForWriting.close()
78 let outData = out.fileHandleForReading.readDataToEndOfFile()
79 let errData = err.fileHandleForReading.readDataToEndOfFile()
80 process.waitUntilExit()
81 return Result(
82 status: process.terminationStatus,
83 stdout: String(decoding: outData, as: UTF8.self),
84 stderr: String(decoding: errData, as: UTF8.self))
85 }
86
87 /// Runs `init` and returns the harness, for tests that need a vault.
88 static func initialized() throws -> CLI {
89 let cli = try CLI()
90 let r = try cli.run(["init"])
91 precondition(r.status == 0, "init failed: \(r.stderr)")
92 return cli
93 }
3294}
Tests/KeycaskCLITests/InitTests.swift added +75
@@ -0,0 +1,75 @@
1import Foundation
2import Testing
3
4@Suite struct InitTests {
5 @Test func initCreatesVaultAndPrintsPath() throws {
6 let cli = try CLI()
7 let r = try cli.run(["init"])
8 #expect(r.status == 0)
9 #expect(r.stdout.contains(cli.vault.path))
10 #expect(FileManager.default.fileExists(atPath: cli.vault.path))
11 let text = try String(contentsOf: cli.vault, encoding: .utf8)
12 #expect(text.contains("\"format\" : 1"))
13 #expect(text.contains("pbkdf2-hmac-sha256"))
14 #expect(!text.contains("entries"))
15 }
16
17 @Test func initRefusesExistingVault() throws {
18 let cli = try CLI.initialized()
19 let r = try cli.run(["init"])
20 #expect(r.status == 1)
21 #expect(r.stderr.contains("already exists"))
22 }
23
24 @Test func initWithoutPassphraseOrTTYIsUsageError() throws {
25 let cli = try CLI()
26 let r = try cli.run(["init"], passphrase: nil)
27 #expect(r.status == 2)
28 #expect(r.stderr.contains("KEYCASK_PASSPHRASE"))
29 }
30
31 @Test func emptyPassphraseIsRejected() throws {
32 let cli = try CLI()
33 let r = try cli.run(["init"], passphrase: "")
34 #expect(r.status == 1)
35 #expect(r.stderr.contains("empty"))
36 }
37
38 @Test func vaultFlagBeatsEnvironment() throws {
39 let cli = try CLI()
40 let other = cli.dir.appendingPathComponent("elsewhere.kc")
41 let r = try cli.run(["--vault", other.path, "init"])
42 #expect(r.status == 0)
43 #expect(FileManager.default.fileExists(atPath: other.path))
44 #expect(!FileManager.default.fileExists(atPath: cli.vault.path))
45 }
46
47 @Test func unknownSubcommandIsUsageError() throws {
48 let cli = try CLI()
49 let r = try cli.run(["frobnicate"])
50 #expect(r.status == 2)
51 #expect(r.stderr.contains("Usage"))
52 }
53
54 @Test func helpExitsZero() throws {
55 let cli = try CLI()
56 let r = try cli.run(["--help"])
57 #expect(r.status == 0)
58 #expect(r.stdout.contains("init"))
59 }
60
61 #if !os(Windows)
62 @Test func vaultIsPrivateOnUnix() throws {
63 let cli = try CLI.initialized()
64 let attrs = try FileManager.default.attributesOfItem(atPath: cli.vault.path)
65 let mode = (attrs[.posixPermissions] as! NSNumber).intValue & 0o777
66 #expect(mode == 0o600)
67 }
68 #endif
69
70 @Test func noTempFileLeftBehind() throws {
71 let cli = try CLI.initialized()
72 let names = try FileManager.default.contentsOfDirectory(atPath: cli.dir.path)
73 #expect(names == ["vault.kc"])
74 }
75}
Tests/KeycaskCLITests/PathsTests.swift added +38
@@ -0,0 +1,38 @@
1import Foundation
2import Testing
3
4@testable import keycask
5
6@Suite struct PathsTests {
7 @Test func overrideWinsOverEverything() {
8 let url = Paths.vaultURL(
9 override: "/x/v.kc", environment: ["KEYCASK_VAULT": "/y", "HOME": "/h"])
10 #expect(url.path == "/x/v.kc")
11 }
12
13 @Test func environmentVariableWinsOverDefaults() {
14 let url = Paths.vaultURL(
15 override: nil, environment: ["KEYCASK_VAULT": "/y/v.kc", "HOME": "/h"])
16 #expect(url.path == "/y/v.kc")
17 }
18
19 #if os(Windows)
20 @Test func windowsUsesLocalAppData() {
21 let url = Paths.vaultURL(
22 override: nil, environment: ["LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"])
23 #expect(
24 url.path.hasSuffix("keycask/vault.kc") || url.path.hasSuffix("keycask\\vault.kc"))
25 }
26 #else
27 @Test func xdgDataHomeIsUsedWhenSet() {
28 let url = Paths.vaultURL(
29 override: nil, environment: ["XDG_DATA_HOME": "/d", "HOME": "/h"])
30 #expect(url.path == "/d/keycask/vault.kc")
31 }
32
33 @Test func homeFallback() {
34 let url = Paths.vaultURL(override: nil, environment: ["HOME": "/h"])
35 #expect(url.path == "/h/.local/share/keycask/vault.kc")
36 }
37 #endif
38}