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`.*
|
||||
|
||||
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
|
||||
|
||||
/// 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? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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