109 lines
3.7 KiB
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
|
|
}
|
|
}
|