From f4565c1289b5df186107d48b2ad4d75308b3e936 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sat, 30 May 2026 11:50:20 -0400 Subject: [PATCH 01/24] docs: design for ConnectRPC + well-known KAS discovery (CWT) Port of opentdf-rs #86 to OpenTDFKit. ConnectRPC unary-JSON transport at /kas.AccessService/*, well-known discovery, SSRF-validated endpoints, no-redirect URLSession, Connect error-envelope parsing, opaque CWT/JWT bearer passthrough. EC public-key request shape validated against the Go opentdf/platform PublicKeyRequest proto. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-05-30-connectrpc-kas-migration-design.md | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-30-connectrpc-kas-migration-design.md 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. From 6b8ecdbf591c458a5eb62fdacec3890087dabdc3 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Sat, 30 May 2026 11:59:04 -0400 Subject: [PATCH 02/24] docs: implementation plan for ConnectRPC KAS migration Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-30-connectrpc-kas-migration.md | 1523 +++++++++++++++++ 1 file changed, 1523 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-connectrpc-kas-migration.md 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 +``` From 5862ab6d732160788bfba0d9deab063dd0c5f2c5 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:24:20 -0400 Subject: [PATCH 03/24] feat(kas): add OpenTDFConfiguration discovery types + builders Introduces OpenTDFConfiguration, KasConfig, and IdpConfig structs for decoding the /.well-known/opentdf-configuration document, plus static builders forKasConnect() and forKasLegacyRest() for synthesizing configurations without a well-known endpoint. Co-Authored-By: Claude Sonnet 4.6 --- OpenTDFKit/KASDiscovery.swift | 137 ++++++++++++++++++++++++ OpenTDFKitTests/KASDiscoveryTests.swift | 77 +++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 OpenTDFKit/KASDiscovery.swift create mode 100644 OpenTDFKitTests/KASDiscoveryTests.swift diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift new file mode 100644 index 0000000..0f76533 --- /dev/null +++ b/OpenTDFKit/KASDiscovery.swift @@ -0,0 +1,137 @@ +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) ?? [] + } +} diff --git a/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift new file mode 100644 index 0000000..a6050bb --- /dev/null +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -0,0 +1,77 @@ +@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") + } +} From 1fa25a34485a7ed8dd69999c9669980f4e43ae0c Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:32:52 -0400 Subject: [PATCH 04/24] feat(kas): add validateKasURL with HTTPS + SSRF guard Adds KASDiscoveryError enum and validateKasURL() function that enforces HTTPS (HTTP allowed only for loopback), rejects private/link-local/unspecified IPs (IPv4, IPv6 ULA/link-local, and IPv4-mapped IPv6 literals). Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASDiscovery.swift | 98 +++++++++++++++++++++++++ OpenTDFKitTests/KASDiscoveryTests.swift | 36 +++++++++ 2 files changed, 134 insertions(+) diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index 0f76533..82ddd35 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation // MARK: - Configuration documents (/.well-known/opentdf-configuration) @@ -135,3 +136,100 @@ public struct IdpConfig: Codable, Sendable { try c.decodeIfPresent([String].self, forKey: .subjectTypesSupported) ?? [] } } + +// 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. +/// +/// - `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 ?? "" + + 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") + } +} diff --git a/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift index a6050bb..2fd7da4 100644 --- a/OpenTDFKitTests/KASDiscoveryTests.swift +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -74,4 +74,40 @@ final class KASDiscoveryTests: XCTestCase { 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")) + } } From dbcc3689e27eda8cc0a45dc743ed3d866575bd33 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:37:51 -0400 Subject: [PATCH 05/24] refactor(kas): explicit Sendable on KASDiscoveryError + reject empty host Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASDiscovery.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index 82ddd35..c6031fa 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -139,7 +139,7 @@ public struct IdpConfig: Codable, Sendable { // MARK: - Errors -public enum KASDiscoveryError: Error, CustomStringConvertible { +public enum KASDiscoveryError: Error, CustomStringConvertible, Sendable { case invalidURL(String) case configError(String) case httpError(Int, String) @@ -217,6 +217,9 @@ public func validateKasURL(_ urlString: String) throws { 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": From b3c608bfd348f21fe9e60a822195e540459b9868 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:39:36 -0400 Subject: [PATCH 06/24] feat(kas): add KasEndpoints resolution (Connect-preferred) Introduce KasTransport enum and KasEndpoints struct with a from(_:) resolver that prefers ConnectRPC URLs, falls back to legacy REST, and validates both resolved URLs (HTTPS/scheme/SSRF) before returning. Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASDiscovery.swift | 43 +++++++++++++++++++++++++ OpenTDFKitTests/KASDiscoveryTests.swift | 41 +++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index c6031fa..e34fdbb 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -236,3 +236,46 @@ public func validateKasURL(_ urlString: String) throws { throw KASDiscoveryError.invalidURL("KAS URL must not target private or link-local IP addresses") } } + +// 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 + + 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") + } + + 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 + } +} diff --git a/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift index 2fd7da4..441f990 100644 --- a/OpenTDFKitTests/KASDiscoveryTests.swift +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -110,4 +110,45 @@ final class KASDiscoveryTests: XCTestCase { 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 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)) + } } From 387de8a9a31f5907d4637de6d6fcad28eaa680e1 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:49:58 -0400 Subject: [PATCH 07/24] feat(kas): add fetchWellKnown + MockURLProtocol; DRY trailing-slash trim Co-Authored-By: Claude Sonnet 4.6 --- OpenTDFKit/KASDiscovery.swift | 50 ++++++++++++++++++++++++- OpenTDFKitTests/KASDiscoveryTests.swift | 34 +++++++++++++++++ OpenTDFKitTests/MockURLProtocol.swift | 40 ++++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 OpenTDFKitTests/MockURLProtocol.swift diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index e34fdbb..f5027bd 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -1,6 +1,17 @@ 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. @@ -23,7 +34,7 @@ public struct OpenTDFConfiguration: Codable, Sendable { /// 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()) + let base = trimmingTrailingSlashes(baseURL) return OpenTDFConfiguration( kas: KasConfig( uri: base, @@ -40,7 +51,7 @@ public struct OpenTDFConfiguration: Codable, Sendable { /// 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()) + let base = trimmingTrailingSlashes(baseURL) return OpenTDFConfiguration( kas: KasConfig( uri: base, @@ -279,3 +290,38 @@ public struct KasEndpoints: Sendable { 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)") + } +} diff --git a/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift index 441f990..e0ccdf7 100644 --- a/OpenTDFKitTests/KASDiscoveryTests.swift +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -151,4 +151,38 @@ final class KASDiscoveryTests: XCTestCase { ) 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)") + } + } } 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) + } +} From 423bf9715c3032a5b2a6eb2d8ec4f22ec5604864 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:44:07 -0400 Subject: [PATCH 08/24] refactor(kas): KasEndpoints Equatable + type DocC comments Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASDiscovery.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index f5027bd..9969d82 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -250,6 +250,7 @@ public func validateKasURL(_ urlString: String) throws { // MARK: - Endpoint resolution +/// The wire protocol used to communicate with a KAS. public enum KasTransport: Sendable, Equatable { /// ConnectRPC endpoints at /kas.AccessService/* case connect @@ -257,7 +258,8 @@ public enum KasTransport: Sendable, Equatable { case legacyRest } -public struct KasEndpoints: Sendable { +/// 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 From a73405d099ac546a2afd6facec80158d2cb07b25 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:45:07 -0400 Subject: [PATCH 09/24] feat(kas): add Connect error-envelope parsing Co-Authored-By: Claude Sonnet 4.6 --- OpenTDFKit/KASDiscovery.swift | 16 ++++++++++++++++ OpenTDFKitTests/KASDiscoveryTests.swift | 12 ++++++++++++ 2 files changed, 28 insertions(+) diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index 9969d82..1297a69 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -327,3 +327,19 @@ public func fetchWellKnown(platformURL: String, 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/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift index e0ccdf7..19cb84a 100644 --- a/OpenTDFKitTests/KASDiscoveryTests.swift +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -185,4 +185,16 @@ final class KASDiscoveryTests: XCTestCase { 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"}"#)) + } } From f256f7abb8c6bec3899c0dac574f811cb64cc0af Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 19:57:40 -0400 Subject: [PATCH 10/24] refactor(kas): carry a reason string on authenticationFailed --- OpenTDFKit/KASRewrapClient.swift | 17 ++++++----- OpenTDFKitTests/IntegrationTests.swift | 34 +++++++++++----------- OpenTDFKitTests/KASRewrapClientTests.swift | 28 +++++++++--------- 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 452d312..1f80105 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -399,7 +399,8 @@ public class KASRewrapClient: KASRewrapClientProtocol { let errorMessage = String(data: data, encoding: .utf8) ?? "Bad request" throw KASRewrapError.httpError(400, errorMessage) case 401: - throw KASRewrapError.authenticationFailed + let message = String(data: data, encoding: .utf8) + throw KASRewrapError.authenticationFailed(message) case 403: let errorMessage = String(data: data, encoding: .utf8) throw KASRewrapError.accessDenied(errorMessage ?? "Forbidden") @@ -542,7 +543,8 @@ public class KASRewrapClient: KASRewrapClientProtocol { let message = String(data: data, encoding: .utf8) throw KASRewrapError.httpError(400, message) 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") @@ -619,7 +621,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") @@ -920,7 +923,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 +943,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 +971,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/OpenTDFKitTests/IntegrationTests.swift b/OpenTDFKitTests/IntegrationTests.swift index 89f32e9..8162b22 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( @@ -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( @@ -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) @@ -409,10 +409,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 +421,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() @@ -499,9 +499,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 +509,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 +580,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/KASRewrapClientTests.swift b/OpenTDFKitTests/KASRewrapClientTests.swift index 989bcef..7047c38 100644 --- a/OpenTDFKitTests/KASRewrapClientTests.swift +++ b/OpenTDFKitTests/KASRewrapClientTests.swift @@ -29,13 +29,13 @@ final class KASRewrapClientTests: XCTestCase { let components = jwt.split(separator: ".") XCTAssertEqual(components.count, 3, "JWT should have 3 parts: header.payload.signature") - let headerData = Data(base64URLDecoded: String(components[0]))! - let header = try JSONSerialization.jsonObject(with: headerData) as! [String: String] + let headerData = try XCTUnwrap(Data(base64URLDecoded: String(components[0]))) + let header = try XCTUnwrap(try JSONSerialization.jsonObject(with: headerData) as? [String: String]) XCTAssertEqual(header["alg"], "ES256") XCTAssertEqual(header["typ"], "JWT") - let payloadData = Data(base64URLDecoded: String(components[1]))! - let payload = try JSONSerialization.jsonObject(with: payloadData) as! [String: Any] + let payloadData = try XCTUnwrap(Data(base64URLDecoded: String(components[1]))) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]) XCTAssertNotNil(payload["requestBody"]) XCTAssertNotNil(payload["iat"]) XCTAssertNotNil(payload["exp"]) @@ -49,7 +49,7 @@ final class KASRewrapClientTests: XCTestCase { let components = jwt.split(separator: ".") let signingInput = "\(components[0]).\(components[1])".data(using: .utf8)! - let signatureData = Data(base64URLDecoded: String(components[2]))! + let signatureData = try XCTUnwrap(Data(base64URLDecoded: String(components[2]))) let signature = try P256.Signing.ECDSASignature(rawRepresentation: signatureData) let publicKey = signingKey.publicKey @@ -64,11 +64,11 @@ final class KASRewrapClientTests: XCTestCase { let jwt = try client.createSignedJWT(requestBody: requestBody, signingKey: signingKey) let components = jwt.split(separator: ".") - let payloadData = Data(base64URLDecoded: String(components[1]))! - let payload = try JSONSerialization.jsonObject(with: payloadData) as! [String: Any] + let payloadData = try XCTUnwrap(Data(base64URLDecoded: String(components[1]))) + let payload = try XCTUnwrap(try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]) - let iat = payload["iat"] as! Int - let exp = payload["exp"] as! Int + let iat = try XCTUnwrap(payload["iat"] as? Int) + let exp = try XCTUnwrap(payload["exp"] as? Int) XCTAssertEqual(exp - iat, 60, "Token should expire 60 seconds after issuance") } @@ -257,7 +257,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 +284,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 +292,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 +329,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 +355,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) From 3e4b8c220ae12bc95dd660dcd300379befb4d5ad Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:03:20 -0400 Subject: [PATCH 11/24] feat(kas): route client transport through resolved KasEndpoints Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASRewrapClient.swift | 199 +++++++++++++++++++------------ 1 file changed, 121 insertions(+), 78 deletions(-) diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 1f80105..230903f 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,46 @@ 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() + } + + /// Legacy initializer: treats `kasURL` as a `{base}/kas` REST endpoint and + /// builds `{kasURL}/v2/*` endpoints, preserving prior behavior. + /// Transitional bridge — prefer `init(configuration:)`. (Removed in a later task.) + public init(kasURL: URL, oauthToken: String, urlSession: URLSession = .shared, + signingKey: P256.Signing.PrivateKey? = nil) + { + endpoints = KasEndpoints( + rewrapURL: kasURL.appendingPathComponent("v2/rewrap").absoluteString, + publicKeyURL: kasURL.appendingPathComponent("v2/kas_public_key").absoluteString, + transport: .legacyRest, + ) + kasIdentityURL = kasURL.absoluteString self.oauthToken = oauthToken self.urlSession = urlSession self.signingKey = signingKey ?? P256.Signing.PrivateKey() @@ -304,7 +341,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 +383,20 @@ 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") + 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,28 +434,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: - let message = String(data: data, encoding: .utf8) - throw KASRewrapError.authenticationFailed(message) - 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) } } @@ -438,7 +457,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] = [] @@ -495,15 +514,18 @@ 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") + 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 @@ -539,28 +561,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: - 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") - 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) } } @@ -578,24 +580,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 @@ -735,8 +751,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(), @@ -744,11 +789,9 @@ public class KASRewrapClient: KASRewrapClientProtocol { else { return false } - guard baseScheme == otherScheme, baseHost == otherHost else { return false } - return effectivePort(for: kasURL) == effectivePort(for: otherURL) } From 52d333c761a8842289069387300a996e96bea78b Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:09:32 -0400 Subject: [PATCH 12/24] fix(kas): send Connect-Protocol-Version only on connect transport Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASRewrapClient.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 230903f..67e7689 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -392,7 +392,9 @@ public class KASRewrapClient: KASRewrapClientProtocol { request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + if endpoints.transport == .connect { + request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + } request.httpBody = try JSONEncoder().encode(signedRequest) // Perform request (no redirects for the bearer-carrying call) @@ -522,7 +524,9 @@ public class KASRewrapClient: KASRewrapClientProtocol { request.timeoutInterval = 30 request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + 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, delegate: noRedirect) From 6258d1c66c3ef44fea23bd213e9477ebe3bac597 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:12:53 -0400 Subject: [PATCH 13/24] feat(cli): resolve KAS config via well-known (Connect, REST fallback) Migrate CLI KASRewrapClient instantiation from legacy kasURL-based init to configuration-based init, resolving platform config via well-known discovery at the platform root with ConnectRPC fallback. Co-Authored-By: Claude Sonnet 4.6 --- OpenTDFKitCLI/Commands.swift | 64 +++++++++++++++++------------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/OpenTDFKitCLI/Commands.swift b/OpenTDFKitCLI/Commands.swift index 0911cb7..28c3dc7 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,39 +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 OpenTDFConfiguration.forKasConnect(platformBase) + } - return keyData + /// 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 } /// Extract compressed P256 public key from PEM @@ -567,7 +563,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 +926,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, From d163fc04636cbc468f93bfff1ff8acff67263e84 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:15:47 -0400 Subject: [PATCH 14/24] refactor(cli): remove dead extractCompressedKeyFromPEM Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKitCLI/Commands.swift | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/OpenTDFKitCLI/Commands.swift b/OpenTDFKitCLI/Commands.swift index 28c3dc7..096cbac 100644 --- a/OpenTDFKitCLI/Commands.swift +++ b/OpenTDFKitCLI/Commands.swift @@ -385,27 +385,6 @@ enum Commands { return result.compressedKey } - /// 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 - } - } - /// Verify and parse a NanoTDF file using OpenTDFKit's parser static func verifyNanoTDF(data: Data, filename: String) throws { print("NanoTDF Verification Report") From 8807b9d380da3e7e835d5cf60b0b74c972379e1f Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:17:22 -0400 Subject: [PATCH 15/24] test(kas): migrate clients to init(configuration:) Co-Authored-By: Claude Sonnet 4.6 --- OpenTDFKitTests/IntegrationTests.swift | 17 ++++++++++------- OpenTDFKitTests/KASRewrapClientTests.swift | 4 ++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/OpenTDFKitTests/IntegrationTests.swift b/OpenTDFKitTests/IntegrationTests.swift index 8162b22..a84634b 100644 --- a/OpenTDFKitTests/IntegrationTests.swift +++ b/OpenTDFKitTests/IntegrationTests.swift @@ -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, ) @@ -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, ) @@ -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, @@ -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, ) diff --git a/OpenTDFKitTests/KASRewrapClientTests.swift b/OpenTDFKitTests/KASRewrapClientTests.swift index 7047c38..92b0660 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, ) } From 1e3eb828e3226c94347f825aef7f043cb792c1f7 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:20:47 -0400 Subject: [PATCH 16/24] feat(kas)!: require OpenTDFConfiguration in KASRewrapClient init Removes the transitional init(kasURL:) bridge. KASRewrapClient now requires a resolved OpenTDFConfiguration (well-known discovery or forKasConnect). Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASRewrapClient.swift | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 67e7689..f40a6a8 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -312,23 +312,6 @@ public class KASRewrapClient: KASRewrapClientProtocol { self.signingKey = signingKey ?? P256.Signing.PrivateKey() } - /// Legacy initializer: treats `kasURL` as a `{base}/kas` REST endpoint and - /// builds `{kasURL}/v2/*` endpoints, preserving prior behavior. - /// Transitional bridge — prefer `init(configuration:)`. (Removed in a later task.) - public init(kasURL: URL, oauthToken: String, urlSession: URLSession = .shared, - signingKey: P256.Signing.PrivateKey? = nil) - { - endpoints = KasEndpoints( - rewrapURL: kasURL.appendingPathComponent("v2/rewrap").absoluteString, - publicKeyURL: kasURL.appendingPathComponent("v2/kas_public_key").absoluteString, - transport: .legacyRest, - ) - kasIdentityURL = kasURL.absoluteString - self.oauthToken = oauthToken - self.urlSession = urlSession - self.signingKey = signingKey ?? P256.Signing.PrivateKey() - } - // MARK: - Public Methods /// Perform NanoTDF rewrap request to KAS From 916a8825eaab636093d626b799b271e5adbb5ed5 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:24:04 -0400 Subject: [PATCH 17/24] test(kas): Connect public-key fetch + rewrap error envelope Add KASConnectTransportTests using MockURLProtocol to verify the Connect transport path: EC public-key POST request headers/parsing and 401 error envelope surfacing as authenticationFailed with code+message reason string. Co-Authored-By: Claude Sonnet 4.6 --- .../KASConnectTransportTests.swift | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 OpenTDFKitTests/KASConnectTransportTests.swift 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, + ) + } +} From 328f8a282eab28f9310f3825306889dcdd0b4ee6 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:27:46 -0400 Subject: [PATCH 18/24] test(kas): live Connect platform integration tests (opt-in) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PlatformConnectIntegrationTests.swift | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 OpenTDFKitTests/PlatformConnectIntegrationTests.swift diff --git a/OpenTDFKitTests/PlatformConnectIntegrationTests.swift b/OpenTDFKitTests/PlatformConnectIntegrationTests.swift new file mode 100644 index 0000000..5ff1a6f --- /dev/null +++ b/OpenTDFKitTests/PlatformConnectIntegrationTests.swift @@ -0,0 +1,74 @@ +@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) + let client = try KASRewrapClient(configuration: cfg, + oauthToken: "eyJhbGciOiJub25lIn0.e30.") + 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, + ) + } +} From 453c60d326ac0fc8836968b995c138e4a30f1925 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:31:04 -0400 Subject: [PATCH 19/24] docs: note ConnectRPC + well-known KAS discovery in CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From a510d882ee102902ffffd0c38311e5c6b2447485 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 20:45:09 -0400 Subject: [PATCH 20/24] test(kas): fix stale unwrapKey HKDF salt round-trip test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testKeyUnwrappingWithValidKeys sealed the wrapped key with an empty HKDF salt (the Standard TDF convention) but called unwrapKey() with no salt arg, which since 0ee0797 defaults to CryptoConstants.hkdfSalt (the NanoTDF v12 session-key salt). The salt mismatch produced different AES-GCM keys, so tag verification failed with authenticationFailure — a stale test, not a product bug (unwrapKey's default matches the real KAS rewrap_dek derivation). Replace the single ambiguous test with a DRY helper and two explicit, self-consistent round-trips: - testKeyUnwrappingNanoTDFDefaultSalt: seal+unwrap with the v12 default salt - testKeyUnwrappingStandardTDFEmptySalt: seal+unwrap with empty salt Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKitTests/KASRewrapClientTests.swift | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/OpenTDFKitTests/KASRewrapClientTests.swift b/OpenTDFKitTests/KASRewrapClientTests.swift index 92b0660..c94bbfd 100644 --- a/OpenTDFKitTests/KASRewrapClientTests.swift +++ b/OpenTDFKitTests/KASRewrapClientTests.swift @@ -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() { From 3d139e1c0cb92a088144167ae3467889fb72461d Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 21:05:14 -0400 Subject: [PATCH 21/24] test(kas): use plain placeholder bearer in live test (avoid secret scan FP) Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKitTests/PlatformConnectIntegrationTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenTDFKitTests/PlatformConnectIntegrationTests.swift b/OpenTDFKitTests/PlatformConnectIntegrationTests.swift index 5ff1a6f..f20accd 100644 --- a/OpenTDFKitTests/PlatformConnectIntegrationTests.swift +++ b/OpenTDFKitTests/PlatformConnectIntegrationTests.swift @@ -34,8 +34,10 @@ final class PlatformConnectIntegrationTests: XCTestCase { 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: "eyJhbGciOiJub25lIn0.e30.") + oauthToken: "invalid-bearer-token") let kas = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "platform.arkavo.net/kas")) let header = makeMinimalHeader(kas: kas) let kp = EphemeralKeyPair( From 0cb6f053fc8ec6f28bffc0c9a98b0b2a21a2a869 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 21:08:46 -0400 Subject: [PATCH 22/24] ci: pin swiftformat via .swiftformat config and lint the tree Adds a .swiftformat config (--swiftversion 6.2 matching Package.swift, --disable noForceUnwrapInTests since its autocorrect breaks non-throwing benchmark functions, --exclude .build) so local 'swiftformat .' and the CI lint step agree. CI now runs 'swiftformat --lint .' driven by the config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/swift.yaml | 3 ++- .swiftformat | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .swiftformat 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 From 213065b0ada36e3264c750467931c2be4d01adc0 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 21:08:47 -0400 Subject: [PATCH 23/24] style: apply swiftformat across the repository Mechanical reformat to satisfy the (previously red) lint check. No behavior change: build and the full test suite are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/BinaryParser.swift | 5 ++--- OpenTDFKit/CryptoHelper.swift | 4 ++-- OpenTDFKit/KASService.swift | 2 +- OpenTDFKit/KeyStore.swift | 20 +++++++++---------- OpenTDFKit/NanoTDF.swift | 16 +++++++-------- OpenTDFKit/PublicKeyStore.swift | 2 +- OpenTDFKit/TDF/TDFCBORContainer.swift | 4 +++- OpenTDFKit/TDF/TDFCBORFormat.swift | 17 +++++++++++----- OpenTDFKit/TDF/TDFJSONContainer.swift | 4 +++- OpenTDFKit/TDF/TDFJSONFormat.swift | 4 +++- OpenTDFKit/TDF/TrustedDataFormat.swift | 4 +++- OpenTDFKitTests/CryptoHelperTests.swift | 5 ++--- OpenTDFKitTests/EncryptedPolicyTests.swift | 2 +- OpenTDFKitTests/GCMEncryptionTests.swift | 20 +++++++++---------- OpenTDFKitTests/InitializationTests.swift | 8 ++++---- OpenTDFKitTests/KeyStoreTests.swift | 8 ++++---- OpenTDFKitTests/NanoTDFBenchmarkTests.swift | 2 +- OpenTDFKitTests/NanoTDFTests.swift | 14 ++++++------- OpenTDFKitTests/OneTimeTDFTests.swift | 3 +-- OpenTDFKitTests/StreamingBenchmarkTests.swift | 4 ++-- OpenTDFKitTests/TDFTests.swift | 4 ++-- 21 files changed, 81 insertions(+), 71 deletions(-) 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/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..e1db120 100644 --- a/OpenTDFKit/NanoTDF.swift +++ b/OpenTDFKit/NanoTDF.swift @@ -52,11 +52,11 @@ 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). + // 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. @@ -74,7 +74,7 @@ public struct NanoTDF: Sendable { key: symmetricKey, iv: paddedIV, ciphertext: payload.ciphertext, - tag: payload.mac + tag: payload.mac, ) } } @@ -684,7 +684,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 +941,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/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/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/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/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") From e34ebca1dc10c0b0166d8bff21c4a0c30b48604e Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 1 Jun 2026 21:25:38 -0400 Subject: [PATCH 24/24] review: address Gitar feedback on #39 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NanoTDF.getPayloadPlaintext: move the orphaned doc comment back onto the method (it had been separated from its declaration by sharedCryptoHelper, which is why swiftformat downgraded it to // — reorder keeps /// and lint green) - isLoopbackHost: normalize a trailing FQDN dot so 'localhost.' is treated as loopback (parity with the Rust loopback detection) - KasEndpoints.from: reject an empty kas.uri up front (an empty identity would make matchesKasURL match nothing and fail every rewrapTDF), with a test Co-Authored-By: Claude Opus 4.8 (1M context) --- OpenTDFKit/KASDiscovery.swift | 8 ++++++++ OpenTDFKit/NanoTDF.swift | 11 +++++------ OpenTDFKitTests/KASDiscoveryTests.swift | 11 +++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/OpenTDFKit/KASDiscovery.swift b/OpenTDFKit/KASDiscovery.swift index 1297a69..d6c030f 100644 --- a/OpenTDFKit/KASDiscovery.swift +++ b/OpenTDFKit/KASDiscovery.swift @@ -186,6 +186,8 @@ private func classifyIP(_ host: String) -> IPLiteral? { } 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 @@ -277,6 +279,12 @@ public struct KasEndpoints: Sendable, Equatable { 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 { diff --git a/OpenTDFKit/NanoTDF.swift b/OpenTDFKit/NanoTDF.swift index e1db120..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) diff --git a/OpenTDFKitTests/KASDiscoveryTests.swift b/OpenTDFKitTests/KASDiscoveryTests.swift index 19cb84a..947cf22 100644 --- a/OpenTDFKitTests/KASDiscoveryTests.swift +++ b/OpenTDFKitTests/KASDiscoveryTests.swift @@ -132,6 +132,17 @@ final class KASDiscoveryTests: XCTestCase { 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,