import CryptoKit import Foundation import RastrilloNative import RastrilloTestSupport import Security import XCTest @testable import RastrilloApple final class SealedStoreTests: XCTestCase { var directory: URL! override func setUpWithError() throws { directory = FileManager.default.temporaryDirectory.appendingPathComponent("sealed-\(UUID().uuidString)", isDirectory: true) } override func tearDownWithError() throws { try? FileManager.default.removeItem(at: directory) } private func store(_ account: String = "account-a", keys: KeyProvider) -> NativeSealedStore { NativeSealedStore(root: directory.appendingPathComponent(account, isDirectory: true), accountID: account, keys: keys) } private func file(_ account: String, _ path: String) -> URL { directory.appendingPathComponent(account).appendingPathComponent(path) } func testRoundTripIsSealedWithAFreshNonceAndBoundToItsPath() throws { let keys = ScriptedKeyProvider() let sealed = store(keys: keys) let secret = Data("the quarterly numbers".utf8) try sealed.write(secret, to: "outbox/0000000000000001.entry") XCTAssertEqual(try sealed.read("outbox/0000000000000001.entry"), secret) XCTAssertNotNil(keys.storedKey, "The key is created on first write") let onDisk = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) XCTAssertNil(onDisk.range(of: secret), "Plaintext must never reach the disk") XCTAssertEqual(onDisk.count, 12 + secret.count + 16, ".combined is nonce, ciphertext, tag") try sealed.write(secret, to: "outbox/0000000000000001.entry") let again = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) XCTAssertNotEqual(onDisk.prefix(12), again.prefix(12), "Every seal draws a fresh nonce") // The associated data is exactly accountID | format version | path. let box = try AES.GCM.SealedBox(combined: again) XCTAssertEqual(try AES.GCM.open(box, using: keys.storedKey!, authenticating: Data("account-a|1|outbox/0000000000000001.entry".utf8)), secret) XCTAssertEqual(try sealed.list("outbox"), ["0000000000000001.entry"]) XCTAssertNil(try sealed.read("outbox/missing.entry")) } func testATamperedFileFailsAndIsRemovedAlone() throws { let sealed = store(keys: ScriptedKeyProvider()) try sealed.write(Data("one".utf8), to: "cache/docs/a/replica") try sealed.write(Data("two".utf8), to: "cache/docs/b/replica") var bytes = try Data(contentsOf: file("account-a", "cache/docs/a/replica")) bytes[bytes.count - 1] ^= 0x01 try bytes.write(to: file("account-a", "cache/docs/a/replica")) XCTAssertThrowsError(try sealed.read("cache/docs/a/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/a/replica")) } XCTAssertFalse(FileManager.default.fileExists(atPath: file("account-a", "cache/docs/a/replica").path)) XCTAssertEqual(try sealed.read("cache/docs/b/replica"), Data("two".utf8), "The directory is never removed for one file") } func testARenamedFileFailsToOpen() throws { let sealed = store(keys: ScriptedKeyProvider()) try sealed.write(Data("document A".utf8), to: "cache/docs/a/replica") try sealed.write(Data("document B".utf8), to: "cache/docs/b/replica") // Swap A's replica in as B's. try FileManager.default.removeItem(at: file("account-a", "cache/docs/b/replica")) try FileManager.default.copyItem(at: file("account-a", "cache/docs/a/replica"), to: file("account-a", "cache/docs/b/replica")) XCTAssertThrowsError(try sealed.read("cache/docs/b/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/b/replica")) } XCTAssertEqual(try sealed.read("cache/docs/a/replica"), Data("document A".utf8)) } func testAFileFromAnotherAccountFailsToOpenEvenUnderTheSameKey() throws { let key = SymmetricKey(size: .bits256) let a = store("account-a", keys: ScriptedKeyProvider(key: key)) let b = store("account-b", keys: ScriptedKeyProvider(key: key)) try a.write(Data("A's entry".utf8), to: "outbox/0000000000000001.entry") try b.write(Data("B's entry".utf8), to: "outbox/0000000000000001.entry") try FileManager.default.removeItem(at: file("account-b", "outbox/0000000000000001.entry")) try FileManager.default.copyItem(at: file("account-a", "outbox/0000000000000001.entry"), to: file("account-b", "outbox/0000000000000001.entry")) XCTAssertThrowsError(try b.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("outbox/0000000000000001.entry")) } } func testALockedKeychainIsUnavailableAndDeletesNothing() throws { let keys = ScriptedKeyProvider() try store(keys: keys).write(Data("kept".utf8), to: "outbox/0000000000000001.entry") for status in [errSecInteractionNotAllowed, errSecAuthFailed, errSecNotAvailable, OSStatus(-1)] { keys.failCopies(with: status) let relaunched = store(keys: keys) // no cached key: it must ask the Keychain XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry"), "status \(status)") { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } XCTAssertThrowsError(try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } XCTAssertTrue(FileManager.default.fileExists(atPath: file("account-a", "outbox/0000000000000001.entry").path), "status \(status) deleted a file") XCTAssertFalse(keys.log.contains("delete")) } keys.failCopies(with: nil) XCTAssertEqual(try store(keys: keys).read("outbox/0000000000000001.entry"), Data("kept".utf8), "Unlocking reads everything again") } func testALostKeyWithFilesPresentRemovesTheDirectoryOnce() throws { let old = store(keys: ScriptedKeyProvider()) try old.write(Data("sealed under a key that is gone".utf8), to: "outbox/0000000000000001.entry") try old.write(Data("cache".utf8), to: "cache/docs/a/replica") let fresh = ScriptedKeyProvider() // errSecItemNotFound let relaunched = store(keys: fresh) XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .keyLost) } XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) // After the loss, the store starts again with a new key. try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry") XCTAssertEqual(try relaunched.read("outbox/0000000000000002.entry"), Data("new".utf8)) XCTAssertNotNil(fresh.storedKey) } func testNoKeyAndNoFilesIsAFirstWriteNotALoss() throws { let keys = ScriptedKeyProvider() let sealed = store(keys: keys) XCTAssertNil(try sealed.read("outbox/0000000000000001.entry")) XCTAssertEqual(try sealed.list("outbox"), []) XCTAssertEqual(keys.log, [], "A missing file needs no key") try sealed.write(Data("first".utf8), to: "outbox/0000000000000001.entry") XCTAssertEqual(keys.log, ["copy", "add"]) } func testAFailedKeyCreationIsUnavailable() throws { let keys = ScriptedKeyProvider() keys.failAdds(with: errSecInteractionNotAllowed) XCTAssertThrowsError(try store(keys: keys).write(Data("x".utf8), to: "outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } } func testTheAccountDirectoryIsExcludedFromBackup() throws { let sealed = store(keys: ScriptedKeyProvider()) try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") let values = try directory.appendingPathComponent("account-a").resourceValues(forKeys: [.isExcludedFromBackupKey]) XCTAssertEqual(values.isExcludedFromBackup, true) #if os(iOS) XCTAssertTrue(NativeSealedStore.writeOptions.contains(.completeFileProtection)) #endif XCTAssertTrue(NativeSealedStore.writeOptions.contains(.atomic)) } func testWipeDeletesTheKeyFirstThenTheDirectory() throws { let keys = ScriptedKeyProvider() let sealed = store(keys: keys) try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") try sealed.wipe() XCTAssertNil(keys.storedKey) XCTAssertEqual(keys.log.last, "delete") XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) } func testPathsCannotEscapeTheAccountDirectory() { let sealed = store(keys: ScriptedKeyProvider()) for path in ["../other/outbox/1.entry", "/etc/passwd", "outbox/../../x", ""] { XCTAssertThrowsError(try sealed.write(Data(), to: path), path) } } /// The outbox over the real sealed store survives a relaunch, and an /// entry copied from another account cannot be smuggled into this one. func testAnOutboxRelaunchesFromSealedFiles() async throws { let keys = ScriptedKeyProvider() let outbox = Outbox(store: store(keys: keys), account: "account-a") let entry = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: Data("typed".utf8)) let relaunched = Outbox(store: store(keys: keys), account: "account-a") let lane = await relaunched.entries(lane: "doc:1") XCTAssertEqual(lane.map(\.id), [entry.id]) } func testSealBlocksCacheStateAndSkipUnusedQuotaAfterCrash() throws { let keys = ScriptedKeyProvider() var nonces = Set() for launch in 1...2 { let sealed = store(keys: keys) for index in 0..<19 { try sealed.write(Data([UInt8(index)]), to: "cache/a") let bytes = try Data(contentsOf: file("account-a", "cache/a")) XCTAssertTrue(nonces.insert(Data(bytes.prefix(12))).inserted, "A random 96-bit nonce must not repeat") XCTAssertEqual(keys.sealState().1?.seals, UInt64(launch * 1024), "Persist the entire block before its first seal; a relaunch skips the unused tail") XCTAssertEqual(try sealed.read("cache/a"), Data([UInt8(index)])) } XCTAssertEqual(keys.log.filter { $0 == "copy" }.count, launch, "Cache the key across reads and writes") } } func testSealQuotaRotatesAndResealsInPlace() throws { let keys = ScriptedKeyProvider() let sealed = store(keys: keys) sealed.rotationLimit = 8 for _ in 0..<8 { try sealed.write(Data("A".utf8), to: "cache/a") } let old = try XCTUnwrap(keys.storedKey) try sealed.write(Data("B".utf8), to: "cache/b") XCTAssertNotEqual(keys.storedKey, old) XCTAssertEqual(try sealed.read("cache/a"), Data("A".utf8)) XCTAssertEqual(try sealed.read("cache/b"), Data("B".utf8)) XCTAssertNil(keys.sealState().1?.previous) XCTAssertEqual(keys.sealState().1?.seals, 8, "Reserve a block, capped at this test key’s rotation limit") let bytes = try Data(contentsOf: file("account-a", "cache/a")) XCTAssertThrowsError(try AES.GCM.open(AES.GCM.SealedBox(combined: bytes), using: old, authenticating: Data("account-a|1|cache/a".utf8))) } func testInterruptedRotationReadsBothKeysAndFinishesOnNextWrite() throws { let keys = ScriptedKeyProvider() let sealed = store(keys: keys) try sealed.write(Data("A".utf8), to: "cache/a") let previous = try XCTUnwrap(keys.sealState().1) let new = SymmetricKey(size: .bits256) // Crash just after installing the new key, before any file is resealed. XCTAssertEqual(keys.saveSealState(StoreKeyState(current: new.withUnsafeBytes { Data($0) }, previous: previous.current)), errSecSuccess) let reopened = store(keys: keys) XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) try reopened.write(Data("B".utf8), to: "cache/b") XCTAssertNil(keys.sealState().1?.previous) XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) XCTAssertEqual(try reopened.read("cache/b"), Data("B".utf8)) } }