ApiCleCrypto.swift
559 lignes · 20381 octets
import CryptoKit import Foundation /// Kotlin `ApiCleCryptoException`. struct ApiCleCryptoException: Error { let message: String let cause: Error? init(_ message: String, cause: Error? = nil) { self.message = message self.cause = cause } } /// Decrypts the Projectiaon API key blob produced by the Rust server: /// KDF Argon2id (crate `argon2` 0.5 defaults: m=19456 KiB, t=2, p=1, 32-byte key) /// + XChaCha20-Poly1305, blob layout = 24-byte nonce || ciphertext || 16-byte tag. /// Same on-disk format as the Android port (BouncyCastle); implemented here with a /// pure-Swift Argon2id/Blake2b + HChaCha20 and CryptoKit `ChaChaPoly` for the AEAD. enum ApiCleCrypto { private static let NONCE_LEN = 24 private static let TAG_LEN = 16 private static let KEY_LEN = 32 private static let SALT_LEN = 16 private static let ARGON2_M_KB = 19456 private static let ARGON2_T = 2 private static let ARGON2_P = 1 static func decryptApiKey(password: String, saltB64: String, cipherB64: String) throws -> String { let salt = try decodeB64(saltB64, "sel") guard salt.count == SALT_LEN else { throw ApiCleCryptoException("sel invalide") } let blob = try decodeB64(cipherB64, "cle_chiffree") guard blob.count >= NONCE_LEN + TAG_LEN else { throw ApiCleCryptoException("blob trop court") } let key = Argon2id.hash( password: [UInt8](password.utf8), salt: salt, timeCost: ARGON2_T, memoryKiB: ARGON2_M_KB, parallelism: ARGON2_P, outputLength: KEY_LEN ) let nonce = Array(blob[0..<NONCE_LEN]) let ciphertext = Array(blob[NONCE_LEN...]) let plaintext: Data do { plaintext = try XChaCha20Poly1305.open(key: key, nonce24: nonce, ciphertextAndTag: ciphertext) } catch { throw ApiCleCryptoException("échec déchiffrement clé API", cause: error) } return String(decoding: plaintext, as: UTF8.self) } private static func decodeB64(_ value: String, _ label: String) throws -> [UInt8] { guard let data = Data(base64Encoded: value) else { throw ApiCleCryptoException("\(label) base64 invalide") } return [UInt8](data) } } // MARK: - XChaCha20-Poly1305 (24-byte nonce) via HChaCha20 + CryptoKit ChaChaPoly enum XChaCha20Poly1305 { struct AuthenticationFailure: Error {} static func open(key: [UInt8], nonce24: [UInt8], ciphertextAndTag: [UInt8]) throws -> Data { guard key.count == 32, nonce24.count == 24, ciphertextAndTag.count >= 16 else { throw AuthenticationFailure() } let subkey = hChaCha20(key: key, nonce16: Array(nonce24[0..<16])) // IETF ChaCha20-Poly1305 nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce. var ietfNonce = [UInt8](repeating: 0, count: 4) ietfNonce.append(contentsOf: nonce24[16..<24]) let ciphertext = Data(ciphertextAndTag.dropLast(16)) let tag = Data(ciphertextAndTag.suffix(16)) let box = try ChaChaPoly.SealedBox( nonce: ChaChaPoly.Nonce(data: Data(ietfNonce)), ciphertext: ciphertext, tag: tag ) return try ChaChaPoly.open(box, using: SymmetricKey(data: Data(subkey))) } private static func hChaCha20(key: [UInt8], nonce16: [UInt8]) -> [UInt8] { var state = [UInt32](repeating: 0, count: 16) state[0] = 0x6170_7865 state[1] = 0x3320_646e state[2] = 0x7962_2d32 state[3] = 0x6b20_6574 for i in 0..<8 { state[4 + i] = load32(key, 4 * i) } for i in 0..<4 { state[12 + i] = load32(nonce16, 4 * i) } for _ in 0..<10 { quarterRound(&state, 0, 4, 8, 12) quarterRound(&state, 1, 5, 9, 13) quarterRound(&state, 2, 6, 10, 14) quarterRound(&state, 3, 7, 11, 15) quarterRound(&state, 0, 5, 10, 15) quarterRound(&state, 1, 6, 11, 12) quarterRound(&state, 2, 7, 8, 13) quarterRound(&state, 3, 4, 9, 14) } var out = [UInt8]() out.reserveCapacity(32) for i in [0, 1, 2, 3, 12, 13, 14, 15] { var word = state[i].littleEndian withUnsafeBytes(of: &word) { out.append(contentsOf: $0) } } return out } private static func load32(_ bytes: [UInt8], _ offset: Int) -> UInt32 { UInt32(bytes[offset]) | (UInt32(bytes[offset + 1]) << 8) | (UInt32(bytes[offset + 2]) << 16) | (UInt32(bytes[offset + 3]) << 24) } private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { s[a] = s[a] &+ s[b]; s[d] = rotl(s[d] ^ s[a], 16) s[c] = s[c] &+ s[d]; s[b] = rotl(s[b] ^ s[c], 12) s[a] = s[a] &+ s[b]; s[d] = rotl(s[d] ^ s[a], 8) s[c] = s[c] &+ s[d]; s[b] = rotl(s[b] ^ s[c], 7) } private static func rotl(_ x: UInt32, _ n: UInt32) -> UInt32 { (x << n) | (x >> (32 - n)) } } // MARK: - Blake2b (unkeyed, variable digest length up to 64 bytes) enum Blake2b { private static let iv: [UInt64] = [ 0x6a09_e667_f3bc_c908, 0xbb67_ae85_84ca_a73b, 0x3c6e_f372_fe94_f82b, 0xa54f_f53a_5f1d_36f1, 0x510e_527f_ade6_82d1, 0x9b05_688c_2b3e_6c1f, 0x1f83_d9ab_fb41_bd6b, 0x5be0_cd19_137e_2179, ] private static let sigma: [[Int]] = [ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], ] static func hash(_ input: [UInt8], outLen: Int) -> [UInt8] { precondition(outLen >= 1 && outLen <= 64) var h = iv h[0] ^= 0x0101_0000 ^ UInt64(outLen) var t: UInt64 = 0 var offset = 0 let count = input.count // Process all blocks but the last (which may be partial or empty). while count - offset > 128 { t &+= 128 compress(&h, block: input, blockOffset: offset, t: t, last: false) offset += 128 } var lastBlock = [UInt8](repeating: 0, count: 128) let remaining = count - offset if remaining > 0 { lastBlock.replaceSubrange(0..<remaining, with: input[offset..<count]) } t &+= UInt64(remaining) compress(&h, block: lastBlock, blockOffset: 0, t: t, last: true) var out = [UInt8]() out.reserveCapacity(outLen) outer: for word in h { var value = word.littleEndian withUnsafeBytes(of: &value) { bytes in for byte in bytes { if out.count == outLen { return } out.append(byte) } } if out.count == outLen { break outer } } return out } private static func compress(_ h: inout [UInt64], block: [UInt8], blockOffset: Int, t: UInt64, last: Bool) { var m = [UInt64](repeating: 0, count: 16) for i in 0..<16 { let o = blockOffset + 8 * i m[i] = UInt64(block[o]) | (UInt64(block[o + 1]) << 8) | (UInt64(block[o + 2]) << 16) | (UInt64(block[o + 3]) << 24) | (UInt64(block[o + 4]) << 32) | (UInt64(block[o + 5]) << 40) | (UInt64(block[o + 6]) << 48) | (UInt64(block[o + 7]) << 56) } var v = [UInt64](repeating: 0, count: 16) for i in 0..<8 { v[i] = h[i] } for i in 0..<8 { v[8 + i] = iv[i] } v[12] ^= t // t high 64 bits always 0 for our input sizes. if last { v[14] = ~v[14] } for round in 0..<12 { let s = sigma[round % 10] g(&v, 0, 4, 8, 12, m[s[0]], m[s[1]]) g(&v, 1, 5, 9, 13, m[s[2]], m[s[3]]) g(&v, 2, 6, 10, 14, m[s[4]], m[s[5]]) g(&v, 3, 7, 11, 15, m[s[6]], m[s[7]]) g(&v, 0, 5, 10, 15, m[s[8]], m[s[9]]) g(&v, 1, 6, 11, 12, m[s[10]], m[s[11]]) g(&v, 2, 7, 8, 13, m[s[12]], m[s[13]]) g(&v, 3, 4, 9, 14, m[s[14]], m[s[15]]) } for i in 0..<8 { h[i] ^= v[i] ^ v[i + 8] } } private static func g(_ v: inout [UInt64], _ a: Int, _ b: Int, _ c: Int, _ d: Int, _ x: UInt64, _ y: UInt64) { v[a] = v[a] &+ v[b] &+ x v[d] = rotr(v[d] ^ v[a], 32) v[c] = v[c] &+ v[d] v[b] = rotr(v[b] ^ v[c], 24) v[a] = v[a] &+ v[b] &+ y v[d] = rotr(v[d] ^ v[a], 16) v[c] = v[c] &+ v[d] v[b] = rotr(v[b] ^ v[c], 63) } private static func rotr(_ x: UInt64, _ n: UInt64) -> UInt64 { (x >> n) | (x << (64 - n)) } } // MARK: - Argon2id (RFC 9106, version 0x13), single-lane (p = 1) enum Argon2id { private static let blockWords = 128 // 1024 bytes private static let syncPoints = 4 private static let addressesInBlock = 128 /// Argon2id hash; `parallelism` must be 1 (matches the server parameters). static func hash( password: [UInt8], salt: [UInt8], timeCost: Int, memoryKiB: Int, parallelism: Int, outputLength: Int ) -> [UInt8] { precondition(parallelism == 1, "single-lane implementation") precondition(timeCost >= 1 && memoryKiB >= 8) let mPrime = (memoryKiB / (4 * parallelism)) * 4 * parallelism let laneLength = mPrime / parallelism let segmentLength = laneLength / syncPoints // H0 var seed = [UInt8]() seed.append(contentsOf: le32(parallelism)) seed.append(contentsOf: le32(outputLength)) seed.append(contentsOf: le32(memoryKiB)) seed.append(contentsOf: le32(timeCost)) seed.append(contentsOf: le32(0x13)) // version seed.append(contentsOf: le32(2)) // type Argon2id seed.append(contentsOf: le32(password.count)) seed.append(contentsOf: password) seed.append(contentsOf: le32(salt.count)) seed.append(contentsOf: salt) seed.append(contentsOf: le32(0)) // secret seed.append(contentsOf: le32(0)) // associated data let h0 = Blake2b.hash(seed, outLen: 64) var memory = [UInt64](repeating: 0, count: mPrime * blockWords) // First two blocks of the lane. storeBlock(hPrime(h0 + le32(0) + le32(0), outLen: 1024), into: &memory, index: 0) storeBlock(hPrime(h0 + le32(1) + le32(0), outLen: 1024), into: &memory, index: 1) for pass in 0..<timeCost { for slice in 0..<syncPoints { fillSegment( memory: &memory, pass: pass, slice: slice, timeCost: timeCost, mPrime: mPrime, laneLength: laneLength, segmentLength: segmentLength ) } } let finalBlock = loadBlockBytes(memory, index: laneLength - 1) return hPrime(finalBlock, outLen: outputLength) } // H': variable-length hash (RFC 9106 §3.3). private static func hPrime(_ input: [UInt8], outLen: Int) -> [UInt8] { let prefixed = le32(outLen) + input if outLen <= 64 { return Blake2b.hash(prefixed, outLen: outLen) } let r = (outLen + 31) / 32 - 2 var out = [UInt8]() out.reserveCapacity(outLen) var v = Blake2b.hash(prefixed, outLen: 64) out.append(contentsOf: v[0..<32]) for _ in 1..<r { v = Blake2b.hash(v, outLen: 64) out.append(contentsOf: v[0..<32]) } v = Blake2b.hash(v, outLen: outLen - 32 * r) out.append(contentsOf: v) return out } private static func fillSegment( memory: inout [UInt64], pass: Int, slice: Int, timeCost: Int, mPrime: Int, laneLength: Int, segmentLength: Int ) { // Argon2id: data-independent addressing for the first two slices of pass 0. let dataIndependent = pass == 0 && slice < 2 var addressBlock = [UInt64](repeating: 0, count: blockWords) var inputBlock = [UInt64](repeating: 0, count: blockWords) let zeroBlock = [UInt64](repeating: 0, count: blockWords) if dataIndependent { inputBlock[0] = UInt64(pass) inputBlock[1] = 0 // lane inputBlock[2] = UInt64(slice) inputBlock[3] = UInt64(mPrime) inputBlock[4] = UInt64(timeCost) inputBlock[5] = 2 // Argon2id } var startingIndex = 0 if pass == 0 && slice == 0 { startingIndex = 2 if dataIndependent { nextAddresses(address: &addressBlock, input: &inputBlock, zero: zeroBlock) } } var currOffset = slice * segmentLength + startingIndex var prevOffset = currOffset == 0 ? laneLength - 1 : currOffset - 1 for i in startingIndex..<segmentLength { if currOffset % laneLength == 0 { prevOffset = currOffset + laneLength - 1 } let pseudoRand: UInt64 if dataIndependent { if i % addressesInBlock == 0 { nextAddresses(address: &addressBlock, input: &inputBlock, zero: zeroBlock) } pseudoRand = addressBlock[i % addressesInBlock] } else { pseudoRand = memory[prevOffset * blockWords] } // Single lane: ref lane is always our lane. let j1 = pseudoRand & 0xFFFF_FFFF let refIndex = indexAlpha( pass: pass, slice: slice, index: i, j1: j1, laneLength: laneLength, segmentLength: segmentLength ) let prevBlock = readBlock(memory, index: prevOffset) let refBlock = readBlock(memory, index: refIndex) var current = readBlock(memory, index: currOffset) fillBlock(prev: prevBlock, ref: refBlock, next: ¤t, withXor: pass > 0) writeBlock(current, into: &memory, index: currOffset) currOffset += 1 prevOffset = currOffset - 1 } } private static func indexAlpha( pass: Int, slice: Int, index: Int, j1: UInt64, laneLength: Int, segmentLength: Int ) -> Int { let refAreaSize: UInt64 if pass == 0 { if slice == 0 { refAreaSize = UInt64(index - 1) } else { refAreaSize = UInt64(slice * segmentLength + index - 1) } } else { refAreaSize = UInt64(laneLength - segmentLength + index - 1) } var relativePosition = j1 relativePosition = (relativePosition &* relativePosition) >> 32 relativePosition = refAreaSize - 1 - ((refAreaSize &* relativePosition) >> 32) let startPosition: Int if pass == 0 || slice == syncPoints - 1 { startPosition = 0 } else { startPosition = (slice + 1) * segmentLength } return (startPosition + Int(relativePosition)) % laneLength } private static func nextAddresses(address: inout [UInt64], input: inout [UInt64], zero: [UInt64]) { input[6] &+= 1 fillBlock(prev: zero, ref: input, next: &address, withXor: false) let copy = address fillBlock(prev: zero, ref: copy, next: &address, withXor: false) } /// Argon2 compression G: next = P(prev ^ ref) ^ (prev ^ ref) [^ next when withXor]. private static func fillBlock(prev: [UInt64], ref: [UInt64], next: inout [UInt64], withXor: Bool) { var blockR = [UInt64](repeating: 0, count: blockWords) for i in 0..<blockWords { blockR[i] = prev[i] ^ ref[i] } var blockTmp = blockR if withXor { for i in 0..<blockWords { blockTmp[i] ^= next[i] } } // Apply P row-wise: 8 rows of 16 consecutive u64. for i in 0..<8 { let o = 16 * i permutation( &blockR, o, o + 1, o + 2, o + 3, o + 4, o + 5, o + 6, o + 7, o + 8, o + 9, o + 10, o + 11, o + 12, o + 13, o + 14, o + 15 ) } // Apply P column-wise: 8 columns of 2-u64 pairs. for i in 0..<8 { let o = 2 * i permutation( &blockR, o, o + 1, o + 16, o + 17, o + 32, o + 33, o + 48, o + 49, o + 64, o + 65, o + 80, o + 81, o + 96, o + 97, o + 112, o + 113 ) } for i in 0..<blockWords { next[i] = blockTmp[i] ^ blockR[i] } } private static func permutation( _ v: inout [UInt64], _ v0: Int, _ v1: Int, _ v2: Int, _ v3: Int, _ v4: Int, _ v5: Int, _ v6: Int, _ v7: Int, _ v8: Int, _ v9: Int, _ v10: Int, _ v11: Int, _ v12: Int, _ v13: Int, _ v14: Int, _ v15: Int ) { blamkaG(&v, v0, v4, v8, v12) blamkaG(&v, v1, v5, v9, v13) blamkaG(&v, v2, v6, v10, v14) blamkaG(&v, v3, v7, v11, v15) blamkaG(&v, v0, v5, v10, v15) blamkaG(&v, v1, v6, v11, v12) blamkaG(&v, v2, v7, v8, v13) blamkaG(&v, v3, v4, v9, v14) } @inline(__always) private static func blamkaG(_ v: inout [UInt64], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { v[a] = fBlaMka(v[a], v[b]); v[d] = rotr(v[d] ^ v[a], 32) v[c] = fBlaMka(v[c], v[d]); v[b] = rotr(v[b] ^ v[c], 24) v[a] = fBlaMka(v[a], v[b]); v[d] = rotr(v[d] ^ v[a], 16) v[c] = fBlaMka(v[c], v[d]); v[b] = rotr(v[b] ^ v[c], 63) } @inline(__always) private static func fBlaMka(_ x: UInt64, _ y: UInt64) -> UInt64 { let m = (x & 0xFFFF_FFFF) &* (y & 0xFFFF_FFFF) return x &+ y &+ (m &<< 1) } @inline(__always) private static func rotr(_ x: UInt64, _ n: UInt64) -> UInt64 { (x >> n) | (x << (64 - n)) } // ---- block/bytes helpers ---- private static func readBlock(_ memory: [UInt64], index: Int) -> [UInt64] { Array(memory[(index * blockWords)..<((index + 1) * blockWords)]) } private static func writeBlock(_ block: [UInt64], into memory: inout [UInt64], index: Int) { memory.replaceSubrange((index * blockWords)..<((index + 1) * blockWords), with: block) } private static func storeBlock(_ bytes: [UInt8], into memory: inout [UInt64], index: Int) { precondition(bytes.count == 1024) for i in 0..<blockWords { let o = 8 * i memory[index * blockWords + i] = UInt64(bytes[o]) | (UInt64(bytes[o + 1]) << 8) | (UInt64(bytes[o + 2]) << 16) | (UInt64(bytes[o + 3]) << 24) | (UInt64(bytes[o + 4]) << 32) | (UInt64(bytes[o + 5]) << 40) | (UInt64(bytes[o + 6]) << 48) | (UInt64(bytes[o + 7]) << 56) } } private static func loadBlockBytes(_ memory: [UInt64], index: Int) -> [UInt8] { var out = [UInt8]() out.reserveCapacity(1024) for i in 0..<blockWords { var word = memory[index * blockWords + i].littleEndian withUnsafeBytes(of: &word) { out.append(contentsOf: $0) } } return out } private static func le32(_ value: Int) -> [UInt8] { let v = UInt32(truncatingIfNeeded: value) return [ UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8((v >> 24) & 0xFF), ] } }
GitRust