Traversio

Host Key Trust

Choose a host-key policy and understand the supported trust models.

Why Traversio Requires a Trust Policy

The transport handshake verifies control of the private key behind the presented host key. Traversio keeps the trust decision explicit, so each connection chooses a host-key policy directly.

Supported public options:

  • acceptAnyVerifiedHostKey
  • requireMatch(SSHTrustedHostKey)
  • requireMatchAny([SSHTrustedHostKey])
  • trustOnFirstUse(lookup:store:)
  • trustOnFirstUse(lookup:store:onStoredHostKeyMismatch:)
  • trustOnFirstUse(using: SSHHostKeyTrustStore)
  • knownHostsFile(String)
  • knownHostsFile(String, additionalLookupNames: [String])
  • callback((SSHHostKeyValidationRequest) async throws -> SSHHostKeyTrustMethod)

Option 1: trustOnFirstUse

If your app wants the simplest "library gives me the verified host key, I store it, next time I compare it" workflow, start here.

actor HostKeyStore {
    private var keys: [String: SSHTrustedHostKey] = [:]

    func lookup(host: String, port: UInt16) -> SSHTrustedHostKey? {
        self.keys["\(host):\(port)"]
    }

    func store(_ request: SSHHostKeyStoreRequest) throws {
        let key = "\(request.endpointHost):\(request.endpointPort)"
        let current = self.keys[key]

        guard request.matchesExpectedStoredHostKey(current) else {
            throw SSHHostKeyPolicyError.concurrentStoredHostKeyUpdate(
                endpointHost: request.endpointHost,
                endpointPort: request.endpointPort,
                expectedStoredHostKey: request.expectedStoredHostKey,
                actualStoredHostKey: current
            )
        }

        self.keys[key] = request.trustedHostKey
    }
}

let store = HostKeyStore()

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .trustOnFirstUse(
        lookup: { host, port in
            await store.lookup(host: host, port: port)
        },
        store: { request in
            try await store.store(request)
        }
    )
)

Behavior:

  • if your lookup closure returns no key, Traversio treats this as first use and calls store
  • if your lookup closure returns a key, Traversio compares it with the newly verified host key
  • store receives an SSHHostKeyStoreRequest with expectedStoredHostKey, so your persistence layer can do compare-and-set instead of blindly overwriting concurrent updates
  • if the key changed, Traversio throws SSHHostKeyPolicyError.storedHostKeyMismatch(...)

This keeps persistence in your app while removing the repetitive compare-or-store boilerplate.

Option 1b: trustOnFirstUse(using:)

If you want the same app-owned persistence boundary but prefer to hide the trust logic inside a dedicated actor or type, use SSHHostKeyTrustStore.

actor HostKeyStore: SSHHostKeyTrustStore {
    private var keys: [String: SSHTrustedHostKey] = [:]

    func lookupHostKey(
        endpointHost: String,
        endpointPort: UInt16
    ) async throws -> SSHTrustedHostKey? {
        self.keys["\(endpointHost):\(endpointPort)"]
    }

    func storeHostKey(_ request: SSHHostKeyStoreRequest) async throws {
        let key = "\(request.endpointHost):\(request.endpointPort)"
        let current = self.keys[key]

        guard request.matchesExpectedStoredHostKey(current) else {
            throw SSHHostKeyPolicyError.concurrentStoredHostKeyUpdate(
                endpointHost: request.endpointHost,
                endpointPort: request.endpointPort,
                expectedStoredHostKey: request.expectedStoredHostKey,
                actualStoredHostKey: current
            )
        }

        self.keys[key] = request.trustedHostKey
    }
}

let store = HostKeyStore()

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .trustOnFirstUse(using: store)
)

Default behavior:

  • new host keys are saved through storeHostKey
  • matching stored keys are accepted without another write
  • changed stored keys are rejected by default

storeHostKey(_:) also receives expectedStoredHostKey, so a shared store can reject stale writes instead of overwriting a newer trust decision.

If your store wants to allow a planned rotation, implement decisionForChangedHostKey(_:) and return .replaceStoredHostKey.

Option 1c: trustOnFirstUse With A Changed-Key Decision

If your app wants the same first-seen flow but needs an explicit decision point when a stored host key changes, use the overload with onStoredHostKeyMismatch.

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .trustOnFirstUse(
        lookup: { host, port in
            await store.lookup(host: host, port: port)
        },
        store: { request in
            try await store.store(request)
        },
        onStoredHostKeyMismatch: { request in
            if isExpectedRotation(
                oldFingerprint: request.storedHostKey.fingerprintSHA256,
                newFingerprint: request.receivedHostKey.fingerprintSHA256
            ) {
                return .replaceStoredHostKey
            }

            return .reject
        }
    )
)

Behavior:

  • if the host key is new, Traversio still calls store
  • if the stored key matches, the connection proceeds without writing anything
  • if the stored key changed, your callback receives both the stored and received key and can either:
    • return .reject to fail with SSHHostKeyPolicyError.storedHostKeyMismatch(...)
    • return .replaceStoredHostKey to persist the new key through the same store closure and continue, while still carrying expectedStoredHostKey so your store can reject stale replacements

Option 2: knownHostsFile

For most production applications, this is the practical default.

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .knownHostsFile("/Users/me/.ssh/known_hosts")
)

Supported OpenSSH known_hosts features:

  • exact host names
  • exact [host]:port entries
  • optional additional lookup names so the configured host plus a resolved IP or alias can both participate in matching
  • wildcard host patterns using * and ?
  • negated host patterns using !
  • hashed host entries
  • hashed [host]:port entries
  • CIDR-style address entries such as 192.0.2.0/24 or 2001:db8::/64
  • @revoked host-key entries
  • @cert-authority CA entries for host certificates
  • multiple exact matches for the same endpoint, which become a trusted set

When a host certificate matches an @cert-authority entry, Traversio trusts the CA key from that entry and still enforces the certificate's principal and validity checks before the session is activated.

Live-validated OpenSSH host-certificate algorithms:

Known limits:

  • broader host-certificate algorithm coverage beyond the current Ed25519 and ECDSA P-256 certificate paths

Traversal of stored trust data remains application-owned, including persistence.

Lookup behavior:

  • port 22 looks up the normalized lowercase host name directly, plus any additionalLookupNames you provided
  • non-default ports check [host]:port first, plus bracketed additionalLookupNames such as [192.0.2.10]:2222
  • if no port-specific entry matches, Traversio falls back to the raw host name
  • that raw-name fallback also includes any additionalLookupNames
  • if a port-specific trusted or revoked entry does match, Traversio does not widen the lookup back to the raw host name

When you already know the address or alias you want known_hosts to consider, pass it explicitly:

let configuration = SSHClientConfiguration(
    host: "example.com",
    port: 2222,
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .knownHostsFile(
        "/Users/me/.ssh/known_hosts",
        additionalLookupNames: ["192.0.2.10"]
    )
)

Option 3: Exact Raw-Key Pinning

If you already have the raw host-key bytes, you can pin them directly with SSHTrustedHostKey.

let rawHostKey: [UInt8] = /* bytes from a trusted source */
let trustedHostKey = try SSHTrustedHostKey(rawRepresentation: rawHostKey)

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .requireMatch(trustedHostKey)
)

You can inspect the computed fingerprint through:

let fingerprint = trustedHostKey.fingerprintSHA256

Option 4: Trust a Small Exact Set

When you need to support host-key rotation or more than one exact key, use requireMatchAny.

let trustedKeys = try [
    SSHTrustedHostKey(rawRepresentation: key1),
    SSHTrustedHostKey(rawRepresentation: key2),
]

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .requireMatchAny(trustedKeys)
)

Option 5: Accept Any Verified Host Key

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .acceptAnyVerifiedHostKey
)

This mode fits disposable test environments, local bring-up, or controlled tooling. Production deployments usually choose a stricter trust policy.

Option 6: Custom Trust Callback

Use callback(...) when your application needs full control over the trust decision.

enum HostTrustError: Error {
    case rejectedFingerprint(String)
}

let configuration = SSHClientConfiguration(
    host: "example.com",
    username: "deploy",
    authentication: .password(secret),
    hostKeyPolicy: .callback { request in
        let fingerprint = request.trustedHostKey.fingerprintSHA256

        guard isAllowed(fingerprint: fingerprint) else {
            throw HostTrustError.rejectedFingerprint(fingerprint)
        }

        return .callback
    }
)

The callback receives:

  • endpointHost
  • endpointPort
  • remoteIdentification
  • trustedHostKey
  • matches(_:)

That is enough to consult your application's trust store, write a first-seen record, or reject a changed key before Traversio continues.

What Metadata You Get After Trust Succeeds

After the connection is established, SSHConnection.metadata records:

  • hostKeyAlgorithm
  • hostKeyFingerprintSHA256
  • hostKeyTrustMethod

That lets you log what actually happened instead of assuming which trust path was used.

Remaining Gaps

Host trust still has a few open areas:

  • broader host-certificate algorithm coverage and any CA-source behavior beyond today's Ed25519 and ECDSA P-256 plus known_hosts @cert-authority floor
  • any additional trust helpers that prove necessary on top of app-owned persistence
  • clearer public error modeling for trust failures

On this page