feat(ios/e2ee): schemaVersion 3 sealed answers + ECIES keyboxes Path A + DeviceKeyStatus (Batch 4)
This commit is contained in:
parent
5c64f69754
commit
60c0003114
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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`.*
|
*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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,21 +38,22 @@ public enum AnswerError: Error, LocalizedError {
|
||||||
|
|
||||||
// MARK: - Answer Reveal View Model
|
// 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
|
/// Daily answers default to schemaVersion 2 (couple-key) per the live Android
|
||||||
/// `AnswerCrypto`, then writes the metadata doc to
|
/// data source. This class also handles schemaVersion 3 sealed answers for
|
||||||
/// `couples/{coupleId}/daily_question/{date}/answers/{userId}` and the secure
|
/// thread/legacy paths: when a sealed payload arrives, it surfaces a
|
||||||
/// payload to `answers/{userId}/secure/payload`.
|
/// "Waiting for partner" state, verifies the commitment on reveal, and shows
|
||||||
///
|
/// a tamper warning if the commitment check fails.
|
||||||
/// Reveal path: reads the partner's secure payload subdoc and decrypts it with
|
|
||||||
/// the same couple key. Gracefully surfaces legacy plaintext as a warning.
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class AnswerRevealViewModel: ObservableObject {
|
public final class AnswerRevealViewModel: ObservableObject {
|
||||||
@Published public private(set) var partnerAnswer: String?
|
@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 isLoading = false
|
||||||
@Published public private(set) var errorMessage: String?
|
@Published public private(set) var errorMessage: String?
|
||||||
@Published public private(set) var legacyWarning: 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 firestore: FirestoreService
|
||||||
private let keyStore: CoupleKeyStoreProtocol
|
private let keyStore: CoupleKeyStoreProtocol
|
||||||
|
|
@ -65,9 +66,9 @@ public final class AnswerRevealViewModel: ObservableObject {
|
||||||
self.keyStore = keyStore
|
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(
|
public func submitAnswer(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
questionId: String,
|
questionId: String,
|
||||||
|
|
@ -114,9 +115,43 @@ public final class AnswerRevealViewModel: ObservableObject {
|
||||||
try await batch.commit()
|
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(
|
public func loadPartnerAnswer(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
questionId: String,
|
questionId: String,
|
||||||
|
|
@ -125,8 +160,11 @@ public final class AnswerRevealViewModel: ObservableObject {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
partnerAnswer = nil
|
partnerAnswer = nil
|
||||||
|
partnerSealedAnswer = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
legacyWarning = nil
|
legacyWarning = nil
|
||||||
|
waitingForPartner = false
|
||||||
|
tamperWarning = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
guard let userId = Auth.auth().currentUser?.uid else {
|
guard let userId = Auth.auth().currentUser?.uid else {
|
||||||
|
|
@ -148,8 +186,6 @@ public final class AnswerRevealViewModel: ObservableObject {
|
||||||
let snapshot = try await secureRef.getDocument()
|
let snapshot = try await secureRef.getDocument()
|
||||||
guard snapshot.exists,
|
guard snapshot.exists,
|
||||||
let encryptedPayload = snapshot.data()?["encryptedPayload"] as? String else {
|
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 metaRef = firestore.dailyAnswerRef(coupleId: coupleId, date: answerDate, userId: partnerId)
|
||||||
let metaSnap = try await metaRef.getDocument()
|
let metaSnap = try await metaRef.getDocument()
|
||||||
if metaSnap.exists {
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func partnerUserId(coupleId: String, myUserId: String) async throws -> String? {
|
private func partnerUserId(coupleId: String, myUserId: String) async throws -> String? {
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,51 @@ final class FirestoreService: @unchecked Sendable {
|
||||||
userDocument(userId).collection("fcmTokens")
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
func userId() throws -> String {
|
func userId() throws -> String {
|
||||||
|
|
@ -219,6 +264,114 @@ final class FirestoreService: @unchecked Sendable {
|
||||||
|
|
||||||
// MARK: - Callable Functions
|
// 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 {
|
extension FirestoreService: FirestoreInvitesProtocol {
|
||||||
/// Creates a strict-E2EE invite. The caller (PairingViewModel) supplies the
|
/// Creates a strict-E2EE invite. The caller (PairingViewModel) supplies the
|
||||||
/// 6-character Crockford code and recovery phrase; this method generates the
|
/// 6-character Crockford code and recovery phrase; this method generates the
|
||||||
|
|
|
||||||
|
|
@ -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 Android↔iOS
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue