Closer/iphone/Closer/Crypto/AnswerCrypto.swift

109 lines
3.7 KiB
Swift

import Foundation
import CryptoKit
/// SchemaVersion 2 daily-answer encryption wrapper.
///
/// Wire format for the subdoc at `answers/{userId}/secure/payload`:
/// ```json
/// { "encryptedPayload": "enc:v1:<base64(IV || ciphertext || tag)>" }
/// ```
///
/// The inner AES-256-GCM AAD is the UTF-8 bytes of `"{userId}:{questionId}"`.
/// The outer wrapper is `enc:v1:<base64(JSON(blob))>` where `blob` is a
/// `SecureAnswerPayload` JSON document.
public struct SecureAnswerPayload: Codable, Sendable {
public let schemaVersion: Int // 2
public let coupleId: String
public let userId: String
public let questionId: String
public let ciphertext: String // enc:v1:...
public let createdAt: Date
public init(
schemaVersion: Int = 2,
coupleId: String,
userId: String,
questionId: String,
ciphertext: String,
createdAt: Date
) {
self.schemaVersion = schemaVersion
self.coupleId = coupleId
self.userId = userId
self.questionId = questionId
self.ciphertext = ciphertext
self.createdAt = createdAt
}
}
public enum AnswerCrypto {
public static let schemaVersion = 2
/// Encrypts a plaintext answer for the schemaVersion 2 couple-key path.
public static func encrypt(
answerPlaintext: String,
userId: String,
questionId: String,
coupleId: String,
key: CoupleKeyMaterial
) throws -> SecureAnswerPayload {
let aad = answerAAD(userId: userId, questionId: questionId)
let ciphertext = try FieldEncryptor.encryptString(
answerPlaintext,
key: key.rawKey,
aad: aad
)
return SecureAnswerPayload(
schemaVersion: schemaVersion,
coupleId: coupleId,
userId: userId,
questionId: questionId,
ciphertext: ciphertext,
createdAt: Date()
)
}
/// Decrypts a schemaVersion 2 secure answer payload.
public static func decrypt(_ payload: SecureAnswerPayload, key: CoupleKeyMaterial) throws -> String {
let aad = answerAAD(userId: payload.userId, questionId: payload.questionId)
return try FieldEncryptor.decryptString(
payload.ciphertext,
key: key.rawKey,
aad: aad
)
}
/// Encodes a payload as the outer `enc:v1:<base64(JSON)>` string.
///
/// This is intentionally symmetric with Android's schemaVersion 2 wrapper:
/// the inner ciphertext is already `enc:v1:`, and the outer string is also
/// `enc:v1:` of the JSON metadata so the Firestore field matches the
/// `isCiphertext` regex and carries enough metadata to decrypt without an
/// extra round-trip.
public static func encode(_ payload: SecureAnswerPayload) throws -> String {
let json = try JSONEncoder().encode(payload)
return FieldEncryptor.prefix + json.base64EncodedString()
}
/// Decodes the outer `enc:v1:<base64(JSON)>` string back to a payload.
public static func decode(_ blob: String) throws -> SecureAnswerPayload {
guard blob.hasPrefix(FieldEncryptor.prefix) else {
throw AnswerCryptoError.missingPrefix
}
let b64 = String(blob.dropFirst(FieldEncryptor.prefix.count))
guard let data = Data(base64Encoded: b64) else {
throw AnswerCryptoError.invalidBase64
}
return try JSONDecoder().decode(SecureAnswerPayload.self, from: data)
}
private static func answerAAD(userId: String, questionId: String) -> Data? {
"\(userId):\(questionId)".data(using: .utf8)
}
public enum AnswerCryptoError: Error {
case missingPrefix
case invalidBase64
}
}