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. There is no implicit trust-store fallback and no default accept-any mode; if the selected policy cannot prove or store trust, connection setup fails before authentication completes.

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:)

Use SSHHostKeyTrustStore for the same app-owned persistence boundary with trust logic hidden inside a dedicated actor or type.

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
  • if no trusted or CA entry matches, connection setup fails with a known-host failure instead of accepting the server key

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, SSHCallbackFailureDiagnosticProviding {
    case rejectedByUser

    var sshCallbackFailureDiagnosticCode: String {
        "host-key-rejected"
    }

    var sshCallbackFailureDiagnosticSummary: String? {
        "The host-key trust decision was rejected by application policy."
    }
}

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.rejectedByUser
        }

        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.

If the callback throws an error that conforms to SSHCallbackFailureDiagnosticProviding, connection failure diagnostics preserve its sshCallbackFailureDiagnosticCode and optional safe summary under diagnostics.callbackFailure. Use that for app-owned distinctions such as user rejection, unavailable interaction context, persistence failure, or concurrent trust-store updates without parsing failure prose or dumping raw key material.

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
  • additional built-in semantic categories if real app integrations prove they should be Traversio-owned rather than app-owned callback diagnostics

On this page