From 3dfaac3ebd64645d46515c3eea2572a211622d75 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 27 Aug 2026 21:01:10 +0800 Subject: [PATCH] Add key names to authorized key exports --- .../Features/Keychain/KeychainPageView.swift | 6 +-- .../SSH/RemoteConnectionCoordinator.swift | 7 ++- .../Infrastructure/SSH/SSHSessionActor.swift | 45 +++++++++++++------ TermindTests/AuthorizedKeyExportTests.swift | 32 +++++++++++++ 4 files changed, 71 insertions(+), 19 deletions(-) create mode 100644 TermindTests/AuthorizedKeyExportTests.swift diff --git a/Termind/Features/Keychain/KeychainPageView.swift b/Termind/Features/Keychain/KeychainPageView.swift index f61ea9cc..bdd8f637 100644 --- a/Termind/Features/Keychain/KeychainPageView.swift +++ b/Termind/Features/Keychain/KeychainPageView.swift @@ -1263,16 +1263,16 @@ struct ExportKeyToHostsSheet: View { } } - let publicKeys = keys.map(\.publicKey) + let exportKeys = keys.map { (name: $0.label, publicKey: $0.publicKey) } let connectionCoordinator = app.connections Task { await withTaskGroup(of: (Host.ID, String?).self) { group in for (id, configuration) in configurations { group.addTask { do { - for publicKey in publicKeys { + for key in exportKeys { try await connectionCoordinator.installAuthorizedKey( - publicKey, using: configuration + key.publicKey, named: key.name, using: configuration ) } return (id, nil) diff --git a/Termind/Infrastructure/SSH/RemoteConnectionCoordinator.swift b/Termind/Infrastructure/SSH/RemoteConnectionCoordinator.swift index c28406a8..0dc0977e 100644 --- a/Termind/Infrastructure/SSH/RemoteConnectionCoordinator.swift +++ b/Termind/Infrastructure/SSH/RemoteConnectionCoordinator.swift @@ -52,8 +52,11 @@ nonisolated final class RemoteConnectionCoordinator: Sendable { } func installAuthorizedKey( - _ publicKey: String, using configuration: SSHConnectionConfiguration + _ publicKey: String, named keyName: String, + using configuration: SSHConnectionConfiguration ) async throws { - try await SSHSessionActor.installAuthorizedKey(publicKey, using: configuration) + try await SSHSessionActor.installAuthorizedKey( + publicKey, named: keyName, using: configuration + ) } } diff --git a/Termind/Infrastructure/SSH/SSHSessionActor.swift b/Termind/Infrastructure/SSH/SSHSessionActor.swift index f28ddd1f..0a591c59 100644 --- a/Termind/Infrastructure/SSH/SSHSessionActor.swift +++ b/Termind/Infrastructure/SSH/SSHSessionActor.swift @@ -474,14 +474,15 @@ actor SSHSessionActor { ) } - /// Appends a public key to a remote account's `authorized_keys`, unless the - /// exact line is already present. The key travels over the channel's stdin, - /// never as shell syntax, so a comment on the key cannot become a command. + /// Appends a named public key to a remote account's `authorized_keys`, unless + /// the exact key line is already present. The generated comment and key travel + /// over the channel's stdin, never as shell syntax. static func installAuthorizedKey( - _ publicKey: String, using configuration: SSHConnectionConfiguration + _ publicKey: String, named keyName: String, + using configuration: SSHConnectionConfiguration ) async throws { try await SSHSessionWorker.installAuthorizedKey( - publicKey, using: configuration + publicKey, named: keyName, using: configuration ) } @@ -647,19 +648,17 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable { } static func installAuthorizedKey( - _ publicKey: String, using configuration: SSHConnectionConfiguration + _ publicKey: String, named keyName: String, + using configuration: SSHConnectionConfiguration ) async throws { _ = initialization - let normalized = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, !normalized.contains("\n"), !normalized.contains("\r") else { - throw TermindSSHError.library("The public key is not a single OpenSSH key line.") - } + let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName) try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { do { try installAuthorizedKeyBlocking( - normalized, using: configuration + input, using: configuration ) continuation.resume() } catch { @@ -673,7 +672,7 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable { /// the OpenSSH directory with restrictive permissions and performs an exact /// line check before appending, making repeated exports idempotent. private static func installAuthorizedKeyBlocking( - _ publicKey: String, using configuration: SSHConnectionConfiguration + _ input: Data, using configuration: SSHConnectionConfiguration ) throws { let (transport, created) = try openAuthenticatedSession( using: configuration, timeout: 20 @@ -702,7 +701,7 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable { channel = opened let command = - #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n' "$key" >> "$file"; }"# + #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"# let started = command.withCString { commandBuffer in libssh2_channel_process_startup( opened, "exec", 4, commandBuffer, UInt32(command.utf8.count) @@ -712,7 +711,6 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable { throw sessionError(created, prefix: "Unable to start authorized_keys update") } - let input = Data((publicKey + "\n").utf8) try input.withUnsafeBytes { bytes in guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } var offset = 0 @@ -743,6 +741,25 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable { } } + static func authorizedKeyInstallInput(publicKey: String, keyName: String) throws -> Data { + let normalizedKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard + !normalizedKey.isEmpty, !normalizedKey.contains("\n"), + !normalizedKey.contains("\r") + else { + throw TermindSSHError.library("The public key is not a single OpenSSH key line.") + } + + let printableName = keyName.unicodeScalars + .map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) } + .joined() + let normalizedName = printableName + .split(whereSeparator: \Character.isWhitespace) + .joined(separator: " ") + let safeName = normalizedName.isEmpty ? "Key" : normalizedName + return Data(("# \(safeName) Key by Termind\n\(normalizedKey)\n").utf8) + } + private static func readAll(from channel: OpaquePointer, stream: Int32) -> String { var data = Data() var buffer = [CChar](repeating: 0, count: 4 * 1024) diff --git a/TermindTests/AuthorizedKeyExportTests.swift b/TermindTests/AuthorizedKeyExportTests.swift new file mode 100644 index 00000000..75d9fd2c --- /dev/null +++ b/TermindTests/AuthorizedKeyExportTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing + +@testable import Termind + +struct AuthorizedKeyExportTests { + @Test + func exportPlacesTheKeyNameCommentAboveThePublicKey() throws { + let input = try SSHSessionWorker.authorizedKeyInstallInput( + publicKey: "ssh-ed25519 AAAAC3Nza test@example.com", + keyName: "Production" + ) + + #expect( + String(decoding: input, as: UTF8.self) + == "# Production Key by Termind\nssh-ed25519 AAAAC3Nza test@example.com\n" + ) + } + + @Test + func exportKeepsTheGeneratedCommentOnOneLine() throws { + let input = try SSHSessionWorker.authorizedKeyInstallInput( + publicKey: "ssh-ed25519 AAAAC3Nza", + keyName: " Production\n\tServer " + ) + + #expect( + String(decoding: input, as: UTF8.self) + == "# Production Server Key by Termind\nssh-ed25519 AAAAC3Nza\n" + ) + } +}