| 1 | import CryptoKit |
| 2 | import Foundation |
| 3 | import RastrilloNative |
| 4 | import RastrilloTestSupport |
| 5 | import Security |
| 6 | import XCTest |
| 7 | @testable import RastrilloApple |
| 8 | |
| 9 | final class SealedStoreTests: XCTestCase { |
| 10 | var directory: URL! |
| 11 | |
| 12 | override func setUpWithError() throws { |
| 13 | directory = FileManager.default.temporaryDirectory.appendingPathComponent("sealed-\(UUID().uuidString)", isDirectory: true) |
| 14 | } |
| 15 | |
| 16 | override func tearDownWithError() throws { |
| 17 | try? FileManager.default.removeItem(at: directory) |
| 18 | } |
| 19 | |
| 20 | private func store(_ account: String = "account-a", keys: KeyProvider) -> NativeSealedStore { |
| 21 | NativeSealedStore(root: directory.appendingPathComponent(account, isDirectory: true), accountID: account, keys: keys) |
| 22 | } |
| 23 | |
| 24 | private func file(_ account: String, _ path: String) -> URL { |
| 25 | directory.appendingPathComponent(account).appendingPathComponent(path) |
| 26 | } |
| 27 | |
| 28 | func testRoundTripIsSealedWithAFreshNonceAndBoundToItsPath() throws { |
| 29 | let keys = ScriptedKeyProvider() |
| 30 | let sealed = store(keys: keys) |
| 31 | let secret = Data("the quarterly numbers".utf8) |
| 32 | try sealed.write(secret, to: "outbox/0000000000000001.entry") |
| 33 | XCTAssertEqual(try sealed.read("outbox/0000000000000001.entry"), secret) |
| 34 | XCTAssertNotNil(keys.storedKey, "The key is created on first write") |
| 35 | |
| 36 | let onDisk = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) |
| 37 | XCTAssertNil(onDisk.range(of: secret), "Plaintext must never reach the disk") |
| 38 | XCTAssertEqual(onDisk.count, 12 + secret.count + 16, ".combined is nonce, ciphertext, tag") |
| 39 | |
| 40 | try sealed.write(secret, to: "outbox/0000000000000001.entry") |
| 41 | let again = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) |
| 42 | XCTAssertNotEqual(onDisk.prefix(12), again.prefix(12), "Every seal draws a fresh nonce") |
| 43 | |
| 44 | // The associated data is exactly accountID | format version | path. |
| 45 | let box = try AES.GCM.SealedBox(combined: again) |
| 46 | XCTAssertEqual(try AES.GCM.open(box, using: keys.storedKey!, authenticating: Data("account-a|1|outbox/0000000000000001.entry".utf8)), secret) |
| 47 | XCTAssertEqual(try sealed.list("outbox"), ["0000000000000001.entry"]) |
| 48 | XCTAssertNil(try sealed.read("outbox/missing.entry")) |
| 49 | } |
| 50 | |
| 51 | func testATamperedFileFailsAndIsRemovedAlone() throws { |
| 52 | let sealed = store(keys: ScriptedKeyProvider()) |
| 53 | try sealed.write(Data("one".utf8), to: "cache/docs/a/replica") |
| 54 | try sealed.write(Data("two".utf8), to: "cache/docs/b/replica") |
| 55 | var bytes = try Data(contentsOf: file("account-a", "cache/docs/a/replica")) |
| 56 | bytes[bytes.count - 1] ^= 0x01 |
| 57 | try bytes.write(to: file("account-a", "cache/docs/a/replica")) |
| 58 | |
| 59 | XCTAssertThrowsError(try sealed.read("cache/docs/a/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/a/replica")) } |
| 60 | XCTAssertFalse(FileManager.default.fileExists(atPath: file("account-a", "cache/docs/a/replica").path)) |
| 61 | XCTAssertEqual(try sealed.read("cache/docs/b/replica"), Data("two".utf8), "The directory is never removed for one file") |
| 62 | } |
| 63 | |
| 64 | func testARenamedFileFailsToOpen() throws { |
| 65 | let sealed = store(keys: ScriptedKeyProvider()) |
| 66 | try sealed.write(Data("document A".utf8), to: "cache/docs/a/replica") |
| 67 | try sealed.write(Data("document B".utf8), to: "cache/docs/b/replica") |
| 68 | // Swap A's replica in as B's. |
| 69 | try FileManager.default.removeItem(at: file("account-a", "cache/docs/b/replica")) |
| 70 | try FileManager.default.copyItem(at: file("account-a", "cache/docs/a/replica"), to: file("account-a", "cache/docs/b/replica")) |
| 71 | XCTAssertThrowsError(try sealed.read("cache/docs/b/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/b/replica")) } |
| 72 | XCTAssertEqual(try sealed.read("cache/docs/a/replica"), Data("document A".utf8)) |
| 73 | } |
| 74 | |
| 75 | func testAFileFromAnotherAccountFailsToOpenEvenUnderTheSameKey() throws { |
| 76 | let key = SymmetricKey(size: .bits256) |
| 77 | let a = store("account-a", keys: ScriptedKeyProvider(key: key)) |
| 78 | let b = store("account-b", keys: ScriptedKeyProvider(key: key)) |
| 79 | try a.write(Data("A's entry".utf8), to: "outbox/0000000000000001.entry") |
| 80 | try b.write(Data("B's entry".utf8), to: "outbox/0000000000000001.entry") |
| 81 | try FileManager.default.removeItem(at: file("account-b", "outbox/0000000000000001.entry")) |
| 82 | try FileManager.default.copyItem(at: file("account-a", "outbox/0000000000000001.entry"), to: file("account-b", "outbox/0000000000000001.entry")) |
| 83 | XCTAssertThrowsError(try b.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("outbox/0000000000000001.entry")) } |
| 84 | } |
| 85 | |
| 86 | func testALockedKeychainIsUnavailableAndDeletesNothing() throws { |
| 87 | let keys = ScriptedKeyProvider() |
| 88 | try store(keys: keys).write(Data("kept".utf8), to: "outbox/0000000000000001.entry") |
| 89 | for status in [errSecInteractionNotAllowed, errSecAuthFailed, errSecNotAvailable, OSStatus(-1)] { |
| 90 | keys.failCopies(with: status) |
| 91 | let relaunched = store(keys: keys) // no cached key: it must ask the Keychain |
| 92 | XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry"), "status \(status)") { |
| 93 | XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| 94 | } |
| 95 | XCTAssertThrowsError(try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry")) { |
| 96 | XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| 97 | } |
| 98 | XCTAssertTrue(FileManager.default.fileExists(atPath: file("account-a", "outbox/0000000000000001.entry").path), "status \(status) deleted a file") |
| 99 | XCTAssertFalse(keys.log.contains("delete")) |
| 100 | } |
| 101 | keys.failCopies(with: nil) |
| 102 | XCTAssertEqual(try store(keys: keys).read("outbox/0000000000000001.entry"), Data("kept".utf8), "Unlocking reads everything again") |
| 103 | } |
| 104 | |
| 105 | func testALostKeyWithFilesPresentRemovesTheDirectoryOnce() throws { |
| 106 | let old = store(keys: ScriptedKeyProvider()) |
| 107 | try old.write(Data("sealed under a key that is gone".utf8), to: "outbox/0000000000000001.entry") |
| 108 | try old.write(Data("cache".utf8), to: "cache/docs/a/replica") |
| 109 | let fresh = ScriptedKeyProvider() // errSecItemNotFound |
| 110 | let relaunched = store(keys: fresh) |
| 111 | XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .keyLost) } |
| 112 | XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) |
| 113 | // After the loss, the store starts again with a new key. |
| 114 | try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry") |
| 115 | XCTAssertEqual(try relaunched.read("outbox/0000000000000002.entry"), Data("new".utf8)) |
| 116 | XCTAssertNotNil(fresh.storedKey) |
| 117 | } |
| 118 | |
| 119 | func testNoKeyAndNoFilesIsAFirstWriteNotALoss() throws { |
| 120 | let keys = ScriptedKeyProvider() |
| 121 | let sealed = store(keys: keys) |
| 122 | XCTAssertNil(try sealed.read("outbox/0000000000000001.entry")) |
| 123 | XCTAssertEqual(try sealed.list("outbox"), []) |
| 124 | XCTAssertEqual(keys.log, [], "A missing file needs no key") |
| 125 | try sealed.write(Data("first".utf8), to: "outbox/0000000000000001.entry") |
| 126 | XCTAssertEqual(keys.log, ["copy", "add"]) |
| 127 | } |
| 128 | |
| 129 | func testAFailedKeyCreationIsUnavailable() throws { |
| 130 | let keys = ScriptedKeyProvider() |
| 131 | keys.failAdds(with: errSecInteractionNotAllowed) |
| 132 | XCTAssertThrowsError(try store(keys: keys).write(Data("x".utf8), to: "outbox/0000000000000001.entry")) { |
| 133 | XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func testTheAccountDirectoryIsExcludedFromBackup() throws { |
| 138 | let sealed = store(keys: ScriptedKeyProvider()) |
| 139 | try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") |
| 140 | let values = try directory.appendingPathComponent("account-a").resourceValues(forKeys: [.isExcludedFromBackupKey]) |
| 141 | XCTAssertEqual(values.isExcludedFromBackup, true) |
| 142 | #if os(iOS) |
| 143 | XCTAssertTrue(NativeSealedStore.writeOptions.contains(.completeFileProtection)) |
| 144 | #endif |
| 145 | XCTAssertTrue(NativeSealedStore.writeOptions.contains(.atomic)) |
| 146 | } |
| 147 | |
| 148 | func testWipeDeletesTheKeyFirstThenTheDirectory() throws { |
| 149 | let keys = ScriptedKeyProvider() |
| 150 | let sealed = store(keys: keys) |
| 151 | try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") |
| 152 | try sealed.wipe() |
| 153 | XCTAssertNil(keys.storedKey) |
| 154 | XCTAssertEqual(keys.log.last, "delete") |
| 155 | XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) |
| 156 | } |
| 157 | |
| 158 | func testPathsCannotEscapeTheAccountDirectory() { |
| 159 | let sealed = store(keys: ScriptedKeyProvider()) |
| 160 | for path in ["../other/outbox/1.entry", "/etc/passwd", "outbox/../../x", ""] { |
| 161 | XCTAssertThrowsError(try sealed.write(Data(), to: path), path) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /// The outbox over the real sealed store survives a relaunch, and an |
| 166 | /// entry copied from another account cannot be smuggled into this one. |
| 167 | func testAnOutboxRelaunchesFromSealedFiles() async throws { |
| 168 | let keys = ScriptedKeyProvider() |
| 169 | let outbox = Outbox(store: store(keys: keys), account: "account-a") |
| 170 | let entry = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: Data("typed".utf8)) |
| 171 | let relaunched = Outbox(store: store(keys: keys), account: "account-a") |
| 172 | let lane = await relaunched.entries(lane: "doc:1") |
| 173 | XCTAssertEqual(lane.map(\.id), [entry.id]) |
| 174 | } |
| 175 | func testSealBlocksCacheStateAndSkipUnusedQuotaAfterCrash() throws { |
| 176 | let keys = ScriptedKeyProvider() |
| 177 | var nonces = Set<Data>() |
| 178 | for launch in 1...2 { |
| 179 | let sealed = store(keys: keys) |
| 180 | for index in 0..<19 { |
| 181 | try sealed.write(Data([UInt8(index)]), to: "cache/a") |
| 182 | let bytes = try Data(contentsOf: file("account-a", "cache/a")) |
| 183 | XCTAssertTrue(nonces.insert(Data(bytes.prefix(12))).inserted, "A random 96-bit nonce must not repeat") |
| 184 | XCTAssertEqual(keys.sealState().1?.seals, UInt64(launch * 1024), "Persist the entire block before its first seal; a relaunch skips the unused tail") |
| 185 | XCTAssertEqual(try sealed.read("cache/a"), Data([UInt8(index)])) |
| 186 | } |
| 187 | XCTAssertEqual(keys.log.filter { $0 == "copy" }.count, launch, "Cache the key across reads and writes") |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func testSealQuotaRotatesAndResealsInPlace() throws { |
| 192 | let keys = ScriptedKeyProvider() |
| 193 | let sealed = store(keys: keys) |
| 194 | sealed.rotationLimit = 8 |
| 195 | for _ in 0..<8 { try sealed.write(Data("A".utf8), to: "cache/a") } |
| 196 | let old = try XCTUnwrap(keys.storedKey) |
| 197 | try sealed.write(Data("B".utf8), to: "cache/b") |
| 198 | XCTAssertNotEqual(keys.storedKey, old) |
| 199 | XCTAssertEqual(try sealed.read("cache/a"), Data("A".utf8)) |
| 200 | XCTAssertEqual(try sealed.read("cache/b"), Data("B".utf8)) |
| 201 | XCTAssertNil(keys.sealState().1?.previous) |
| 202 | XCTAssertEqual(keys.sealState().1?.seals, 8, "Reserve a block, capped at this test key’s rotation limit") |
| 203 | let bytes = try Data(contentsOf: file("account-a", "cache/a")) |
| 204 | XCTAssertThrowsError(try AES.GCM.open(AES.GCM.SealedBox(combined: bytes), using: old, authenticating: Data("account-a|1|cache/a".utf8))) |
| 205 | } |
| 206 | |
| 207 | func testInterruptedRotationReadsBothKeysAndFinishesOnNextWrite() throws { |
| 208 | let keys = ScriptedKeyProvider() |
| 209 | let sealed = store(keys: keys) |
| 210 | try sealed.write(Data("A".utf8), to: "cache/a") |
| 211 | let previous = try XCTUnwrap(keys.sealState().1) |
| 212 | let new = SymmetricKey(size: .bits256) |
| 213 | // Crash just after installing the new key, before any file is resealed. |
| 214 | XCTAssertEqual(keys.saveSealState(StoreKeyState(current: new.withUnsafeBytes { Data($0) }, previous: previous.current)), errSecSuccess) |
| 215 | let reopened = store(keys: keys) |
| 216 | XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) |
| 217 | try reopened.write(Data("B".utf8), to: "cache/b") |
| 218 | XCTAssertNil(keys.sealState().1?.previous) |
| 219 | XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) |
| 220 | XCTAssertEqual(try reopened.read("cache/b"), Data("B".utf8)) |
| 221 | } |
| 222 | |
| 223 | } |
| 224 | |