krz/keycask

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

Sources/KeycaskCore/Envelope.swift

109 lines · 3778 bytes

  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        guard (1...Int(UInt32.max)).contains(kdf.iterations) else {
 98            throw KeycaskError.corrupt("bad iteration count \(kdf.iterations)")
 99        }
100        let normalized = Array(passphrase.precomposedStringWithCanonicalMapping.utf8)
101        do {
102            return try KDF.Insecure.PBKDF2.deriveKey(
103                from: normalized, salt: kdf.salt, using: .sha256,
104                outputByteCount: keyLength, unsafeUncheckedRounds: kdf.iterations)
105        } catch {
106            throw KeycaskError.failure("derive key: \(error)")
107        }
108    }
109}