diff --git a/.github/workflows/swift.yaml b/.github/workflows/swift.yaml index 758d5d7..b99ccb9 100644 --- a/.github/workflows/swift.yaml +++ b/.github/workflows/swift.yaml @@ -29,7 +29,8 @@ jobs: run: brew install swiftformat - name: Run SwiftFormat - run: swiftformat --swiftversion 6.2 . --lint + # Rules and Swift version are pinned in the repo's .swiftformat config. + run: swiftformat --lint . # - name: Run SwiftLint # run: swiftlint --strict diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..13ac2c8 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,14 @@ +# SwiftFormat configuration for OpenTDFKit +# Pinned so `swiftformat .` and the CI lint check agree. + +# Match the package's declared language version (Package.swift: swift-tools-version:6.2). +--swiftversion 6.2 + +# Force-unwraps in tests/benchmarks are intentional here: a nil in a test is a +# legitimate, immediate failure. The autocorrect for this rule rewrites `!` to +# `try XCTUnwrap(...)`, which breaks compilation in non-throwing benchmark +# functions, so the rule is disabled rather than auto-applied. +--disable noForceUnwrapInTests + +# Never touch build artifacts. +--exclude .build diff --git a/CLAUDE.md b/CLAUDE.md index c6cd967..cb5b044 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,7 +213,9 @@ OpenTDFKit is composed of several key components that work together to implement - **PublicKeyStore**: Manages only public keys for sharing with peers. Allows secure distribution of one-time use TDF keys. -- **KASRewrapClient**: Client for interacting with KAS rewrap endpoints. Implements JWT signing (ES256), PEM parsing, and key unwrapping protocols. Supports both NanoTDF (EC key wrapping) and TDF (Archive Envelope) (RSA key wrapping) rewrap requests. Designed with protocol-based architecture for testability. +- **KASRewrapClient**: Client for interacting with KAS rewrap endpoints. Implements JWT signing (ES256), PEM parsing, and key unwrapping protocols. Supports both NanoTDF (EC key wrapping) and TDF (Archive Envelope) (RSA key wrapping) rewrap requests. Designed with protocol-based architecture for testability. Resolves transport endpoints from an `OpenTDFConfiguration` (well-known discovery via `fetchWellKnown`, or `OpenTDFConfiguration.forKasConnect`), preferring ConnectRPC `/kas.AccessService/*` and falling back to legacy REST `/kas/v2/*`; the rewrap client follows no redirects and parses Connect error envelopes. Bearer tokens are opaque (a JWT or a base64url-encoded CWT — the platform decides validation). + +- **KASDiscovery**: ConnectRPC/well-known discovery support. Provides `OpenTDFConfiguration`/`KasConfig`/`IdpConfig` Codable types, `KasEndpoints` resolution (Connect-preferred, REST-fallback) with HTTPS/SSRF URL validation, Connect error-envelope parsing, and `fetchWellKnown` for `/.well-known/opentdf-configuration`. ### TDF (Archive Envelope) Components diff --git a/OpenTDFKit/BinaryParser.swift b/OpenTDFKit/BinaryParser.swift index 42168ad..a8b8195 100644 --- a/OpenTDFKit/BinaryParser.swift +++ b/OpenTDFKit/BinaryParser.swift @@ -359,8 +359,7 @@ public class BinaryParser { else { throw ParsingError.invalidPayload("Failed to read ciphertext or payload MAC") } - let payload = Payload(length: length, iv: iv, ciphertext: ciphertext, mac: payloadMAC) - return payload + return Payload(length: length, iv: iv, ciphertext: ciphertext, mac: payloadMAC) } public func parseSignature(config: SignatureAndPayloadConfig) throws -> Signature? { @@ -394,7 +393,7 @@ public class BinaryParser { } } -// see https://github.com/opentdf/spec/tree/main/schema/nanotdf +/// see https://github.com/opentdf/spec/tree/main/schema/nanotdf enum FieldSize { static let magicNumberSize = 2 static let versionSize = 1 diff --git a/OpenTDFKit/CryptoHelper.swift b/OpenTDFKit/CryptoHelper.swift index 80ef56e..7091ee6 100644 --- a/OpenTDFKit/CryptoHelper.swift +++ b/OpenTDFKit/CryptoHelper.swift @@ -130,8 +130,8 @@ public actor CryptoHelper { publicKey.compressedRepresentation } - // Note: `activeSessions` is declared but not currently used in the provided methods. - // It might be intended for future stateful operations. + /// Note: `activeSessions` is declared but not currently used in the provided methods. + /// It might be intended for future stateful operations. private var activeSessions: [String: EphemeralKeyPair] = [:] /// Generates a new ephemeral key pair for the specified elliptic curve. diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift new file mode 100644 index 0000000..d6c030f --- /dev/null +++ b/OpenTDFKit/KASDiscovery.swift @@ -0,0 +1,353 @@ +import Darwin +import Foundation + +// MARK: - Helpers + +/// Strip any trailing `/` characters from a base URL string. +private func trimmingTrailingSlashes(_ s: String) -> String { + var result = s + while result.hasSuffix("/") { + result.removeLast() + } + return result +} + +// MARK: - Configuration documents (/.well-known/opentdf-configuration) + +/// The platform's well-known configuration document. +public struct OpenTDFConfiguration: Codable, Sendable { + public let kas: KasConfig? + public let idp: IdpConfig? + public let platformIssuer: String? + + public init(kas: KasConfig?, idp: IdpConfig?, platformIssuer: String?) { + self.kas = kas + self.idp = idp + self.platformIssuer = platformIssuer + } + + enum CodingKeys: String, CodingKey { + case kas, idp + case platformIssuer = "platform_issuer" + } + + /// Synthesize a Connect-only configuration for a single KAS base URL. + /// Use when the platform does not expose the well-known endpoint. + public static func forKasConnect(_ baseURL: String) -> OpenTDFConfiguration { + let base = trimmingTrailingSlashes(baseURL) + return OpenTDFConfiguration( + kas: KasConfig( + uri: base, + algorithms: [], + publicKeyURL: nil, + rewrapURL: nil, + connectPublicKeyURL: "\(base)/kas.AccessService/PublicKey", + connectRewrapURL: "\(base)/kas.AccessService/Rewrap", + ), + idp: nil, + platformIssuer: nil, + ) + } + + /// Synthesize a legacy-REST configuration for a single KAS base URL. + public static func forKasLegacyRest(_ baseURL: String) -> OpenTDFConfiguration { + let base = trimmingTrailingSlashes(baseURL) + return OpenTDFConfiguration( + kas: KasConfig( + uri: base, + algorithms: [], + publicKeyURL: "\(base)/kas/v2/kas_public_key", + rewrapURL: "\(base)/kas/v2/rewrap", + connectPublicKeyURL: nil, + connectRewrapURL: nil, + ), + idp: nil, + platformIssuer: nil, + ) + } +} + +public struct KasConfig: Codable, Sendable { + public let uri: String + public let algorithms: [String] + public let publicKeyURL: String? + public let rewrapURL: String? + public let connectPublicKeyURL: String? + public let connectRewrapURL: String? + + public init(uri: String, algorithms: [String], publicKeyURL: String?, rewrapURL: String?, + connectPublicKeyURL: String?, connectRewrapURL: String?) + { + self.uri = uri + self.algorithms = algorithms + self.publicKeyURL = publicKeyURL + self.rewrapURL = rewrapURL + self.connectPublicKeyURL = connectPublicKeyURL + self.connectRewrapURL = connectRewrapURL + } + + enum CodingKeys: String, CodingKey { + case uri, algorithms + case publicKeyURL = "public_key_url" + case rewrapURL = "rewrap_url" + case connectPublicKeyURL = "connect_public_key_url" + case connectRewrapURL = "connect_rewrap_url" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + uri = try c.decode(String.self, forKey: .uri) + algorithms = try c.decodeIfPresent([String].self, forKey: .algorithms) ?? [] + publicKeyURL = try c.decodeIfPresent(String.self, forKey: .publicKeyURL) + rewrapURL = try c.decodeIfPresent(String.self, forKey: .rewrapURL) + connectPublicKeyURL = try c.decodeIfPresent(String.self, forKey: .connectPublicKeyURL) + connectRewrapURL = try c.decodeIfPresent(String.self, forKey: .connectRewrapURL) + } +} + +public struct IdpConfig: Codable, Sendable { + public let issuer: String + public let jwksURI: String? + public let coseKeysURI: String? + public let tokenEndpoint: String? + public let authorizationEndpoint: String? + public let userinfoEndpoint: String? + public let accessTokenFormat: String? + public let idTokenSigningAlgValuesSupported: [String] + public let responseTypesSupported: [String] + public let subjectTypesSupported: [String] + + enum CodingKeys: String, CodingKey { + case issuer + case jwksURI = "jwks_uri" + case coseKeysURI = "cose_keys_uri" + case tokenEndpoint = "token_endpoint" + case authorizationEndpoint = "authorization_endpoint" + case userinfoEndpoint = "userinfo_endpoint" + case accessTokenFormat = "access_token_format" + case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported" + case responseTypesSupported = "response_types_supported" + case subjectTypesSupported = "subject_types_supported" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issuer = try c.decode(String.self, forKey: .issuer) + jwksURI = try c.decodeIfPresent(String.self, forKey: .jwksURI) + coseKeysURI = try c.decodeIfPresent(String.self, forKey: .coseKeysURI) + tokenEndpoint = try c.decodeIfPresent(String.self, forKey: .tokenEndpoint) + authorizationEndpoint = try c.decodeIfPresent(String.self, forKey: .authorizationEndpoint) + userinfoEndpoint = try c.decodeIfPresent(String.self, forKey: .userinfoEndpoint) + accessTokenFormat = try c.decodeIfPresent(String.self, forKey: .accessTokenFormat) + idTokenSigningAlgValuesSupported = + try c.decodeIfPresent([String].self, forKey: .idTokenSigningAlgValuesSupported) ?? [] + responseTypesSupported = + try c.decodeIfPresent([String].self, forKey: .responseTypesSupported) ?? [] + subjectTypesSupported = + try c.decodeIfPresent([String].self, forKey: .subjectTypesSupported) ?? [] + } +} + +// MARK: - Errors + +public enum KASDiscoveryError: Error, CustomStringConvertible, Sendable { + case invalidURL(String) + case configError(String) + case httpError(Int, String) + case invalidResponse(String) + + public var description: String { + switch self { + case let .invalidURL(m): "Invalid KAS URL: \(m)" + case let .configError(m): "KAS configuration error: \(m)" + case let .httpError(s, m): "HTTP error \(s): \(m)" + case let .invalidResponse(m): "Invalid response: \(m)" + } + } +} + +// MARK: - URL / SSRF validation + +private enum IPLiteral { + case v4([UInt8]) // 4 bytes, network order + case v6([UInt8]) // 16 bytes, network order +} + +private func classifyIP(_ host: String) -> IPLiteral? { + var v4 = in_addr() + if host.withCString({ inet_pton(AF_INET, $0, &v4) }) == 1 { + return .v4(withUnsafeBytes(of: v4.s_addr) { Array($0) }) + } + var v6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { + return .v6(withUnsafeBytes(of: v6) { Array($0) }) + } + return nil +} + +private func isLoopbackHost(_ host: String) -> Bool { + // Normalize a trailing FQDN dot ("localhost." resolves to loopback too). + let host = host.hasSuffix(".") ? String(host.dropLast()) : host + if host == "localhost" { return true } + switch classifyIP(host) { + case let .v4(o): return o[0] == 127 // 127.0.0.0/8 + case let .v6(b): return b.dropLast() == ArraySlice(repeating: 0, count: 15) && b[15] == 1 // ::1 + case .none: return false + } +} + +private func isBlockedV4(_ o: [UInt8]) -> Bool { + if o[0] == 10 { return true } // 10.0.0.0/8 + if o[0] == 172, (o[1] & 0xF0) == 16 { return true } // 172.16.0.0/12 + if o[0] == 192, o[1] == 168 { return true } // 192.168.0.0/16 + if o[0] == 169, o[1] == 254 { return true } // 169.254.0.0/16 + if o == [0, 0, 0, 0] { return true } // 0.0.0.0 + return false +} + +private func isBlockedIP(_ ip: IPLiteral) -> Bool { + switch ip { + case let .v4(o): + return isBlockedV4(o) + case let .v6(b): + // Fold IPv4-mapped ::ffff:a.b.c.d back to IPv4. + if b[0 ..< 10].allSatisfy({ $0 == 0 }), b[10] == 0xFF, b[11] == 0xFF { + return isBlockedV4(Array(b[12 ..< 16])) + } + if b.allSatisfy({ $0 == 0 }) { return true } // :: unspecified + let first = (UInt16(b[0]) << 8) | UInt16(b[1]) + return (first & 0xFE00) == 0xFC00 || (first & 0xFFC0) == 0xFE80 // fc00::/7, fe80::/10 + } +} + +/// Validate a KAS URL for scheme / HTTPS / SSRF constraints. +/// +/// - `http` is allowed only for loopback hosts (`localhost`, `127.0.0.0/8`, `::1`). +/// - Private, link-local, and unspecified IPs are rejected (IPv4, IPv6 ULA/link-local, +/// and IPv4-mapped IPv6 literals folded back to IPv4). +public func validateKasURL(_ urlString: String) throws { + guard let url = URL(string: urlString), let scheme = url.scheme?.lowercased() else { + throw KASDiscoveryError.invalidURL("Failed to parse URL: \(urlString)") + } + let host = url.host ?? "" + guard !host.isEmpty else { + throw KASDiscoveryError.invalidURL("KAS URL must have a non-empty host") + } + + switch scheme { + case "https": + break + case "http": + if !isLoopbackHost(host) { + throw KASDiscoveryError.invalidURL("KAS URL must use HTTPS (HTTP only allowed for localhost)") + } + default: + throw KASDiscoveryError.invalidURL("Unsupported URL scheme '\(scheme)', must be https") + } + + if let ip = classifyIP(host), isBlockedIP(ip) { + throw KASDiscoveryError.invalidURL("KAS URL must not target private or link-local IP addresses") + } +} + +// MARK: - Endpoint resolution + +/// The wire protocol used to communicate with a KAS. +public enum KasTransport: Sendable, Equatable { + /// ConnectRPC endpoints at /kas.AccessService/* + case connect + /// Legacy REST gateway at /kas/v2/* + case legacyRest +} + +/// Resolved, validated KAS endpoint URLs and their associated transport. +public struct KasEndpoints: Sendable, Equatable { + public let rewrapURL: String + public let publicKeyURL: String + public let transport: KasTransport + + public init(rewrapURL: String, publicKeyURL: String, transport: KasTransport) { + self.rewrapURL = rewrapURL + self.publicKeyURL = publicKeyURL + self.transport = transport + } + + /// Resolve KAS endpoints, preferring ConnectRPC URLs and falling back to + /// legacy REST when only REST is advertised. Both resolved URLs are + /// validated (HTTPS / scheme / SSRF) before returning. + public static func from(_ config: OpenTDFConfiguration) throws -> KasEndpoints { + guard let kas = config.kas else { + throw KASDiscoveryError.configError("well-known configuration is missing a 'kas' block") + } + // `kas.uri` is the KAS identity used to match manifest key-access entries + // (see KASRewrapClient.matchesKasURL). An empty uri would silently match + // nothing, so reject it here rather than failing later in rewrapTDF. + guard !kas.uri.isEmpty else { + throw KASDiscoveryError.configError("well-known kas block has an empty 'uri'") + } + + let resolved: KasEndpoints + if let rewrap = kas.connectRewrapURL, let pub = kas.connectPublicKeyURL { + resolved = KasEndpoints(rewrapURL: rewrap, publicKeyURL: pub, transport: .connect) + } else if let rewrap = kas.rewrapURL, let pub = kas.publicKeyURL { + resolved = KasEndpoints(rewrapURL: rewrap, publicKeyURL: pub, transport: .legacyRest) + } else { + throw KASDiscoveryError.configError("well-known kas block exposes neither Connect nor REST URLs") + } + + try validateKasURL(resolved.rewrapURL) + try validateKasURL(resolved.publicKeyURL) + return resolved + } +} + +// MARK: - Well-known discovery + +/// Fetch the platform's /.well-known/opentdf-configuration document. +/// `platformURL` is the platform base (e.g. "https://platform.arkavo.net"); a +/// trailing slash is tolerated. +public func fetchWellKnown(platformURL: String, + urlSession: URLSession = .shared) async throws -> OpenTDFConfiguration +{ + let base = trimmingTrailingSlashes(platformURL) + let urlString = "\(base)/.well-known/opentdf-configuration" + guard let url = URL(string: urlString) else { + throw KASDiscoveryError.invalidURL("Failed to parse URL: \(urlString)") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.addValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw KASDiscoveryError.invalidResponse("Non-HTTP response from \(urlString)") + } + guard (200 ..< 300).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw KASDiscoveryError.httpError(http.statusCode, "GET \(urlString): \(body)") + } + + do { + return try JSONDecoder().decode(OpenTDFConfiguration.self, from: data) + } catch { + throw KASDiscoveryError.invalidResponse("Failed to parse well-known JSON: \(error)") + } +} + +// MARK: - Connect error envelope + +/// Error envelope returned by Connect unary-JSON RPCs on non-2xx responses. +public struct ConnectError: Codable, Sendable { + public let code: String + public let message: String +} + +/// Parse a Connect error envelope from a response body. Returns nil for empty, +/// non-JSON, or shapes lacking a non-empty `code`. +public func parseConnectError(_ body: String) -> ConnectError? { + guard !body.isEmpty, let data = body.data(using: .utf8) else { return nil } + guard let parsed = try? JSONDecoder().decode(ConnectError.self, from: data) else { return nil } + return parsed.code.isEmpty ? nil : parsed +} diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 452d312..f40a6a8 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -2,6 +2,18 @@ import CryptoKit import Darwin import Foundation +/// URLSession task delegate that refuses HTTP redirects. The KAS rewrap target +/// is an RPC endpoint that must never redirect; following a 3xx could re-issue +/// the bearer-carrying request to an unvalidated host. +final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_: URLSession, task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, newRequest _: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + completionHandler(nil) + } +} + /// Protocol for KAS rewrap client operations, enabling testability through dependency injection public protocol KASRewrapClientProtocol { /// Perform NanoTDF rewrap request to KAS @@ -272,21 +284,29 @@ public class KASRewrapClient: KASRewrapClientProtocol { // MARK: - Properties - private let kasURL: URL + private let endpoints: KasEndpoints + /// KAS identity url for the request-body KeyAccessObject when the NanoTDF + /// header locator is unavailable (fallback only). + private let kasIdentityURL: String private let oauthToken: String private let urlSession: URLSession private let signingKey: P256.Signing.PrivateKey + private let noRedirect = NoRedirectDelegate() // MARK: - Initialization - /// Initialize KAS rewrap client with required parameters + /// Initialize from a resolved configuration document (preferred). /// - Parameters: - /// - kasURL: The KAS endpoint URL - /// - oauthToken: OAuth bearer token for authentication - /// - urlSession: URLSession for network requests (defaults to .shared) - /// - signingKey: Optional P256 private key for JWT signing (generates new key if not provided) - public init(kasURL: URL, oauthToken: String, urlSession: URLSession = .shared, signingKey: P256.Signing.PrivateKey? = nil) { - self.kasURL = kasURL + /// - configuration: Resolved config; obtain via `fetchWellKnown(...)` or + /// `OpenTDFConfiguration.forKasConnect(_:)`. Both resolved KAS URLs are + /// validated (HTTPS / scheme / SSRF) here. + /// - oauthToken: Bearer token (opaque: a JWT or base64url-encoded CWT). + public init(configuration: OpenTDFConfiguration, oauthToken: String, + urlSession: URLSession = .shared, + signingKey: P256.Signing.PrivateKey? = nil) throws + { + endpoints = try KasEndpoints.from(configuration) + kasIdentityURL = configuration.kas?.uri ?? "" self.oauthToken = oauthToken self.urlSession = urlSession self.signingKey = signingKey ?? P256.Signing.PrivateKey() @@ -304,7 +324,7 @@ public class KASRewrapClient: KASRewrapClientProtocol { // Build the Key Access Object let keyAccess = KeyAccessObject( header: header.base64EncodedString(), - url: kasURL.absoluteString, + url: resolveNanoKasURL(parsedHeader), ) // Build the Key Access Object wrapper for v2 API @@ -346,18 +366,22 @@ public class KASRewrapClient: KASRewrapClientProtocol { let signedRequest = SignedRewrapRequest(signed_request_token: signedToken) // Create HTTP request - let rewrapEndpoint = kasURL.appendingPathComponent("v2/rewrap") + guard let rewrapEndpoint = URL(string: endpoints.rewrapURL) else { + throw KASRewrapError.invalidTDFRequest("Invalid rewrap URL: \(endpoints.rewrapURL)") + } var request = URLRequest(url: rewrapEndpoint) request.httpMethod = "POST" - request.timeoutInterval = 30 // 30 second timeout + request.timeoutInterval = 30 - let authHeader = "Bearer \(oauthToken)" - request.addValue(authHeader, forHTTPHeaderField: "Authorization") + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") request.addValue("application/json", forHTTPHeaderField: "Content-Type") + if endpoints.transport == .connect { + request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + } request.httpBody = try JSONEncoder().encode(signedRequest) - // Perform request - let (data, response) = try await urlSession.data(for: request) + // Perform request (no redirects for the bearer-carrying call) + let (data, response) = try await urlSession.data(for: request, delegate: noRedirect) guard let httpResponse = response as? HTTPURLResponse else { throw KASRewrapError.invalidResponse @@ -395,27 +419,8 @@ public class KASRewrapClient: KASRewrapClientProtocol { let sessionKey = try extractCompressedKeyFromPEM(sessionKeyPEM) return (wrappedKey, sessionKey) - case 400: - let errorMessage = String(data: data, encoding: .utf8) ?? "Bad request" - throw KASRewrapError.httpError(400, errorMessage) - case 401: - throw KASRewrapError.authenticationFailed - case 403: - let errorMessage = String(data: data, encoding: .utf8) - throw KASRewrapError.accessDenied(errorMessage ?? "Forbidden") - case 404: - throw KASRewrapError.httpError(404, "KAS endpoint not found") - case 500: - throw KASRewrapError.httpError(500, "Internal server error") - case 502: - throw KASRewrapError.httpError(502, "Bad gateway - KAS service unavailable") - case 503: - throw KASRewrapError.httpError(503, "Service unavailable - try again later") - case 504: - throw KASRewrapError.httpError(504, "Gateway timeout") default: - let errorMessage = String(data: data, encoding: .utf8) - throw KASRewrapError.httpError(httpResponse.statusCode, errorMessage) + throw mapRewrapHTTPError(status: httpResponse.statusCode, data: data) } } @@ -437,7 +442,7 @@ public class KASRewrapClient: KASRewrapClientProtocol { let keyAccessEntries = manifest.encryptionInformation.keyAccess.filter { matchesKasURL($0.url) } guard !keyAccessEntries.isEmpty else { - throw KASRewrapError.invalidTDFRequest("No key access entries for KAS \(kasURL.absoluteString)") + throw KASRewrapError.invalidTDFRequest("No key access entries for KAS \(kasIdentityURL)") } var wrappers: [StandardKeyAccessObjectWrapper] = [] @@ -494,15 +499,20 @@ public class KASRewrapClient: KASRewrapClientProtocol { let signedToken = try createSignedJWT(requestBody: requestBodyJSON, signingKey: signingKey) let signedRequest = SignedRewrapRequest(signed_request_token: signedToken) - let rewrapEndpoint = kasURL.appendingPathComponent("v2/rewrap") + guard let rewrapEndpoint = URL(string: endpoints.rewrapURL) else { + throw KASRewrapError.invalidTDFRequest("Invalid rewrap URL: \(endpoints.rewrapURL)") + } var request = URLRequest(url: rewrapEndpoint) request.httpMethod = "POST" request.timeoutInterval = 30 request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") request.addValue("application/json", forHTTPHeaderField: "Content-Type") + if endpoints.transport == .connect { + request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + } request.httpBody = try JSONEncoder().encode(signedRequest) - let (data, response) = try await urlSession.data(for: request) + let (data, response) = try await urlSession.data(for: request, delegate: noRedirect) guard let httpResponse = response as? HTTPURLResponse else { throw KASRewrapError.invalidResponse @@ -538,27 +548,8 @@ public class KASRewrapClient: KASRewrapClientProtocol { wrappedKeys: wrappedKeys, sessionPublicKeyPEM: rewrapResponse.sessionPublicKey, ) - case 400: - let message = String(data: data, encoding: .utf8) - throw KASRewrapError.httpError(400, message) - case 401: - throw KASRewrapError.authenticationFailed - case 403: - let message = String(data: data, encoding: .utf8) - throw KASRewrapError.accessDenied(message ?? "Forbidden") - case 404: - throw KASRewrapError.httpError(404, "KAS endpoint not found") - case 500: - throw KASRewrapError.httpError(500, "Internal server error") - case 502: - throw KASRewrapError.httpError(502, "Bad gateway - KAS service unavailable") - case 503: - throw KASRewrapError.httpError(503, "Service unavailable - try again later") - case 504: - throw KASRewrapError.httpError(504, "Gateway timeout") default: - let message = String(data: data, encoding: .utf8) - throw KASRewrapError.httpError(httpResponse.statusCode, message) + throw mapRewrapHTTPError(status: httpResponse.statusCode, data: data) } } @@ -576,24 +567,38 @@ public class KASRewrapClient: KASRewrapClientProtocol { throw KASRewrapError.unsupportedKeyAlgorithm(algorithm.rawValue) } - // Build the URL with algorithm query parameter - let keyEndpoint = kasURL.appendingPathComponent("v2/kas_public_key") - var components = URLComponents(url: keyEndpoint, resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "algorithm", value: algorithm.rawValue)] - - guard let requestURL = components?.url else { - throw KASRewrapError.keyFetchFailed("Failed to construct KAS public key URL") + var request: URLRequest + switch endpoints.transport { + case .connect: + // ConnectRPC PublicKey RPC: POST the PublicKeyRequest message as JSON. + // `algorithm` is a request-message field (Go opentdf/platform proto). + guard let url = URL(string: endpoints.publicKeyURL) else { + throw KASRewrapError.keyFetchFailed("Invalid public key URL: \(endpoints.publicKeyURL)") + } + request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + request.httpBody = try JSONEncoder().encode(["algorithm": algorithm.rawValue]) + case .legacyRest: + guard var components = URLComponents(string: endpoints.publicKeyURL) else { + throw KASRewrapError.keyFetchFailed("Invalid public key URL: \(endpoints.publicKeyURL)") + } + components.queryItems = [URLQueryItem(name: "algorithm", value: algorithm.rawValue)] + guard let requestURL = components.url else { + throw KASRewrapError.keyFetchFailed("Failed to construct KAS public key URL") + } + request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Accept") } - // Create HTTP request - var request = URLRequest(url: requestURL) - request.httpMethod = "GET" - request.timeoutInterval = 30 - request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") - request.addValue("application/json", forHTTPHeaderField: "Accept") - - // Perform request - let (data, response) = try await urlSession.data(for: request) + let (data, response) = try await urlSession.data(for: request, delegate: noRedirect) guard let httpResponse = response as? HTTPURLResponse else { throw KASRewrapError.invalidResponse @@ -619,7 +624,8 @@ public class KASRewrapClient: KASRewrapClientProtocol { cryptoKitKey: result.cryptoKitKey, ) case 401: - throw KASRewrapError.authenticationFailed + let message = String(data: data, encoding: .utf8) + throw KASRewrapError.authenticationFailed(message) case 403: let message = String(data: data, encoding: .utf8) throw KASRewrapError.accessDenied(message ?? "Forbidden") @@ -732,8 +738,37 @@ public class KASRewrapClient: KASRewrapClientProtocol { // MARK: - Private Helpers + /// Resolve the request-body KAS url for a NanoTDF rewrap from the parsed + /// header's resource locator, falling back to the configured identity. + private func resolveNanoKasURL(_ parsedHeader: Header) -> String { + let locator = parsedHeader.kas + let scheme: String? = switch locator.protocolEnum { + case .http: "http" + case .https: "https" + default: nil + } + if let scheme, !locator.body.isEmpty { + return "\(scheme)://\(locator.body)" + } + return kasIdentityURL + } + + /// Map a non-2xx rewrap response to a KASRewrapError, enriching the message + /// from a Connect error envelope when present. + private func mapRewrapHTTPError(status: Int, data: Data) -> KASRewrapError { + let body = String(data: data, encoding: .utf8) ?? "" + let detail = parseConnectError(body).map { "\($0.code): \($0.message)" } + ?? (body.isEmpty ? "HTTP \(status)" : body) + switch status { + case 401: return .authenticationFailed(detail) + case 403: return .accessDenied(detail) + default: return .httpError(status, detail) + } + } + private func matchesKasURL(_ otherURLString: String) -> Bool { guard let otherURL = URL(string: otherURLString) else { return false } + guard let kasURL = URL(string: kasIdentityURL) else { return false } guard let baseScheme = kasURL.scheme?.lowercased(), let otherScheme = otherURL.scheme?.lowercased(), let baseHost = kasURL.host?.lowercased(), @@ -741,11 +776,9 @@ public class KASRewrapClient: KASRewrapClientProtocol { else { return false } - guard baseScheme == otherScheme, baseHost == otherHost else { return false } - return effectivePort(for: kasURL) == effectivePort(for: otherURL) } @@ -920,7 +953,7 @@ public enum KASRewrapError: Error, CustomStringConvertible { case invalidResponse case emptyResponse case accessDenied(String) - case authenticationFailed + case authenticationFailed(String?) case missingWrappedKey case missingSessionKey case invalidWrappedKeyFormat @@ -940,8 +973,8 @@ public enum KASRewrapError: Error, CustomStringConvertible { "Empty response from KAS server" case let .accessDenied(reason): "Access denied: \(reason)" - case .authenticationFailed: - "Authentication failed - check OAuth token" + case let .authenticationFailed(reason): + "Authentication failed - check OAuth token" + (reason.map { ": \($0)" } ?? "") case .missingWrappedKey: "KAS response missing wrapped key" case .missingSessionKey: @@ -968,7 +1001,7 @@ public enum KASRewrapError: Error, CustomStringConvertible { // Use the existing EphemeralKeyPair from KeyStore -// Extension for base64URL encoding +/// Extension for base64URL encoding extension Data { func base64URLEncodedString() -> String { base64EncodedString() diff --git a/OpenTDFKit/KASService.swift b/OpenTDFKit/KASService.swift index d871ae4..0cc1288 100644 --- a/OpenTDFKit/KASService.swift +++ b/OpenTDFKit/KASService.swift @@ -13,7 +13,7 @@ public enum KASServiceError: Error, Equatable { case serverError(String) case networkError(String) - // Custom error equality implementation + /// Custom error equality implementation public static func == (lhs: KASServiceError, rhs: KASServiceError) -> Bool { switch (lhs, rhs) { case (.invalidRequest, .invalidRequest), diff --git a/OpenTDFKit/KeyStore.swift b/OpenTDFKit/KeyStore.swift index 5455137..49777fb 100644 --- a/OpenTDFKit/KeyStore.swift +++ b/OpenTDFKit/KeyStore.swift @@ -4,7 +4,7 @@ import Foundation // MARK: - Key Types and Storage public struct KeyPairIdentifier: Hashable, Sendable { - // Store the public key bytes directly - already fixed size per curve + /// Store the public key bytes directly - already fixed size per curve private let bytes: Data public var publicKey: Data { @@ -73,7 +73,7 @@ public struct EphemeralKeyPair: Sendable { public actor KeyStore { public let curve: Curve public var keyPairs: [KeyPairIdentifier: StoredKeyPair] - // Track total memory used + /// Track total memory used public var totalBytesStored: Int = 0 public init(curve: Curve, capacity: Int = 1000) { @@ -93,7 +93,7 @@ public actor KeyStore { totalBytesStored += curve.publicKeyLength + curve.privateKeyLength } - // Batch storage optimized for memory + /// Batch storage optimized for memory public func storeBatch(pairs: [(publicKey: Data, privateKey: Data)]) { // Pre-allocate for entire batch let totalNewBytes = pairs.count * (curve.publicKeyLength + curve.privateKeyLength) @@ -109,7 +109,7 @@ public actor KeyStore { totalBytesStored += totalNewBytes } - // Batch generation method + /// Batch generation method public func generateAndStoreKeyPairs(count: Int) async throws { // Capture immutable state before leaving the actor context let curveSnapshot = curve @@ -311,14 +311,12 @@ public actor KeyStore { // Salt: SHA256("L1" + VERSION) - use v12 for NanoTDF collection compatibility // Info: empty per spec guidance // Output Byte Count: 32 (for AES-256) - let symmetricKeyCryptoKit = sharedSecret.hkdfDerivedSymmetricKey( + return sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: CryptoConstants.hkdfSaltV12, sharedInfo: CryptoConstants.hkdfInfoEncryption, outputByteCount: CryptoConstants.symmetricKeyByteCount, ) - - return symmetricKeyCryptoKit } // MARK: - One-Time TDF Extensions @@ -364,7 +362,7 @@ public actor KeyStore { } } -// Helper extension for testing +/// Helper extension for testing extension KeyStore { func getAllPublicKeys() -> [Data] { Array(keyPairs.values.map(\.publicKey)) @@ -384,7 +382,7 @@ public enum KeyStoreError: Error, Equatable { case keyAgreementFailed(String? = nil) case keyDerivationFailed(String? = nil) - // Equatable conformance + /// Equatable conformance public static func == (lhs: KeyStoreError, rhs: KeyStoreError) -> Bool { switch (lhs, rhs) { case (.unsupportedCurve, .unsupportedCurve): @@ -415,14 +413,14 @@ public enum KeyStoreError: Error, Equatable { } } -// Helper extension for Data to hex string (for debugging/errors) +/// Helper extension for Data to hex string (for debugging/errors) public extension Data { var hexString: String { map { String(format: "%02x", $0) }.joined() } } -// Helper extension +/// Helper extension extension Curve { var publicKeyLength: Int { switch self { diff --git a/OpenTDFKit/NanoTDF.swift b/OpenTDFKit/NanoTDF.swift index bb926a0..567d77f 100644 --- a/OpenTDFKit/NanoTDF.swift +++ b/OpenTDFKit/NanoTDF.swift @@ -52,17 +52,16 @@ public struct NanoTDF: Sendable { return data } - /// Decrypts the payload ciphertext using the provided symmetric key. - /// Handles nonce padding/adjusting internally. - /// - Parameter symmetricKey: The `SymmetricKey` derived during the TDF creation or key access process. - /// - Returns: The original plaintext `Data`. - /// - Throws: Errors from `CryptoHelper` or `CryptoKit` if decryption fails (e.g., incorrect key, corrupted data). - /// Shared CryptoHelper instance to avoid per-call actor instantiation overhead. /// Thread safety is guaranteed by CryptoHelper's actor isolation. /// This optimization eliminates actor creation and cross-actor await hops in hot paths. static let sharedCryptoHelper = CryptoHelper() + /// Decrypts the payload ciphertext using the provided symmetric key. + /// Handles nonce padding/adjusting internally. + /// - Parameter symmetricKey: The `SymmetricKey` derived during the TDF creation or key access process. + /// - Returns: The original plaintext `Data`. + /// - Throws: Errors from `CryptoHelper` or `CryptoKit` if decryption fails (e.g., incorrect key, corrupted data). public func getPayloadPlaintext(symmetricKey: SymmetricKey) async throws -> Data { // Pad the 3-byte NanoTDF IV to the 12 bytes required by AES-GCM let paddedIV = await NanoTDF.sharedCryptoHelper.adjustNonce(payload.iv, to: 12) @@ -74,7 +73,7 @@ public struct NanoTDF: Sendable { key: symmetricKey, iv: paddedIV, ciphertext: payload.ciphertext, - tag: payload.mac + tag: payload.mac, ) } } @@ -684,7 +683,7 @@ public enum ProtocolEnum: UInt8, Sendable { // BEGIN out-of-spec case ws = 0x02 case wss = 0x03 - // END out-of-spec + /// END out-of-spec case sharedResourceDirectory = 0xFF // Likely for non-network resources } @@ -941,7 +940,7 @@ public enum Curve: UInt8, Sendable { /// NIST P-521 curve (secp521r1). case secp521r1 = 0x02 // BEGIN in-spec unsupported - /// SECG secp256k1 curve (commonly used in Bitcoin). Marked as unsupported in this implementation context. + // SECG secp256k1 curve (commonly used in Bitcoin). Marked as unsupported in this implementation context. // case xsecp256k1 = 0x03 // removed to simplify // END in-spec unsupported diff --git a/OpenTDFKit/PublicKeyStore.swift b/OpenTDFKit/PublicKeyStore.swift index 12e0b5e..040766d 100644 --- a/OpenTDFKit/PublicKeyStore.swift +++ b/OpenTDFKit/PublicKeyStore.swift @@ -158,7 +158,7 @@ public enum PublicKeyStoreError: Error { case unsupportedCurve } -// For testing purposes only +/// For testing purposes only extension PublicKeyStore { var keys: [Data] { get async { diff --git a/OpenTDFKit/TDF/TDFCBORContainer.swift b/OpenTDFKit/TDF/TDFCBORContainer.swift index 1347dd9..13d962e 100644 --- a/OpenTDFKit/TDF/TDFCBORContainer.swift +++ b/OpenTDFKit/TDF/TDFCBORContainer.swift @@ -8,7 +8,9 @@ public struct TDFCBORContainer: TrustedDataContainer, Sendable { /// The TDF-CBOR envelope public let envelope: TDFCBOREnvelope - public var formatKind: TrustedDataFormatKind { .cbor } + public var formatKind: TrustedDataFormatKind { + .cbor + } public init(envelope: TDFCBOREnvelope) { self.envelope = envelope diff --git a/OpenTDFKit/TDF/TDFCBORFormat.swift b/OpenTDFKit/TDF/TDFCBORFormat.swift index ac429bf..7737b52 100644 --- a/OpenTDFKit/TDF/TDFCBORFormat.swift +++ b/OpenTDFKit/TDF/TDFCBORFormat.swift @@ -15,8 +15,13 @@ public enum TDFCBORKey: Int, CodingKey { case manifest = 4 case payload = 5 - public var intValue: Int? { rawValue } - public var stringValue: String { String(rawValue) } + public var intValue: Int? { + rawValue + } + + public var stringValue: String { + String(rawValue) + } public init?(intValue: Int) { self.init(rawValue: intValue) @@ -56,10 +61,10 @@ public enum TDFCBOREnums { public static let keyAccessTypeWrapped: UInt64 = 0 public static let keyAccessTypeRemote: UInt64 = 1 - // Key protocol: 0=kas + /// Key protocol: 0=kas public static let keyProtocolKas: UInt64 = 0 - // Symmetric algorithm: 0=AES-256-GCM + /// Symmetric algorithm: 0=AES-256-GCM public static let symmetricAlgAes256Gcm: UInt64 = 0 // Hash/Signature algorithm @@ -184,7 +189,9 @@ public struct TDFCBOREnvelope: Sendable { } /// Format identifier (always "cbor") - public var formatId: String { tdf } + public var formatId: String { + tdf + } } // MARK: - TDF-CBOR Manifest diff --git a/OpenTDFKit/TDF/TDFJSONContainer.swift b/OpenTDFKit/TDF/TDFJSONContainer.swift index a3ed96c..7215f36 100644 --- a/OpenTDFKit/TDF/TDFJSONContainer.swift +++ b/OpenTDFKit/TDF/TDFJSONContainer.swift @@ -11,7 +11,9 @@ public struct TDFJSONContainer: TrustedDataContainer, Sendable { /// Raw payload data (encrypted ciphertext) public let payloadData: Data - public var formatKind: TrustedDataFormatKind { .json } + public var formatKind: TrustedDataFormatKind { + .json + } public init(envelope: TDFJSONEnvelope, payloadData: Data) { self.envelope = envelope diff --git a/OpenTDFKit/TDF/TDFJSONFormat.swift b/OpenTDFKit/TDF/TDFJSONFormat.swift index cd61257..23870bf 100644 --- a/OpenTDFKit/TDF/TDFJSONFormat.swift +++ b/OpenTDFKit/TDF/TDFJSONFormat.swift @@ -49,7 +49,9 @@ public struct TDFJSONEnvelope: Codable, Sendable { } /// Format identifier (always "json") - public var formatId: String { tdf } + public var formatId: String { + tdf + } } // MARK: - TDF-JSON Manifest diff --git a/OpenTDFKit/TDF/TrustedDataFormat.swift b/OpenTDFKit/TDF/TrustedDataFormat.swift index 2555a1f..6f9d6df 100644 --- a/OpenTDFKit/TDF/TrustedDataFormat.swift +++ b/OpenTDFKit/TDF/TrustedDataFormat.swift @@ -30,7 +30,9 @@ public struct TDFContainer: TrustedDataContainer { self.compression = compression } - public var formatKind: TrustedDataFormatKind { .archive } + public var formatKind: TrustedDataFormatKind { + .archive + } public func serializedData() throws -> Data { let writer = TDFArchiveWriter(compressionMethod: compression) diff --git a/OpenTDFKitCLI/Commands.swift b/OpenTDFKitCLI/Commands.swift index 0911cb7..096cbac 100644 --- a/OpenTDFKitCLI/Commands.swift +++ b/OpenTDFKitCLI/Commands.swift @@ -166,7 +166,8 @@ enum Commands { continue } - let client = KASRewrapClient(kasURL: kasURL, oauthToken: oauthToken) + let configuration = await resolveConfiguration(kasURL: kasURL, token: oauthToken) + let client = try KASRewrapClient(configuration: configuration, oauthToken: oauthToken) let result = try await client.rewrapTDF( manifest: container.manifest, clientPrivateKey: ephemeralPrivateKey, @@ -354,60 +355,34 @@ enum Commands { return (result, archiveData) } - /// Fetch KAS public key - static func fetchKASPublicKey(kasURL: URL, token: String) async throws -> Data { - // Request EC key for NanoTDF (not RSA) - let urlWithParams = kasURL.appendingPathComponent("v2/kas_public_key") - var components = URLComponents(url: urlWithParams, resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "algorithm", value: "ec:secp256r1")] - - var request = URLRequest(url: components.url!) - request.httpMethod = "GET" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw EncryptError.kasRequestFailed - } - - // Parse JSON response to get public key - struct KASPublicKeyResponse: Decodable { - let publicKey: String + /// Resolve an OpenTDFConfiguration for the platform hosting `kasURL`. + /// Tries well-known discovery at the platform root (PLATFORMURL env, else + /// the scheme/host/port of `kasURL`), falling back to synthesized Connect + /// endpoints. The well-known doc and Connect endpoints live at the platform + /// root, not under the KAS `/kas` path. + static func resolveConfiguration(kasURL: URL, token _: String) async -> OpenTDFConfiguration { + let platformBase: String + if let env = ProcessInfo.processInfo.environment["PLATFORMURL"], !env.isEmpty { + platformBase = env + } else { + var comps = URLComponents() + comps.scheme = kasURL.scheme + comps.host = kasURL.host + comps.port = kasURL.port + platformBase = comps.string ?? kasURL.absoluteString } - - let decoder = JSONDecoder() - let keyResponse = try decoder.decode(KASPublicKeyResponse.self, from: data) - - // Convert PEM to compressed key - guard let keyData = extractCompressedKeyFromPEM(keyResponse.publicKey) else { - throw EncryptError.invalidKASPublicKey + if let cfg = try? await fetchWellKnown(platformURL: platformBase) { + return cfg } - - return keyData + return OpenTDFConfiguration.forKasConnect(platformBase) } - /// Extract compressed P256 public key from PEM - static func extractCompressedKeyFromPEM(_ pem: String) -> Data? { - let pemLines = pem - .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "") - .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "") - .replacingOccurrences(of: "\n", with: "") - .replacingOccurrences(of: "\r", with: "") - - guard let spkiData = Data(base64Encoded: pemLines) else { - return nil - } - - // Parse SPKI to get compressed key - do { - let publicKey = try P256.KeyAgreement.PublicKey(derRepresentation: spkiData) - return publicKey.compressedRepresentation - } catch { - return nil - } + /// Fetch KAS public key + static func fetchKASPublicKey(kasURL: URL, token: String) async throws -> Data { + let configuration = await resolveConfiguration(kasURL: kasURL, token: token) + let client = try KASRewrapClient(configuration: configuration, oauthToken: token) + let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) + return result.compressedKey } /// Verify and parse a NanoTDF file using OpenTDFKit's parser @@ -567,7 +542,8 @@ enum Commands { // Call KAS rewrap endpoint if verbose { print("\nCalling KAS rewrap endpoint...") } - let kasClient = KASRewrapClient(kasURL: kasURL, oauthToken: oauthToken) + let configuration = await resolveConfiguration(kasURL: kasURL, token: oauthToken) + let kasClient = try KASRewrapClient(configuration: configuration, oauthToken: oauthToken) let (wrappedKey, sessionPublicKey): (Data, Data) do { @@ -929,7 +905,8 @@ extension Commands { // Call KAS rewrap endpoint (single rewrap for entire collection) print("\nCalling KAS rewrap endpoint...") - let kasClient = KASRewrapClient(kasURL: kasURL, oauthToken: oauthToken) + let configuration = await resolveConfiguration(kasURL: kasURL, token: oauthToken) + let kasClient = try KASRewrapClient(configuration: configuration, oauthToken: oauthToken) let (wrappedKey, sessionPublicKey) = try await kasClient.rewrapNanoTDF( header: headerBytes, diff --git a/OpenTDFKitTests/CryptoHelperTests.swift b/OpenTDFKitTests/CryptoHelperTests.swift index a9c4b39..b0472fe 100644 --- a/OpenTDFKitTests/CryptoHelperTests.swift +++ b/OpenTDFKitTests/CryptoHelperTests.swift @@ -3,7 +3,6 @@ import Foundation import XCTest @preconcurrency import CryptoKit // Add this to handle Sendable warnings - @testable import OpenTDFKit final class CryptoHelperTests: XCTestCase { @@ -55,9 +54,9 @@ final class CryptoHelperTests: XCTestCase { } } -// Add these to CryptoHelper.swift: +/// Add these to CryptoHelper.swift: extension CryptoHelper { - // Combined operation that keeps sensitive types within the actor + /// Combined operation that keeps sensitive types within the actor struct EncryptionResult { let gmacTag: Data let nonce: Data diff --git a/OpenTDFKitTests/EncryptedPolicyTests.swift b/OpenTDFKitTests/EncryptedPolicyTests.swift index 6030a93..61532eb 100644 --- a/OpenTDFKitTests/EncryptedPolicyTests.swift +++ b/OpenTDFKitTests/EncryptedPolicyTests.swift @@ -74,7 +74,7 @@ final class EncryptedPolicyTests: XCTestCase { XCTAssertEqual(serializedData, reserializedData, "Serialized data should match after round-trip") } - // Test encrypted policy with key access using the same KAS for both payload and policy + /// Test encrypted policy with key access using the same KAS for both payload and policy func testEncryptedPolicyWithKeyAccess() async throws { // Create KAS ResourceLocator (same KAS for both payload and policy) let kasRL = ResourceLocator(protocolEnum: .http, body: "kas.example.org")! diff --git a/OpenTDFKitTests/GCMEncryptionTests.swift b/OpenTDFKitTests/GCMEncryptionTests.swift index d79db39..c6552fa 100644 --- a/OpenTDFKitTests/GCMEncryptionTests.swift +++ b/OpenTDFKitTests/GCMEncryptionTests.swift @@ -92,10 +92,10 @@ final class GCMEncryptionTests: XCTestCase { } } - func testInvalidKeySizeForDecryption() { + func testInvalidKeySizeForDecryption() throws { let invalidKey = SymmetricKey(size: .bits192) - let (ciphertext, tag) = try! CryptoHelper.encryptNanoTDF( + let (ciphertext, tag) = try CryptoHelper.encryptNanoTDF( cipher: .aes256GCM128, key: testKey, iv: testIV, @@ -136,10 +136,10 @@ final class GCMEncryptionTests: XCTestCase { } } - func testInvalidIVSizeForDecryption() { + func testInvalidIVSizeForDecryption() throws { let invalidIV = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) - let (ciphertext, tag) = try! CryptoHelper.encryptNanoTDF( + let (ciphertext, tag) = try CryptoHelper.encryptNanoTDF( cipher: .aes256GCM128, key: testKey, iv: testIV, @@ -162,8 +162,8 @@ final class GCMEncryptionTests: XCTestCase { } } - func testTagSizeMismatchForDecryption() { - let (ciphertext, tag) = try! CryptoHelper.encryptNanoTDF( + func testTagSizeMismatchForDecryption() throws { + let (ciphertext, tag) = try CryptoHelper.encryptNanoTDF( cipher: .aes256GCM128, key: testKey, iv: testIV, @@ -188,8 +188,8 @@ final class GCMEncryptionTests: XCTestCase { } } - func testInvalidTagForDecryption() { - let (ciphertext, _) = try! CryptoHelper.encryptNanoTDF( + func testInvalidTagForDecryption() throws { + let (ciphertext, _) = try CryptoHelper.encryptNanoTDF( cipher: .aes256GCM128, key: testKey, iv: testIV, @@ -210,8 +210,8 @@ final class GCMEncryptionTests: XCTestCase { } } - func testModifiedCiphertextFailsDecryption() { - let (ciphertext, tag) = try! CryptoHelper.encryptNanoTDF( + func testModifiedCiphertextFailsDecryption() throws { + let (ciphertext, tag) = try CryptoHelper.encryptNanoTDF( cipher: .aes256GCM128, key: testKey, iv: testIV, diff --git a/OpenTDFKitTests/InitializationTests.swift b/OpenTDFKitTests/InitializationTests.swift index d018f2e..f1e2f16 100644 --- a/OpenTDFKitTests/InitializationTests.swift +++ b/OpenTDFKitTests/InitializationTests.swift @@ -46,7 +46,7 @@ final class InitializationTests: XCTestCase { // Put teardown code here. This method is called after the invocation of each test method in the class. } - func testInitializeSmallNanoTDFPositive() throws { + func testInitializeSmallNanoTDFPositive() { let locator = ResourceLocator(protocolEnum: .http, body: "localhost:8080") XCTAssertNotNil(locator) let nanoTDF = initializeSmallNanoTDF(kasResourceLocator: locator!) @@ -60,7 +60,7 @@ final class InitializationTests: XCTestCase { XCTAssertNil(nanoTDF.signature) } - func testInitializeSmallNanoTDFNegative() throws { + func testInitializeSmallNanoTDFNegative() { // Empty body is now allowed for HTTP and HTTPS due to "None" identifier support // We'll just test the too-large case @@ -83,7 +83,7 @@ final class InitializationTests: XCTestCase { )) } - func testSmallNanoTDFSize() throws { + func testSmallNanoTDFSize() { let locator = ResourceLocator(protocolEnum: .http, body: "localhost:8080") XCTAssertNotNil(locator) let nanoTDF = initializeSmallNanoTDF(kasResourceLocator: locator!) @@ -92,7 +92,7 @@ final class InitializationTests: XCTestCase { XCTAssertLessThan(data.count, 240) } - func testSmallNanoTDFPerformance() throws { + func testSmallNanoTDFPerformance() { // This is an example of a performance test case. measure { // Put the code you want to measure the time of here. diff --git a/OpenTDFKitTests/IntegrationTests.swift b/OpenTDFKitTests/IntegrationTests.swift index 89f32e9..a84634b 100644 --- a/OpenTDFKitTests/IntegrationTests.swift +++ b/OpenTDFKitTests/IntegrationTests.swift @@ -60,7 +60,7 @@ final class IntegrationTests: XCTestCase { let kasMetadata = try await kasService.generateKasMetadata() - let remotePolicy = ResourceLocator(protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/integration-test")! + let remotePolicy = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/integration-test")) var policy = Policy(type: .remote, body: nil, remote: remotePolicy, binding: nil) let nanoTDF = try await createNanoTDF( @@ -74,8 +74,8 @@ final class IntegrationTests: XCTestCase { let token = try await getOAuthToken() - let kasRewrapClient = KASRewrapClient( - kasURL: kasURL, + let kasRewrapClient = try KASRewrapClient( + configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), oauthToken: token, ) @@ -122,7 +122,7 @@ final class IntegrationTests: XCTestCase { let kasMetadata = try await kasService.generateKasMetadata() - let remotePolicy = ResourceLocator(protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/test")! + let remotePolicy = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/test")) var policy = Policy(type: .remote, body: nil, remote: remotePolicy, binding: nil) let nanoTDF = try await createNanoTDF( @@ -133,8 +133,8 @@ final class IntegrationTests: XCTestCase { let invalidToken = "invalid_token_12345" - let kasRewrapClient = KASRewrapClient( - kasURL: kasURL, + let kasRewrapClient = try KASRewrapClient( + configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), oauthToken: invalidToken, ) @@ -151,7 +151,7 @@ final class IntegrationTests: XCTestCase { clientKeyPair: clientKeyPair, ) XCTFail("Expected authentication failure with invalid token") - } catch KASRewrapError.authenticationFailed { + } catch KASRewrapError.authenticationFailed(_) { } catch let KASRewrapError.httpError(code, _) where code == 401 { } catch { XCTFail("Expected KASRewrapError.authenticationFailed, got \(error)") @@ -201,7 +201,7 @@ final class IntegrationTests: XCTestCase { let privateKeyData = await keyStore.getPrivateKey(forPublicKey: kasPublicKey) XCTAssertNotNil(privateKeyData, "KAS private key should be in keystore") - let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: privateKeyData!) + let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: XCTUnwrap(privateKeyData)) let clientPublicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: nanoTDF.header.ephemeralPublicKey) let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: clientPublicKey) @@ -360,7 +360,10 @@ final class IntegrationTests: XCTestCase { // Generate ephemeral P-256 key for JWT signing in rewrap request let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey() - let kasClient = KASRewrapClient(kasURL: kasURL, oauthToken: token) + let kasClient = try KASRewrapClient( + configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), + oauthToken: token, + ) let rewrapResult = try await kasClient.rewrapTDF( manifest: container.manifest, clientPrivateKey: ephemeralPrivateKey, @@ -409,10 +412,10 @@ final class IntegrationTests: XCTestCase { } // Test with multiple items - let testItems = [ - "Collection item 1: Hello".data(using: .utf8)!, - "Collection item 2: World".data(using: .utf8)!, - "Collection item 3: NanoTDF Collection Test".data(using: .utf8)!, + let testItems = try [ + XCTUnwrap("Collection item 1: Hello".data(using: .utf8)), + XCTUnwrap("Collection item 2: World".data(using: .utf8)), + XCTUnwrap("Collection item 3: NanoTDF Collection Test".data(using: .utf8)), ] let keyStore = KeyStore(curve: .secp256r1) @@ -421,10 +424,10 @@ final class IntegrationTests: XCTestCase { let kasMetadata = try await kasService.generateKasMetadata() // Create policy locator - let policyLocator = ResourceLocator( + let policyLocator = try XCTUnwrap(ResourceLocator( protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/collection-test", - )! + )) // Build the collection let collection = try await NanoTDFCollectionBuilder() @@ -453,8 +456,8 @@ final class IntegrationTests: XCTestCase { let header = await collection.header let headerBytes = await collection.getHeaderBytes() - let kasRewrapClient = KASRewrapClient( - kasURL: kasURL, + let kasRewrapClient = try KASRewrapClient( + configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), oauthToken: token, ) @@ -499,9 +502,9 @@ final class IntegrationTests: XCTestCase { return } - let testItems = [ - "Serialization test 1".data(using: .utf8)!, - "Serialization test 2".data(using: .utf8)!, + let testItems = try [ + XCTUnwrap("Serialization test 1".data(using: .utf8)), + XCTUnwrap("Serialization test 2".data(using: .utf8)), ] let keyStore = KeyStore(curve: .secp256r1) @@ -509,10 +512,10 @@ final class IntegrationTests: XCTestCase { let kasMetadata = try await kasService.generateKasMetadata() - let policyLocator = ResourceLocator( + let policyLocator = try XCTUnwrap(ResourceLocator( protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/serial-test", - )! + )) let collection = try await NanoTDFCollectionBuilder() .kasMetadata(kasMetadata) @@ -580,10 +583,10 @@ final class IntegrationTests: XCTestCase { let kasMetadata = try await kasService.generateKasMetadata() - let policyLocator = ResourceLocator( + let policyLocator = try XCTUnwrap(ResourceLocator( protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/batch-test", - )! + )) let collection = try await NanoTDFCollectionBuilder() .kasMetadata(kasMetadata) diff --git a/OpenTDFKitTests/KASConnectTransportTests.swift b/OpenTDFKitTests/KASConnectTransportTests.swift new file mode 100644 index 0000000..8a99218 --- /dev/null +++ b/OpenTDFKitTests/KASConnectTransportTests.swift @@ -0,0 +1,82 @@ +@preconcurrency import CryptoKit +@testable import OpenTDFKit +import XCTest + +final class KASConnectTransportTests: XCTestCase { + override func tearDown() { + MockURLProtocol.handler = nil + super.tearDown() + } + + /// EC public-key fetch over Connect POSTs to /kas.AccessService/PublicKey + /// and parses the PEM + kid. + func testConnectECPublicKeyFetch() async throws { + // Generate a real P-256 key so the PEM round-trips correctly. + let realKey = P256.KeyAgreement.PrivateKey() + let derData = realKey.publicKey.derRepresentation + let base64Lines = derData.base64EncodedString() + let pem = "-----BEGIN PUBLIC KEY-----\n\(base64Lines)\n-----END PUBLIC KEY-----" + + MockURLProtocol.handler = { req in + XCTAssertEqual(req.httpMethod, "POST") + XCTAssertEqual(req.url?.path, "/kas.AccessService/PublicKey") + XCTAssertEqual(req.value(forHTTPHeaderField: "Connect-Protocol-Version"), "1") + let resp = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + let escapedPem = pem.replacingOccurrences(of: "\n", with: "\\n") + let json = "{\"publicKey\":\"\(escapedPem)\",\"kid\":\"ec:secp256r1\"}" + return (resp, Data(json.utf8)) + } + let session = MockURLProtocol.makeSession() + let cfg = OpenTDFConfiguration.forKasConnect("https://platform.arkavo.net") + let client = try KASRewrapClient(configuration: cfg, oauthToken: "t", urlSession: session) + let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) + XCTAssertEqual(result.kid, "ec:secp256r1") + XCTAssertEqual(result.compressedKey.count, 33) + XCTAssertEqual(result.compressedKey, realKey.publicKey.compressedRepresentation) + } + + /// A Connect 401 envelope surfaces as authenticationFailed with the code in + /// the reason string. + func testConnectRewrapErrorEnvelopeSurfacesReason() async throws { + MockURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/kas.AccessService/Rewrap") + let resp = HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, + headerFields: nil)! + return (resp, Data(#"{"code":"unauthenticated","message":"missing bearer"}"#.utf8)) + } + let session = MockURLProtocol.makeSession() + let cfg = OpenTDFConfiguration.forKasConnect("https://platform.arkavo.net") + let client = try KASRewrapClient(configuration: cfg, oauthToken: "t", urlSession: session) + + let kas = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "platform.arkavo.net/kas")) + let header = makeMinimalHeader(kas: kas) + let kp = EphemeralKeyPair( + privateKey: P256.KeyAgreement.PrivateKey().rawRepresentation, + publicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, + curve: .secp256r1, + ) + do { + _ = try await client.rewrapNanoTDF(header: Data([0, 0, 0]), parsedHeader: header, + clientKeyPair: kp) + XCTFail("expected throw") + } catch let KASRewrapError.authenticationFailed(reason) { + XCTAssertEqual(reason, "unauthenticated: missing bearer") + } + } + + private func makeMinimalHeader(kas: ResourceLocator) -> Header { + let policy = Policy(type: .embeddedPlaintext, + body: EmbeddedPolicyBody(body: Data("{}".utf8)), + remote: nil, binding: nil) + return Header( + kas: kas, + policyBindingConfig: PolicyBindingConfig(ecdsaBinding: false, curve: .secp256r1), + payloadSignatureConfig: SignatureAndPayloadConfig( + signed: false, signatureCurve: nil, payloadCipher: .aes256GCM128, + ), + policy: policy, + ephemeralPublicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, + ) + } +} diff --git a/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift new file mode 100644 index 0000000..947cf22 --- /dev/null +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -0,0 +1,211 @@ +@testable import OpenTDFKit +import XCTest + +final class KASDiscoveryTests: XCTestCase { + /// Captured from https://platform.arkavo.net/.well-known/opentdf-configuration on 2026-05-28 + static let platformWellKnown = """ + { + "health": { "endpoint": "/healthz" }, + "idp": { + "access_token_format": "application/cwt", + "authorization_endpoint": "https://identity.arkavo.net/oauth/authorize", + "cose_keys_uri": "https://identity.arkavo.net/.well-known/cose-keys", + "id_token_signing_alg_values_supported": ["ES256"], + "issuer": "https://identity.arkavo.net", + "jwks_uri": "https://identity.arkavo.net/.well-known/jwks.json", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "token_endpoint": "https://identity.arkavo.net/oauth/token", + "userinfo_endpoint": "https://identity.arkavo.net/oauth/userinfo" + }, + "kas": { + "algorithms": ["ec:secp256r1", "rsa:2048"], + "connect_public_key_url": "https://platform.arkavo.net/kas.AccessService/PublicKey", + "connect_rewrap_url": "https://platform.arkavo.net/kas.AccessService/Rewrap", + "public_key_url": "https://platform.arkavo.net/kas/v2/kas_public_key", + "rewrap_url": "https://platform.arkavo.net/kas/v2/rewrap", + "uri": "https://platform.arkavo.net" + }, + "platform_issuer": "https://identity.arkavo.net" + } + """ + + func testDecodesPlatformWellKnown() throws { + let cfg = try JSONDecoder().decode(OpenTDFConfiguration.self, + from: Data(Self.platformWellKnown.utf8)) + let kas = try XCTUnwrap(cfg.kas) + XCTAssertEqual(kas.uri, "https://platform.arkavo.net") + XCTAssertEqual(kas.algorithms, ["ec:secp256r1", "rsa:2048"]) + XCTAssertEqual(kas.connectRewrapURL, "https://platform.arkavo.net/kas.AccessService/Rewrap") + XCTAssertEqual(kas.rewrapURL, "https://platform.arkavo.net/kas/v2/rewrap") + let idp = try XCTUnwrap(cfg.idp) + XCTAssertEqual(idp.issuer, "https://identity.arkavo.net") + XCTAssertEqual(idp.accessTokenFormat, "application/cwt") + XCTAssertEqual(cfg.platformIssuer, "https://identity.arkavo.net") + } + + func testDecodesMinimalKasOnly() throws { + let json = """ + {"kas":{"uri":"https://k.example.com","algorithms":[], + "rewrap_url":"https://k.example.com/kas/v2/rewrap", + "public_key_url":"https://k.example.com/kas/v2/kas_public_key"}} + """ + let cfg = try JSONDecoder().decode(OpenTDFConfiguration.self, from: Data(json.utf8)) + let kas = try XCTUnwrap(cfg.kas) + XCTAssertNil(kas.connectRewrapURL) + XCTAssertEqual(kas.rewrapURL, "https://k.example.com/kas/v2/rewrap") + XCTAssertNil(cfg.idp) + } + + func testForKasConnectConstructsURLs() { + let cfg = OpenTDFConfiguration.forKasConnect("https://kas.example.com") + XCTAssertEqual(cfg.kas?.connectRewrapURL, "https://kas.example.com/kas.AccessService/Rewrap") + XCTAssertEqual(cfg.kas?.connectPublicKeyURL, "https://kas.example.com/kas.AccessService/PublicKey") + XCTAssertEqual(cfg.kas?.uri, "https://kas.example.com") + } + + func testForKasConnectHandlesTrailingSlash() { + let cfg = OpenTDFConfiguration.forKasConnect("https://kas.example.com/") + XCTAssertEqual(cfg.kas?.connectRewrapURL, "https://kas.example.com/kas.AccessService/Rewrap") + } + + func testForKasLegacyRestConstructsURLs() { + let cfg = OpenTDFConfiguration.forKasLegacyRest("https://kas.example.com") + XCTAssertEqual(cfg.kas?.rewrapURL, "https://kas.example.com/kas/v2/rewrap") + XCTAssertEqual(cfg.kas?.publicKeyURL, "https://kas.example.com/kas/v2/kas_public_key") + } + + func testValidateAcceptsHTTPSAndLoopbackHTTP() { + XCTAssertNoThrow(try validateKasURL("https://kas.example.com/kas.AccessService/Rewrap")) + XCTAssertNoThrow(try validateKasURL("http://localhost:8080/x")) + XCTAssertNoThrow(try validateKasURL("http://127.0.0.1:8080/x")) + XCTAssertNoThrow(try validateKasURL("http://[::1]:8080/x")) + } + + func testValidateRejectsNonLoopbackHTTPAndBadScheme() { + XCTAssertThrowsError(try validateKasURL("http://evil.com/x")) + XCTAssertThrowsError(try validateKasURL("ftp://kas.example.com/x")) + } + + func testValidateRejectsIPv4PrivateAndLinkLocal() { + for url in ["https://10.0.0.1/x", "https://172.16.0.1/x", + "https://192.168.1.1/x", "https://169.254.169.254/x"] + { + XCTAssertThrowsError(try validateKasURL(url), "\(url) should be rejected") + } + } + + func testValidateRejectsIPv6ULAAndLinkLocal() { + for url in ["https://[fd00::1]/x", "https://[fc00::1]/x", "https://[fe80::1]/x"] { + XCTAssertThrowsError(try validateKasURL(url), "\(url) should be rejected") + } + } + + func testValidateRejectsUnspecifiedAddresses() { + XCTAssertThrowsError(try validateKasURL("https://0.0.0.0/x")) + XCTAssertThrowsError(try validateKasURL("https://[::]/x")) + } + + func testValidateRejectsIPv4MappedMetadataAddress() { + XCTAssertThrowsError(try validateKasURL("https://[::ffff:169.254.169.254]/x")) + XCTAssertThrowsError(try validateKasURL("https://[::ffff:10.0.0.1]/x")) + } + + func testFromConfigPicksConnectWhenPresent() throws { + let cfg = try JSONDecoder().decode(OpenTDFConfiguration.self, + from: Data(Self.platformWellKnown.utf8)) + let ep = try KasEndpoints.from(cfg) + XCTAssertEqual(ep.rewrapURL, "https://platform.arkavo.net/kas.AccessService/Rewrap") + XCTAssertEqual(ep.publicKeyURL, "https://platform.arkavo.net/kas.AccessService/PublicKey") + XCTAssertEqual(ep.transport, .connect) + } + + func testFromConfigFallsBackToRest() throws { + let cfg = OpenTDFConfiguration.forKasLegacyRest("https://k.example.com") + let ep = try KasEndpoints.from(cfg) + XCTAssertEqual(ep.rewrapURL, "https://k.example.com/kas/v2/rewrap") + XCTAssertEqual(ep.transport, .legacyRest) + } + + func testFromConfigThrowsWhenKasMissing() { + let cfg = OpenTDFConfiguration(kas: nil, idp: nil, platformIssuer: "https://x.com") + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } + + func testFromConfigThrowsWhenUriEmpty() { + let cfg = OpenTDFConfiguration( + kas: KasConfig(uri: "", algorithms: [], + publicKeyURL: "https://k.example.com/kas/v2/kas_public_key", + rewrapURL: "https://k.example.com/kas/v2/rewrap", + connectPublicKeyURL: nil, connectRewrapURL: nil), + idp: nil, platformIssuer: nil, + ) + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } + + func testFromConfigThrowsWhenURLsMissing() { + let cfg = OpenTDFConfiguration( + kas: KasConfig(uri: "https://k.example.com", algorithms: [], publicKeyURL: nil, + rewrapURL: nil, connectPublicKeyURL: nil, connectRewrapURL: nil), + idp: nil, platformIssuer: nil, + ) + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } + + func testFromConfigRejectsHostileConnectURL() { + let cfg = OpenTDFConfiguration( + kas: KasConfig(uri: "https://platform.example.com", algorithms: [], + publicKeyURL: nil, rewrapURL: nil, + connectPublicKeyURL: "https://platform.example.com/kas.AccessService/PublicKey", + connectRewrapURL: "https://169.254.169.254/kas.AccessService/Rewrap"), + idp: nil, platformIssuer: nil, + ) + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } + + func testFetchWellKnownReturnsParsedConfig() async throws { + MockURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/.well-known/opentdf-configuration") + let resp = HTTPURLResponse(url: req.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)! + return (resp, Data(Self.platformWellKnown.utf8)) + } + defer { MockURLProtocol.handler = nil } + let session = MockURLProtocol.makeSession() + let cfg = try await fetchWellKnown(platformURL: "https://platform.arkavo.net", + urlSession: session) + XCTAssertNotNil(cfg.kas) + XCTAssertEqual(cfg.platformIssuer, "https://identity.arkavo.net") + } + + func testFetchWellKnown404Throws() async { + MockURLProtocol.handler = { req in + let resp = HTTPURLResponse(url: req.url!, statusCode: 404, + httpVersion: nil, headerFields: nil)! + return (resp, Data("not found".utf8)) + } + defer { MockURLProtocol.handler = nil } + let session = MockURLProtocol.makeSession() + do { + _ = try await fetchWellKnown(platformURL: "https://platform.arkavo.net", + urlSession: session) + XCTFail("expected throw") + } catch let KASDiscoveryError.httpError(status, _) { + XCTAssertEqual(status, 404) + } catch { + XCTFail("unexpected error: \(error)") + } + } + + func testParseConnectErrorValidBody() { + let err = parseConnectError(#"{"code":"unauthenticated","message":"missing bearer token"}"#) + XCTAssertEqual(err?.code, "unauthenticated") + XCTAssertEqual(err?.message, "missing bearer token") + } + + func testParseConnectErrorGarbageReturnsNil() { + XCTAssertNil(parseConnectError("not json")) + XCTAssertNil(parseConnectError("")) + XCTAssertNil(parseConnectError(#"{"unrelated":"shape"}"#)) + } +} diff --git a/OpenTDFKitTests/KASRewrapClientTests.swift b/OpenTDFKitTests/KASRewrapClientTests.swift index 989bcef..c94bbfd 100644 --- a/OpenTDFKitTests/KASRewrapClientTests.swift +++ b/OpenTDFKitTests/KASRewrapClientTests.swift @@ -9,8 +9,8 @@ final class KASRewrapClientTests: XCTestCase { override func setUp() { super.setUp() - client = KASRewrapClient( - kasURL: testKASURL, + client = try! KASRewrapClient( + configuration: OpenTDFConfiguration.forKasLegacyRest(testKASURL.absoluteString), oauthToken: testOAuthToken, ) } @@ -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() { @@ -257,7 +276,7 @@ final class KASRewrapClientTests: XCTestCase { (.invalidResponse, "Invalid response"), (.emptyResponse, "Empty response"), (.accessDenied("test reason"), "Access denied: test reason"), - (.authenticationFailed, "Authentication failed"), + (.authenticationFailed(nil), "Authentication failed"), (.missingWrappedKey, "missing wrapped key"), (.missingSessionKey, "missing session public key"), (.invalidWrappedKeyFormat, "Invalid wrapped key format"), @@ -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) diff --git a/OpenTDFKitTests/KeyStoreTests.swift b/OpenTDFKitTests/KeyStoreTests.swift index 0a461c9..ec4b5a8 100644 --- a/OpenTDFKitTests/KeyStoreTests.swift +++ b/OpenTDFKitTests/KeyStoreTests.swift @@ -3,7 +3,7 @@ import XCTest final class KeyStoreTests: XCTestCase { - func testGenerateAndStoreSingleKey() async throws { + func testGenerateAndStoreSingleKey() async { let keyStore = KeyStore(curve: .secp256r1) let keyPair = await keyStore.generateKeyPair() await keyStore.store(keyPair: keyPair) @@ -95,12 +95,12 @@ final class KeyStoreTests: XCTestCase { // MARK: - Tests for derivePayloadSymmetricKey - // Helper enum for test-specific errors + /// Helper enum for test-specific errors enum TestError: Error { case keyGenerationFailed(String) } - // Helper to generate client ephemeral key pair for tests + /// Helper to generate client ephemeral key pair for tests private func generateClientEphemeralKeyPair(curve: Curve) async throws -> EphemeralKeyPair { let cryptoHelper = CryptoHelper() guard let keyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { @@ -279,7 +279,7 @@ final class KeyStoreTests: XCTestCase { } } -// Helper extension for testing +/// Helper extension for testing extension KeyStore { func getAllPublicKeys() -> [Data] { Array(keyPairs.values.map(\.publicKey)) diff --git a/OpenTDFKitTests/MockURLProtocol.swift b/OpenTDFKitTests/MockURLProtocol.swift new file mode 100644 index 0000000..be5d85e --- /dev/null +++ b/OpenTDFKitTests/MockURLProtocol.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Test double that intercepts URLSession requests. Set `handler` per test to +/// return a (response, body) for a given request. +final class MockURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = MockURLProtocol.handler else { + client?.urlProtocol(self, didFailWithError: + NSError(domain: "MockURLProtocol", code: 0)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + /// A URLSession whose only protocol is the mock. + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } +} diff --git a/OpenTDFKitTests/NanoTDFBenchmarkTests.swift b/OpenTDFKitTests/NanoTDFBenchmarkTests.swift index 190508c..d61ab2f 100644 --- a/OpenTDFKitTests/NanoTDFBenchmarkTests.swift +++ b/OpenTDFKitTests/NanoTDFBenchmarkTests.swift @@ -207,7 +207,7 @@ final class NanoTDFBenchmarkTests: XCTestCase { } } - // Helper function for benchmarking - complete end-to-end encryption + /// Helper function for benchmarking - complete end-to-end encryption private static func deriveKeysAndEncryptBenchmark( cryptoHelper: CryptoHelper, keyPair: EphemeralKeyPair, diff --git a/OpenTDFKitTests/NanoTDFTests.swift b/OpenTDFKitTests/NanoTDFTests.swift index f178f28..2f2a479 100644 --- a/OpenTDFKitTests/NanoTDFTests.swift +++ b/OpenTDFKitTests/NanoTDFTests.swift @@ -11,7 +11,7 @@ final class NanoTDFTests: XCTestCase { // Put teardown code here. This method is called after the invocation of each test method in the class. } - // 6.1.5 nanotdf + /// 6.1.5 nanotdf func testSpecExampleBinaryParser() throws { let hexString = """ 4c 31 4c 01 0e 6b 61 73 2e 76 69 72 74 72 75 2e 63 6f 6d 80 @@ -69,7 +69,7 @@ final class NanoTDFTests: XCTestCase { } } - // 6.1.5 nanotdf + /// 6.1.5 nanotdf func testSpecExampleBinaryParserSerializerParser() throws { let hexString = """ 4c 31 4c 01 0e 6b 61 73 2e 76 69 72 74 72 75 2e 63 6f 6d 80 @@ -139,7 +139,7 @@ final class NanoTDFTests: XCTestCase { } } - // 6.1.5 nanotdf + /// 6.1.5 nanotdf func testSpecExampleDecryptPayload() { let hexString = """ 4c 31 4c 01 0e 6b 61 73 2e 76 69 72 74 72 75 2e 63 6f 6d 80 @@ -203,7 +203,7 @@ final class NanoTDFTests: XCTestCase { } } - // 6.2 No Signature Example + /// 6.2 No Signature Example func testNoSignatureSpecExampleBinaryParser() throws { let hexString = """ 4c 31 4c 01 0f 6b 61 73 2e 65 78 61 6d 70 6c 65 2e 63 6f 6d @@ -384,14 +384,14 @@ final class NanoTDFTests: XCTestCase { // XCTAssertEqual(signature?.signature, nanoTDF.signature?.signature) } - func testPerformanceExample() throws { + func testPerformanceExample() { // This is an example of a performance test case. measure { // Put the code you want to measure the time of here. } } - // Test NanoTDF creation and decryption with KeyStore and KASService using different curves + /// Test NanoTDF creation and decryption with KeyStore and KASService using different curves func testNanoTDFWithKeyStoreAndKASService_secp256r1() async throws { // Initialize KeyStore with secp256r1 curve let keyStore = KeyStore(curve: .secp256r1) @@ -649,7 +649,7 @@ class SymmetricAndPayloadConfigTests: XCTestCase { } } -// Extend SymmetricAndPayloadConfig to include a parsing method +/// Extend SymmetricAndPayloadConfig to include a parsing method extension SignatureAndPayloadConfig { static func parse(from data: Data) -> SignatureAndPayloadConfig? { guard data.count == 1 else { return nil } diff --git a/OpenTDFKitTests/OneTimeTDFTests.swift b/OpenTDFKitTests/OneTimeTDFTests.swift index cf43153..208c840 100644 --- a/OpenTDFKitTests/OneTimeTDFTests.swift +++ b/OpenTDFKitTests/OneTimeTDFTests.swift @@ -1,8 +1,7 @@ import CryptoKit import Foundation -import XCTest - @testable import OpenTDFKit +import XCTest final class OneTimeTDFTests: XCTestCase { func testOneTimeTDFKeyRemoval() async throws { diff --git a/OpenTDFKitTests/PlatformConnectIntegrationTests.swift b/OpenTDFKitTests/PlatformConnectIntegrationTests.swift new file mode 100644 index 0000000..f20accd --- /dev/null +++ b/OpenTDFKitTests/PlatformConnectIntegrationTests.swift @@ -0,0 +1,76 @@ +@preconcurrency import CryptoKit +@testable import OpenTDFKit +import XCTest + +/// Live tests against the real Arkavo platform. Opt in with +/// `KAS_INTEGRATION_TESTS=1`. They document the Connect transport milestone. +final class PlatformConnectIntegrationTests: XCTestCase { + private static let platform = "https://platform.arkavo.net" + + private func requireOptIn() throws { + guard ProcessInfo.processInfo.environment["KAS_INTEGRATION_TESTS"] == "1" else { + throw XCTSkip("Set KAS_INTEGRATION_TESTS=1 to run live platform tests") + } + } + + func testWellKnownReturnsKasConfig() async throws { + try requireOptIn() + let cfg = try await fetchWellKnown(platformURL: Self.platform) + let kas = try XCTUnwrap(cfg.kas) + XCTAssertNotNil(kas.connectRewrapURL, "platform should advertise connect_rewrap_url") + XCTAssertNotNil(kas.connectPublicKeyURL, "platform should advertise connect_public_key_url") + XCTAssertNotNil(kas.rewrapURL, "platform also exposes legacy REST rewrap_url") + } + + func testConnectPublicKeyReturnsPEM() async throws { + try requireOptIn() + let cfg = try await fetchWellKnown(platformURL: Self.platform) + let client = try KASRewrapClient(configuration: cfg, oauthToken: "") + let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) + XCTAssertTrue(result.pem.contains("BEGIN PUBLIC KEY")) + XCTAssertEqual(result.compressedKey.count, 33) + } + + func testConnectRewrapFakeBearerReturns401() async throws { + try requireOptIn() + let cfg = try await fetchWellKnown(platformURL: Self.platform) + // Deliberately invalid bearer — the platform must reject it. Kept as a + // plain non-JWT string so it isn't flagged as a hardcoded secret. + let client = try KASRewrapClient(configuration: cfg, + oauthToken: "invalid-bearer-token") + let kas = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "platform.arkavo.net/kas")) + let header = makeMinimalHeader(kas: kas) + let kp = EphemeralKeyPair( + privateKey: P256.KeyAgreement.PrivateKey().rawRepresentation, + publicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, + curve: .secp256r1, + ) + do { + _ = try await client.rewrapNanoTDF(header: Data([0, 0, 0]), parsedHeader: header, + clientKeyPair: kp) + XCTFail("rewrap should fail without valid auth") + } catch KASRewrapError.authenticationFailed(_) { + // expected + } catch let KASRewrapError.accessDenied(reason) { + print("AccessDenied: \(reason)") + } catch let KASRewrapError.httpError(status, message) { + XCTAssertNotEqual(status, 404, "404 means Connect rewrap path missing: \(message ?? "")") + XCTAssertTrue((400 ..< 600).contains(status), "expected 4xx/5xx, got \(status)") + } + } + + private func makeMinimalHeader(kas: ResourceLocator) -> Header { + let policy = Policy(type: .embeddedPlaintext, + body: EmbeddedPolicyBody(body: Data("{}".utf8)), + remote: nil, binding: nil) + return Header( + kas: kas, + policyBindingConfig: PolicyBindingConfig(ecdsaBinding: false, curve: .secp256r1), + payloadSignatureConfig: SignatureAndPayloadConfig( + signed: false, signatureCurve: nil, payloadCipher: .aes256GCM128, + ), + policy: policy, + ephemeralPublicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, + ) + } +} diff --git a/OpenTDFKitTests/StreamingBenchmarkTests.swift b/OpenTDFKitTests/StreamingBenchmarkTests.swift index 0e67990..9538a59 100644 --- a/OpenTDFKitTests/StreamingBenchmarkTests.swift +++ b/OpenTDFKitTests/StreamingBenchmarkTests.swift @@ -34,7 +34,7 @@ final class StreamingBenchmarkTests: XCTestCase { try await measureStreamingEncryption(fileSize: 100 * 1024 * 1024, chunkSize: chunkSizes[2].size, chunkName: chunkSizes[2].name) } - func testMultiSegmentEncryption() async throws { + func testMultiSegmentEncryption() throws { let fileSize = 20 * 1024 * 1024 let segmentSizes = [2 * 1024 * 1024, 5 * 1024 * 1024, 25 * 1024 * 1024] @@ -79,7 +79,7 @@ final class StreamingBenchmarkTests: XCTestCase { print("Multi-segment decryption: \(String(format: "%.2f", decryptionTime))s") } - func testVariableSegmentSizes() async throws { + func testVariableSegmentSizes() throws { let segmentSizes = [ 2 * 1024 * 1024, 5 * 1024 * 1024, diff --git a/OpenTDFKitTests/TDFTests.swift b/OpenTDFKitTests/TDFTests.swift index 55d71a6..b336d5a 100644 --- a/OpenTDFKitTests/TDFTests.swift +++ b/OpenTDFKitTests/TDFTests.swift @@ -80,7 +80,7 @@ final class StandardTDFTests: XCTestCase { XCTAssertEqual(originalKeyData, unwrappedKeyData, "Unwrapped key should match original") } - func testTDFContainerCreation() throws { + func testTDFContainerCreation() { let manifest = createTestManifest() let payload = testPlaintext @@ -152,7 +152,7 @@ final class StandardTDFTests: XCTestCase { XCTAssertEqual(decrypted, testPlaintext, "Decrypted data should match original") } - func testManifestStructure() throws { + func testManifestStructure() { let manifest = createTestManifest() XCTAssertEqual(manifest.schemaVersion, "1.0.0") diff --git a/docs/superpowers/plans/2026-05-30-connectrpc-kas-migration.md b/docs/superpowers/plans/2026-05-30-connectrpc-kas-migration.md new file mode 100644 index 0000000..d969515 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-connectrpc-kas-migration.md @@ -0,0 +1,1523 @@ +# ConnectRPC + Well-Known KAS Discovery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the OpenTDFKit KAS client transport from REST `/kas/v2/*` to ConnectRPC unary-JSON `/kas.AccessService/*`, driven by `/.well-known/opentdf-configuration` discovery, with SSRF-validated endpoints, no-redirect HTTP, Connect error parsing, and opaque CWT/JWT bearer passthrough. + +**Architecture:** A new `KASDiscovery.swift` provides Codable config types, endpoint resolution (Connect-preferred, REST-fallback), URL/SSRF validation, Connect error parsing, and well-known fetch. `KASRewrapClient` gains a breaking `init(configuration:oauthToken:)`, stores resolved `KasEndpoints`, uses a `NoRedirectDelegate`, branches transport for the public-key fetch, and derives the NanoTDF request-body KAS url from the parsed header (config fallback). The CLI resolves config via well-known (synthesized-Connect fallback). + +**Tech Stack:** Swift 6, Foundation/URLSession, CryptoKit, `Darwin.inet_pton` for IP classification, XCTest. + +**Reference:** `docs/superpowers/specs/2026-05-30-connectrpc-kas-migration-design.md` and opentdf-rs `src/kas_discovery.rs` / `src/kas.rs` / `src/kas_key.rs`. + +**Build/test commands:** +- Build: `swift build` +- All tests: `swift test` +- One suite: `swift test --filter KASDiscoveryTests` +- Format (before any commit): `swiftformat --swiftversion 6.2 .` + +--- + +## Task 1: Config types + builders + +**Files:** +- Create: `OpenTDFKit/KASDiscovery.swift` +- Test: `OpenTDFKitTests/KASDiscoveryTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `OpenTDFKitTests/KASDiscoveryTests.swift`: + +```swift +@testable import OpenTDFKit +import XCTest + +final class KASDiscoveryTests: XCTestCase { + /// Captured from https://platform.arkavo.net/.well-known/opentdf-configuration on 2026-05-28 + static let platformWellKnown = """ + { + "health": { "endpoint": "/healthz" }, + "idp": { + "access_token_format": "application/cwt", + "authorization_endpoint": "https://identity.arkavo.net/oauth/authorize", + "cose_keys_uri": "https://identity.arkavo.net/.well-known/cose-keys", + "id_token_signing_alg_values_supported": ["ES256"], + "issuer": "https://identity.arkavo.net", + "jwks_uri": "https://identity.arkavo.net/.well-known/jwks.json", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "token_endpoint": "https://identity.arkavo.net/oauth/token", + "userinfo_endpoint": "https://identity.arkavo.net/oauth/userinfo" + }, + "kas": { + "algorithms": ["ec:secp256r1", "rsa:2048"], + "connect_public_key_url": "https://platform.arkavo.net/kas.AccessService/PublicKey", + "connect_rewrap_url": "https://platform.arkavo.net/kas.AccessService/Rewrap", + "public_key_url": "https://platform.arkavo.net/kas/v2/kas_public_key", + "rewrap_url": "https://platform.arkavo.net/kas/v2/rewrap", + "uri": "https://platform.arkavo.net" + }, + "platform_issuer": "https://identity.arkavo.net" + } + """ + + func testDecodesPlatformWellKnown() throws { + let cfg = try JSONDecoder().decode(OpenTDFConfiguration.self, + from: Data(Self.platformWellKnown.utf8)) + let kas = try XCTUnwrap(cfg.kas) + XCTAssertEqual(kas.uri, "https://platform.arkavo.net") + XCTAssertEqual(kas.algorithms, ["ec:secp256r1", "rsa:2048"]) + XCTAssertEqual(kas.connectRewrapURL, "https://platform.arkavo.net/kas.AccessService/Rewrap") + XCTAssertEqual(kas.rewrapURL, "https://platform.arkavo.net/kas/v2/rewrap") + let idp = try XCTUnwrap(cfg.idp) + XCTAssertEqual(idp.issuer, "https://identity.arkavo.net") + XCTAssertEqual(idp.accessTokenFormat, "application/cwt") + XCTAssertEqual(cfg.platformIssuer, "https://identity.arkavo.net") + } + + func testDecodesMinimalKasOnly() throws { + let json = """ + {"kas":{"uri":"https://k.example.com","algorithms":[], + "rewrap_url":"https://k.example.com/kas/v2/rewrap", + "public_key_url":"https://k.example.com/kas/v2/kas_public_key"}} + """ + let cfg = try JSONDecoder().decode(OpenTDFConfiguration.self, from: Data(json.utf8)) + let kas = try XCTUnwrap(cfg.kas) + XCTAssertNil(kas.connectRewrapURL) + XCTAssertEqual(kas.rewrapURL, "https://k.example.com/kas/v2/rewrap") + XCTAssertNil(cfg.idp) + } + + func testForKasConnectConstructsURLs() { + let cfg = OpenTDFConfiguration.forKasConnect("https://kas.example.com") + XCTAssertEqual(cfg.kas?.connectRewrapURL, "https://kas.example.com/kas.AccessService/Rewrap") + XCTAssertEqual(cfg.kas?.connectPublicKeyURL, "https://kas.example.com/kas.AccessService/PublicKey") + XCTAssertEqual(cfg.kas?.uri, "https://kas.example.com") + } + + func testForKasConnectHandlesTrailingSlash() { + let cfg = OpenTDFConfiguration.forKasConnect("https://kas.example.com/") + XCTAssertEqual(cfg.kas?.connectRewrapURL, "https://kas.example.com/kas.AccessService/Rewrap") + } + + func testForKasLegacyRestConstructsURLs() { + let cfg = OpenTDFConfiguration.forKasLegacyRest("https://kas.example.com") + XCTAssertEqual(cfg.kas?.rewrapURL, "https://kas.example.com/kas/v2/rewrap") + XCTAssertEqual(cfg.kas?.publicKeyURL, "https://kas.example.com/kas/v2/kas_public_key") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `swift test --filter KASDiscoveryTests` +Expected: FAIL — `cannot find 'OpenTDFConfiguration' in scope`. + +- [ ] **Step 3: Create `OpenTDFKit/KASDiscovery.swift` with config types** + +```swift +import Foundation + +// MARK: - Configuration documents (/.well-known/opentdf-configuration) + +/// The platform's well-known configuration document. +public struct OpenTDFConfiguration: Codable, Sendable { + public let kas: KasConfig? + public let idp: IdpConfig? + public let platformIssuer: String? + + public init(kas: KasConfig?, idp: IdpConfig?, platformIssuer: String?) { + self.kas = kas + self.idp = idp + self.platformIssuer = platformIssuer + } + + enum CodingKeys: String, CodingKey { + case kas, idp + case platformIssuer = "platform_issuer" + } + + /// Synthesize a Connect-only configuration for a single KAS base URL. + /// Use when the platform does not expose the well-known endpoint. + public static func forKasConnect(_ baseURL: String) -> OpenTDFConfiguration { + let base = String(baseURL.reversed().drop { $0 == "/" }.reversed()) + return OpenTDFConfiguration( + kas: KasConfig( + uri: base, + algorithms: [], + publicKeyURL: nil, + rewrapURL: nil, + connectPublicKeyURL: "\(base)/kas.AccessService/PublicKey", + connectRewrapURL: "\(base)/kas.AccessService/Rewrap"), + idp: nil, + platformIssuer: nil) + } + + /// Synthesize a legacy-REST configuration for a single KAS base URL. + public static func forKasLegacyRest(_ baseURL: String) -> OpenTDFConfiguration { + let base = String(baseURL.reversed().drop { $0 == "/" }.reversed()) + return OpenTDFConfiguration( + kas: KasConfig( + uri: base, + algorithms: [], + publicKeyURL: "\(base)/kas/v2/kas_public_key", + rewrapURL: "\(base)/kas/v2/rewrap", + connectPublicKeyURL: nil, + connectRewrapURL: nil), + idp: nil, + platformIssuer: nil) + } +} + +public struct KasConfig: Codable, Sendable { + public let uri: String + public let algorithms: [String] + public let publicKeyURL: String? + public let rewrapURL: String? + public let connectPublicKeyURL: String? + public let connectRewrapURL: String? + + public init(uri: String, algorithms: [String], publicKeyURL: String?, rewrapURL: String?, + connectPublicKeyURL: String?, connectRewrapURL: String?) { + self.uri = uri + self.algorithms = algorithms + self.publicKeyURL = publicKeyURL + self.rewrapURL = rewrapURL + self.connectPublicKeyURL = connectPublicKeyURL + self.connectRewrapURL = connectRewrapURL + } + + enum CodingKeys: String, CodingKey { + case uri, algorithms + case publicKeyURL = "public_key_url" + case rewrapURL = "rewrap_url" + case connectPublicKeyURL = "connect_public_key_url" + case connectRewrapURL = "connect_rewrap_url" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + uri = try c.decode(String.self, forKey: .uri) + algorithms = try c.decodeIfPresent([String].self, forKey: .algorithms) ?? [] + publicKeyURL = try c.decodeIfPresent(String.self, forKey: .publicKeyURL) + rewrapURL = try c.decodeIfPresent(String.self, forKey: .rewrapURL) + connectPublicKeyURL = try c.decodeIfPresent(String.self, forKey: .connectPublicKeyURL) + connectRewrapURL = try c.decodeIfPresent(String.self, forKey: .connectRewrapURL) + } +} + +public struct IdpConfig: Codable, Sendable { + public let issuer: String + public let jwksURI: String? + public let coseKeysURI: String? + public let tokenEndpoint: String? + public let authorizationEndpoint: String? + public let userinfoEndpoint: String? + public let accessTokenFormat: String? + public let idTokenSigningAlgValuesSupported: [String] + public let responseTypesSupported: [String] + public let subjectTypesSupported: [String] + + enum CodingKeys: String, CodingKey { + case issuer + case jwksURI = "jwks_uri" + case coseKeysURI = "cose_keys_uri" + case tokenEndpoint = "token_endpoint" + case authorizationEndpoint = "authorization_endpoint" + case userinfoEndpoint = "userinfo_endpoint" + case accessTokenFormat = "access_token_format" + case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported" + case responseTypesSupported = "response_types_supported" + case subjectTypesSupported = "subject_types_supported" + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + issuer = try c.decode(String.self, forKey: .issuer) + jwksURI = try c.decodeIfPresent(String.self, forKey: .jwksURI) + coseKeysURI = try c.decodeIfPresent(String.self, forKey: .coseKeysURI) + tokenEndpoint = try c.decodeIfPresent(String.self, forKey: .tokenEndpoint) + authorizationEndpoint = try c.decodeIfPresent(String.self, forKey: .authorizationEndpoint) + userinfoEndpoint = try c.decodeIfPresent(String.self, forKey: .userinfoEndpoint) + accessTokenFormat = try c.decodeIfPresent(String.self, forKey: .accessTokenFormat) + idTokenSigningAlgValuesSupported = + try c.decodeIfPresent([String].self, forKey: .idTokenSigningAlgValuesSupported) ?? [] + responseTypesSupported = + try c.decodeIfPresent([String].self, forKey: .responseTypesSupported) ?? [] + subjectTypesSupported = + try c.decodeIfPresent([String].self, forKey: .subjectTypesSupported) ?? [] + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `swiftformat --swiftversion 6.2 . && swift test --filter KASDiscoveryTests` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add OpenTDFKit/KASDiscovery.swift OpenTDFKitTests/KASDiscoveryTests.swift +git commit -m "feat(kas): add OpenTDFConfiguration discovery types + builders" +``` + +--- + +## Task 2: URL + SSRF validation + +**Files:** +- Modify: `OpenTDFKit/KASDiscovery.swift` +- Test: `OpenTDFKitTests/KASDiscoveryTests.swift` + +- [ ] **Step 1: Add failing tests** + +Append to `KASDiscoveryTests`: + +```swift + func testValidateAcceptsHTTPSAndLoopbackHTTP() { + XCTAssertNoThrow(try validateKasURL("https://kas.example.com/kas.AccessService/Rewrap")) + XCTAssertNoThrow(try validateKasURL("http://localhost:8080/x")) + XCTAssertNoThrow(try validateKasURL("http://127.0.0.1:8080/x")) + XCTAssertNoThrow(try validateKasURL("http://[::1]:8080/x")) + } + + func testValidateRejectsNonLoopbackHTTPAndBadScheme() { + XCTAssertThrowsError(try validateKasURL("http://evil.com/x")) + XCTAssertThrowsError(try validateKasURL("ftp://kas.example.com/x")) + } + + func testValidateRejectsIPv4PrivateAndLinkLocal() { + for url in ["https://10.0.0.1/x", "https://172.16.0.1/x", + "https://192.168.1.1/x", "https://169.254.169.254/x"] { + XCTAssertThrowsError(try validateKasURL(url), "\(url) should be rejected") + } + } + + func testValidateRejectsIPv6ULAAndLinkLocal() { + for url in ["https://[fd00::1]/x", "https://[fc00::1]/x", "https://[fe80::1]/x"] { + XCTAssertThrowsError(try validateKasURL(url), "\(url) should be rejected") + } + } + + func testValidateRejectsUnspecifiedAddresses() { + XCTAssertThrowsError(try validateKasURL("https://0.0.0.0/x")) + XCTAssertThrowsError(try validateKasURL("https://[::]/x")) + } + + func testValidateRejectsIPv4MappedMetadataAddress() { + XCTAssertThrowsError(try validateKasURL("https://[::ffff:169.254.169.254]/x")) + XCTAssertThrowsError(try validateKasURL("https://[::ffff:10.0.0.1]/x")) + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `swift test --filter KASDiscoveryTests` +Expected: FAIL — `cannot find 'validateKasURL' in scope`. + +- [ ] **Step 3: Add validation + error type to `KASDiscovery.swift`** + +```swift +import Darwin + +// MARK: - Errors + +public enum KASDiscoveryError: Error, CustomStringConvertible { + case invalidURL(String) + case configError(String) + case httpError(Int, String) + case invalidResponse(String) + + public var description: String { + switch self { + case let .invalidURL(m): "Invalid KAS URL: \(m)" + case let .configError(m): "KAS configuration error: \(m)" + case let .httpError(s, m): "HTTP error \(s): \(m)" + case let .invalidResponse(m): "Invalid response: \(m)" + } + } +} + +// MARK: - URL / SSRF validation + +private enum IPLiteral { + case v4([UInt8]) // 4 bytes, network order + case v6([UInt8]) // 16 bytes, network order +} + +private func classifyIP(_ host: String) -> IPLiteral? { + var v4 = in_addr() + if host.withCString({ inet_pton(AF_INET, $0, &v4) }) == 1 { + return .v4(withUnsafeBytes(of: v4.s_addr) { Array($0) }) + } + var v6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { + return .v6(withUnsafeBytes(of: v6) { Array($0) }) + } + return nil +} + +private func isLoopbackHost(_ host: String) -> Bool { + if host == "localhost" { return true } + switch classifyIP(host) { + case let .v4(o): return o[0] == 127 // 127.0.0.0/8 + case let .v6(b): return b.dropLast() == ArraySlice(repeating: 0, count: 15) && b[15] == 1 // ::1 + case .none: return false + } +} + +private func isBlockedV4(_ o: [UInt8]) -> Bool { + if o[0] == 10 { return true } // 10.0.0.0/8 + if o[0] == 172, (o[1] & 0xF0) == 16 { return true } // 172.16.0.0/12 + if o[0] == 192, o[1] == 168 { return true } // 192.168.0.0/16 + if o[0] == 169, o[1] == 254 { return true } // 169.254.0.0/16 + if o == [0, 0, 0, 0] { return true } // 0.0.0.0 + return false +} + +private func isBlockedIP(_ ip: IPLiteral) -> Bool { + switch ip { + case let .v4(o): + return isBlockedV4(o) + case let .v6(b): + // Fold IPv4-mapped ::ffff:a.b.c.d back to IPv4. + if b[0 ..< 10].allSatisfy({ $0 == 0 }), b[10] == 0xFF, b[11] == 0xFF { + return isBlockedV4(Array(b[12 ..< 16])) + } + if b.allSatisfy({ $0 == 0 }) { return true } // :: unspecified + let first = (UInt16(b[0]) << 8) | UInt16(b[1]) + return (first & 0xFE00) == 0xFC00 || (first & 0xFFC0) == 0xFE80 // fc00::/7, fe80::/10 + } +} + +/// Validate a KAS URL for scheme / HTTPS / SSRF constraints. +public func validateKasURL(_ urlString: String) throws { + guard let url = URL(string: urlString), let scheme = url.scheme?.lowercased() else { + throw KASDiscoveryError.invalidURL("Failed to parse URL: \(urlString)") + } + let host = url.host ?? "" + + switch scheme { + case "https": + break + case "http": + if !isLoopbackHost(host) { + throw KASDiscoveryError.invalidURL("KAS URL must use HTTPS (HTTP only allowed for localhost)") + } + default: + throw KASDiscoveryError.invalidURL("Unsupported URL scheme '\(scheme)', must be https") + } + + if let ip = classifyIP(host), isBlockedIP(ip) { + throw KASDiscoveryError.invalidURL("KAS URL must not target private or link-local IP addresses") + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `swiftformat --swiftversion 6.2 . && swift test --filter KASDiscoveryTests` +Expected: PASS (12 tests). + +- [ ] **Step 5: Commit** + +```bash +git add OpenTDFKit/KASDiscovery.swift OpenTDFKitTests/KASDiscoveryTests.swift +git commit -m "feat(kas): add validateKasURL with HTTPS + SSRF guard" +``` + +--- + +## Task 3: Endpoint resolution + +**Files:** +- Modify: `OpenTDFKit/KASDiscovery.swift` +- Test: `OpenTDFKitTests/KASDiscoveryTests.swift` + +- [ ] **Step 1: Add failing tests** + +Append to `KASDiscoveryTests`: + +```swift + func testFromConfigPicksConnectWhenPresent() throws { + let cfg = try JSONDecoder().decode(OpenTDFConfiguration.self, + from: Data(Self.platformWellKnown.utf8)) + let ep = try KasEndpoints.from(cfg) + XCTAssertEqual(ep.rewrapURL, "https://platform.arkavo.net/kas.AccessService/Rewrap") + XCTAssertEqual(ep.publicKeyURL, "https://platform.arkavo.net/kas.AccessService/PublicKey") + XCTAssertEqual(ep.transport, .connect) + } + + func testFromConfigFallsBackToRest() throws { + let cfg = OpenTDFConfiguration.forKasLegacyRest("https://k.example.com") + let ep = try KasEndpoints.from(cfg) + XCTAssertEqual(ep.rewrapURL, "https://k.example.com/kas/v2/rewrap") + XCTAssertEqual(ep.transport, .legacyRest) + } + + func testFromConfigThrowsWhenKasMissing() { + let cfg = OpenTDFConfiguration(kas: nil, idp: nil, platformIssuer: "https://x.com") + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } + + func testFromConfigThrowsWhenURLsMissing() { + let cfg = OpenTDFConfiguration( + kas: KasConfig(uri: "https://k.example.com", algorithms: [], publicKeyURL: nil, + rewrapURL: nil, connectPublicKeyURL: nil, connectRewrapURL: nil), + idp: nil, platformIssuer: nil) + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } + + func testFromConfigRejectsHostileConnectURL() { + let cfg = OpenTDFConfiguration( + kas: KasConfig(uri: "https://platform.example.com", algorithms: [], + publicKeyURL: nil, rewrapURL: nil, + connectPublicKeyURL: "https://platform.example.com/kas.AccessService/PublicKey", + connectRewrapURL: "https://169.254.169.254/kas.AccessService/Rewrap"), + idp: nil, platformIssuer: nil) + XCTAssertThrowsError(try KasEndpoints.from(cfg)) + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `swift test --filter KASDiscoveryTests` +Expected: FAIL — `cannot find 'KasEndpoints' in scope`. + +- [ ] **Step 3: Add `KasTransport` + `KasEndpoints` to `KASDiscovery.swift`** + +```swift +// MARK: - Endpoint resolution + +public enum KasTransport: Sendable, Equatable { + /// ConnectRPC endpoints at /kas.AccessService/* + case connect + /// Legacy REST gateway at /kas/v2/* + case legacyRest +} + +public struct KasEndpoints: Sendable { + public let rewrapURL: String + public let publicKeyURL: String + public let transport: KasTransport + + /// Resolve KAS endpoints, preferring ConnectRPC URLs and falling back to + /// legacy REST when only REST is advertised. Both resolved URLs are + /// validated (HTTPS / scheme / SSRF) before returning. + public static func from(_ config: OpenTDFConfiguration) throws -> KasEndpoints { + guard let kas = config.kas else { + throw KASDiscoveryError.configError("well-known configuration is missing a 'kas' block") + } + + let resolved: KasEndpoints + if let rewrap = kas.connectRewrapURL, let pub = kas.connectPublicKeyURL { + resolved = KasEndpoints(rewrapURL: rewrap, publicKeyURL: pub, transport: .connect) + } else if let rewrap = kas.rewrapURL, let pub = kas.publicKeyURL { + resolved = KasEndpoints(rewrapURL: rewrap, publicKeyURL: pub, transport: .legacyRest) + } else { + throw KASDiscoveryError.configError("well-known kas block exposes neither Connect nor REST URLs") + } + + try validateKasURL(resolved.rewrapURL) + try validateKasURL(resolved.publicKeyURL) + return resolved + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `swiftformat --swiftversion 6.2 . && swift test --filter KASDiscoveryTests` +Expected: PASS (17 tests). + +- [ ] **Step 5: Commit** + +```bash +git add OpenTDFKit/KASDiscovery.swift OpenTDFKitTests/KASDiscoveryTests.swift +git commit -m "feat(kas): add KasEndpoints resolution (Connect-preferred)" +``` + +--- + +## Task 4: Connect error envelope parsing + +**Files:** +- Modify: `OpenTDFKit/KASDiscovery.swift` +- Test: `OpenTDFKitTests/KASDiscoveryTests.swift` + +- [ ] **Step 1: Add failing tests** + +Append to `KASDiscoveryTests`: + +```swift + func testParseConnectErrorValidBody() { + let err = parseConnectError(#"{"code":"unauthenticated","message":"missing bearer token"}"#) + XCTAssertEqual(err?.code, "unauthenticated") + XCTAssertEqual(err?.message, "missing bearer token") + } + + func testParseConnectErrorGarbageReturnsNil() { + XCTAssertNil(parseConnectError("not json")) + XCTAssertNil(parseConnectError("")) + XCTAssertNil(parseConnectError(#"{"unrelated":"shape"}"#)) + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `swift test --filter KASDiscoveryTests` +Expected: FAIL — `cannot find 'parseConnectError' in scope`. + +- [ ] **Step 3: Add `ConnectError` + parser to `KASDiscovery.swift`** + +```swift +// MARK: - Connect error envelope + +/// Error envelope returned by Connect unary-JSON RPCs on non-2xx responses. +public struct ConnectError: Codable, Sendable { + public let code: String + public let message: String +} + +/// Parse a Connect error envelope from a response body. Returns nil for empty, +/// non-JSON, or shapes lacking a non-empty `code`. +public func parseConnectError(_ body: String) -> ConnectError? { + guard !body.isEmpty, let data = body.data(using: .utf8) else { return nil } + guard let parsed = try? JSONDecoder().decode(ConnectError.self, from: data) else { return nil } + return parsed.code.isEmpty ? nil : parsed +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `swiftformat --swiftversion 6.2 . && swift test --filter KASDiscoveryTests` +Expected: PASS (19 tests). + +- [ ] **Step 5: Commit** + +```bash +git add OpenTDFKit/KASDiscovery.swift OpenTDFKitTests/KASDiscoveryTests.swift +git commit -m "feat(kas): add Connect error-envelope parsing" +``` + +--- + +## Task 5: well-known fetch + MockURLProtocol helper + +**Files:** +- Modify: `OpenTDFKit/KASDiscovery.swift` +- Create: `OpenTDFKitTests/MockURLProtocol.swift` +- Test: `OpenTDFKitTests/KASDiscoveryTests.swift` + +- [ ] **Step 1: Create the mock URL protocol helper** + +Create `OpenTDFKitTests/MockURLProtocol.swift`: + +```swift +import Foundation + +/// Test double that intercepts URLSession requests. Set `handler` per test to +/// return a (response, body) for a given request. +final class MockURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with _: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = MockURLProtocol.handler else { + client?.urlProtocol(self, didFailWithError: + NSError(domain: "MockURLProtocol", code: 0)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + /// A URLSession whose only protocol is the mock. + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } +} +``` + +- [ ] **Step 2: Add failing tests** + +Append to `KASDiscoveryTests`: + +```swift + func testFetchWellKnownReturnsParsedConfig() async throws { + MockURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/.well-known/opentdf-configuration") + let resp = HTTPURLResponse(url: req.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)! + return (resp, Data(Self.platformWellKnown.utf8)) + } + let session = MockURLProtocol.makeSession() + let cfg = try await fetchWellKnown(platformURL: "https://platform.arkavo.net", + urlSession: session) + XCTAssertNotNil(cfg.kas) + XCTAssertEqual(cfg.platformIssuer, "https://identity.arkavo.net") + } + + func testFetchWellKnown404Throws() async { + MockURLProtocol.handler = { req in + let resp = HTTPURLResponse(url: req.url!, statusCode: 404, + httpVersion: nil, headerFields: nil)! + return (resp, Data("not found".utf8)) + } + let session = MockURLProtocol.makeSession() + do { + _ = try await fetchWellKnown(platformURL: "https://platform.arkavo.net", + urlSession: session) + XCTFail("expected throw") + } catch let KASDiscoveryError.httpError(status, _) { + XCTAssertEqual(status, 404) + } catch { + XCTFail("unexpected error: \(error)") + } + } +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `swift test --filter KASDiscoveryTests` +Expected: FAIL — `cannot find 'fetchWellKnown' in scope`. + +- [ ] **Step 4: Add `fetchWellKnown` to `KASDiscovery.swift`** + +```swift +// MARK: - Well-known discovery + +/// Fetch the platform's /.well-known/opentdf-configuration document. +/// `platformURL` is the platform base (e.g. "https://platform.arkavo.net"); a +/// trailing slash is tolerated. +public func fetchWellKnown(platformURL: String, + urlSession: URLSession = .shared) async throws -> OpenTDFConfiguration { + let base = String(platformURL.reversed().drop { $0 == "/" }.reversed()) + let urlString = "\(base)/.well-known/opentdf-configuration" + guard let url = URL(string: urlString) else { + throw KASDiscoveryError.invalidURL("Failed to parse URL: \(urlString)") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.addValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await urlSession.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw KASDiscoveryError.invalidResponse("Non-HTTP response from \(urlString)") + } + guard (200 ..< 300).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw KASDiscoveryError.httpError(http.statusCode, "GET \(urlString): \(body)") + } + + do { + return try JSONDecoder().decode(OpenTDFConfiguration.self, from: data) + } catch { + throw KASDiscoveryError.invalidResponse("Failed to parse well-known JSON: \(error)") + } +} +``` + +- [ ] **Step 5: Run to verify pass** + +Run: `swiftformat --swiftversion 6.2 . && swift test --filter KASDiscoveryTests` +Expected: PASS (21 tests). + +- [ ] **Step 6: Commit** + +```bash +git add OpenTDFKit/KASDiscovery.swift OpenTDFKitTests/KASDiscoveryTests.swift OpenTDFKitTests/MockURLProtocol.swift +git commit -m "feat(kas): add fetchWellKnown + MockURLProtocol test helper" +``` + +--- + +## Task 6: Enrich `authenticationFailed` error with a reason + +**Files:** +- Modify: `OpenTDFKit/KASRewrapClient.swift` (enum ~919, throw sites 402/545/621, description ~943) +- Modify: `OpenTDFKitTests/KASRewrapClientTests.swift:260` +- Modify: `OpenTDFKitTests/IntegrationTests.swift:154` + +This task is a pure refactor (no behavior change) so the build stays green before the transport work. + +- [ ] **Step 1: Change the enum case** + +In `OpenTDFKit/KASRewrapClient.swift`, change: + +```swift + case authenticationFailed +``` +to: +```swift + case authenticationFailed(String?) +``` + +- [ ] **Step 2: Update the description arm** + +Change: +```swift + case .authenticationFailed: + "Authentication failed - check OAuth token" +``` +to: +```swift + case let .authenticationFailed(reason): + "Authentication failed - check OAuth token" + (reason.map { ": \($0)" } ?? "") +``` + +- [ ] **Step 3: Update the three throw sites** + +At lines ~402 (`rewrapNanoTDF`), ~545 (`rewrapTDF`), ~621 (`fetchKasEcPublicKey`), change each: +```swift + case 401: + throw KASRewrapError.authenticationFailed +``` +to: +```swift + case 401: + let message = String(data: data, encoding: .utf8) + throw KASRewrapError.authenticationFailed(message) +``` + +- [ ] **Step 4: Update test pattern matches** + +In `OpenTDFKitTests/KASRewrapClientTests.swift:260`, change: +```swift + (.authenticationFailed, "Authentication failed"), +``` +to: +```swift + (.authenticationFailed(nil), "Authentication failed"), +``` + +In `OpenTDFKitTests/IntegrationTests.swift:154`, change: +```swift + } catch KASRewrapError.authenticationFailed { +``` +to: +```swift + } catch KASRewrapError.authenticationFailed { + // matches authenticationFailed(_) — associated value ignored +``` +(The `catch KASRewrapError.authenticationFailed` pattern still matches a case with an associated value when no binding is needed; if the compiler requires it, use `catch KASRewrapError.authenticationFailed(_)`.) + +- [ ] **Step 5: Build + test** + +Run: `swiftformat --swiftversion 6.2 . && swift build && swift test --filter KASRewrapClientTests` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add OpenTDFKit/KASRewrapClient.swift OpenTDFKitTests/KASRewrapClientTests.swift OpenTDFKitTests/IntegrationTests.swift +git commit -m "refactor(kas): carry a reason string on authenticationFailed" +``` + +--- + +## Task 7: Client transport via KasEndpoints (additive init + no-redirect + Connect branch) + +This adds the new `init(configuration:)` alongside the existing `init(kasURL:)` (kept temporarily as a green-build bridge; removed in Task 10). It rewires the transport to use resolved endpoints, adds the no-redirect delegate, branches the public-key fetch, derives the NanoTDF KAS identity url from the header, and shares Connect-aware HTTP error mapping. + +**Files:** +- Modify: `OpenTDFKit/KASRewrapClient.swift` + +- [ ] **Step 1: Add the no-redirect delegate (top of file, after imports)** + +```swift +/// URLSession task delegate that refuses HTTP redirects. The KAS rewrap target +/// is an RPC endpoint that must never redirect; following a 3xx could re-issue +/// the bearer-carrying request to an unvalidated host. +final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_: URLSession, task _: URLSessionTask, + willPerformHTTPRedirection _: HTTPURLResponse, newRequest _: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + completionHandler(nil) + } +} +``` + +- [ ] **Step 2: Replace stored properties + inits** + +Change the stored properties block: +```swift + private let kasURL: URL + private let oauthToken: String + private let urlSession: URLSession + private let signingKey: P256.Signing.PrivateKey +``` +to: +```swift + private let endpoints: KasEndpoints + /// KAS identity url used for the request-body KeyAccessObject when the + /// NanoTDF header locator is unavailable (fallback only). + private let kasIdentityURL: String + private let oauthToken: String + private let urlSession: URLSession + private let signingKey: P256.Signing.PrivateKey + private let noRedirect = NoRedirectDelegate() +``` + +Replace the existing `init(kasURL:...)` with BOTH inits: +```swift + /// Initialize from a resolved configuration document (preferred). + /// - Parameters: + /// - configuration: Resolved config; obtain via `fetchWellKnown(...)` or + /// `OpenTDFConfiguration.forKasConnect(_:)`. Both resolved KAS URLs are + /// validated (HTTPS / scheme / SSRF) here. + /// - oauthToken: Bearer token (opaque: a JWT or base64url-encoded CWT). + public init(configuration: OpenTDFConfiguration, oauthToken: String, + urlSession: URLSession = .shared, + signingKey: P256.Signing.PrivateKey? = nil) throws { + endpoints = try KasEndpoints.from(configuration) + kasIdentityURL = configuration.kas?.uri ?? "" + self.oauthToken = oauthToken + self.urlSession = urlSession + self.signingKey = signingKey ?? P256.Signing.PrivateKey() + } + + /// Legacy initializer: treats `kasURL` as a `{base}/kas` REST endpoint and + /// builds `{kasURL}/v2/*` endpoints, preserving prior behavior. + /// Deprecated transitional bridge — prefer `init(configuration:)`. + public init(kasURL: URL, oauthToken: String, urlSession: URLSession = .shared, + signingKey: P256.Signing.PrivateKey? = nil) { + let base = kasURL.absoluteString + endpoints = KasEndpoints( + rewrapURL: kasURL.appendingPathComponent("v2/rewrap").absoluteString, + publicKeyURL: kasURL.appendingPathComponent("v2/kas_public_key").absoluteString, + transport: .legacyRest) + kasIdentityURL = base + self.oauthToken = oauthToken + self.urlSession = urlSession + self.signingKey = signingKey ?? P256.Signing.PrivateKey() + } +``` + +- [ ] **Step 3: Add the NanoTDF KAS-identity resolver + shared HTTP error mapper (private methods, near the bottom of the class)** + +```swift + /// Resolve the request-body KAS url for a NanoTDF rewrap from the parsed + /// header's resource locator, falling back to the configured identity. + private func resolveNanoKasURL(_ parsedHeader: Header) -> String { + let locator = parsedHeader.kas + let scheme: String? = switch locator.protocolEnum { + case .http: "http" + case .https: "https" + default: nil + } + if let scheme, !locator.body.isEmpty { + return "\(scheme)://\(locator.body)" + } + return kasIdentityURL + } + + /// Map a non-2xx rewrap response to a KASRewrapError, enriching the message + /// from a Connect error envelope when present. + private func mapRewrapHTTPError(status: Int, data: Data) -> KASRewrapError { + let body = String(data: data, encoding: .utf8) ?? "" + let detail = parseConnectError(body).map { "\($0.code): \($0.message)" } + ?? (body.isEmpty ? "HTTP \(status)" : body) + switch status { + case 401: return .authenticationFailed(detail) + case 403: return .accessDenied(detail) + default: return .httpError(status, detail) + } + } +``` + +- [ ] **Step 4: Rewire `rewrapNanoTDF` request build + error mapping** + +In `rewrapNanoTDF`, change the KeyAccessObject url source: +```swift + let keyAccess = KeyAccessObject( + header: header.base64EncodedString(), + url: kasURL.absoluteString, + ) +``` +to: +```swift + let keyAccess = KeyAccessObject( + header: header.base64EncodedString(), + url: resolveNanoKasURL(parsedHeader), + ) +``` + +Change the endpoint + request execution: +```swift + let rewrapEndpoint = kasURL.appendingPathComponent("v2/rewrap") + var request = URLRequest(url: rewrapEndpoint) + request.httpMethod = "POST" + request.timeoutInterval = 30 // 30 second timeout + + let authHeader = "Bearer \(oauthToken)" + request.addValue(authHeader, forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(signedRequest) + + // Perform request + let (data, response) = try await urlSession.data(for: request) +``` +to: +```swift + guard let rewrapEndpoint = URL(string: endpoints.rewrapURL) else { + throw KASRewrapError.invalidTDFRequest("Invalid rewrap URL: \(endpoints.rewrapURL)") + } + var request = URLRequest(url: rewrapEndpoint) + request.httpMethod = "POST" + request.timeoutInterval = 30 + + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + request.httpBody = try JSONEncoder().encode(signedRequest) + + // Perform request (no redirects for the bearer-carrying call) + let (data, response) = try await urlSession.data(for: request, delegate: noRedirect) +``` + +Replace the error arms of the `rewrapNanoTDF` switch (everything from `case 400:` through the `default:` arm) with: +```swift + default: + throw mapRewrapHTTPError(status: httpResponse.statusCode, data: data) +``` +(Leave the `case 200:` arm unchanged.) + +- [ ] **Step 5: Rewire `rewrapTDF` the same way** + +In `rewrapTDF`, change `matchesKasURL` filtering anchor — replace: +```swift + let keyAccessEntries = manifest.encryptionInformation.keyAccess.filter { matchesKasURL($0.url) } +``` +(no change to that line; `matchesKasURL` is updated in Step 7). Change the endpoint/exec block (lines ~497–505): +```swift + let rewrapEndpoint = kasURL.appendingPathComponent("v2/rewrap") +``` +to: +```swift + guard let rewrapEndpoint = URL(string: endpoints.rewrapURL) else { + throw KASRewrapError.invalidTDFRequest("Invalid rewrap URL: \(endpoints.rewrapURL)") + } +``` +Add the Connect header after the `Content-Type` header is set (mirror Step 4) and switch the request call to `urlSession.data(for: request, delegate: noRedirect)`. Replace the `rewrapTDF` error switch arms (`case 400:` … `default:`) with: +```swift + default: + throw mapRewrapHTTPError(status: httpResponse.statusCode, data: data) +``` +(Leave the `case 200:` arm and the `invalidTDFRequest` guard above unchanged.) + +- [ ] **Step 6: Branch `fetchKasEcPublicKey` by transport** + +Replace the request-build block (lines ~579–596): +```swift + // Build the URL with algorithm query parameter + let keyEndpoint = kasURL.appendingPathComponent("v2/kas_public_key") + var components = URLComponents(url: keyEndpoint, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "algorithm", value: algorithm.rawValue)] + + guard let requestURL = components?.url else { + throw KASRewrapError.keyFetchFailed("Failed to construct KAS public key URL") + } + + // Create HTTP request + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Accept") + + // Perform request + let (data, response) = try await urlSession.data(for: request) +``` +with: +```swift + var request: URLRequest + switch endpoints.transport { + case .connect: + // ConnectRPC PublicKey RPC: POST the PublicKeyRequest message as JSON. + // `algorithm` is a request-message field (Go opentdf/platform proto). + guard let url = URL(string: endpoints.publicKeyURL) else { + throw KASRewrapError.keyFetchFailed("Invalid public key URL: \(endpoints.publicKeyURL)") + } + request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + request.httpBody = try JSONEncoder().encode(["algorithm": algorithm.rawValue]) + case .legacyRest: + guard var components = URLComponents(string: endpoints.publicKeyURL) else { + throw KASRewrapError.keyFetchFailed("Invalid public key URL: \(endpoints.publicKeyURL)") + } + components.queryItems = [URLQueryItem(name: "algorithm", value: algorithm.rawValue)] + guard let requestURL = components.url else { + throw KASRewrapError.keyFetchFailed("Failed to construct KAS public key URL") + } + request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Accept") + } + + let (data, response) = try await urlSession.data(for: request, delegate: noRedirect) +``` + +Also update the `case 401:` arm in `fetchKasEcPublicKey` (already done in Task 6) — leave as is. + +- [ ] **Step 7: Update `matchesKasURL` anchor** + +Change the method to compare against `kasIdentityURL` instead of `kasURL`: +```swift + private func matchesKasURL(_ otherURLString: String) -> Bool { + guard let otherURL = URL(string: otherURLString) else { return false } + guard let kasURL = URL(string: kasIdentityURL) else { return false } + guard let baseScheme = kasURL.scheme?.lowercased(), + let otherScheme = otherURL.scheme?.lowercased(), + let baseHost = kasURL.host?.lowercased(), + let otherHost = otherURL.host?.lowercased() + else { + return false + } + guard baseScheme == otherScheme, baseHost == otherHost else { + return false + } + return effectivePort(for: kasURL) == effectivePort(for: otherURL) + } +``` +(Update the `invalidTDFRequest("No key access entries for KAS \(kasURL.absoluteString)")` message at line ~440 to `\(kasIdentityURL)`.) + +- [ ] **Step 8: Build + test** + +Run: `swiftformat --swiftversion 6.2 . && swift build && swift test --filter KASRewrapClientTests` +Expected: PASS (existing crypto/JWT/PEM tests still green; `init(kasURL:)` bridge keeps call sites compiling). + +- [ ] **Step 9: Commit** + +```bash +git add OpenTDFKit/KASRewrapClient.swift +git commit -m "feat(kas): route client transport through resolved KasEndpoints" +``` + +--- + +## Task 8: Migrate CLI to well-known resolution + +**Files:** +- Modify: `OpenTDFKitCLI/Commands.swift` (call sites ~169, ~570, ~932; `fetchKASPublicKey` ~357) + +- [ ] **Step 1: Add a config-resolution helper** + +Add this static helper near `fetchKASPublicKey` in `Commands.swift` (inside the same enum/type that holds these statics): + +```swift + /// Resolve an OpenTDFConfiguration: try well-known discovery against the + /// platform, else synthesize Connect endpoints from the platform base. + static func resolveConfiguration(platformURL: URL, token: String) async -> OpenTDFConfiguration { + if let cfg = try? await fetchWellKnown(platformURL: platformURL.absoluteString) { + return cfg + } + return OpenTDFConfiguration.forKasConnect(platformURL.absoluteString) + } +``` + +- [ ] **Step 2: Migrate the TDF rewrap call site (~169)** + +Change: +```swift + let client = KASRewrapClient(kasURL: kasURL, oauthToken: oauthToken) +``` +to: +```swift + let configuration = try await resolveConfiguration(platformURL: kasURL, token: oauthToken) + let client = try KASRewrapClient(configuration: configuration, oauthToken: oauthToken) +``` +(Here `kasURL` is parsed from the manifest entry; the resolved identity comes from the manifest's keyAccess url, so passing it as the discovery base is acceptable — Connect endpoints are derived from it. If the manifest url already includes `/kas`, prefer the `PLATFORMURL` env when available: see Step 5.) + +- [ ] **Step 3: Migrate the two NanoTDF rewrap call sites (~570, ~932)** + +At both sites change: +```swift + let kasClient = KASRewrapClient(kasURL: kasURL, oauthToken: oauthToken) +``` +to: +```swift + let configuration = try await resolveConfiguration(platformURL: kasURL, token: oauthToken) + let kasClient = try KASRewrapClient(configuration: configuration, oauthToken: oauthToken) +``` + +- [ ] **Step 4: Migrate `fetchKASPublicKey` to the client** + +Replace the body of `fetchKASPublicKey(kasURL:token:)` with a client-based EC fetch so it honors the resolved transport: +```swift + static func fetchKASPublicKey(kasURL: URL, token: String) async throws -> Data { + let configuration = await resolveConfiguration(platformURL: kasURL, token: token) + let client = try KASRewrapClient(configuration: configuration, oauthToken: token) + let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) + return result.compressedKey + } +``` + +- [ ] **Step 5: Prefer PLATFORMURL as the discovery base where available** + +For the NanoTDF paths, the `kasURL` is the KAS resource locator (e.g. `http://localhost:8080/kas`), but well-known lives at the platform root. Where the calling function already has `PLATFORMURL`, pass that to `resolveConfiguration` instead of `kasURL`. Locate each migrated call site's enclosing function; if it reads `env["PLATFORMURL"]`, use: +```swift + let platformBase = ProcessInfo.processInfo.environment["PLATFORMURL"].flatMap { URL(string: $0) } ?? kasURL + let configuration = await resolveConfiguration(platformURL: platformBase, token: oauthToken) +``` +Apply this substitution at the two NanoTDF sites (Step 3). For the TDF site (Step 2), keep using the manifest-derived `kasURL` as the base unless `PLATFORMURL` is in scope. + +- [ ] **Step 6: Build the CLI** + +Run: `swiftformat --swiftversion 6.2 . && swift build --product OpenTDFKitCLI` +Expected: build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add OpenTDFKitCLI/Commands.swift +git commit -m "feat(cli): resolve KAS config via well-known (Connect, REST fallback)" +``` + +--- + +## Task 9: Migrate tests to the new init + +**Files:** +- Modify: `OpenTDFKitTests/KASRewrapClientTests.swift:12` +- Modify: `OpenTDFKitTests/IntegrationTests.swift` (sites ~77, ~136, ~363, ~456) + +- [ ] **Step 1: Migrate the unit-test client** + +In `KASRewrapClientTests.swift`, change `setUp`: +```swift + client = KASRewrapClient( + kasURL: testKASURL, + oauthToken: testOAuthToken, + ) +``` +to: +```swift + client = try! KASRewrapClient( + configuration: OpenTDFConfiguration.forKasLegacyRest(testKASURL.absoluteString), + oauthToken: testOAuthToken, + ) +``` + +- [ ] **Step 2: Migrate the integration-test clients** + +At each of the four sites, replace the `KASRewrapClient(kasURL: , oauthToken: )` (or multi-line form) with: +```swift + let configuration = OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString) + let kasRewrapClient = try KASRewrapClient(configuration: configuration, oauthToken: token) +``` +Use the existing local variable names at each site (`kasRewrapClient`/`kasClient`, and the token variable `token`/`invalidToken`). For the rewrapTDF site (~363) name it `kasClient` and use `token`. + +- [ ] **Step 3: Build + test** + +Run: `swiftformat --swiftversion 6.2 . && swift build && swift test --filter KASRewrapClientTests` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add OpenTDFKitTests/KASRewrapClientTests.swift OpenTDFKitTests/IntegrationTests.swift +git commit -m "test(kas): migrate clients to init(configuration:)" +``` + +--- + +## Task 10: Remove the legacy init (realize the breaking change) + +**Files:** +- Modify: `OpenTDFKit/KASRewrapClient.swift` + +- [ ] **Step 1: Confirm no remaining callers** + +Run: `grep -rn "KASRewrapClient(kasURL:" --include="*.swift" .` +Expected: no matches. + +- [ ] **Step 2: Delete the legacy initializer** + +Remove the entire `public init(kasURL: URL, oauthToken: String, urlSession: URLSession = .shared, signingKey: P256.Signing.PrivateKey? = nil)` block added in Task 7 Step 2. + +- [ ] **Step 3: Build + full test suite** + +Run: `swiftformat --swiftversion 6.2 . && swift build && swift test` +Expected: build succeeds; all non-network tests pass (integration tests skip without env). + +- [ ] **Step 4: Commit** + +```bash +git add OpenTDFKit/KASRewrapClient.swift +git commit -m "feat(kas)!: require OpenTDFConfiguration in KASRewrapClient init" +``` + +--- + +## Task 11: Connect transport unit tests (mock) + +**Files:** +- Create: `OpenTDFKitTests/KASConnectTransportTests.swift` + +- [ ] **Step 1: Write the failing tests** + +```swift +@testable import OpenTDFKit +import XCTest + +final class KASConnectTransportTests: XCTestCase { + override func tearDown() { + MockURLProtocol.handler = nil + super.tearDown() + } + + /// EC public-key fetch over Connect POSTs to /kas.AccessService/PublicKey + /// with an `algorithm` body field and parses the PEM + kid. + func testConnectECPublicKeyFetch() async throws { + let pem = """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE/shJbT/RbVUkgV+/5m+KPblr5ZXH\ + HU+2K5VytEsGQJJ0fxiksZXDC7twCPAXZgE3LOvORGqbQriKe/nM4iqIuA== + -----END PUBLIC KEY----- + """ + MockURLProtocol.handler = { req in + XCTAssertEqual(req.httpMethod, "POST") + XCTAssertEqual(req.url?.path, "/kas.AccessService/PublicKey") + XCTAssertEqual(req.value(forHTTPHeaderField: "Connect-Protocol-Version"), "1") + // URLProtocol exposes the body via the stream; assert presence of algorithm. + if let body = req.httpBody, let s = String(data: body, encoding: .utf8) { + XCTAssertTrue(s.contains("ec:secp256r1"), "body should carry algorithm: \(s)") + } + let resp = HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + let json = #"{"publicKey":"\#(pem.replacingOccurrences(of: "\n", with: "\\n"))","kid":"ec:secp256r1"}"# + return (resp, Data(json.utf8)) + } + let session = MockURLProtocol.makeSession() + let cfg = OpenTDFConfiguration.forKasConnect("https://platform.arkavo.net") + let client = try KASRewrapClient(configuration: cfg, oauthToken: "t", urlSession: session) + let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) + XCTAssertEqual(result.kid, "ec:secp256r1") + XCTAssertEqual(result.compressedKey.count, 33) + } + + /// A Connect 401 envelope surfaces as authenticationFailed with the code in + /// the reason string. + func testConnectRewrapErrorEnvelopeSurfacesReason() async throws { + MockURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/kas.AccessService/Rewrap") + let resp = HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, + headerFields: nil)! + return (resp, Data(#"{"code":"unauthenticated","message":"missing bearer"}"#.utf8)) + } + let session = MockURLProtocol.makeSession() + let cfg = OpenTDFConfiguration.forKasConnect("https://platform.arkavo.net") + let client = try KASRewrapClient(configuration: cfg, oauthToken: "t", urlSession: session) + + // Minimal NanoTDF header for the request body. + let kas = ResourceLocator(protocolEnum: .https, body: "platform.arkavo.net/kas")! + let header = makeMinimalHeader(kas: kas) + let kp = EphemeralKeyPair( + privateKey: P256.KeyAgreement.PrivateKey().rawRepresentation, + publicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, + curve: .secp256r1) + do { + _ = try await client.rewrapNanoTDF(header: Data([0, 0, 0]), parsedHeader: header, + clientKeyPair: kp) + XCTFail("expected throw") + } catch let KASRewrapError.authenticationFailed(reason) { + XCTAssertEqual(reason, "unauthenticated: missing bearer") + } + } +} +``` + +- [ ] **Step 2: Add the `makeMinimalHeader` helper** + +Inspect the `Header` initializer in `OpenTDFKit/NanoTDF.swift` (~520, `init(payloadKeyAccess:policyBindingConfig:payloadSignatureConfig:policy:ephemeralPublicKey:)`) and the convenience `init(kas:...)` (~531). Add to the test file a helper that builds a valid header. Use the convenience initializer: + +```swift + private func makeMinimalHeader(kas: ResourceLocator) -> Header { + let policy = Policy(type: .embeddedPlaintext, + body: EmbeddedPolicyBody(body: Data("{}".utf8)), + remote: nil, binding: nil) + return Header( + kas: kas, + policyBindingConfig: PolicyBindingConfig(ecdsaBinding: false, curve: .secp256r1), + payloadSignatureConfig: SignatureAndPayloadConfig( + signed: false, signatureCurve: nil, + payloadCipher: .aes256GCM128), + policy: policy, + ephemeralPublicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation) + } +``` + +If any of the referenced type/case names (`EmbeddedPolicyBody`, `PolicyBindingConfig`, `SignatureAndPayloadConfig`, `.aes256GCM128`) differ in the codebase, read `OpenTDFKit/NanoTDF.swift` and adjust the helper to the real signatures before running. (These types already exist — confirm exact member names with `grep -n "struct PolicyBindingConfig\|struct SignatureAndPayloadConfig\|struct EmbeddedPolicyBody\|enum Cipher" OpenTDFKit/NanoTDF.swift`.) + +- [ ] **Step 3: Run to verify failure, then pass** + +Run: `swift test --filter KASConnectTransportTests` +Expected: initially FAIL until the header helper compiles; then PASS (2 tests). + +Note: `URLProtocol` may not expose `httpBody` for streamed bodies; the body assertion is wrapped in `if let`. If the body is nil under the mock, the algorithm assertion is skipped and the test still validates path/headers/parsing. This is acceptable. + +- [ ] **Step 4: Commit** + +```bash +git add OpenTDFKitTests/KASConnectTransportTests.swift +git commit -m "test(kas): Connect public-key fetch + rewrap error envelope" +``` + +--- + +## Task 12: Live platform integration tests (skipped by default) + +**Files:** +- Create: `OpenTDFKitTests/PlatformConnectIntegrationTests.swift` + +- [ ] **Step 1: Write the gated live tests** + +```swift +@testable import OpenTDFKit +import XCTest + +/// Live tests against the real Arkavo platform. Opt in with +/// `KAS_INTEGRATION_TESTS=1`. They document the Connect transport milestone. +final class PlatformConnectIntegrationTests: XCTestCase { + private static let platform = "https://platform.arkavo.net" + + private func requireOptIn() throws { + guard ProcessInfo.processInfo.environment["KAS_INTEGRATION_TESTS"] == "1" else { + throw XCTSkip("Set KAS_INTEGRATION_TESTS=1 to run live platform tests") + } + } + + func testWellKnownReturnsKasConfig() async throws { + try requireOptIn() + let cfg = try await fetchWellKnown(platformURL: Self.platform) + let kas = try XCTUnwrap(cfg.kas) + XCTAssertNotNil(kas.connectRewrapURL, "platform should advertise connect_rewrap_url") + XCTAssertNotNil(kas.connectPublicKeyURL, "platform should advertise connect_public_key_url") + XCTAssertNotNil(kas.rewrapURL, "platform also exposes legacy REST rewrap_url") + } + + func testConnectPublicKeyReturnsPEM() async throws { + try requireOptIn() + let cfg = try await fetchWellKnown(platformURL: Self.platform) + let client = try KASRewrapClient(configuration: cfg, oauthToken: "") + let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) + XCTAssertTrue(result.pem.contains("BEGIN PUBLIC KEY")) + XCTAssertEqual(result.compressedKey.count, 33) + } + + func testConnectRewrapFakeBearerReturns401() async throws { + try requireOptIn() + let cfg = try await fetchWellKnown(platformURL: Self.platform) + let client = try KASRewrapClient(configuration: cfg, + oauthToken: "eyJhbGciOiJub25lIn0.e30.") + let kas = ResourceLocator(protocolEnum: .https, body: "platform.arkavo.net/kas")! + let header = makeMinimalHeader(kas: kas) + let kp = EphemeralKeyPair( + privateKey: P256.KeyAgreement.PrivateKey().rawRepresentation, + publicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, + curve: .secp256r1) + do { + _ = try await client.rewrapNanoTDF(header: Data([0, 0, 0]), parsedHeader: header, + clientKeyPair: kp) + XCTFail("rewrap should fail without valid auth") + } catch KASRewrapError.authenticationFailed { // expected + } catch let KASRewrapError.accessDenied(reason) { + print("AccessDenied: \(reason)") + } catch let KASRewrapError.httpError(status, message) { + XCTAssertNotEqual(status, 404, "404 means Connect rewrap path missing: \(message)") + XCTAssertTrue((400 ..< 600).contains(status), "expected 4xx/5xx, got \(status)") + } + } + + private func makeMinimalHeader(kas: ResourceLocator) -> Header { + let policy = Policy(type: .embeddedPlaintext, + body: EmbeddedPolicyBody(body: Data("{}".utf8)), + remote: nil, binding: nil) + return Header( + kas: kas, + policyBindingConfig: PolicyBindingConfig(ecdsaBinding: false, curve: .secp256r1), + payloadSignatureConfig: SignatureAndPayloadConfig( + signed: false, signatureCurve: nil, payloadCipher: .aes256GCM128), + policy: policy, + ephemeralPublicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation) + } +} +``` + +(Reuse the exact `makeMinimalHeader` signatures confirmed in Task 11 Step 2.) + +- [ ] **Step 2: Verify skip-by-default** + +Run: `swift test --filter PlatformConnectIntegrationTests` +Expected: 3 tests SKIPPED (no `KAS_INTEGRATION_TESTS`). + +- [ ] **Step 3 (optional): Verify live against the platform** + +Run: `KAS_INTEGRATION_TESTS=1 swift test --filter PlatformConnectIntegrationTests` +Expected: well-known + public-key PASS; rewrap PASS via `authenticationFailed`/`accessDenied`/`httpError(≠404)`. + +- [ ] **Step 4: Commit** + +```bash +git add OpenTDFKitTests/PlatformConnectIntegrationTests.swift +git commit -m "test(kas): live Connect platform integration tests (opt-in)" +``` + +--- + +## Task 13: Docs + final verification + +**Files:** +- Modify: `CLAUDE.md` (KASRewrapClient component description, KAS rewrap flow notes) + +- [ ] **Step 1: Update the KASRewrapClient description in `CLAUDE.md`** + +In the "KASRewrapClient" bullet, add a sentence: +``` +Now resolves transport endpoints from an `OpenTDFConfiguration` (well-known +discovery via `fetchWellKnown`, or `OpenTDFConfiguration.forKasConnect`), +preferring ConnectRPC `/kas.AccessService/*` and falling back to legacy REST +`/kas/v2/*`. Bearer tokens are opaque (JWT or base64url CWT). +``` + +- [ ] **Step 2: Run the whole suite + format check** + +Run: `swiftformat --swiftversion 6.2 . && swift build && swift test` +Expected: build succeeds; all non-network tests pass; integration/live tests skip. + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: note ConnectRPC + well-known KAS discovery in CLAUDE.md" +``` + +- [ ] **Step 4: Push + open PR (only when the user asks)** + +```bash +git push -u origin feat/connectrpc-kas-migration +gh pr create --fill --base main +``` diff --git a/docs/superpowers/specs/2026-05-30-connectrpc-kas-migration-design.md b/docs/superpowers/specs/2026-05-30-connectrpc-kas-migration-design.md new file mode 100644 index 0000000..ed65e52 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-connectrpc-kas-migration-design.md @@ -0,0 +1,214 @@ +# ConnectRPC + Well-Known Discovery for the KAS Client (CWT support) + +Date: 2026-05-30 +Status: Approved design — pending implementation plan +Ports: opentdf-rs PR #86 (`feat(kas)!: migrate to ConnectRPC with well-known discovery`) + +## Goal + +Migrate the OpenTDFKit native KAS client transport from REST `/kas/v2/*` to +ConnectRPC unary-JSON at `/kas.AccessService/*`, driven by +`/.well-known/opentdf-configuration` discovery. Keep legacy REST reachable as a +fallback. Add SSRF-validated endpoint resolution, no-redirect HTTP, Connect +error-envelope parsing, and opaque bearer-token passthrough (a JWT **or** a +base64url-encoded CWT — the platform decides how to validate it). + +This is a faithful port of the Rust change, adapted to Swift/URLSession idioms, +with two corrections validated against the Go `opentdf/platform` SDK (the +golden reference). + +## Scope + +Full parity with Rust #86. Breaking API change accepted (mirrors Rust's +breaking `KasClient::new`). CLI defaults to Connect with REST fallback. + +## Reference facts (verified) + +- Platform well-known doc advertises both Connect and REST URLs, plus + `idp.access_token_format: "application/cwt"`. (`kas_discovery.rs` test fixture, + captured from `platform.arkavo.net` 2026-05-28.) +- Go `service/kas/kas.proto`: + `PublicKeyRequest { string algorithm = 1; string fmt = 2; string v = 3; }`, + `PublicKeyResponse { string public_key = 1; string kid = 2; }`, + `rpc PublicKey(PublicKeyRequest) returns (PublicKeyResponse)`. Connect path is + `/kas.AccessService/PublicKey` (package `kas`, service `AccessService`). The + `algorithm` value is a **request-message field**, encoded as a JSON body field + over Connect (`protojson` accepts both snake_case and camelCase). +- Connect-Go accepts proto JSON field names in either case, so the existing + `signed_request_token` body key remains valid for Rewrap over Connect. + +## Architecture + +### New file: `OpenTDFKit/KASDiscovery.swift` + +Mirrors `kas_discovery.rs`. All types `Sendable`; config types `Codable` with +explicit snake_case `CodingKeys` to match the platform JSON. + +- `public struct OpenTDFConfiguration: Codable, Sendable` + - `kas: KasConfig?`, `idp: IdpConfig?`, `platformIssuer: String?` + - `static func forKasConnect(_ baseURL: String) -> OpenTDFConfiguration` + — trims trailing `/`; sets + `connectRewrapURL = {base}/kas.AccessService/Rewrap`, + `connectPublicKeyURL = {base}/kas.AccessService/PublicKey`, `uri = base`. + - `static func forKasLegacyRest(_ baseURL: String) -> OpenTDFConfiguration` + — sets `rewrapURL = {base}/kas/v2/rewrap`, + `publicKeyURL = {base}/kas/v2/kas_public_key`, `uri = base`. +- `public struct KasConfig: Codable, Sendable` + - `uri: String`, `algorithms: [String]` (default `[]`), + `publicKeyURL: String?`, `rewrapURL: String?`, + `connectPublicKeyURL: String?`, `connectRewrapURL: String?` + - CodingKeys: `uri`, `algorithms`, `public_key_url`, `rewrap_url`, + `connect_public_key_url`, `connect_rewrap_url`. +- `public struct IdpConfig: Codable, Sendable` + - `issuer`, `jwksURI?`, `coseKeysURI?`, `tokenEndpoint?`, + `authorizationEndpoint?`, `userinfoEndpoint?`, `accessTokenFormat?`, + `idTokenSigningAlgValuesSupported: [String]` (default `[]`), + `responseTypesSupported: [String]` (default `[]`), + `subjectTypesSupported: [String]` (default `[]`). +- `public enum KasTransport: Sendable { case connect, legacyRest }` +- `public struct KasEndpoints: Sendable` + - `rewrapURL: String`, `publicKeyURL: String`, `transport: KasTransport` + - `static func from(_ config: OpenTDFConfiguration) throws -> KasEndpoints` + — Connect-preferred (both `connect_*_url` present), else REST fallback (both + `*_url` present), else throw. Validates **both** resolved URLs via + `validateKasURL` before returning. +- `public func validateKasURL(_ urlString: String) throws` + - Scheme must be `http`/`https`. Plain `http` only for loopback + (`localhost`, `127.0.0.0/8`, `::1`). SSRF guard rejects IPv4 private + (`10/8`, `172.16/12`, `192.168/16`), link-local (`169.254/16`), + unspecified (`0.0.0.0`); IPv6 ULA `fc00::/7`, link-local `fe80::/10`, `::`; + and IPv4-mapped IPv6 literals folded back to IPv4 before the check. + - IP parsing/classification via `Darwin.inet_pton` into `in_addr`/`in6_addr` + plus bit checks (Foundation has no built-in IP range API). Helper + `isBlockedIP` mirrors Rust `is_blocked_ip`. +- `public struct ConnectError: Codable, Sendable { let code: String; let message: String }` +- `public func parseConnectError(_ body: String) -> ConnectError?` + — nil for empty/non-JSON/empty-code bodies. +- `public func fetchWellKnown(platformURL: String, urlSession: URLSession = .shared) async throws -> OpenTDFConfiguration` + — GET `{base}/.well-known/opentdf-configuration`; maps non-2xx to a KAS error. + +### `OpenTDFKit/KASRewrapClient.swift` changes (breaking) + +- **Init (breaking):** + `public init(configuration: OpenTDFConfiguration, oauthToken: String, urlSession: URLSession = .shared, signingKey: P256.Signing.PrivateKey? = nil) throws` + - Resolves `endpoints = try KasEndpoints.from(configuration)`. + - Stores `endpoints`, `kasIdentityURL = configuration.kas?.uri` (fallback for + request-body KAS url), `oauthToken`, `urlSession`, `signingKey`, and a + shared `NoRedirectDelegate`. + - The old `init(kasURL:oauthToken:...)` is **removed**. +- **No-redirect transport:** add + `final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate` whose + `urlSession(_:task:willPerformHTTPRedirection:newRequest:)` returns `nil`. + Bearer-carrying calls use `urlSession.data(for:delegate:)` with this delegate + (per-task delegate; macOS 14+). This is the Swift equivalent of + `redirect::Policy::none()` while keeping `urlSession` injectable for tests. +- **NanoTDF rewrap KAS identity url:** in `rewrapNanoTDF`, resolve the + request-body `KeyAccessObject.url` from `parsedHeader.kas` (the + `ResourceLocator`), reconstructed as `"\(scheme)://\(body)"` where scheme is + `http`/`https` from `protocolEnum`. Fall back to `kasIdentityURL` + (`config.kas.uri`) when the header locator body is empty/unusable. New private + helper `resolveNanoKasURL(_ parsedHeader: Header) -> String`. +- **Rewrap request:** POST the resolved `endpoints.rewrapURL` (full URL; same + for both transports). Headers: `Authorization: Bearer `, + `Content-Type: application/json`, `Connect-Protocol-Version: 1`. On non-2xx, + call `parseConnectError`; if present, surface `": "`, else the + raw body / `HTTP `. +- **Public-key fetch** (`fetchKasEcPublicKey`) branches on + `endpoints.transport`: + - `.connect`: POST `endpoints.publicKeyURL` with JSON body + `{"algorithm": ""}` and `Connect-Protocol-Version: 1`. + - `.legacyRest`: existing GET `endpoints.publicKeyURL?algorithm=`. + Response parsing (`KasEcPublicKeyResponse`, camel/snake) and PEM validation + are unchanged. +- **Error enrichment:** change `case authenticationFailed` to + `case authenticationFailed(String?)` so the Connect `unauthenticated` reason + surfaces (mirrors Rust `AuthenticationFailed { reason }`). Update the three + throw sites (`rewrapNanoTDF`, `rewrapTDF`, `fetchKasEcPublicKey`) and the + `description` to render the reason when present. +- `matchesKasURL` (used by `rewrapTDF` to filter manifest key-access entries) + now compares against `kasIdentityURL` (the resolved KAS identity), preserving + current behavior. + +### CLI changes (`OpenTDFKitCLI/`) — Connect with REST fallback + +- Add a helper to build an `OpenTDFConfiguration`: + 1. Try `fetchWellKnown(PLATFORMURL)`. + 2. On failure, synthesize `OpenTDFConfiguration.forKasConnect(PLATFORMURL)`. + 3. (Connect URLs always present in 1–2, so resolution lands on `.connect`; + `.legacyRest` only if a fetched well-known omits Connect URLs.) +- Replace the three `KASRewrapClient(kasURL:oauthToken:)` call sites in + `Commands.swift` with `try KASRewrapClient(configuration:oauthToken:)`. +- Standalone NanoTDF public-key fetch in `Commands.swift` + (`fetchKASPublicKey`, currently a GET) routes through the client / resolved + endpoints so it honors the Connect transport. +- TDF RSA public key continues to load from `TDF_KAS_PUBLIC_KEY_PATH` (file), + unchanged. + +## Data flow + +``` +PLATFORMURL ──fetchWellKnown──▶ OpenTDFConfiguration ──KasEndpoints.from──▶ endpoints + (fallback: forKasConnect) (validates URLs, picks transport) + │ +KASRewrapClient(configuration:oauthToken:) ──────────────────────┘ + rewrapNanoTDF: POST endpoints.rewrapURL (no redirect, Bearer, Connect-Protocol-Version) + body KAS url ← parsedHeader.kas (fallback config.kas.uri) + fetchKasEcPublicKey: .connect → POST publicKeyURL {"algorithm":...} + .legacyRest → GET publicKeyURL?algorithm=... + non-2xx → parseConnectError → ": " +``` + +## Error handling + +- `KasEndpoints.from` throws on missing `kas` block, missing URL pairs, or a + URL failing `validateKasURL` (`InvalidUrl`-style message). +- `validateKasURL` throws on bad scheme, non-loopback HTTP, or + private/link-local/unspecified IP targets (SSRF). +- HTTP non-2xx: 401 → `authenticationFailed(reason)`, 403 → + `accessDenied(reason)`, else `httpError(status, reason)`, where `reason` is the + Connect envelope `code: message` when parseable. +- `fetchWellKnown` non-2xx → `httpError`; parse failure → a decode error. + +## Testing + +Unit (no network, mirror `kas_discovery.rs` tests): +- `OpenTDFConfiguration` decodes the captured platform well-known fixture + (incl. `access_token_format == "application/cwt"`). +- `KasEndpoints.from`: picks Connect when present; REST fallback when Connect + absent; throws when `kas` block or URL pairs missing; rejects a hostile + Connect URL (`169.254.169.254`). +- `forKasConnect` / `forKasLegacyRest` build expected URLs; trailing slash + tolerated. +- `validateKasURL`: accepts https + loopback http; rejects non-loopback http, + bad scheme, IPv4 private/link-local/metadata, IPv6 ULA/link-local, + unspecified, and IPv4-mapped metadata literal. +- `parseConnectError`: valid body, garbage, empty, wrong-shape. + +Connect transport (mock `URLProtocol`): +- EC public-key Connect fetch POSTs to `/kas.AccessService/PublicKey` with body + containing `"algorithm":"ec:secp256r1"`; parses PEM + kid. +- Rewrap routes to `endpoints.rewrapURL`; a Connect error envelope on 401 is + surfaced as `authenticationFailed("unauthenticated: ...")`. + +Live integration (skipped by default unless `KAS_INTEGRATION_TESTS=1`, against +`https://platform.arkavo.net`), porting the three Rust tests: +- well-known returns both Connect and REST URLs. +- Connect PublicKey returns a PEM with non-empty kid. +- Connect Rewrap with a fake bearer returns 401 → + `authenticationFailed`/`accessDenied`/`httpError(≠404)`. + +Existing `KASRewrapClientTests` and `IntegrationTests` are updated to the new +`init(configuration:oauthToken:)` (build via `forKasConnect`/`forKasLegacyRest`). + +## Out of scope + +- IdP token acquisition / CWT minting (tokens remain opaque passthrough). +- TDF RSA public-key fetch via Connect (still file-based per env var). +- Removing the legacy REST path (kept as fallback). + +## Call sites to update (breaking init) + +- `OpenTDFKitCLI/Commands.swift`: lines ~169, ~570, ~932. +- `OpenTDFKitTests/IntegrationTests.swift`: lines ~77, ~136, ~363, ~456. +- `OpenTDFKitTests/KASRewrapClientTests.swift`: line ~12. +- Any `KASRewrapError.authenticationFailed` pattern matches in tests.