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 | } | ||