From 113d692dc6dd37c911891f03d8b898f61f691fae Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:45:09 -0400 Subject: [PATCH] test(kas): fix stale unwrapKey HKDF salt round-trip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testKeyUnwrappingWithValidKeys sealed the wrapped key with an empty HKDF salt (the Standard TDF convention) but called unwrapKey() with no salt arg, which since 0ee0797 defaults to CryptoConstants.hkdfSalt (the NanoTDF v12 session-key salt). The salt mismatch produced different AES-GCM keys, so tag verification failed with authenticationFailure — a stale test, not a product bug (unwrapKey's default matches the real KAS rewrap_dek derivation). Replace the single ambiguous test with a DRY helper and two explicit, self-consistent round-trips: - testKeyUnwrappingNanoTDFDefaultSalt: seal+unwrap with the v12 default salt - testKeyUnwrappingStandardTDFEmptySalt: seal+unwrap with empty salt Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKitTests/KASRewrapClientTests.swift | 53 +++++++++++++++------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/OpenTDFKitTests/KASRewrapClientTests.swift b/OpenTDFKitTests/KASRewrapClientTests.swift index 989bcef..bf6a247 100644 --- a/OpenTDFKitTests/KASRewrapClientTests.swift +++ b/OpenTDFKitTests/KASRewrapClientTests.swift @@ -29,13 +29,13 @@ final class KASRewrapClientTests: XCTestCase { let components = jwt.split(separator: ".") XCTAssertEqual(components.count, 3, "JWT should have 3 parts: header.payload.signature") - let headerData = Data(base64URLDecoded: String(components[0]))! - let header = try JSONSerialization.jsonObject(with: headerData) as! [String: String] + let headerData = try XCTUnwrap(Data(base64URLDecoded: String(components[0]))) + let header = try XCTUnwrap(try JSONSerialization.jsonObject(with: headerData) as? [String: String]) XCTAssertEqual(header["alg"], "ES256") XCTAssertEqual(header["typ"], "JWT") - let payloadData = Data(base64URLDecoded: String(components[1]))! - let payload = try JSONSerialization.jsonObject(with: payloadData) as! [String: Any] + let payloadData = try XCTUnwrap(Data(base64URLDecoded: String(components[1]))) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]) XCTAssertNotNil(payload["requestBody"]) XCTAssertNotNil(payload["iat"]) XCTAssertNotNil(payload["exp"]) @@ -49,7 +49,7 @@ final class KASRewrapClientTests: XCTestCase { let components = jwt.split(separator: ".") let signingInput = "\(components[0]).\(components[1])".data(using: .utf8)! - let signatureData = Data(base64URLDecoded: String(components[2]))! + let signatureData = try XCTUnwrap(Data(base64URLDecoded: String(components[2]))) let signature = try P256.Signing.ECDSASignature(rawRepresentation: signatureData) let publicKey = signingKey.publicKey @@ -64,11 +64,11 @@ final class KASRewrapClientTests: XCTestCase { let jwt = try client.createSignedJWT(requestBody: requestBody, signingKey: signingKey) let components = jwt.split(separator: ".") - let payloadData = Data(base64URLDecoded: String(components[1]))! - let payload = try JSONSerialization.jsonObject(with: payloadData) as! [String: Any] + let payloadData = try XCTUnwrap(Data(base64URLDecoded: String(components[1]))) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]) - let iat = payload["iat"] as! Int - let exp = payload["exp"] as! Int + let iat = try XCTUnwrap(payload["iat"] as? Int) + let exp = try XCTUnwrap(payload["exp"] as? Int) XCTAssertEqual(exp - iat, 60, "Token should expire 60 seconds after issuance") } @@ -166,16 +166,22 @@ final class KASRewrapClientTests: XCTestCase { } } - func testKeyUnwrappingWithValidKeys() throws { + /// Round-trips a random 256-bit key through HKDF(`sealSalt`) → AES-GCM seal → + /// `unwrapKey(salt: unwrapSalt)` and asserts the recovered key matches. + /// The seal and unwrap salts must agree for the GCM tag to verify, mirroring + /// the KAS session-key derivation. `unwrapSalt == nil` exercises `unwrapKey`'s + /// default (NanoTDF v12 salt). + private func assertUnwrapRoundTrip(sealSalt: Data, unwrapSalt: Data?, + file: StaticString = #filePath, line: UInt = #line) throws + { let clientPrivateKey = P256.KeyAgreement.PrivateKey() let sessionPrivateKey = P256.KeyAgreement.PrivateKey() let sharedSecret = try sessionPrivateKey.sharedSecretFromKeyAgreement(with: clientPrivateKey.publicKey) - // Use empty salt/info to match KAS implementation let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, - salt: Data(), + salt: sealSalt, sharedInfo: Data(), outputByteCount: 32, ) @@ -195,10 +201,23 @@ final class KASRewrapClientTests: XCTestCase { wrappedKey: wrappedKey, sessionPublicKey: sessionPrivateKey.publicKey.compressedRepresentation, clientPrivateKey: clientPrivateKey.rawRepresentation, + salt: unwrapSalt, ) let unwrappedKeyData = unwrappedKey.withUnsafeBytes { Data(Array($0)) } - XCTAssertEqual(unwrappedKeyData, testKeyData, "Unwrapped key should match original key") + XCTAssertEqual(unwrappedKeyData, testKeyData, "Unwrapped key should match original key", + file: file, line: line) + } + + /// NanoTDF: `unwrapKey` defaults (salt == nil) to the v12 session-key salt + /// (`CryptoConstants.hkdfSalt`), matching the KAS `rewrap_dek` derivation. + func testKeyUnwrappingNanoTDFDefaultSalt() throws { + try assertUnwrapRoundTrip(sealSalt: CryptoConstants.hkdfSalt, unwrapSalt: nil) + } + + /// Standard (Base) TDF: empty HKDF salt on both the seal and the unwrap. + func testKeyUnwrappingStandardTDFEmptySalt() throws { + try assertUnwrapRoundTrip(sealSalt: Data(), unwrapSalt: Data()) } func testKeyUnwrappingWithInvalidWrappedKeyFormat() { @@ -284,7 +303,7 @@ final class KASRewrapClientTests: XCTestCase { let encoder = JSONEncoder() let jsonData = try encoder.encode(keyAccess) - let json = try JSONSerialization.jsonObject(with: jsonData) as! [String: Any] + let json = try XCTUnwrap(try JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) XCTAssertEqual(json["type"] as? String, "remote") XCTAssertEqual(json["protocol"] as? String, "kas") @@ -292,7 +311,7 @@ final class KASRewrapClientTests: XCTestCase { XCTAssertEqual(json["header"] as? String, header.base64EncodedString()) } - func testRewrapRequestEntryWithDefaultAlgorithm() throws { + func testRewrapRequestEntryWithDefaultAlgorithm() { let policyBody = "test policy".data(using: .utf8)! let policy = KASRewrapClient.Policy(body: policyBody.base64EncodedString()) @@ -329,7 +348,7 @@ final class KASRewrapClientTests: XCTestCase { } """ - let jsonData = jsonString.data(using: .utf8)! + let jsonData = try XCTUnwrap(jsonString.data(using: .utf8)) let decoder = JSONDecoder() let response = try decoder.decode(KASRewrapClient.RewrapResponse.self, from: jsonData) @@ -355,7 +374,7 @@ final class KASRewrapClientTests: XCTestCase { } """ - let jsonData = jsonString.data(using: .utf8)! + let jsonData = try XCTUnwrap(jsonString.data(using: .utf8)) let decoder = JSONDecoder() let response = try decoder.decode(KASRewrapClient.RewrapResponse.self, from: jsonData)