Last active
January 4, 2025 14:43
-
-
Save retsl/57f6c920474da953cfad4e21d16bb7a9 to your computer and use it in GitHub Desktop.
A proof of concept to authorise copy/paste events between a Mac host and VM
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
OVERVIEW | |
-------- | |
* Assumes that it is safe to allow untrusted hosts to connect via (properly configured) SSH | |
* The options for authorized_keys are taken from forgejo's authorized_keys[^authorized_keys], | |
so they should provide enough isolation | |
* Uses swift to be able to display the VM name and action (copy/paste) in the Touch ID prompt | |
* This does not seem to be possible with sudo, which afaict is the only way to launch a Touch ID | |
prompt from the shell | |
* With the text used for Touch ID prompts hardcoded in the authorized_keys commands, VMs shouldn't | |
be able to modify it | |
WARNING: Not made by a UNIX wizard. This is my first Swift program. It is not audited by anyone. | |
There might still be (super easy) ways to execute arbitrary commands/code on the host. | |
[^authorized_keys]: https://codeberg.org/forgejo/forgejo/src/commit/b54424316410803da5d67fe7315ee931b1c84035/models/asymkey/ssh_key_authorized_keys.go#L42 | |
USAGE | |
----- | |
1. Compile this file: swiftc ClipGuard.swift -o ClipGuard | |
2. In the VM, create the ssh keys: | |
mkdir -p ~/.ssh && \ | |
chmod 700 ~/.ssh && \ | |
cd ~/.ssh && \ | |
ssh-keygen -t ed25519 -N "" -f copy && \ | |
ssh-keygen -t ed25519 -N "" -f paste && \ | |
printf "\n\n\n=== copy.pub ===\n" && \ | |
cat copy.pub && \ | |
printf "=== paste.pub ===\n" && \ | |
cat paste.pub && \ | |
cd - | |
3. On the host, add | |
command="/usr/bin/env -i HOME=/nonexistent PATH=/usr/bin:/bin LANG=en_US.UTF-8 /PATH/TO/ClipGuard VM_NAME",from="VM_IP_ADDRESS",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict VM_SSH_KEY | |
to `~/.ssh/authorized_keys`, replace VM_SSH_KEY with the contents of copy.pub, and replace | |
VN_NAME and VM_IP_ADDRESS. | |
To also allow pasting from VM to host, add a second entry with `--paste` after VM_NAME in the command | |
and use paste.pub this time. | |
If the VM is cloned later on, the from= field likely keeps this script from working and hopefully reminds | |
you to generate new keys for the cloned VM. | |
3.1 On the host, ensure you have a strong password for your user account, then go to Settings, General, Sharing and enable Remote Login (you can keep full disk access disabled). | |
4. In the VM, you should now be able to run `ssh -T -i ~/.ssh/copy $USER@$IP` to copy the contents of the | |
host's clipboard to stdout and `echo "test" | ssh -T -i ~/.ssh/paste $USER@$IP` to copy the string "test" to | |
the host clipboard. To directly copy/paste to the VM clipboard, configure keyboard shortcuts in your | |
desktop environment or aliases in your terminal for the commands: | |
* On Wayland: `ssh -T -i ~/.ssh/copy $USER@$IP | wl-copy` and `wl-paste | ssh -T -i ~/.ssh/paste $USER@$IP` | |
* On X (untested): `ssh -T -i ~/.ssh/copy $USER@$IP | xsel — clipboard — input` and `xsel — clipboard — output | ssh -T -i ~/.ssh/paste $USER@$IP` | |
Example `authorized_keys` entries: | |
COPY (host → VM): | |
command="/usr/bin/env -i HOME=/nonexistent PATH=/usr/bin:/bin /path/to/ClipGuard 'Debian 12'",from="10.1.2.3",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 ... | |
PASTE (VM → host): | |
command="/usr/bin/env -i HOME=/nonexistent PATH=/usr/bin:/bin /path/to/ClipGuard 'Debian 12' --paste",from="10.1.2.3",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-ed25519 ... | |
*/ | |
import Foundation | |
import Cocoa | |
import LocalAuthentication | |
import OSLog | |
let logger = Logger(subsystem: "re.lets.ClipGuard", category: "ClipboardAccess") | |
// Make sure we have at least the VM name | |
guard CommandLine.arguments.count >= 2 else { | |
logger.error("No VM name provided. Usage: ClipGuard <VM_NAME> [--paste]") | |
fputs("Usage: ClipGuard <VM_NAME> [--paste]\n", stderr) | |
exit(1) | |
} | |
let vmName = CommandLine.arguments[1] | |
// Check if the third argument is `--paste` | |
let isPasteOperation = (CommandLine.arguments.count >= 3 && CommandLine.arguments[2] == "--paste") | |
// Log the incoming request | |
if isPasteOperation { | |
logger.info("Clipboard PASTE request from VM: \(vmName, privacy: .public)") | |
} else { | |
logger.info("Clipboard COPY request from VM: \(vmName, privacy: .public)") | |
} | |
// Prepare LocalAuthentication context | |
let context = LAContext() | |
context.localizedReason = isPasteOperation | |
? "\n\nPASTE\n\n\(vmName)\n\ncontents into the host clipboard" | |
: "\n\nCOPY\n\nthe host clipboard to\n\n\(vmName)" | |
let policy = LAPolicy.deviceOwnerAuthentication | |
var authError: NSError? | |
// Check if we can evaluate policy | |
guard context.canEvaluatePolicy(policy, error: &authError) else { | |
let errorDesc = authError?.localizedDescription ?? "Unknown error" | |
logger.error("Cannot evaluate LocalAuthentication policy: \(errorDesc, privacy: .public)") | |
fputs("Unable to evaluate authentication policy: \(errorDesc)\n", stderr) | |
exit(1) | |
} | |
var authenticationDone = false | |
let runLoop = RunLoop.current | |
let runLoopMode = RunLoop.Mode.default | |
// Evaluate policy | |
context.evaluatePolicy(policy, localizedReason: context.localizedReason) { success, error in | |
defer { authenticationDone = true } | |
if success { | |
// Authentication succeeded | |
logger.info("Authentication succeeded for VM: \(vmName, privacy: .public)") | |
let pasteboard = NSPasteboard.general | |
if isPasteOperation { | |
// 1) Read from stdin | |
let inputData = FileHandle.standardInput.readDataToEndOfFile() | |
guard let clipboardString = String(data: inputData, encoding: .utf8), | |
!clipboardString.isEmpty | |
else { | |
logger.error("No valid data from stdin for VM: \(vmName, privacy: .public)") | |
fputs("No valid data provided on stdin.\n", stderr) | |
exit(1) | |
} | |
// 2) Write to host clipboard | |
pasteboard.declareTypes([.string], owner: nil) | |
pasteboard.setString(clipboardString, forType: .string) | |
logger.info("Successfully set the host clipboard with VM data for: \(vmName, privacy: .public)") | |
// (No output back to the VM needed, unless you want to confirm success) | |
exit(0) | |
} else { | |
// Copy from host → VM | |
// Attempt to retrieve string data from the host clipboard | |
if let clipboardString = pasteboard.string(forType: .string) { | |
logger.info("Successfully retrieved clipboard text for VM: \(vmName, privacy: .public)") | |
print(clipboardString) | |
exit(0) | |
} else { | |
logger.error("Clipboard has no valid string content for VM: \(vmName, privacy: .public)") | |
fputs("Clipboard does not contain a valid string.\n", stderr) | |
exit(1) | |
} | |
} | |
} else { | |
// Authentication failed or was canceled | |
let reason = error?.localizedDescription ?? "User canceled or Touch ID not available." | |
logger.error("Authentication failed for VM: \(vmName, privacy: .public) – \(reason, privacy: .public)") | |
fputs("Authentication failed: \(reason)\n", stderr) | |
exit(1) | |
} | |
} | |
// Keep the run loop running until authentication is done | |
while !authenticationDone && runLoop.run(mode: runLoopMode, before: Date(timeIntervalSinceNow: 0.2)) { } | |
// If somehow we exit the loop without completing authentication, exit non-zero | |
logger.error("Unexpected exit without completing authentication for VM: \(vmName, privacy: .public)") | |
exit(1) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment