rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import CryptoKit
2import Foundation
3import RastrilloNative
4import RastrilloTestSupport
5import Security
6import XCTest
7@testable import RastrilloApple
8
9final 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