krz/keycask

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

Commit 6821fa25ee

6821fa25eec48b1712f348de328862fb7a5b2e0d

parent: 983db3132e

Verified · cmc

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

Add passphrase-encrypted vault envelope

Layout: unified · split

Sources/KeycaskCore/Envelope.swift added +106
@@ -0,0 +1,106 @@
1import Crypto
2import Foundation
3import _CryptoExtras
4
5public struct Envelope: Codable, Equatable, Sendable {
6 public struct KDFParams: Codable, Equatable, Sendable {
7 public var name: String
8 public var iterations: Int
9 public var salt: Data
10
11 public init(name: String, iterations: Int, salt: Data) {
12 self.name = name
13 self.iterations = iterations
14 self.salt = salt
15 }
16
17 public static func fresh(iterations: Int = Envelope.defaultIterations) -> KDFParams {
18 var rng = SystemRandomNumberGenerator()
19 let salt = Data(
20 (0..<Envelope.saltLength).map { _ in UInt8.random(in: .min ... .max, using: &rng) })
21 return KDFParams(name: Envelope.kdfName, iterations: iterations, salt: salt)
22 }
23 }
24
25 public static let currentFormat = 1
26 public static let defaultIterations = 600_000
27 public static let kdfName = "pbkdf2-hmac-sha256"
28 public static let saltLength = 16
29 static let keyLength = 32
30 static let minimumBoxLength = 12 + 16
31
32 public var format: Int
33 public var kdf: KDFParams
34 public var box: Data
35
36 public static func seal(_ plaintext: Data, passphrase: String, kdf: KDFParams) throws
37 -> Envelope
38 {
39 let key = try deriveKey(passphrase: passphrase, kdf: kdf)
40 do {
41 let sealed = try ChaChaPoly.seal(plaintext, using: key)
42 return Envelope(format: currentFormat, kdf: kdf, box: sealed.combined)
43 } catch {
44 throw KeycaskError.failure("encrypt: \(error)")
45 }
46 }
47
48 public func open(passphrase: String) throws -> Data {
49 guard format == Self.currentFormat else {
50 throw KeycaskError.corrupt("unsupported format \(format)")
51 }
52 guard kdf.name == Self.kdfName else {
53 throw KeycaskError.corrupt("unsupported kdf \(kdf.name)")
54 }
55 guard box.count >= Self.minimumBoxLength else {
56 throw KeycaskError.corrupt("box too short")
57 }
58 let key = try Self.deriveKey(passphrase: passphrase, kdf: kdf)
59 let sealed: ChaChaPoly.SealedBox
60 do {
61 sealed = try ChaChaPoly.SealedBox(combined: box)
62 } catch {
63 throw KeycaskError.corrupt("box is malformed")
64 }
65 do {
66 return try ChaChaPoly.open(sealed, using: key)
67 } catch {
68 throw KeycaskError.cannotDecrypt
69 }
70 }
71
72 public init(parsing data: Data) throws {
73 do {
74 self = try JSONDecoder().decode(Envelope.self, from: data)
75 } catch {
76 throw KeycaskError.corrupt("not a keycask vault: \(error)")
77 }
78 }
79
80 public func encoded() throws -> Data {
81 let encoder = JSONEncoder()
82 encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
83 do {
84 return try encoder.encode(self)
85 } catch {
86 throw KeycaskError.io("encode envelope: \(error)")
87 }
88 }
89
90 init(format: Int, kdf: KDFParams, box: Data) {
91 self.format = format
92 self.kdf = kdf
93 self.box = box
94 }
95
96 static func deriveKey(passphrase: String, kdf: KDFParams) throws -> SymmetricKey {
97 let normalized = Array(passphrase.precomposedStringWithCanonicalMapping.utf8)
98 do {
99 return try KDF.Insecure.PBKDF2.deriveKey(
100 from: normalized, salt: kdf.salt, using: .sha256,
101 outputByteCount: keyLength, unsafeUncheckedRounds: kdf.iterations)
102 } catch {
103 throw KeycaskError.failure("derive key: \(error)")
104 }
105 }
106}
Tests/KeycaskCoreTests/EnvelopeTests.swift added +102
@@ -0,0 +1,102 @@
1import Crypto
2import Foundation
3import Testing
4
5@testable import KeycaskCore
6
7@Suite struct EnvelopeTests {
8 // Low iteration count keeps the suite fast. Production uses Envelope.defaultIterations.
9 let kdf = Envelope.KDFParams(
10 name: Envelope.kdfName, iterations: 1_000, salt: Data(repeating: 7, count: 16))
11
12 func hex(_ key: SymmetricKey) -> String {
13 key.withUnsafeBytes { $0.map { String(format: "%02x", $0) }.joined() }
14 }
15
16 @Test func pbkdf2MatchesPublishedVectors() throws {
17 let one = Envelope.KDFParams(name: Envelope.kdfName, iterations: 1, salt: Data("salt".utf8))
18 #expect(
19 hex(try Envelope.deriveKey(passphrase: "password", kdf: one))
20 == "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b")
21 let many = Envelope.KDFParams(
22 name: Envelope.kdfName, iterations: 4096, salt: Data("salt".utf8))
23 #expect(
24 hex(try Envelope.deriveKey(passphrase: "password", kdf: many))
25 == "c5e478d59288c841aa530db6845c4c8d962893a001ce4e11a4963873aa98134a")
26 }
27
28 @Test func sealThenOpenRoundTrips() throws {
29 let env = try Envelope.seal(Data("hello vault".utf8), passphrase: "pw", kdf: kdf)
30 #expect(env.format == 1)
31 #expect(env.kdf == kdf)
32 #expect(try env.open(passphrase: "pw") == Data("hello vault".utf8))
33 }
34
35 @Test func wrongPassphraseCannotDecrypt() throws {
36 let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
37 #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "PW") }
38 }
39
40 @Test func tamperedBoxCannotDecrypt() throws {
41 var env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
42 env.box[env.box.count - 1] ^= 0x01
43 #expect(throws: KeycaskError.cannotDecrypt) { try env.open(passphrase: "pw") }
44 }
45
46 @Test func nonceIsFreshAndSaltIsKept() throws {
47 let a = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
48 let b = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
49 #expect(a.box != b.box)
50 #expect(a.kdf.salt == b.kdf.salt)
51 }
52
53 @Test func freshParamsUseDefaults() {
54 let p = Envelope.KDFParams.fresh()
55 #expect(p.name == "pbkdf2-hmac-sha256")
56 #expect(p.iterations == 600_000)
57 #expect(p.salt.count == 16)
58 #expect(p.salt != Envelope.KDFParams.fresh().salt)
59 }
60
61 @Test func encodedShapeMatchesTheSpec() throws {
62 let env = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
63 let json = try JSONSerialization.jsonObject(with: env.encoded()) as! [String: Any]
64 #expect(json["format"] as? Int == 1)
65 let k = json["kdf"] as! [String: Any]
66 #expect(k["name"] as? String == "pbkdf2-hmac-sha256")
67 #expect(k["iterations"] as? Int == 1_000)
68 #expect(Data(base64Encoded: k["salt"] as! String) == kdf.salt)
69 #expect(Data(base64Encoded: json["box"] as! String) == env.box)
70 #expect(try Envelope(parsing: env.encoded()) == env)
71 }
72
73 @Test func malformedInputsAreCorrupt() throws {
74 #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("not json".utf8)) }
75 #expect(throws: KeycaskError.self) { try Envelope(parsing: Data("{\"format\":1}".utf8)) }
76
77 var wrongFormat = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
78 wrongFormat.format = 2
79 #expect(throws: KeycaskError.corrupt("unsupported format 2")) {
80 try wrongFormat.open(passphrase: "pw")
81 }
82
83 var wrongKDF = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
84 wrongKDF.kdf.name = "argon2id"
85 #expect(throws: KeycaskError.corrupt("unsupported kdf argon2id")) {
86 try wrongKDF.open(passphrase: "pw")
87 }
88
89 var shortBox = try Envelope.seal(Data("x".utf8), passphrase: "pw", kdf: kdf)
90 shortBox.box = Data([1, 2, 3])
91 #expect(throws: KeycaskError.corrupt("box too short")) {
92 try shortBox.open(passphrase: "pw")
93 }
94 }
95
96 @Test func passphraseIsNFCNormalized() throws {
97 let composed = "caf\u{00E9}"
98 let decomposed = "cafe\u{0301}"
99 let env = try Envelope.seal(Data("x".utf8), passphrase: composed, kdf: kdf)
100 #expect(try env.open(passphrase: decomposed) == Data("x".utf8))
101 }
102}