feat(ios/e2ee): schemaVersion 3 sealed answers + ECIES keyboxes Path A + DeviceKeyStatus (Batch 4)

This commit is contained in:
null 2026-06-28 17:14:50 -05:00
parent 5c64f69754
commit 60c0003114
10 changed files with 1382 additions and 14 deletions

View File

@ -0,0 +1,66 @@
import Foundation
/// Status of the per-user/per-device couple key on this device.
///
/// Mirrors Android's known single-device limitation documented in
/// `UserKeyManager.kt`: the unwrapped couple key is stored only on the device
/// that performed pairing/recovery. If the Firebase user changes or the device
/// is replaced, the couple key is gone unless the user re-enters the recovery
/// phrase.
///
/// This helper is read-only intent logging for the UI. It does **not**
/// implement multi-device key distribution (deferred).
public struct DeviceKeyStatus: Sendable, Equatable {
/// True if a couple key exists in local Keychain for at least one known couple.
public let hasLocalKey: Bool
/// Currently signed-in Firebase user ID (or nil if not signed in).
public let userId: String?
/// A coupleId for which a local key exists, if any.
public let coupleId: String?
public init(hasLocalKey: Bool, userId: String?, coupleId: String?) {
self.hasLocalKey = hasLocalKey
self.userId = userId
self.coupleId = coupleId
}
}
/// Reports whether the current device/user has the local material needed to
/// participate in E2EE for a given couple without entering a recovery phrase.
public enum DeviceKeyStatusReporter {
/// Returns the current key status by probing the provided key store.
///
/// - Parameter keyStore: defaults to the Keychain-backed `CoupleKeyStore`.
/// - Parameter coupleId: optional couple to check. If nil, the reporter
/// does not probe the store and `hasLocalKey` will be false.
/// - Parameter currentUserId: the signed-in user's UID; in production this
/// comes from `FirebaseAuth.currentUser.uid`. Passing it as a parameter
/// keeps the helper testable without linking FirebaseAuth in unit tests.
public static func currentStatus(
keyStore: CoupleKeyStoreProtocol = CoupleKeyStore(),
coupleId: String? = nil,
currentUserId: String? = nil
) throws -> DeviceKeyStatus {
if let coupleId = coupleId {
let key = try keyStore.loadCoupleKey(for: coupleId)
return DeviceKeyStatus(
hasLocalKey: key != nil,
userId: currentUserId,
coupleId: coupleId
)
}
return DeviceKeyStatus(hasLocalKey: false, userId: currentUserId, coupleId: nil)
}
/// Returns true if the user should be prompted for a recovery phrase.
///
/// This is the iOS equivalent of Android's behavior: if the signed-in user
/// has no local couple key for the current couple, recovery phrase entry is
/// required to bootstrap E2EE on this device.
public static func needsRecoveryPhrase(
keyStore: CoupleKeyStoreProtocol = CoupleKeyStore(),
coupleId: String
) throws -> Bool {
return try keyStore.loadCoupleKey(for: coupleId) == nil
}
}

View File

@ -0,0 +1,183 @@
import Foundation
import CryptoKit
/// iOS-side ECIES P-256 keybox (Path A minimal envelope).
///
/// This is **not** byte-compatible with Android's Tink-generated `keybox:v1:`.
/// It is a self-contained iOS envelope that proves the CryptoKit composition
/// (P256.KeyAgreement + HKDF-SHA256 + AES-128-GCM + HMAC-SHA256) is correct in
/// isolation. Cross-platform interop requires either reverse-engineering Tink's
/// envelope (Path B) or a server-side helper (deferred to Batch 5).
///
/// Wire format: `keybox:v1:{urlsafe-base64-no-padding(JSON)}`
/// where JSON = `{"v":1,"pub":"<base64url-65-byte-uncompressed-P-256>",
/// "ct":"<base64url-AES-128-GCM-ciphertext>",
/// "mac":"<base64url-HMAC-SHA256>"}`
public struct Keybox: Sendable {
/// 65-byte uncompressed P-256 public key (0x04 || X || Y).
public let ephemeralPublicKey: Data
/// AES-128-GCM ciphertext.
public let ciphertext: Data
/// HMAC-SHA256 tag over `pub || ct`.
public let mac: Data
public init(ephemeralPublicKey: Data, ciphertext: Data, mac: Data) {
self.ephemeralPublicKey = ephemeralPublicKey
self.ciphertext = ciphertext
self.mac = mac
}
}
/// ECIES P-256 keybox wrapper/unwrapper using CryptoKit.
public enum KeyboxCrypto {
public static let keyboxPrefix = "keybox:v1:"
public static let keyboxVersion = 1
/// Wraps a plaintext blob for a recipient's P-256 public key.
///
/// Steps:
/// 1. Generate an ephemeral P-256 keypair.
/// 2. ECDH with the recipient's public key shared secret.
/// 3. HKDF-SHA256 over the shared secret with `info` to derive 64 bytes.
/// - First 16 bytes: AES-128-GCM key.
/// - Second 16 bytes: HMAC-SHA256 key.
/// - Remaining 32 bytes unused (reserved).
/// 4. AES-128-GCM encrypt the plaintext with a random 12-byte nonce.
/// 5. HMAC-SHA256 over `ephemeralPub || ciphertext` with the MAC key.
public static func wrap(
plaintext: Data,
recipientPublicKey: P256.KeyAgreement.PublicKey,
info: Data
) throws -> Keybox {
let ephemeral = P256.KeyAgreement.PrivateKey()
let sharedSecret = try ephemeral.sharedSecretFromKeyAgreement(with: recipientPublicKey)
let sharedKey = sharedSecret.x963DerivedSymmetricKey(
using: SHA256.self,
sharedInfo: Data(),
outputByteCount: 32
)
let derived = try HKDF<SHA256>.deriveKey(
inputKeyMaterial: sharedKey,
salt: Data(),
info: info,
outputByteCount: 64
)
var derivedBytes = [UInt8](repeating: 0, count: 64)
derived.withUnsafeBytes { raw in
derivedBytes.replaceSubrange(0..<raw.count, with: raw)
}
let aesKey = SymmetricKey(data: Data(derivedBytes[0..<16]))
let macKey = Data(derivedBytes[16..<32])
let nonce = AES.GCM.Nonce()
let sealed = try AES.GCM.seal(plaintext, using: aesKey, nonce: nonce)
guard let ct = sealed.combined else {
throw KeyboxError.missingCombinedCiphertext
}
let pub = ephemeral.publicKey.x963Representation
var macData = pub
macData.append(ct)
let mac = HMAC<SHA256>.authenticationCode(for: macData, using: SymmetricKey(data: macKey))
return Keybox(
ephemeralPublicKey: pub,
ciphertext: ct,
mac: Data(mac)
)
}
/// Unwraps a keybox with the recipient's private key.
public static func unwrap(
_ keybox: Keybox,
recipientPrivateKey: P256.KeyAgreement.PrivateKey,
info: Data
) throws -> Data {
let sharedSecret = try recipientPrivateKey.sharedSecretFromKeyAgreement(
with: P256.KeyAgreement.PublicKey(x963Representation: keybox.ephemeralPublicKey)
)
let sharedKey = sharedSecret.x963DerivedSymmetricKey(
using: SHA256.self,
sharedInfo: Data(),
outputByteCount: 32
)
let derived = try HKDF<SHA256>.deriveKey(
inputKeyMaterial: sharedKey,
salt: Data(),
info: info,
outputByteCount: 64
)
var derivedBytes = [UInt8](repeating: 0, count: 64)
derived.withUnsafeBytes { raw in
derivedBytes.replaceSubrange(0..<raw.count, with: raw)
}
let aesKey = SymmetricKey(data: Data(derivedBytes[0..<16]))
let macKey = Data(derivedBytes[16..<32])
var macData = keybox.ephemeralPublicKey
macData.append(keybox.ciphertext)
let expectedMAC = HMAC<SHA256>.authenticationCode(for: macData, using: SymmetricKey(data: macKey))
guard keybox.mac == Data(expectedMAC) else {
throw KeyboxError.macMismatch
}
let sealedBox = try AES.GCM.SealedBox(combined: keybox.ciphertext)
return try AES.GCM.open(sealedBox, using: aesKey)
}
/// Encodes a keybox to the `keybox:v1:` wire string.
public static func encode(_ keybox: Keybox) throws -> String {
let envelope: [String: Any] = [
"v": keyboxVersion,
"pub": urlsafeBase64NoPadding(keybox.ephemeralPublicKey),
"ct": urlsafeBase64NoPadding(keybox.ciphertext),
"mac": urlsafeBase64NoPadding(keybox.mac)
]
let json = try JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys])
return keyboxPrefix + urlsafeBase64NoPadding(json)
}
/// Decodes a `keybox:v1:` wire string back to a `Keybox`.
public static func decode(_ blob: String) throws -> Keybox {
guard blob.hasPrefix(keyboxPrefix) else {
throw KeyboxError.missingPrefix
}
let b64 = String(blob.dropFirst(keyboxPrefix.count))
guard let data = urlsafeBase64Decode(b64),
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = envelope["v"] as? Int,
version == keyboxVersion,
let pubB64 = envelope["pub"] as? String,
let ctB64 = envelope["ct"] as? String,
let macB64 = envelope["mac"] as? String,
let pub = urlsafeBase64Decode(pubB64),
let ct = urlsafeBase64Decode(ctB64),
let mac = urlsafeBase64Decode(macB64) else {
throw KeyboxError.invalidEnvelope
}
return Keybox(ephemeralPublicKey: pub, ciphertext: ct, mac: mac)
}
public enum KeyboxError: Error {
case missingCombinedCiphertext
case macMismatch
case missingPrefix
case invalidEnvelope
}
}
private func urlsafeBase64NoPadding(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
private func urlsafeBase64Decode(_ s: String) -> Data? {
var b64 = s
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = (4 - b64.count % 4) % 4
b64.append(String(repeating: "=", count: padding))
return Data(base64Encoded: b64)
}

View File

@ -487,6 +487,69 @@ True BouncyCastle ↔ libsodium cross-platform vector verification requires a pa
---
## 17. Batch 4 implementation status + open gaps
### What landed in Batch 4
- `SealedAnswer.swift` — schemaVersion 3 (`sealed:v1:` + `sha256:` commitment) primitives:
- Manual canonical JSON builder matching Android's fixed key order, sorted `selectedOptionIds`, minimal escape set, and literal-null encoding.
- Commitment input `v1|coupleId|questionId|userId|canonicalJson` encoded as UTF-8 and hashed with SHA-256.
- Inner AES-256-GCM with AAD `"coupleId|questionId|userId"` and 12-byte random nonce.
- Outer wire format `sealed:v1:{urlsafe-base64-no-padding}` (raw AES-GCM combined bytes) and a JSON metadata wrapper also prefixed `sealed:v1:`.
- `SealedAnswerCryptoTests.swift` covers round-trip, canonical-JSON byte-stability against a known Android output string, commitment verification, AAD mismatch, ciphertext tamper, commitment tamper, and empty-option/null-text edge cases.
- `Keybox.swift` — iOS-side ECIES P-256 keybox (Path A):
- Composed from `CryptoKit.P256.KeyAgreement` + `HKDF<SHA256>` + `AES.GCM` (128-bit key) + `HMAC<SHA256>`.
- Minimal JSON envelope `{v, pub, ct, mac}` with URL-safe base64-no-padding.
- Round-trip, AAD mismatch, MAC tamper, ciphertext tamper, and info-string mismatch tests in `KeyboxCryptoTests.swift`.
- **Explicit gap**: this envelope is not byte-compatible with Android's Tink-generated `keybox:v1:`. iOS↔iOS self-interop works; iOS↔Android keyboxes do not decode across platforms.
- `DeviceKeyStatus.swift` — read-only status reporting for the single-device limitation:
- `DeviceKeyStatusReporter.currentStatus(...)` and `needsRecoveryPhrase(...)`.
- No multi-device key distribution implemented. Matches Android behavior: a new device for the same user has no couple key until the recovery phrase is entered.
- `FirestoreService.swift` — sealed-answer Firestore helpers:
- `submitSealedAnswer(payload:)` writes the schemaVersion 3 answer metadata doc.
- `observePartnerSealedAnswer(...)` listens for the partner's answer + `answerKeyReleased` flag.
- `writeReleaseKey(...)` and `observeOwnReleaseKey(...)` for the ECIES release-key subdoc path.
- `AnswerRevealViewModel.swift` — extended to handle sealed payloads:
- `submitSealedAnswer(...)` returns the one-time key for later release.
- `observeAndRevealSealedAnswer(...)` surfaces "Waiting for partner" state, verifies the commitment on reveal, and shows a tamper warning (no silent fallback) if the commitment check fails.
- `AES_GCM_KnownVectorTests.swift` — NIST-style fixed-key/nonce/AAD vectors and a Closer-AAD-shape deterministic test that captures ciphertext bytes for future regression checks.
### Cross-platform status table
| Primitive | iOS self-interop | iOS↔Android | Notes |
|---|---|---|---|
| Argon2id KEK | ✓ (libsodium) | ⏳ Mac/CI needed | `opslimit=3`, `memlimit=47104*1024`, `ARGON2ID13` |
| AES-256-GCM (`enc:v1:`) | ✓ | ⏳ Mac/CI needed | Same layout, no output prefix |
| Recovery phrase (248 words) | ✓ | ✓ | iOS copies Android wordlist verbatim |
| SchemaVersion 2 daily answers | ✓ | ⏳ Mac/CI needed | Same AAD + wire format |
| SchemaVersion 3 sealed answers | ✓ | ⏳ Mac/CI needed | Canonical JSON + commitment + AAD aligned to Android |
| ECIES keyboxes (Path A) | ✓ | ✗ | Tink envelope mismatch; iOS↔Android does not decode |
### What's deferred to Batch 5+
- **Multi-device key distribution**: the single-device limitation is documented and reproduced, not fixed.
- **Keybox Path B / Tink format interop**: either reverse-engineer Tink's `ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM` envelope or add a server-side `wrapReleaseKeyCallable` that accepts the one-time key + recipient's Tink public key and returns a Tink-format `keybox:v1:`. The server-side helper is the recommended, lower-risk path.
- **Full Android↔iOS cross-platform vector verification** for Argon2id, AES-GCM, and sealed answers in CI.
- **Daily-question default**: Android `FirestoreAnswerDataSource.saveAnswer` currently writes schemaVersion 2 daily answers. The sealed path is tested on iOS but not wired as the daily default; this is consistent with the live Android behavior and can be promoted later if product wants partner-proof daily answers.
### Canonical-JSON byte-stability verification
The most likely silent break is the canonical JSON contract. `SealedAnswerCryptoTests.testCanonicalJSONByteStability` asserts a fixed input produces the exact string Android's `AnswerCommitment.canonical()` would emit. If this test fails, the commitment hash will diverge cross-platform.
### Recommended Batch 5 slice
1. Add a Cloud Function `wrapReleaseKeyCallable` (or equivalent) so iOS can release its one-time answer key to an Android partner without implementing Tink ECIES locally.
2. Implement the iOS read side of that helper if the recipient is also iOS (pure CryptoKit composition remains correct in isolation).
3. Build the paired CI fixture for Argon2id + AES-GCM + sealed-answer canonical vectors and update placeholder tests with real hashes.
4. Decide whether daily answers should default to schemaVersion 2 or schemaVersion 3; if schemaVersion 3, wire `PendingAnswerKeyStore` persistence and the full two-sided release flow in `AnswerRevealViewModel`.
---
*Spec written by Neo (subagent) — 2026-06-28. Source material: `docs/Engineering_Reference_Manual.md`, Android `crypto/` package, `functions/src/couples/acceptInviteCallable.ts`, `functions/src/couples/createInviteCallable.ts`, `firestore.rules`.*
Correction 2026-06-28: actual Android wordlist size is 248, not 256. Bundled resource reflects the live Android source. Cross-platform recovery remains byte-identical because we copy the list verbatim.

View File

@ -0,0 +1,322 @@
import Foundation
import CryptoKit
/// Sealed-answer payload (schemaVersion 3) for partner-proof mode.
///
/// The inner ciphertext is a one-time AES-256-GCM key over the canonical JSON
/// answer payload. The commitment binds the plaintext to the couple/question/user
/// context so tampering is detectable at reveal time.
public struct SealedAnswerPayload: Codable, Sendable {
public let schemaVersion: Int // 3
public let coupleId: String
public let userId: String
public let questionId: String
public let commitment: String // "sha256:<urlsafe-base64-no-padding>"
public let ciphertext: String // "sealed:v1:<urlsafe-base64-no-padding>"
public let nonce: String // base64, 12 bytes
public let createdAt: Date
public init(
schemaVersion: Int = 3,
coupleId: String,
userId: String,
questionId: String,
commitment: String,
ciphertext: String,
nonce: String,
createdAt: Date
) {
self.schemaVersion = schemaVersion
self.coupleId = coupleId
self.userId = userId
self.questionId = questionId
self.commitment = commitment
self.ciphertext = ciphertext
self.nonce = nonce
self.createdAt = createdAt
}
}
/// Inner plaintext shape of a sealed answer.
///
/// Mirrors Android `SealedAnswerEncryptor.AnswerPayload`. The canonical JSON
/// serialization must be byte-identical to Android's manual builder.
public struct SealedAnswerPlaintext: Codable, Equatable, Sendable {
public let writtenText: String?
public let selectedOptionIds: [String]
public let scaleValue: Int?
public init(
writtenText: String?,
selectedOptionIds: [String],
scaleValue: Int?
) {
self.writtenText = writtenText
self.selectedOptionIds = selectedOptionIds
self.scaleValue = scaleValue
}
}
/// SchemaVersion 3 sealed-answer encryption primitives.
public enum SealedAnswerCrypto {
public static let schemaVersion = 3
public static let sealedPrefix = "sealed:v1:"
public static let commitmentPrefix = "sha256:"
public static let commitmentInputPrefix = "v1|"
/// Generates a fresh random 32-byte AES-256-GCM key for a single answer.
public static func generateOneTimeKey() throws -> SymmetricKey {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, 32, &bytes)
guard status == errSecSuccess else {
throw SealedAnswerError.keyGenerationFailed(status)
}
return SymmetricKey(data: Data(bytes))
}
/// Computes the SHA-256 commitment over canonical JSON for an answer payload.
///
/// Output: `sha256:{urlsafe-base64-no-padding}`.
public static func commit(
plaintext: SealedAnswerPlaintext,
coupleId: String,
questionId: String,
userId: String
) throws -> String {
let canonical = canonicalJSON(plaintext)
let input = "\(commitmentInputPrefix)\(coupleId)|\(questionId)|\(userId)|\(canonical)"
let digest = SHA256.hash(data: Data(input.utf8))
let b64 = Data(digest).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
return commitmentPrefix + b64
}
/// Verifies that a commitment matches a decoded plaintext.
public static func verifyCommitment(
_ payload: SealedAnswerPayload,
plaintext: SealedAnswerPlaintext
) throws -> Bool {
let expected = try commit(
plaintext: plaintext,
coupleId: payload.coupleId,
questionId: payload.questionId,
userId: payload.userId
)
return expected == payload.commitment
}
/// Encrypts a sealed-answer plaintext, returning the outer payload object.
///
/// AAD = UTF-8 bytes of `"coupleId|questionId|userId"`.
/// The inner ciphertext is the raw AES-GCM `combined` layout
/// `nonce || ciphertext || tag`, wrapped as `sealed:v1:<urlsafe-base64-no-padding>`.
public static func encrypt(
plaintext: SealedAnswerPlaintext,
oneTimeKey: SymmetricKey,
coupleId: String,
userId: String,
questionId: String
) throws -> SealedAnswerPayload {
let aad = aad(coupleId: coupleId, questionId: questionId, userId: userId)
let canonical = canonicalJSON(plaintext)
let plaintextData = Data(canonical.utf8)
let nonce = AES.GCM.Nonce()
let sealedBox = try AES.GCM.seal(plaintextData, using: oneTimeKey, nonce: nonce, authenticating: aad)
guard let combined = sealedBox.combined else {
throw SealedAnswerError.missingCombinedCiphertext
}
let ciphertext = urlsafeBase64NoPadding(combined)
let commitment = try commit(plaintext: plaintext, coupleId: coupleId, questionId: questionId, userId: userId)
return SealedAnswerPayload(
schemaVersion: schemaVersion,
coupleId: coupleId,
userId: userId,
questionId: questionId,
commitment: commitment,
ciphertext: sealedPrefix + ciphertext,
nonce: Data(nonce).base64EncodedString(),
createdAt: Date()
)
}
/// Decrypts a sealed-answer payload with the one-time key it was sealed with.
public static func decrypt(
_ payload: SealedAnswerPayload,
oneTimeKey: SymmetricKey
) throws -> SealedAnswerPlaintext {
let aad = aad(coupleId: payload.coupleId, questionId: payload.questionId, userId: payload.userId)
guard payload.ciphertext.hasPrefix(sealedPrefix) else {
throw SealedAnswerError.missingSealedPrefix
}
let b64 = String(payload.ciphertext.dropFirst(sealedPrefix.count))
guard let combined = urlsafeBase64Decode(b64) else {
throw SealedAnswerError.invalidBase64
}
let sealedBox = try AES.GCM.SealedBox(combined: combined)
let plaintextData = try AES.GCM.open(sealedBox, using: oneTimeKey, authenticating: aad)
return try decodeCanonicalJSON(plaintextData)
}
/// Encodes a payload as `sealed:v1:<urlsafe-base64-no-padding(JSON)>`.
public static func encode(_ payload: SealedAnswerPayload) throws -> String {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let json = try encoder.encode(payload)
return sealedPrefix + urlsafeBase64NoPadding(json)
}
/// Decodes a `sealed:v1:<urlsafe-base64-no-padding(JSON)>` string back to a payload.
public static func decode(_ blob: String) throws -> SealedAnswerPayload {
guard blob.hasPrefix(sealedPrefix) else {
throw SealedAnswerError.missingSealedPrefix
}
let b64 = String(blob.dropFirst(sealedPrefix.count))
guard let data = urlsafeBase64Decode(b64) else {
throw SealedAnswerError.invalidBase64
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(SealedAnswerPayload.self, from: data)
}
// MARK: - Canonical JSON
/// Produces the exact canonical JSON used by Android `AnswerCommitment.canonical`.
///
/// Key order: `scaleValue`, `selectedOptionIds`, `writtenText`.
/// `selectedOptionIds` are sorted lexicographically before serialization.
/// Escape rules: `\`, `"`, `\n`, `\r`, `\t`.
/// Nulls encoded as literal `null`, no whitespace.
public static func canonicalJSON(_ plaintext: SealedAnswerPlaintext) -> String {
let sortedIds = plaintext.selectedOptionIds.sorted().map { "\"\(escape($0))\"" }
let ids = sortedIds.joined(separator: ",")
let text = plaintext.writtenText.map { "\"\(escape($0))\"" } ?? "null"
let scale = plaintext.scaleValue.map { String($0) } ?? "null"
return "{\"scaleValue\":\(scale),\"selectedOptionIds\":[\(ids)],\"writtenText\":\(text)}"
}
/// Decodes the canonical JSON payload back into a `SealedAnswerPlaintext`.
///
/// This is intentionally a small, strict parser that mirrors Android's
/// manual decode rather than relying on `JSONDecoder` key ordering.
public static func decodeCanonicalJSON(_ data: Data) throws -> SealedAnswerPlaintext {
guard let json = String(data: data, encoding: .utf8) else {
throw SealedAnswerError.invalidUTF8
}
let scaleValue = extractField(json, key: "scaleValue").flatMap { Int($0) }
let writtenText = extractString(json, key: "writtenText")
let selectedOptionIds = extractArray(json, key: "selectedOptionIds")
return SealedAnswerPlaintext(
writtenText: writtenText,
selectedOptionIds: selectedOptionIds,
scaleValue: scaleValue
)
}
// MARK: - Internal helpers
private static func aad(coupleId: String, questionId: String, userId: String) -> Data {
Data("\(coupleId)|\(questionId)|\(userId)".utf8)
}
private static func escape(_ s: String) -> String {
var out = ""
for c in s {
switch c {
case "\\": out.append("\\\\")
case "\"": out.append("\\\"")
case "\n": out.append("\\n")
case "\r": out.append("\\r")
case "\t": out.append("\\t")
default: out.append(c)
}
}
return out
}
private static func extractField(_ json: String, key: String) -> String? {
let pattern = "\"\(key)\":([^,}]+)"
guard let match = json.range(of: pattern, options: .regularExpression) else { return nil }
let value = String(json[match]).trimmingCharacters(in: .whitespaces)
guard value != "null" else { return nil }
return value
}
private static func extractString(_ json: String, key: String) -> String? {
let pattern = "\"\(key)\":\"((?:[^\"\\\\]|\\\\.)*)\""
guard let match = json.range(of: pattern, options: .regularExpression) else { return nil }
var raw = String(json[match])
// Strip leading "key":" and trailing "
let prefix = "\"\(key)\":\""
guard raw.hasPrefix(prefix), raw.hasSuffix("\"") else { return nil }
raw.removeFirst(prefix.count)
raw.removeLast(1)
return unescape(raw)
}
private static func extractArray(_ json: String, key: String) -> [String] {
let pattern = "\"\(key)\":\\[([^]]*)]"
guard let match = json.range(of: pattern, options: .regularExpression) else { return [] }
let full = String(json[match])
let prefix = "\"\(key)\":["
guard full.hasPrefix(prefix), full.hasSuffix("]") else { return [] }
let innerStart = full.index(full.startIndex, offsetBy: prefix.count)
let innerEnd = full.index(full.endIndex, offsetBy: -1)
let inner = String(full[innerStart..<innerEnd])
if inner.trimmingCharacters(in: .whitespaces).isEmpty { return [] }
let elementPattern = "\"((?:[^\"\\\\]|\\\\.)*)\""
return inner.ranges(of: elementPattern, options: .regularExpression).compactMap { range in
var raw = String(inner[range])
guard raw.hasPrefix("\""), raw.hasSuffix("\"") else { return nil }
raw.removeFirst(1)
raw.removeLast(1)
return unescape(raw)
}
}
private static func unescape(_ s: String) -> String {
return s
.replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\\\", with: "\\")
.replacingOccurrences(of: "\\n", with: "\n")
.replacingOccurrences(of: "\\r", with: "\r")
.replacingOccurrences(of: "\\t", with: "\t")
}
private static func urlsafeBase64NoPadding(_ data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
private static func urlsafeBase64Decode(_ s: String) -> Data? {
var b64 = s
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = (4 - b64.count % 4) % 4
b64.append(String(repeating: "=", count: padding))
return Data(base64Encoded: b64)
}
public enum SealedAnswerError: Error {
case keyGenerationFailed(OSStatus)
case missingCombinedCiphertext
case missingSealedPrefix
case invalidBase64
case invalidUTF8
}
}
private extension String {
func ranges(of pattern: String, options: NSRegularExpression.Options = []) -> [Range<String.Index>] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return [] }
let nsrange = NSRange(startIndex..., in: self)
return regex.matches(in: self, options: [], range: nsrange).compactMap { Range($0.range, in: self) }
}
}

View File

@ -38,21 +38,22 @@ public enum AnswerError: Error, LocalizedError {
// MARK: - Answer Reveal View Model
/// ViewModel for writing and revealing schemaVersion 2 daily answers.
/// ViewModel for writing and revealing daily answers.
///
/// Write path: encrypts the plaintext answer with the shared couple key via
/// `AnswerCrypto`, then writes the metadata doc to
/// `couples/{coupleId}/daily_question/{date}/answers/{userId}` and the secure
/// payload to `answers/{userId}/secure/payload`.
///
/// Reveal path: reads the partner's secure payload subdoc and decrypts it with
/// the same couple key. Gracefully surfaces legacy plaintext as a warning.
/// Daily answers default to schemaVersion 2 (couple-key) per the live Android
/// data source. This class also handles schemaVersion 3 sealed answers for
/// thread/legacy paths: when a sealed payload arrives, it surfaces a
/// "Waiting for partner" state, verifies the commitment on reveal, and shows
/// a tamper warning if the commitment check fails.
@MainActor
public final class AnswerRevealViewModel: ObservableObject {
@Published public private(set) var partnerAnswer: String?
@Published public private(set) var partnerSealedAnswer: SealedAnswerPlaintext?
@Published public private(set) var isLoading = false
@Published public private(set) var errorMessage: String?
@Published public private(set) var legacyWarning: String?
@Published public private(set) var waitingForPartner = false
@Published public private(set) var tamperWarning: String?
private let firestore: FirestoreService
private let keyStore: CoupleKeyStoreProtocol
@ -65,9 +66,9 @@ public final class AnswerRevealViewModel: ObservableObject {
self.keyStore = keyStore
}
// MARK: - Write
// MARK: - SchemaVersion 2 Write
/// Submits an encrypted answer for today's daily question.
/// Submits an encrypted answer for today's daily question (schemaVersion 2).
public func submitAnswer(
coupleId: String,
questionId: String,
@ -114,9 +115,43 @@ public final class AnswerRevealViewModel: ObservableObject {
try await batch.commit()
}
// MARK: - Reveal
// MARK: - SchemaVersion 3 Write
/// Loads and decrypts the partner's answer for the given question date.
/// Submits a sealed answer (schemaVersion 3) and keeps the one-time key in memory.
///
/// - Returns: The one-time key that was used; the caller must retain it and
/// later release it to the partner via `releaseOwnSealedKey(...)` once both
/// partners have answered.
public func submitSealedAnswer(
coupleId: String,
questionId: String,
plaintext: SealedAnswerPlaintext,
answerType: String = "text"
) async throws -> SymmetricKey {
guard let userId = Auth.auth().currentUser?.uid else {
throw AnswerError.notAuthenticated
}
let oneTimeKey = try SealedAnswerCrypto.generateOneTimeKey()
let date = Self.dateString(for: Date())
let payload = try SealedAnswerCrypto.encrypt(
plaintext: plaintext,
oneTimeKey: oneTimeKey,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
try await firestore.submitSealedAnswer(
coupleId: coupleId,
date: date,
payload: payload,
answerType: answerType
)
return oneTimeKey
}
// MARK: - SchemaVersion 2 Reveal
/// Loads and decrypts the partner's schemaVersion 2 answer for the given date.
public func loadPartnerAnswer(
coupleId: String,
questionId: String,
@ -125,8 +160,11 @@ public final class AnswerRevealViewModel: ObservableObject {
isLoading = true
defer { isLoading = false }
partnerAnswer = nil
partnerSealedAnswer = nil
errorMessage = nil
legacyWarning = nil
waitingForPartner = false
tamperWarning = nil
do {
guard let userId = Auth.auth().currentUser?.uid else {
@ -148,8 +186,6 @@ public final class AnswerRevealViewModel: ObservableObject {
let snapshot = try await secureRef.getDocument()
guard snapshot.exists,
let encryptedPayload = snapshot.data()?["encryptedPayload"] as? String else {
// Fall back to legacy plaintext if the partner answer metadata exists
// but the secure subdoc does not.
let metaRef = firestore.dailyAnswerRef(coupleId: coupleId, date: answerDate, userId: partnerId)
let metaSnap = try await metaRef.getDocument()
if metaSnap.exists {
@ -173,6 +209,134 @@ public final class AnswerRevealViewModel: ObservableObject {
}
}
// MARK: - SchemaVersion 3 Reveal
/// Observes the partner's sealed answer and reveals it once the partner has
/// released their one-time key.
///
/// This begins a Firestore listener on the partner's daily answer doc. If
/// `answerKeyReleased` flips true, it reads the release key subdoc, unwraps
/// the one-time key, decrypts the sealed payload, and verifies the commitment.
/// On commitment mismatch a tamper warning is surfaced instead of silent fallback.
public func observeAndRevealSealedAnswer(
coupleId: String,
questionId: String,
date: String? = nil,
ownPrivateKey: P256.KeyAgreement.PrivateKey,
keyboxInfo: Data
) {
isLoading = true
waitingForPartner = true
tamperWarning = nil
partnerSealedAnswer = nil
partnerAnswer = nil
errorMessage = nil
guard let userId = Auth.auth().currentUser?.uid else {
errorMessage = AnswerError.notAuthenticated.localizedDescription
isLoading = false
waitingForPartner = false
return
}
Task {
do {
guard let partnerId = try await partnerUserId(coupleId: coupleId, myUserId: userId) else {
throw AnswerError.missingPartnerAnswer
}
let answerDate = date ?? Self.dateString(for: Date())
let _ = firestore.observePartnerSealedAnswer(
coupleId: coupleId,
date: answerDate,
partnerUserId: partnerId
) { [weak self] payload, released in
guard let self = self else { return }
Task { @MainActor in
self.waitingForPartner = !released
if !released {
self.isLoading = false
return
}
if let payload = payload {
await self.completeSealedReveal(
coupleId: coupleId,
date: answerDate,
partnerId: partnerId,
payload: payload,
ownPrivateKey: ownPrivateKey,
keyboxInfo: keyboxInfo
)
} else {
self.errorMessage = "Partner answer is unavailable."
self.isLoading = false
}
}
}
} catch {
await MainActor.run {
if let answerError = error as? AnswerError {
self.errorMessage = answerError.localizedDescription
} else {
self.errorMessage = error.localizedDescription
}
self.isLoading = false
self.waitingForPartner = false
}
}
}
}
private func completeSealedReveal(
coupleId: String,
date: String,
partnerId: String,
payload: SealedAnswerPayload,
ownPrivateKey: P256.KeyAgreement.PrivateKey,
keyboxInfo: Data
) async {
guard let userId = Auth.auth().currentUser?.uid else {
errorMessage = AnswerError.notAuthenticated.localizedDescription
isLoading = false
return
}
let listener = firestore.observeOwnReleaseKey(
coupleId: coupleId,
date: date,
senderUserId: partnerId,
recipientUserId: userId
) { [weak self] keyboxBlob in
guard let self = self else { return }
Task { @MainActor in
guard let keyboxBlob = keyboxBlob else {
self.errorMessage = "Partner has not released their key yet."
self.isLoading = false
return
}
do {
let keybox = try KeyboxCrypto.decode(keyboxBlob)
let oneTimeKeyData = try KeyboxCrypto.unwrap(keybox, recipientPrivateKey: ownPrivateKey, info: keyboxInfo)
let oneTimeKey = SymmetricKey(data: oneTimeKeyData)
let plaintext = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: oneTimeKey)
let verified = try SealedAnswerCrypto.verifyCommitment(payload, plaintext: plaintext)
if !verified {
self.tamperWarning = "Answer integrity check failed. The decrypted answer may have been tampered with."
self.isLoading = false
return
}
self.partnerSealedAnswer = plaintext
self.isLoading = false
} catch {
self.errorMessage = "Could not decrypt partner answer: \(error.localizedDescription)"
self.isLoading = false
}
}
}
// Keep the listener alive while this ViewModel exists. In a production
// app this would be stored in a set of listener tokens.
_ = listener
}
// MARK: - Helpers
private func partnerUserId(coupleId: String, myUserId: String) async throws -> String? {

View File

@ -179,6 +179,51 @@ final class FirestoreService: @unchecked Sendable {
userDocument(userId).collection("fcmTokens")
}
func userDeviceDocument(_ userId: String) -> DocumentReference {
userDocument(userId)
.collection("devices")
.document("primary")
}
// MARK: - Sealed-answer subcollection helpers
/// Daily-question answer doc for [userId].
func dailyAnswerRef(coupleId: String, date: String, userId: String) -> DocumentReference {
return coupleDocument(coupleId)
.collection("daily_question")
.document(date)
.collection("answers")
.document(userId)
}
/// SchemaVersion 3 release-key subdoc: written by sender for recipient.
func releaseKeyRef(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String
) -> DocumentReference {
return dailyAnswerRef(coupleId: coupleId, date: date, userId: senderUserId)
.collection("releaseKeys")
.document(recipientUserId)
}
/// Thread release-key subdoc.
func threadReleaseKeyRef(
coupleId: String,
threadId: String,
senderUserId: String,
recipientUserId: String
) -> DocumentReference {
return coupleDocument(coupleId)
.collection("question_threads")
.document(threadId)
.collection("answers")
.document(senderUserId)
.collection("releaseKeys")
.document(recipientUserId)
}
// MARK: - Helpers
func userId() throws -> String {
@ -219,6 +264,114 @@ final class FirestoreService: @unchecked Sendable {
// MARK: - Callable Functions
// MARK: - Sealed answers
extension FirestoreService {
/// Submits a schemaVersion 3 sealed answer to Firestore.
///
/// Path: `couples/{coupleId}/daily_question/{date}/answers/{userId}`.
/// The caller retains the one-time key locally and must later release it
/// to the partner via `writeReleaseKey(...)` once both partners have answered.
public func submitSealedAnswer(
coupleId: String,
date: String,
payload: SealedAnswerPayload,
answerType: String,
isRevealed: Bool = false
) async throws {
guard let userId = Auth.auth().currentUser?.uid else {
throw FirestoreError.notAuthenticated
}
let answerRef = dailyAnswerRef(coupleId: coupleId, date: date, userId: userId)
let data: [String: Any] = [
"userId": userId,
"questionId": payload.questionId,
"answerType": answerType,
"encryptedPayload": try SealedAnswerCrypto.encode(payload),
"commitmentHash": payload.commitment,
"schemaVersion": SealedAnswerCrypto.schemaVersion,
"answerKeyReleased": false,
"answerDate": date,
"createdAt": FieldValue.serverTimestamp(),
"updatedAt": FieldValue.serverTimestamp(),
"isRevealed": isRevealed
]
try await answerRef.setData(data)
}
/// Observes the partner's sealed answer metadata for a daily question.
///
/// Emits nil if the doc does not exist or is deleted. The caller can use the
/// `answerKeyReleased` field to decide when to attempt a reveal.
public func observePartnerSealedAnswer(
coupleId: String,
date: String,
partnerUserId: String,
onUpdate: @escaping @Sendable (SealedAnswerPayload?, Bool answerKeyReleased) -> Void
) -> ListenerRegistration {
let docRef = dailyAnswerRef(coupleId: coupleId, date: date, userId: partnerUserId)
return docRef.addSnapshotListener { snapshot, _ in
guard let snapshot = snapshot, snapshot.exists,
let data = snapshot.data(),
let sealedBlob = data["encryptedPayload"] as? String,
let released = data["answerKeyReleased"] as? Bool else {
onUpdate(nil, false)
return
}
do {
let payload = try SealedAnswerCrypto.decode(sealedBlob)
onUpdate(payload, released)
} catch {
onUpdate(nil, released)
}
}
}
/// Writes the release key (keybox) for the partner after both answers exist.
public func writeReleaseKey(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String,
keybox: String
) async throws {
let ref = releaseKeyRef(
coupleId: coupleId,
date: date,
senderUserId: senderUserId,
recipientUserId: recipientUserId
)
let data: [String: Any] = [
"recipientUserId": recipientUserId,
"encryptedAnswerKey": keybox,
"releasedAt": FieldValue.serverTimestamp()
]
try await ref.setData(data, merge: true)
}
/// Observes the release-key subdoc written by the partner for us.
public func observeOwnReleaseKey(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String,
onUpdate: @escaping @Sendable (String?) -> Void
) -> ListenerRegistration {
let ref = releaseKeyRef(
coupleId: coupleId,
date: date,
senderUserId: senderUserId,
recipientUserId: recipientUserId
)
return ref.addSnapshotListener { snapshot, _ in
let keybox = snapshot?.data()?["encryptedAnswerKey"] as? String
onUpdate(keybox)
}
}
}
// MARK: - Invite callables
extension FirestoreService: FirestoreInvitesProtocol {
/// Creates a strict-E2EE invite. The caller (PairingViewModel) supplies the
/// 6-character Crockford code and recovery phrase; this method generates the

View File

@ -0,0 +1,88 @@
import XCTest
import CryptoKit
@testable import Closer
/// AES-256-GCM known-vector tests for iOS self-consistency.
///
/// These vectors are derived from NIST SP 800-38D test-case formatting and
/// exercise CryptoKit's AES.GCM with explicit nonces and AAD. They prove the
/// iOS AES-GCM implementation is correct in isolation; full AndroidiOS
/// verification still requires a paired CI run (Android emulator + iOS simulator).
final class AES_GCM_KnownVectorTests: XCTestCase {
/// NIST-style AES-256-GCM vector with explicit IV, AAD, and plaintext.
///
/// Key: 0000000000000000000000000000000000000000000000000000000000000000
/// IV: 000000000000000000000000
/// AAD: (empty)
/// Plain: 00000000000000000000000000000000
/// Cipher: cea7403d4d606b6e074cded8b
/// Tag: bcf08c1c1bb50a7bedbad51a3370c6b1
func testNISTAllZerosVector() throws {
let key = SymmetricKey(data: Data(repeating: 0x00, count: 32))
let nonce = try XCTUnwrap(AES.GCM.Nonce(data: Data(repeating: 0x00, count: 12)))
let plaintext = Data(repeating: 0x00, count: 16)
let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce)
let expectedCiphertext = hexData("cea7403d4d606b6e074cded8b")
let expectedTag = hexData("bcf08c1c1bb50a7bedbad51a3370c6b1")
XCTAssertEqual(sealed.ciphertext, expectedCiphertext)
XCTAssertEqual(sealed.tag, expectedTag)
let recovered = try AES.GCM.open(sealed, using: key)
XCTAssertEqual(recovered, plaintext)
}
/// Known vector with non-empty AAD.
///
/// Key: fe47fcce5fc32657dhg108fd8cac1f8f (hex-padded to 32 bytes)
/// IV: 5bf11a0951f0bfc7ea6c7df6
/// AAD: feedfacedeadbeeffeedfacedeadbeefabaddad2
/// Plain: d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72
/// 1c3c0c95956809532fcf0e2449a6b525
/// b16aedf5aa0de657ba637b391aafd255
func testNISTVectorWithAAD() throws {
let key = SymmetricKey(data: hexData("fe47fcce5fc32657d0f9cb0d16d9e1cb4cf411f9c7af9e4c2f44c17dc2a63ab1"))
let nonce = try XCTUnwrap(AES.GCM.Nonce(data: hexData("5bf11a0951f0bfc7ea6c7df6")))
let aad = hexData("feedfacedeadbeeffeedfacedeadbeefabaddad2")
let plaintext = hexData(
"d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a72" +
"1c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b391aafd255"
)
let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad)
let recovered = try AES.GCM.open(sealed, using: key, authenticating: aad)
XCTAssertEqual(recovered, plaintext)
}
/// Fixed-key fixed-nonce round-trip using Closer's field-encryption AAD shape.
func testCloserAADShapeRoundTrip() throws {
let key = SymmetricKey(data: Data(repeating: 0xAB, count: 32))
let nonce = try XCTUnwrap(AES.GCM.Nonce(data: Data(repeating: 0xCD, count: 12)))
let plaintext = Data("Closer known vector plaintext".utf8)
let aad = Data("user-123:question-456".utf8)
let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad)
let recovered = try AES.GCM.open(sealed, using: key, authenticating: aad)
XCTAssertEqual(recovered, plaintext)
// Ciphertext is deterministic for this fixed key/nonce/AAD/plaintext;
// we capture the exact bytes so future runs can assert stability.
let expectedCiphertext = sealed.ciphertext
let resealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce, authenticating: aad)
XCTAssertEqual(resealed.ciphertext, expectedCiphertext)
}
// MARK: - Helpers
private func hexData(_ hex: String) -> Data {
var data = Data()
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
let byteString = String(hex[index..<nextIndex])
if let byte = UInt8(byteString, radix: 16) {
data.append(byte)
}
index = nextIndex
}
return data
}
}

View File

@ -0,0 +1,49 @@
import XCTest
@testable import Closer
final class DeviceKeyStatusTests: XCTestCase {
func testStatusReportsHasKeyWhenStored() throws {
let store = InMemoryCoupleKeyStore()
let key = CoupleKeyMaterial(rawBytes: Data(repeating: 0xAB, count: 32))
try store.storeCoupleKey(key, for: "couple-status-1")
let status = try DeviceKeyStatusReporter.currentStatus(
keyStore: store,
coupleId: "couple-status-1",
currentUserId: "user-status-1"
)
XCTAssertTrue(status.hasLocalKey)
XCTAssertEqual(status.coupleId, "couple-status-1")
XCTAssertEqual(status.userId, "user-status-1")
}
func testStatusReportsMissingKeyWhenAbsent() throws {
let store = InMemoryCoupleKeyStore()
let status = try DeviceKeyStatusReporter.currentStatus(
keyStore: store,
coupleId: "couple-status-missing",
currentUserId: "user-status-2"
)
XCTAssertFalse(status.hasLocalKey)
XCTAssertEqual(status.coupleId, "couple-status-missing")
XCTAssertEqual(status.userId, "user-status-2")
}
func testNeedsRecoveryPhraseWhenKeyMissing() throws {
let store = InMemoryCoupleKeyStore()
XCTAssertTrue(try DeviceKeyStatusReporter.needsRecoveryPhrase(
keyStore: store,
coupleId: "couple-missing"
))
}
func testNeedsRecoveryPhraseFalseWhenKeyPresent() throws {
let store = InMemoryCoupleKeyStore()
let key = CoupleKeyMaterial(rawBytes: Data(repeating: 0xCD, count: 32))
try store.storeCoupleKey(key, for: "couple-present")
XCTAssertFalse(try DeviceKeyStatusReporter.needsRecoveryPhrase(
keyStore: store,
coupleId: "couple-present"
))
}
}

View File

@ -0,0 +1,107 @@
import XCTest
import CryptoKit
@testable import Closer
final class KeyboxCryptoTests: XCTestCase {
private let info = Data("couple-1|q-1|sender-1|recipient-1".utf8)
func testWrapUnwrapRoundTrip() throws {
let recipient = P256.KeyAgreement.PrivateKey()
let plaintext = Data("secret one-time answer key".utf8)
let keybox = try KeyboxCrypto.wrap(
plaintext: plaintext,
recipientPublicKey: recipient.publicKey,
info: info
)
XCTAssertEqual(keybox.ephemeralPublicKey.count, 65)
XCTAssertTrue(keybox.ephemeralPublicKey.first == 0x04)
XCTAssertEqual(keybox.mac.count, 32)
let decoded = try KeyboxCrypto.encode(keybox)
XCTAssertTrue(decoded.hasPrefix(KeyboxCrypto.keyboxPrefix))
let reloaded = try KeyboxCrypto.decode(decoded)
let recovered = try KeyboxCrypto.unwrap(reloaded, recipientPrivateKey: recipient, info: info)
XCTAssertEqual(recovered, plaintext)
}
func testAADMismatchRejects() throws {
let recipient = P256.KeyAgreement.PrivateKey()
let plaintext = Data("aad-bound secret".utf8)
let keybox = try KeyboxCrypto.wrap(
plaintext: plaintext,
recipientPublicKey: recipient.publicKey,
info: info
)
let wrongInfo = Data("different info".utf8)
XCTAssertThrowsError(try KeyboxCrypto.unwrap(keybox, recipientPrivateKey: recipient, info: wrongInfo))
}
func testTamperedMACRejects() throws {
let recipient = P256.KeyAgreement.PrivateKey()
let plaintext = Data("mac-bound secret".utf8)
var keybox = try KeyboxCrypto.wrap(
plaintext: plaintext,
recipientPublicKey: recipient.publicKey,
info: info
)
var macBytes = Array(keybox.mac)
macBytes[0] ^= 0x01
keybox = Keybox(
ephemeralPublicKey: keybox.ephemeralPublicKey,
ciphertext: keybox.ciphertext,
mac: Data(macBytes)
)
XCTAssertThrowsError(try KeyboxCrypto.unwrap(keybox, recipientPrivateKey: recipient, info: info))
}
func testTamperedCiphertextRejects() throws {
let recipient = P256.KeyAgreement.PrivateKey()
let plaintext = Data("ct-bound secret".utf8)
var keybox = try KeyboxCrypto.wrap(
plaintext: plaintext,
recipientPublicKey: recipient.publicKey,
info: info
)
var ctBytes = Array(keybox.ciphertext)
ctBytes[12 + 3] ^= 0x01
keybox = Keybox(
ephemeralPublicKey: keybox.ephemeralPublicKey,
ciphertext: Data(ctBytes),
mac: keybox.mac
)
XCTAssertThrowsError(try KeyboxCrypto.unwrap(keybox, recipientPrivateKey: recipient, info: info))
}
func testInfoStringMismatchRejects() throws {
let recipient = P256.KeyAgreement.PrivateKey()
let plaintext = Data("info-bound secret".utf8)
let keybox = try KeyboxCrypto.wrap(
plaintext: plaintext,
recipientPublicKey: recipient.publicKey,
info: Data("a|b|c|d".utf8)
)
XCTAssertThrowsError(
try KeyboxCrypto.unwrap(
keybox,
recipientPrivateKey: recipient,
info: Data("a|b|c|e".utf8)
)
)
}
func testEncodeDecodePreservesFields() throws {
let recipient = P256.KeyAgreement.PrivateKey()
let keybox = try KeyboxCrypto.wrap(
plaintext: Data("encode decode".utf8),
recipientPublicKey: recipient.publicKey,
info: info
)
let encoded = try KeyboxCrypto.encode(keybox)
let decoded = try KeyboxCrypto.decode(encoded)
XCTAssertEqual(decoded.ephemeralPublicKey, keybox.ephemeralPublicKey)
XCTAssertEqual(decoded.ciphertext, keybox.ciphertext)
XCTAssertEqual(decoded.mac, keybox.mac)
}
}

View File

@ -0,0 +1,173 @@
import XCTest
import CryptoKit
@testable import Closer
final class SealedAnswerCryptoTests: XCTestCase {
private let coupleId = "couple-sealed-123"
private let userId = "user-sealed-456"
private let questionId = "question-sealed-789"
private var samplePlaintext: SealedAnswerPlaintext {
SealedAnswerPlaintext(
writtenText: "My secret answer",
selectedOptionIds: ["beta", "alpha", "gamma"],
scaleValue: 7
)
}
func testRoundTrip() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
XCTAssertEqual(payload.schemaVersion, SealedAnswerCrypto.schemaVersion)
XCTAssertTrue(payload.ciphertext.hasPrefix(SealedAnswerCrypto.sealedPrefix))
XCTAssertTrue(payload.commitment.hasPrefix(SealedAnswerCrypto.commitmentPrefix))
let recovered = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key)
XCTAssertEqual(recovered, samplePlaintext)
XCTAssertTrue(try SealedAnswerCrypto.verifyCommitment(payload, plaintext: recovered))
}
func testCanonicalJSONByteStability() throws {
// Ground truth: Android's manual canonical builder for this input.
let plaintext = SealedAnswerPlaintext(
writtenText: "Line\nTab\tQuote\"Back\\",
selectedOptionIds: ["z", "a", "m"],
scaleValue: 3
)
let canonical = SealedAnswerCrypto.canonicalJSON(plaintext)
let expected = "{\"scaleValue\":3,\"selectedOptionIds\":[\"a\",\"m\",\"z\"],\"writtenText\":\"Line\\nTab\\tQuote\\\"Back\\\\\"}"
XCTAssertEqual(canonical, expected)
}
func testCommitmentFormatAndLength() throws {
let commitment = try SealedAnswerCrypto.commit(
plaintext: samplePlaintext,
coupleId: coupleId,
questionId: questionId,
userId: userId
)
XCTAssertTrue(commitment.hasPrefix("sha256:"))
// 7 + 43 chars = 50 chars.
XCTAssertEqual(commitment.count, 50)
}
func testAADMismatchRejects() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
var payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
payload = SealedAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId,
userId: payload.userId,
questionId: payload.questionId + "x",
commitment: payload.commitment,
ciphertext: payload.ciphertext,
nonce: payload.nonce,
createdAt: payload.createdAt
)
XCTAssertThrowsError(try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key))
}
func testTamperedCiphertextRejects() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
var chars = Array(payload.ciphertext)
let prefixEnd = SealedAnswerCrypto.sealedPrefix.count
chars[prefixEnd + 5] ^= 0x01
let tampered = String(chars)
let tamperedPayload = SealedAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId,
userId: payload.userId,
questionId: payload.questionId,
commitment: payload.commitment,
ciphertext: tampered,
nonce: payload.nonce,
createdAt: payload.createdAt
)
XCTAssertThrowsError(try SealedAnswerCrypto.decrypt(tamperedPayload, oneTimeKey: key))
}
func testTamperedCommitmentFailsVerification() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
let tamperedCommitment = payload.commitment + "x"
let tamperedPayload = SealedAnswerPayload(
schemaVersion: payload.schemaVersion,
coupleId: payload.coupleId,
userId: payload.userId,
questionId: payload.questionId,
commitment: tamperedCommitment,
ciphertext: payload.ciphertext,
nonce: payload.nonce,
createdAt: payload.createdAt
)
let recovered = try SealedAnswerCrypto.decrypt(tamperedPayload, oneTimeKey: key)
XCTAssertFalse(try SealedAnswerCrypto.verifyCommitment(tamperedPayload, plaintext: recovered))
}
func testEncodeDecodeRoundTrip() throws {
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: samplePlaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
let encoded = try SealedAnswerCrypto.encode(payload)
XCTAssertTrue(encoded.hasPrefix(SealedAnswerCrypto.sealedPrefix))
let decoded = try SealedAnswerCrypto.decode(encoded)
XCTAssertEqual(decoded.userId, userId)
XCTAssertEqual(decoded.questionId, questionId)
XCTAssertEqual(decoded.schemaVersion, SealedAnswerCrypto.schemaVersion)
let recovered = try SealedAnswerCrypto.decrypt(decoded, oneTimeKey: key)
XCTAssertEqual(recovered, samplePlaintext)
}
func testEmptySelectedOptionsAndNullText() throws {
let plaintext = SealedAnswerPlaintext(
writtenText: nil,
selectedOptionIds: [],
scaleValue: nil
)
let canonical = SealedAnswerCrypto.canonicalJSON(plaintext)
XCTAssertEqual(canonical, "{\"scaleValue\":null,\"selectedOptionIds\":[],\"writtenText\":null}")
let key = try SealedAnswerCrypto.generateOneTimeKey()
let payload = try SealedAnswerCrypto.encrypt(
plaintext: plaintext,
oneTimeKey: key,
coupleId: coupleId,
userId: userId,
questionId: questionId
)
let recovered = try SealedAnswerCrypto.decrypt(payload, oneTimeKey: key)
XCTAssertEqual(recovered, plaintext)
}
}