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}