| 1 | import CryptoKit |
| 2 | import XCTest |
| 3 | @testable import LChatCore |
| 4 | |
| 5 | // 🤖 Cross-implementation vector: the envelope below was sealed by the home |
| 6 | // server's JS crypto (home/web/static/crypto.js) with a fixed PRF output and |
| 7 | // DEK. If Swift opens it to the expected server list, the KEK derivation, |
| 8 | // AES-GCM primitive layout, DEK unwrap, padding and envelope framing all match |
| 9 | // the web byte-for-byte — so a backup written on the web restores on the phone. |
| 10 | // Regenerate with `node home/test/gen-home-vector.mjs`. |
| 11 | final class HomeCryptoTests: XCTestCase { |
| 12 | // Base64url inputs from the JS generator. |
| 13 | let prf = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" |
| 14 | let dekRaw = "AAcOFRwjKjE4P0ZNVFtiaXB3foWMk5qhqK-2vcTL0tk" |
| 15 | let credId = "cred-abc" |
| 16 | let wrapped = "LfDy9OjW1vWNWfVcpShcxANG7mcWIpsrDtS2yrlhic7KwdN1abiQIF8zOg7EdHF5p9oaI0f09LGg_Wvl" |
| 17 | |
| 18 | private func data(_ b64url: String) -> Data { Data(base64urlEncoded: b64url)! } |
| 19 | |
| 20 | func testKEKUnwrapsTheJSWrappedDEK() throws { |
| 21 | let kek = HomeCrypto.kekFromPRF(data(prf)) |
| 22 | let dek = try HomeCrypto.unwrapDEK(wrapped, kek: kek) |
| 23 | XCTAssertEqual(dek.withUnsafeBytes { Data($0) }, data(dekRaw), |
| 24 | "Swift KEK + unwrap must recover the JS-wrapped DEK") |
| 25 | } |
| 26 | |
| 27 | func testOpensJSSealedEnvelope() throws { |
| 28 | // The full JS-sealed envelope (truncated here is not allowed — it's the |
| 29 | // exact bytes the generator printed). |
| 30 | let envelope = data(HomeCryptoVectors.envelope) |
| 31 | let dek = SymmetricKey(data: data(dekRaw)) |
| 32 | let (objJSON, keys) = try HomeCrypto.openBlob(envelope, dek: dek) |
| 33 | XCTAssertEqual(keys[credId], wrapped, "header preserves the wrapped-DEK map") |
| 34 | |
| 35 | struct Blob: Decodable { |
| 36 | struct Server: Decodable { let url: String; let name: String } |
| 37 | let v: Int |
| 38 | let servers: [Server] |
| 39 | } |
| 40 | let blob = try JSONDecoder().decode(Blob.self, from: objJSON) |
| 41 | XCTAssertEqual(blob.v, 1) |
| 42 | XCTAssertEqual(blob.servers.first?.url, "https://test.example.com") |
| 43 | XCTAssertEqual(blob.servers.first?.name, "Test") |
| 44 | } |
| 45 | |
| 46 | func testSwiftRoundTrip() throws { |
| 47 | let dek = HomeCrypto.generateDEK() |
| 48 | let kek = HomeCrypto.kekFromPRF(Data("some-prf-output-32-bytes--------!".utf8)) |
| 49 | let wrappedDEK = try HomeCrypto.wrapDEK(dek, kek: kek) |
| 50 | let obj = Data(#"{"v":1,"servers":[{"url":"https://a.example","name":"A"}]}"#.utf8) |
| 51 | let sealed = try HomeCrypto.sealBlob(objJSON: obj, dek: dek, keys: ["c1": wrappedDEK]) |
| 52 | |
| 53 | // Unwrap the DEK the way a real open would, then read it back. |
| 54 | let reDEK = try HomeCrypto.unwrapDEK(wrappedDEK, kek: kek) |
| 55 | let (out, keys) = try HomeCrypto.openBlob(sealed, dek: reDEK) |
| 56 | XCTAssertEqual(out, obj) |
| 57 | XCTAssertEqual(keys["c1"], wrappedDEK) |
| 58 | } |
| 59 | |
| 60 | func testPaddingHidesLength() throws { |
| 61 | let small = try HomeCrypto.pad(Data([1, 2, 3])) |
| 62 | let bigger = try HomeCrypto.pad(Data(repeating: 9, count: 5000)) |
| 63 | XCTAssertEqual(small.count, HomeCrypto.blobPad) |
| 64 | XCTAssertEqual(bigger.count, HomeCrypto.blobPad) |
| 65 | XCTAssertEqual(try HomeCrypto.unpad(small), Data([1, 2, 3])) |
| 66 | XCTAssertEqual(try HomeCrypto.unpad(bigger), Data(repeating: 9, count: 5000)) |
| 67 | } |
| 68 | } |
| 69 | |