Commit 6821fa25ee
Verified · cmc
Layout: unified · split
Sources/KeycaskCore/Envelope.swift added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | import Crypto | |
| 2 | import Foundation | |
| 3 | import _CryptoExtras | |
| 4 | ||
| 5 | public 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 @@ | ||
| 1 | import Crypto | |
| 2 | import Foundation | |
| 3 | import 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 | } | |