Improve SSH import detection and scrolling
This commit is contained in:
1 parent
472906c01c
commit
4958bb9029
12 files changed
+582
-170
No files matched your search
@@ -1,8 +1,10 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
|
||||
/// One import session owns its secrets. Only metadata leaves this actor until commit.
|
||||
/// One import session owns only source descriptors and public metadata. Private
|
||||
/// key text is read inside scan, unlock, or commit and never becomes actor state.
|
||||
public actor SSHImportReader: SSHImportReading {
|
||||
private let username: String
|
||||
private let home: URL?
|
||||
@@ -12,164 +14,264 @@ public actor SSHImportReader: SSHImportReading {
|
||||
self.home = home
|
||||
}
|
||||
|
||||
private struct Material {
|
||||
var text: String
|
||||
var certificate: String
|
||||
private struct KeySource {
|
||||
let url: URL
|
||||
let digest: Data
|
||||
}
|
||||
private var materials: [UUID: Material] = [:]
|
||||
|
||||
private struct KeyCandidate {
|
||||
let url: URL
|
||||
let path: String
|
||||
let digest: Data
|
||||
let encrypted: Bool
|
||||
}
|
||||
|
||||
private struct CertificateSource {
|
||||
let path: String
|
||||
let text: String
|
||||
}
|
||||
|
||||
private struct FilePayload {
|
||||
let text: String
|
||||
let digest: Data
|
||||
}
|
||||
|
||||
private var keySources: [UUID: KeySource] = [:]
|
||||
private var certificateSources: [CertificateSource] = []
|
||||
private var accessScopes: [URL] = []
|
||||
private var stableIDs: [String: UUID] = [:]
|
||||
|
||||
public func discard() { materials.removeAll() }
|
||||
public func discard() {
|
||||
keySources.removeAll()
|
||||
certificateSources.removeAll()
|
||||
accessScopes.removeAll()
|
||||
}
|
||||
|
||||
public func scan(root: URL, knownHostsOnly: Bool, grants: [URL] = []) throws -> [SSHImportItem] {
|
||||
discard()
|
||||
let scopes = [root] + grants
|
||||
accessScopes = scopes
|
||||
let acquired = scopes.filter { $0.startAccessingSecurityScopedResource() }
|
||||
defer { for url in acquired { url.stopAccessingSecurityScopedResource() } }
|
||||
materials.removeAll()
|
||||
|
||||
let sourceType = try root.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
|
||||
guard knownHostsOnly ? sourceType.isRegularFile == true : sourceType.isDirectory == true else {
|
||||
throw knownHostsOnly ? SSHImportSourceError.expectedFile : SSHImportSourceError.expectedFolder
|
||||
}
|
||||
if knownHostsOnly {
|
||||
return stabilize(SSHImportParser.knownHosts(try read(root), source: root.path))
|
||||
let payload = try readPayload(root)
|
||||
let kind = SSHImportParser.contentKind(payload.text)
|
||||
guard kind == .knownHosts || containsOnlyCommentsAndWhitespace(payload.text) else {
|
||||
throw SSHImportSourceError.unsupportedKnownHostsContent
|
||||
}
|
||||
return stabilize(SSHImportParser.knownHosts(payload.text, source: root.path))
|
||||
}
|
||||
let root = root.standardizedFileURL
|
||||
|
||||
let standardizedRoot = root.standardizedFileURL
|
||||
let authorized = scopes.map { $0.resolvingSymlinksInPath().standardizedFileURL.path }
|
||||
func allowed(_ url: URL) -> Bool {
|
||||
let path = url.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
return authorized.contains { path == $0 || path.hasPrefix($0 + "/") }
|
||||
}
|
||||
var files: [String: String] = [:]
|
||||
|
||||
var configurationFiles: [String: String] = [:]
|
||||
var knownHostItems: [SSHImportItem] = []
|
||||
var keyCandidates: [String: KeyCandidate] = [:]
|
||||
var contentKinds: [String: SSHImportContentKind] = [:]
|
||||
var availablePaths: Set<String> = []
|
||||
var inaccessible: [String] = []
|
||||
var fileCount = 0
|
||||
var totalBytes = 0
|
||||
let manager = FileManager.default
|
||||
|
||||
for scope in scopes {
|
||||
var directory: ObjCBool = false
|
||||
guard manager.fileExists(atPath: scope.path, isDirectory: &directory) else { continue }
|
||||
let urls: [URL]
|
||||
if directory.boolValue {
|
||||
let enumerator = manager.enumerator(
|
||||
at: scope, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [])
|
||||
at: scope,
|
||||
includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [])
|
||||
urls = enumerator?.allObjects.compactMap { $0 as? URL } ?? []
|
||||
} else {
|
||||
urls = [scope]
|
||||
}
|
||||
for url in urls {
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
let values = try? url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey, .isSymbolicLinkKey])
|
||||
guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue }
|
||||
guard allowed(url) else {
|
||||
inaccessible.append(url.path)
|
||||
continue
|
||||
}
|
||||
fileCount += 1
|
||||
guard fileCount <= 4096 else { throw CocoaError(.fileReadTooLarge) }
|
||||
guard fileCount <= 4_096 else { throw CocoaError(.fileReadTooLarge) }
|
||||
let fileSize = values?.fileSize ?? 0
|
||||
totalBytes += fileSize
|
||||
guard fileSize <= 8 * 1_024 * 1_024, totalBytes <= 64 * 1_024 * 1_024 else {
|
||||
throw CocoaError(.fileReadTooLarge)
|
||||
}
|
||||
let path = url.standardizedFileURL.path
|
||||
do {
|
||||
let value = try read(url)
|
||||
totalBytes += value.utf8.count
|
||||
guard totalBytes <= 64 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) }
|
||||
files[url.standardizedFileURL.path] = value
|
||||
let payload = try readPayload(url)
|
||||
let kind = SSHImportParser.contentKind(payload.text)
|
||||
availablePaths.insert(path)
|
||||
contentKinds[path] = kind
|
||||
switch kind {
|
||||
case .privateKey:
|
||||
keyCandidates[path] = KeyCandidate(
|
||||
url: url, path: path, digest: payload.digest,
|
||||
encrypted: isEncrypted(payload.text))
|
||||
case .userCertificate:
|
||||
certificateSources.append(
|
||||
CertificateSource(
|
||||
path: path,
|
||||
text: payload.text.trimmingCharacters(in: .whitespacesAndNewlines)))
|
||||
case .knownHosts:
|
||||
knownHostItems += SSHImportParser.knownHosts(payload.text, source: path)
|
||||
case .configuration:
|
||||
configurationFiles[path] = payload.text
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
inaccessible.append(url.path)
|
||||
inaccessible.append(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parser = SSHConfigParser(
|
||||
root: root, home: home ?? root.deletingLastPathComponent(),
|
||||
username: username, files: files)
|
||||
var items = parser.parse()
|
||||
let knownPath = root.appendingPathComponent("known_hosts").path
|
||||
if let known = files[knownPath] { items += SSHImportParser.knownHosts(known, source: knownPath) }
|
||||
root: standardizedRoot, home: home ?? standardizedRoot.deletingLastPathComponent(),
|
||||
username: username, files: configurationFiles,
|
||||
entryPaths: configurationFiles.keys.sorted())
|
||||
var items = parser.parse() + knownHostItems
|
||||
let hosts = items.compactMap(\.host)
|
||||
let referenced = Set(hosts.flatMap(\.identityPaths))
|
||||
let privateFiles = files.keys.filter { isPrivateKey(files[$0] ?? "") }
|
||||
let paths = Set(privateFiles).union(referenced.filter { files[$0] != nil })
|
||||
for path in paths.sorted() {
|
||||
let content = files[path] ?? ""
|
||||
guard isPrivateKey(content) else {
|
||||
let referencedKeys = Set(hosts.flatMap(\.identityPaths))
|
||||
|
||||
for path in Set(keyCandidates.keys).union(referencedKeys).sorted() {
|
||||
guard let candidate = keyCandidates[path] else {
|
||||
if availablePaths.contains(path), contentKinds[path] != .privateKey {
|
||||
items.append(
|
||||
SSHImportItem(
|
||||
category: .keys, name: URL(fileURLWithPath: path).lastPathComponent,
|
||||
source: path, selected: false,
|
||||
issue: String(localized: "No supported private key in this file."), unavailable: true))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if candidate.encrypted {
|
||||
let id = stableID(path + "\n")
|
||||
keySources[id] = KeySource(url: candidate.url, digest: candidate.digest)
|
||||
items.append(
|
||||
SSHImportItem(
|
||||
id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent,
|
||||
source: path,
|
||||
issue: String(localized: "Enter the key passphrase to validate this private key."),
|
||||
requiresPassphrase: true))
|
||||
continue
|
||||
}
|
||||
do {
|
||||
let material = try inspect(candidate, passphrase: nil)
|
||||
items += makeKeyItems(candidate: candidate, material: material)
|
||||
} catch {
|
||||
items.append(
|
||||
SSHImportItem(
|
||||
category: .keys, name: URL(fileURLWithPath: path).lastPathComponent,
|
||||
source: path, selected: false,
|
||||
issue: String(localized: "No supported private key in this file."), unavailable: true))
|
||||
continue
|
||||
}
|
||||
let explicitCertificates = hosts.filter { $0.identityPaths.contains(path) }.flatMap(\.certificatePaths)
|
||||
let certificatePaths = Set(explicitCertificates + [path + "-cert.pub"])
|
||||
var variants: [String: [String]] = [:]
|
||||
for certificatePath in certificatePaths.sorted() {
|
||||
if let certificate = files[certificatePath] {
|
||||
variants[certificate, default: []].append(certificatePath)
|
||||
}
|
||||
}
|
||||
if files[path + "-cert.pub"] == nil { variants[""] = [] }
|
||||
for certificate in variants.keys.sorted() {
|
||||
let stable = path + "\n" + certificate
|
||||
let id = stableID(stable)
|
||||
materials[id] = Material(text: content, certificate: certificate)
|
||||
var item = SSHImportItem(
|
||||
id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path)
|
||||
item.certificatePaths = variants[certificate] ?? []
|
||||
do {
|
||||
item.key = try inspect(id: id, name: item.name, passphrase: nil)
|
||||
} catch {
|
||||
if isEncrypted(content) {
|
||||
item.requiresPassphrase = true
|
||||
item.issue = String(localized: "Enter the key passphrase to validate this private key.")
|
||||
} else {
|
||||
item.selected = false
|
||||
item.unavailable = true
|
||||
item.issue = String(localized: "No supported private key in this file.")
|
||||
}
|
||||
}
|
||||
items.append(item)
|
||||
}
|
||||
}
|
||||
let missing = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter { files[$0] == nil }
|
||||
let certificateMissing = hosts.flatMap(\.certificatePaths).filter { files[$0] == nil }
|
||||
for path in Set(inaccessible).union(parser.externalPaths).union(missing).union(certificateMissing).sorted() {
|
||||
|
||||
let missingKeys = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter {
|
||||
!availablePaths.contains($0)
|
||||
}
|
||||
let missingCertificates = hosts.flatMap(\.certificatePaths).filter { !availablePaths.contains($0) }
|
||||
for path in Set(inaccessible).union(parser.externalPaths).union(missingKeys).union(missingCertificates).sorted()
|
||||
{
|
||||
items.append(
|
||||
SSHImportItem(
|
||||
category: path == knownPath ? .knownHosts : parser.externalPaths.contains(path) ? .hosts : .keys,
|
||||
category: parser.externalPaths.contains(path) ? .hosts : .keys,
|
||||
name: URL(fileURLWithPath: path).lastPathComponent, source: path,
|
||||
issue: String(localized: "Authorize access to this referenced file, or deselect it."),
|
||||
needsAuthorization: path))
|
||||
}
|
||||
|
||||
// Missing explicit references must also be visible on the affected host.
|
||||
for index in items.indices {
|
||||
guard let host = items[index].host else { continue }
|
||||
let absent = (host.identityPaths + host.certificatePaths).filter {
|
||||
files[$0] == nil && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0))
|
||||
!availablePaths.contains($0) && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0))
|
||||
}
|
||||
items[index].warnings += absent.map { String(localized: "Referenced file is unavailable:") + " " + $0 }
|
||||
}
|
||||
return stabilize(items)
|
||||
}
|
||||
|
||||
public func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef {
|
||||
try inspect(id: id, name: name, passphrase: passphrase)
|
||||
public func unlock(id: UUID, name: String, passphrase: String) throws -> [SSHImportItem] {
|
||||
guard let source = keySources[id] else { throw CocoaError(.fileReadUnknown) }
|
||||
return try withAccess {
|
||||
let payload = try readPayload(source.url)
|
||||
guard payload.digest == source.digest, SSHImportParser.contentKind(payload.text) == .privateKey else {
|
||||
throw SSHImportSourceError.sourceChanged
|
||||
}
|
||||
let material = try SSHKeyMaterial.inspect(privateKey: payload.text, passphrase: passphrase)
|
||||
let candidate = KeyCandidate(
|
||||
url: source.url, path: source.url.standardizedFileURL.path,
|
||||
digest: source.digest, encrypted: true)
|
||||
keySources.removeValue(forKey: id)
|
||||
return makeKeyItems(candidate: candidate, material: material, name: name, hasPassphrase: true)
|
||||
}
|
||||
}
|
||||
|
||||
public func credentials(for ids: Set<UUID>) throws -> [UUID: StoredSSHKeyCredential] {
|
||||
var result: [UUID: StoredSSHKeyCredential] = [:]
|
||||
for id in ids {
|
||||
guard let material = materials[id] else { throw CocoaError(.fileReadUnknown) }
|
||||
result[id] = .software(SSHPrivateKeySecret(privateKey: material.text))
|
||||
try withAccess {
|
||||
var result: [UUID: StoredSSHKeyCredential] = [:]
|
||||
for id in ids.sorted(by: { $0.uuidString < $1.uuidString }) {
|
||||
guard let source = keySources[id] else { throw CocoaError(.fileReadUnknown) }
|
||||
let payload = try readPayload(source.url)
|
||||
guard payload.digest == source.digest, SSHImportParser.contentKind(payload.text) == .privateKey else {
|
||||
throw SSHImportSourceError.sourceChanged
|
||||
}
|
||||
result[id] = .software(SSHPrivateKeySecret(privateKey: payload.text))
|
||||
}
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func inspect(id: UUID, name: String, passphrase: String?) throws -> SSHKeyRef {
|
||||
guard let input = materials[id] else { throw CocoaError(.fileReadUnknown) }
|
||||
let material = try SSHKeyMaterial.inspect(privateKey: input.text, passphrase: passphrase)
|
||||
if !input.certificate.isEmpty {
|
||||
_ = try SSHUserCertificate.parse(input.certificate, matching: material.publicKey)
|
||||
private func inspect(_ candidate: KeyCandidate, passphrase: String?) throws -> SSHKeyMaterial {
|
||||
try withAccess {
|
||||
let payload = try readPayload(candidate.url)
|
||||
guard payload.digest == candidate.digest else { throw SSHImportSourceError.sourceChanged }
|
||||
return try SSHKeyMaterial.inspect(privateKey: payload.text, passphrase: passphrase)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeKeyItems(
|
||||
candidate: KeyCandidate, material: SSHKeyMaterial, name: String? = nil, hasPassphrase: Bool = false
|
||||
) -> [SSHImportItem] {
|
||||
var variants: [String: [String]] = [:]
|
||||
for certificate in certificateSources {
|
||||
if (try? SSHUserCertificate.parse(certificate.text, matching: material.publicKey)) != nil {
|
||||
variants[certificate.text, default: []].append(certificate.path)
|
||||
}
|
||||
}
|
||||
// The private key remains a valid variant in its own right. Hosts with
|
||||
// an explicit CertificateFile select the matching certificate path;
|
||||
// hosts without one continue to use the bare key.
|
||||
variants[""] = []
|
||||
return variants.keys.sorted().map { certificate in
|
||||
let id = stableID(candidate.path + "\n" + certificate)
|
||||
keySources[id] = KeySource(url: candidate.url, digest: candidate.digest)
|
||||
let label = name ?? URL(fileURLWithPath: candidate.path).lastPathComponent
|
||||
let key = SSHKeyRef(
|
||||
id: id, label: label, algorithm: material.algorithm,
|
||||
publicKey: material.publicKey, certificate: certificate, fingerprint: material.fingerprint,
|
||||
credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize,
|
||||
hasPassphrase: hasPassphrase, createdAt: Date())
|
||||
return SSHImportItem(
|
||||
id: id, category: .keys, name: label, source: candidate.path,
|
||||
key: key, certificatePaths: variants[certificate] ?? [])
|
||||
}
|
||||
return SSHKeyRef(
|
||||
id: id, label: name, algorithm: material.algorithm,
|
||||
publicKey: material.publicKey, certificate: input.certificate, fingerprint: material.fingerprint,
|
||||
credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize,
|
||||
hasPassphrase: passphrase?.isEmpty == false, createdAt: Date())
|
||||
}
|
||||
|
||||
private func stableID(_ source: String) -> UUID {
|
||||
@@ -183,7 +285,7 @@ public actor SSHImportReader: SSHImportReading {
|
||||
var occurrences: [String: Int] = [:]
|
||||
return values.map { item in
|
||||
var item = item
|
||||
if materials[item.id] == nil {
|
||||
if keySources[item.id] == nil {
|
||||
let token = "\(item.category.rawValue):\(item.source):\(item.name)"
|
||||
let occurrence = occurrences[token, default: 0]
|
||||
occurrences[token] = occurrence + 1
|
||||
@@ -193,12 +295,29 @@ public actor SSHImportReader: SSHImportReading {
|
||||
}
|
||||
}
|
||||
|
||||
private func read(_ url: URL) throws -> String {
|
||||
private func withAccess<T>(_ operation: () throws -> T) rethrows -> T {
|
||||
let acquired = accessScopes.filter { $0.startAccessingSecurityScopedResource() }
|
||||
defer { for url in acquired { url.stopAccessingSecurityScopedResource() } }
|
||||
return try operation()
|
||||
}
|
||||
|
||||
private func readPayload(_ url: URL) throws -> FilePayload {
|
||||
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
|
||||
guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else {
|
||||
guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1_024 * 1_024 else {
|
||||
throw CocoaError(.fileReadTooLarge)
|
||||
}
|
||||
return try String(contentsOf: url, encoding: .utf8)
|
||||
let data = try Data(contentsOf: url, options: [.mappedIfSafe])
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
throw CocoaError(.fileReadInapplicableStringEncoding)
|
||||
}
|
||||
return FilePayload(text: text, digest: Data(SHA256.hash(data: data)))
|
||||
}
|
||||
|
||||
private func containsOnlyCommentsAndWhitespace(_ text: String) -> Bool {
|
||||
text.components(separatedBy: .newlines).allSatisfy {
|
||||
let line = $0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return line.isEmpty || line.hasPrefix("#")
|
||||
}
|
||||
}
|
||||
|
||||
private func isEncrypted(_ text: String) -> Bool {
|
||||
@@ -215,12 +334,4 @@ public actor SSHImportReader: SSHImportReading {
|
||||
let cipher = String(data: data.subdata(in: offset + 4..<offset + 4 + Int(length)), encoding: .utf8)
|
||||
return cipher != nil && cipher != "none"
|
||||
}
|
||||
|
||||
private func isPrivateKey(_ text: String) -> Bool {
|
||||
[
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----",
|
||||
].contains { text.hasPrefix($0) }
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user