Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,65 @@ jobs:
xcodebuild test -scheme CZitiTests -derivedDataPath ./DerivedData/CZiti \
-sdk macosx -destination 'platform=macOS' ONLY_ACTIVE_ARCH=YES | xcpretty

# Builds the integration test tool to catch compile regressions. End-to-end
# execution against `ziti edge quickstart` is deferred until the macOS data
# protection keychain / entitlement issue is resolved (see ziti-test-runner/main.swift).
- name: Build ziti-test-runner
# Build ziti-test-runner with the insecure-keys test flag so enrollment can generate
# ephemeral keys and store them in the .zid file instead of the macOS data protection
# keychain (which requires provisioning-profile-backed entitlements CI doesn't have).
# NEVER use this flag in a release build.
- name: Build ziti-test-runner (insecure test keys)
run: |
set -o pipefail
xcodebuild build -configuration Debug -scheme ziti-test-runner \
-derivedDataPath ./DerivedData/CZiti -sdk macosx \
-destination 'platform=macOS' ONLY_ACTIVE_ARCH=YES \
SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) CZITI_TEST_INSECURE_KEYS' \
CODE_SIGNING_ALLOWED=NO | xcpretty

- name: Install ziti CLI
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh release download --repo openziti/ziti \
--pattern '*darwin-arm64*.tar.gz' \
--output /tmp/ziti.tgz
sudo tar -xzf /tmp/ziti.tgz -C /usr/local/bin ziti
ziti version

- name: Integration test (OTT enrollment against quickstart)
run: |
set -euo pipefail
rm -rf /tmp/qs && mkdir /tmp/qs
nohup ziti edge quickstart --home /tmp/qs > /tmp/qs.log 2>&1 &
QS_PID=$!
trap "kill $QS_PID 2>/dev/null || true; cat /tmp/qs.log | tail -60" EXIT

# Wait for controller
for i in $(seq 1 60); do
if curl -sk https://localhost:1280/edge/client/v1/version >/dev/null 2>&1; then
echo "controller ready after ${i}s"; break
fi
sleep 1
done

# Login, create an OTT identity, and preseed a service + dial policy so the
# test verifies service discovery after auth (not just auth).
ziti edge login localhost:1280 -u admin -p admin -y
ziti edge create identity ztr-integ -a ztr-integ -o /tmp/ztr.jwt
ziti edge create service ztr-svc -a ztr-svc
ziti edge create service-policy ztr-svc-dial Dial \
--identity-roles '#ztr-integ' --service-roles '#ztr-svc'

TOOL=./DerivedData/CZiti/Build/Products/Debug/ziti-test-runner

# Pass 1: enroll and verify context auth, keeping the .zid for pass 2.
echo "=== Pass 1: enroll + run ==="
"$TOOL" --timeout 60 --keep-zid /tmp/ztr.zid /tmp/ztr.jwt

# Pass 2: fresh process, load the .zid and re-auth. Verifies that the persisted
# key+cert are sufficient to start Ziti without any in-memory state carryover.
echo "=== Pass 2: only-run from saved zid ==="
"$TOOL" --timeout 60 --only-run /tmp/ztr.zid

- name: Create Frameworks
run: |
./make_dist.sh
Expand Down
30 changes: 30 additions & 0 deletions CZitiTests/ZitiIdentityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,34 @@ class ZitiIdentityTests: XCTestCase {
let decoded = try JSONDecoder().decode(ZitiIdentity.self, from: data)
XCTAssertEqual(decoded.startDisabled, true)
}

#if CZITI_TEST_INSECURE_KEYS
func testKeyDefaultsToNil() throws {
let id = ZitiIdentity(id: "x", ztAPIs: ["https://ctrl:1280"])
XCTAssertNil(id.key)

let data = try JSONEncoder().encode(id)
let decoded = try JSONDecoder().decode(ZitiIdentity.self, from: data)
XCTAssertNil(decoded.key)
}

func testKeyRoundTrip() throws {
let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJB...\n-----END RSA PRIVATE KEY-----\n"
let id = ZitiIdentity(id: "x", ztAPIs: ["https://ctrl:1280"])
id.key = pem

let data = try JSONEncoder().encode(id)
let decoded = try JSONDecoder().decode(ZitiIdentity.self, from: data)
XCTAssertEqual(decoded.key, pem)
}
#endif

func testDecodeOldZidWithoutKeyField() throws {
// Existing .zid files in the wild never have a key field. Must still decode.
let json = """
{"id":"legacy","ztAPI":"https://ctrl:1280","certs":"CERT","ca":"CA"}
"""
let decoded = try JSONDecoder().decode(ZitiIdentity.self, from: json.data(using: .utf8)!)
XCTAssertEqual(decoded.id, "legacy")
}
}
24 changes: 21 additions & 3 deletions lib/Ziti.swift
Original file line number Diff line number Diff line change
Expand Up @@ -368,20 +368,28 @@ import CZitiPrivate
}

let certs = dropFirst("pem:", respCert)
#if !CZITI_TEST_INSECURE_KEYS
// Release build: persist the signed cert in the keychain alongside the private key.
_ = zkc.deleteCertificate(silent: true)
guard zkc.storeCertificate(fromPem: certs) == nil else {
let errStr = "Unable to store certificate\n"
log.error(errStr, function:"enroll()")
enrollCallback(nil, ZitiError(errStr))
return
}
#endif

var ca = resp.id.ca
if let idCa = resp.id.ca {
ca = dropFirst("pem:", idCa)
}

let zid = ZitiIdentity(id: subj, ztAPIs: resp.ztAPIs, certs: certs, ca: ca)
#if CZITI_TEST_INSECURE_KEYS
// Insecure test build: stash the ephemeral PEM in the identity so run() can use
// it without a keychain lookup.
zid.key = pem
#endif
log.info("Enrolled id:\(subj) with controller: \(zid.ztAPI)", function:"enroll()")

enrollCallback(zid, nil)
Expand Down Expand Up @@ -955,10 +963,20 @@ import CZitiPrivate
log.debug("identity \(id.id) does not have certificates. this is ok if using external authentication")
}

// Get private key
// Get private key. Under CZITI_TEST_INSECURE_KEYS, prefer a PEM stored in the
// identity file so ad-hoc-signed CLI tools can use enrolled identities without
// touching the data protection keychain.
var privKeyPEMPtr: UnsafeMutablePointer<Int8>? = nil
if let privKey = zkc.getPrivateKey() {
let privKeyPEM = zkc.getKeyPEM(privKey)
let privKeyPEM: String? = {
#if CZITI_TEST_INSECURE_KEYS
if let idKey = id.key, !idKey.isEmpty { return idKey }
#endif
if let privKey = zkc.getPrivateKey() {
return zkc.getKeyPEM(privKey)
}
return nil
}()
if let privKeyPEM = privKeyPEM {
privKeyPEMPtr = UnsafeMutablePointer<Int8>.allocate(capacity: privKeyPEM.count + 1)
privKeyPEMPtr!.initialize(from: privKeyPEM, count: privKeyPEM.count + 1)
} else {
Expand Down
16 changes: 11 additions & 5 deletions lib/ZitiIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ import Foundation

/// Common names for the certificates in this identities certificate chain.
/// This is only set when the identity is decoded from an older representation. When `certs` is not set, this value can be used to
/// retrieve the identity's certificates from the Keychain.
@available(*, deprecated, message: "store certificates in `certs` instead")
/// retrieve the identity's certificates from the Keychain. Legacy-only; new identities store certs in `certs` instead.
@objc private var certCNs:[String]?

/// scheme, host, and port used to communicate with Ziti controller
Expand All @@ -54,18 +53,25 @@ import Foundation
/// Certificates (PEM)
@objc public var certs:String?

#if CZITI_TEST_INSECURE_KEYS
/// Private key (PEM). Only present in builds compiled with `CZITI_TEST_INSECURE_KEYS`.
/// The enrollment flow writes the ephemeral key here so `Ziti.run()` can use it
/// without touching the data protection keychain.
@objc public var key:String?
Comment thread
scareything marked this conversation as resolved.
#endif

/// CA pool verified as part of enrollment that can be used to establish trust with of the Ziti controller
@objc public var ca:String?

/// Request `Ziti` to start this identity in disabled state
public var startDisabled:Bool? = false

/// Initialize a `ZitiIdentity` given the provided identity infomation
///
/// - Parameters:
/// - id: unique identifier of this identity
/// - ztAPIs: URLs for accessing Ziti controller API
/// - certCNs: common names of certififcates
/// - certCNs: common names of certififcates
/// - name: name currently configured for this identity
/// - ca: CA pool that can be used to verify trust of the Ziti controller
@objc public init(id:String, ztAPIs:[String], name:String?=nil, certs:String?=nil, ca:String?=nil) {
Expand Down
30 changes: 28 additions & 2 deletions lib/ZitiKeychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ limitations under the License.

import Foundation

#if CZITI_TEST_INSECURE_KEYS
// Lazy-initialized so the warning prints at most once per process, the first time
// anything in this module actually generates an ephemeral key.
internal let _cztiInsecureBuildWarningPrinted: Void = {
let msg = "⚠️ CZITI_TEST_INSECURE_KEYS build: private keys are generated ephemerally and written into .zid files in plaintext. NEVER SHIP THIS BUILD.\n"
FileHandle.standardError.write(Data(msg.utf8))
}()
#endif

/// This class manages access to the Keychain, creating and storing keys and certificates needed to access a Ziti network.
///
/// This is primarily an internally used class, though certain methods are marked public in order to support senarios where the enrollment is
Expand All @@ -41,11 +50,27 @@ public class ZitiKeychain : NSObject {

private let keySize = 3072
func createPrivateKey() -> SecKey? {
#if CZITI_TEST_INSECURE_KEYS
// Insecure test build: generate an ephemeral key with no keychain interaction.
// Caller is expected to stash the extracted PEM in the ZitiIdentity for persistence.
_ = _cztiInsecureBuildWarningPrinted // fire once, first time anyone mints a key
let parameters: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits: keySize,
kSecAttrIsPermanent: false,
]
var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(parameters as CFDictionary, &error) else {
log.error("Unable to create ephemeral private key for \(tag): \(error!.takeRetainedValue() as Error)")
return nil
}
return privateKey
#else
let privateKeyParams: [CFString: Any] = [ // iOS
kSecAttrIsPermanent: true,
kSecAttrLabel: tag,
kSecAttrApplicationTag: atag]

var parameters: [CFString: Any] = [
kSecAttrKeyType: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits: keySize,
Expand All @@ -57,13 +82,14 @@ public class ZitiKeychain : NSObject {
if #available(iOS 13.0, OSX 10.15, *) {
parameters[kSecUseDataProtectionKeychain] = true
}

var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(parameters as CFDictionary, &error) else {
log.error("Unable to create private key for \(tag): \(error!.takeRetainedValue() as Error)")
return nil
}
return privateKey
#endif
}

func getPrivateKey() -> SecKey? {
Expand Down
109 changes: 109 additions & 0 deletions ziti-test-runner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# ziti-test-runner

End-to-end integration test tool for the CZiti Swift SDK. Drives real enrollment
and context bring-up against a live Ziti controller, returning a pass/fail exit
code suitable for CI.

Used by the CI workflow (`.github/workflows/CI.yml`) against a
`ziti edge quickstart` controller.

## What it does

**Default mode** - enroll, save, load, run, verify:

1. Enrolls a Ziti identity from a one-time JWT file (OTT by default)
2. Saves the resulting identity to a `.zid` file
3. Loads the identity back from the `.zid` file via `Ziti(fromFile:)`
4. Runs the Ziti context
5. Waits for a `ContextEvent` with status OK (auth success)
6. Waits for a `ServiceEvent` with at least one service (service channel works)
7. Exits 0

**`--only-run` mode** - load an existing `.zid` file and verify auth+services,
no enrollment. Useful for verifying persistence across process boundaries.

## Usage

```
ziti-test-runner [options] <jwt-file> # enroll, save, load, run, verify
ziti-test-runner --only-run [options] <zid-file> # load an existing zid and run
```

Options:
- `--mode <ott|cert-jwt|token-jwt>` - enrollment mode (default: `ott`)
- `--timeout <seconds>` - total test timeout (default: 60)
- `--keep-zid <path>` - keep the enrolled `.zid` at this path
- `--log-level <level>` - `WTF|ERROR|WARN|INFO|DEBUG|VERBOSE|TRACE` (default: `INFO`)
- `--only-run` - input is a `.zid` file; skip enrollment

Exit codes:
- `0` - success
- `1` - enrollment failed
- `2` - identity load / run failed
- `3` - context status != OK, service timeout, or overall timeout
- `64` - usage error

## Building with the insecure-keys test flag

macOS enrollment in this SDK uses the data protection keychain
(`kSecUseDataProtectionKeychain = true` in `ZitiKeychain.createPrivateKey()`),
which requires a provisioning-profile-backed `application-identifier`
entitlement. Ad-hoc signed CLI tools don't have that, so enrollment fails with
`errSecMissingEntitlement` (-34018) - both in CI and on dev machines using
"Sign to Run Locally".

To work around this **in test builds only**, the SDK supports the compile-time
condition `CZITI_TEST_INSECURE_KEYS`. When set:

- `ZitiKeychain.createPrivateKey()` generates an ephemeral RSA key with no
keychain interaction.
- `Ziti.enroll()` skips `storeCertificate()` in the keychain and writes the
ephemeral private key PEM into `ZitiIdentity.key` instead.
- `Ziti.run()` prefers `id.key` if present, over calling
`ZitiKeychain.getPrivateKey()`.
- A one-shot `⚠️ CZITI_TEST_INSECURE_KEYS build` warning prints to stderr the
first time a key is minted in the process.

**This flag must never be used in a release build.** Keys end up in plaintext
in the `.zid` file on disk, with none of the OS-level isolation the keychain
provides.

### Command-line usage

```bash
xcodebuild build -scheme ziti-test-runner \
SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) CZITI_TEST_INSECURE_KEYS'
```

The flag propagates to the `CZiti-macOS` dependency automatically (xcodebuild
applies command-line build settings across the whole build graph).

### Running locally

```bash
# 1. Start a quickstart
ziti edge quickstart --home /tmp/qs &

# 2. Log in and create an identity + service + dial policy
ziti edge login localhost:1280 -u admin -p admin -y
ziti edge create identity ztr -a ztr -o /tmp/ztr.jwt
ziti edge create service ztr-svc -a ztr-svc
ziti edge create service-policy ztr-dial Dial \
--identity-roles '#ztr' --service-roles '#ztr-svc'

# 3. Run the tool (built with the flag)
./DerivedData/CZiti/Build/Products/Debug/ziti-test-runner /tmp/ztr.jwt
```

## Scope

Only OTT enrollment is exercised end-to-end. The `cert-jwt` and `token-jwt`
modes compile under the flag but aren't wired through CI because:

- `Ziti.enrollToCert(jwtFile:)` / `enrollToToken(jwtFile:)` use the OIDC flow,
which can't be driven non-interactively in CI without a mock JWT signer.
- The `runEnrollTo(mode:)` path in `lib/Ziti.swift` has a keychain retag step
(`retagPrivateKey(to:)`) that isn't bracketed by `CZITI_TEST_INSECURE_KEYS`
and would likely fail under the flag.

Fixing either is a legitimate follow-up.
Loading
Loading