Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ While initially created for use with YubiKeys and GitHub, Keycutter supports oth

- **[FIDO SSH keys (e.g. Yubikey)](./docs/yubikeys/fido2-on-yubikeys.md):** Uncopiable, physical presence verification, pin retry lockout.
- **[Multi-account SSH access to services](./docs/ssh-keytags.md#key-innovation-multi-account-ssh):** GitHub.com, Sourcehut (sr.ht), GitLab.com, etc.
- **[Git commit signing](./docs/git-signing.md):** Portable per-identity signing with automatic identity switching.
- **[Selective SSH Agent Forwarding](./ssh_config/keycutter/agents/README.md):** Enforce security boundaries.
- **[Public SSH Key privacy](./docs/design/defense-layers-to-protect-against-key-misuse.md):** Only offer relevant keys to remote host.
- **SSH over SSM (AWS):** Public key removed from remote host after login.
Expand Down
270 changes: 269 additions & 1 deletion bin/keycutter
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ usage() {
echo
echo "Commands:"
echo " create <ssh-keytag> [--resident] [--type <value>] Create a new SSH key"
echo " setup Bootstrap keycutter on a new machine"
echo " check-requirements Check if all required software is installed"
echo " authorized-keys <hostname> Show public keys that would be offered to host"
echo " push-keys <hostname> Push public keys to remote host"
Expand All @@ -81,6 +82,11 @@ usage() {
echo " git-signing disable [--global] Disable SSH commit signing"
echo " git-signing status Show current signing configuration"
echo
echo "Git identity management:"
echo " git-identity create <keytag> Create portable identity config for a key"
echo " git-identity list List all identity configs"
echo " git-config setup Generate master config with includeIf rules"
echo
echo "SSH known_hosts management:"
echo " ssh-known-hosts delete-line <line_number> Delete a specific line from known_hosts"
echo " ssh-known-hosts remove <hostname> Remove all entries for a host"
Expand All @@ -107,6 +113,7 @@ usage() {
echo " key show <key> Show key details"
echo " key agents <key> List agents containing key"
echo " key hosts <key> List hosts using key"
echo " key link [--dry-run] Create portable symlinks for machine-specific keys"
echo
echo "For create command:"
echo " ssh-keytag Required. Identifier for key (e.g. github.com_alex@laptop-personal)"
Expand All @@ -123,6 +130,102 @@ usage() {
echo " - device : Device this ssh key resides on (e.g. 'yubikey1', 'work-laptop', 'zfold5')"
}

# Helper to set up git identity during key creation
keycutter-create-git-identity() {
local service_identity="$1"
local github_username="$2"
local git_dir="${KEYCUTTER_CONFIG_DIR}/git"
local config_file="${git_dir}/${service_identity}.conf"
local key_path="${KEYCUTTER_SSH_KEY_DIR}/${service_identity}.pub"
local master_config="${git_dir}/config"
local gitdir_pattern="~/Code/github.com/${github_username}/"

mkdir -p "$git_dir"

# Try to get name and email from GitHub if logged in
local user_name="" user_email=""
if gh auth status -h github.com &> /dev/null; then
user_name=$(gh api user --jq '.name // empty' 2>/dev/null)
user_email=$(gh api user --jq '.email // empty' 2>/dev/null)
# If no public email, try to get from emails endpoint
if [[ -z "$user_email" ]]; then
user_email=$(gh api user/emails --jq '.[] | select(.primary) | .email' 2>/dev/null)
fi
fi

# Prompt if we couldn't get from GitHub
if [[ -z "$user_name" ]]; then
prompt "Enter your name for commits: "
read -r user_name
else
log "Using name from GitHub: $user_name"
fi

if [[ -z "$user_email" ]]; then
prompt "Enter your email for commits: "
read -r user_email
else
log "Using email from GitHub: $user_email"
fi

# Create identity config
cat > "$config_file" << EOF
# Git identity and signing config for: $service_identity
# Generated by keycutter $(date +%Y-%m-%d)

[user]
name = $user_name
email = $user_email
signingkey = $key_path

[gpg]
format = ssh

[commit]
gpgsign = true
EOF

log "Created identity config: $config_file"

# Update or create master config
if [[ -f "$master_config" ]]; then
# Check if this identity is already in the master config
if ! grep -q "path = ${config_file}" "$master_config"; then
cat >> "$master_config" << EOF

# Identity: $service_identity
[includeIf "gitdir/i:$gitdir_pattern"]
path = $config_file
EOF
log "Added includeIf to master config"
fi
else
# Create new master config
cat > "$master_config" << EOF
# Keycutter Git Configuration
# Generated by keycutter
#
# Include from ~/.gitconfig with:
# [include]
# path = ~/.ssh/keycutter/git/config

# Identity: $service_identity
[includeIf "gitdir/i:$gitdir_pattern"]
path = $config_file
EOF
log "Created master config: $master_config"
fi

# Ensure ~/.gitconfig has the include
local home_gitconfig="${HOME}/.gitconfig"
if ! grep -q "path = ~/.ssh/keycutter/git/config\|path = ${git_dir}/config" "$home_gitconfig" 2>/dev/null; then
git config --global --add include.path "~/.ssh/keycutter/git/config"
log "Added include to ~/.gitconfig"
fi

log "Git commit signing configured for repos in $gitdir_pattern"
}

keycutter-create() {

if [[ $# -lt 1 ]]; then
Expand Down Expand Up @@ -220,12 +323,34 @@ keycutter-create() {

chmod 0600 "${ssh_key_path}.pub"

# Create portable symlink (e.g., github.com_alex -> github.com_alex@laptop)
# This enables portable git config that references the generic path
local service_identity="$(_ssh-keytag-service-identity "$ssh_keytag")"
local generic_path="${KEYCUTTER_CONFIG_DIR}/keys/${service_identity}"
if [[ ! -e "$generic_path" && "$ssh_keytag" =~ @ ]]; then
local key_basename=$(basename "$ssh_key_path")
ln -sf "$key_basename" "$generic_path"
ln -sf "${key_basename}.pub" "${generic_path}.pub"
log "Created portable symlinks: ${service_identity}"
fi

# If the SSH Keytag includes github.com
local service="$(_ssh-keytag-service "$ssh_keytag")"
if [[ $service =~ github.com ]]; then
# Optionally add SSH key to GitHub for auth and commit/tag signing: $ssh_key_path
github-ssh-key-add "$ssh_key_path" "$ssh_keytag"
local demo_message="\nYou can SSH to GitHub by running:\n\n ssh -T $(_ssh-keytag-service-identity "$ssh_key_path")\n"

# Offer to set up git commit signing
local github_username="$(_ssh-keytag-identity "$ssh_keytag")"
echo
prompt "Set up git commit signing for repos under ~/Code/github.com/${github_username}/? [Y/n] "
read -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
keycutter-create-git-identity "$service_identity" "$github_username"
fi

# Option to access GitHub when firewall blocks outbound port 22
prompt "Symlink key to enable ssh.github.com:443? [Y/n] "
read -n 1 -r
Expand Down Expand Up @@ -1040,6 +1165,9 @@ keycutter-key() {
hosts)
keycutter-key-hosts "$@"
;;
link)
keycutter-key-link "$@"
;;
*)
log "Error: Unknown key subcommand: $subcmd"
usage
Expand Down Expand Up @@ -1112,6 +1240,15 @@ keycutter-key-hosts() {
done
}

keycutter-key-link() {
# Create portable symlinks for machine-specific keys
# e.g., github.com_alex@laptop -> github.com_alex (symlink)
#
# This enables portable git config that references the generic path
# while actual keys are machine-specific.
ssh-keys-create-symlinks "$@"
}

keycutter-install-touch-detector() {
local installer_script="${KEYCUTTER_ROOT}/libexec/keycutter/install-touch-detector"

Expand Down Expand Up @@ -1167,6 +1304,131 @@ keycutter-git-signing() {
esac
}

# Setup command - bootstrap keycutter on a new machine

keycutter-setup() {
echo "Keycutter Setup"
echo "==============="
echo
echo "This command bootstraps keycutter on a new machine."
echo

# Step 1: Check requirements
log "Step 1: Checking requirements..."
check_requirements

# Step 2: Create key symlinks
log ""
log "Step 2: Creating portable key symlinks..."
ssh-keys-create-symlinks

# Step 3: Set up git config include
log ""
log "Step 3: Setting up git config..."
local git_dir="${KEYCUTTER_CONFIG_DIR}/git"
local master_config="${git_dir}/config"
local home_gitconfig="${HOME}/.gitconfig"

if [[ -f "$master_config" ]]; then
# Check if already included
if grep -q "path = ~/.ssh/keycutter/git/config\|path = ${git_dir}/config" "$home_gitconfig" 2>/dev/null; then
log "~/.gitconfig already includes keycutter git config"
else
prompt "Add keycutter git config include to ~/.gitconfig? [Y/n] "
read -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
git config --global --add include.path "~/.ssh/keycutter/git/config"
log "Added include to ~/.gitconfig"
fi
fi
else
log "No git identity configs found."
log "Create some with 'keycutter git-identity create <keytag>'"
log "Then run 'keycutter git-config setup' to generate the master config."
fi

echo
log "Setup complete!"
echo
echo "Next steps:"
echo " 1. Create keys: keycutter create github.com_yourname"
echo " 2. Create git identity: keycutter git-identity create github.com_yourname"
echo " 3. Generate git config: keycutter git-config setup"
}

# Git config subcommands

keycutter-git-config() {
# Show help for no args or explicit help flag
if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "help" ]]; then
echo "Usage: keycutter git-config <subcommand>"
echo
echo "Manage keycutter git configuration."
echo
echo "Subcommands:"
echo " setup Generate master git config with includeIf rules"
echo
echo "Examples:"
echo " keycutter git-config setup # Interactive setup of master config"
echo
[[ $# -eq 0 ]] && return 1 || return 0
fi

local subcmd="$1"
shift

case "$subcmd" in
setup)
git-config-setup "$@"
;;
*)
log "Error: Unknown git-config subcommand: $subcmd"
echo "Available subcommands: setup"
return 1
;;
esac
}

# Git identity subcommands

keycutter-git-identity() {
# Show help for no args or explicit help flag
if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "help" ]]; then
echo "Usage: keycutter git-identity <subcommand>"
echo
echo "Manage portable git identity configs for per-key signing."
echo
echo "Subcommands:"
echo " create <keytag> [--name <name>] [--email <email>] Create identity config for a key"
echo " list List all identity configs"
echo
echo "Examples:"
echo " keycutter git-identity create github.com_alex # Interactive prompts"
echo " keycutter git-identity create github.com_alex --name 'Alex' --email 'alex@example.com'"
echo " keycutter git-identity list"
echo
[[ $# -eq 0 ]] && return 1 || return 0
fi

local subcmd="$1"
shift

case "$subcmd" in
create)
git-identity-create "$@"
;;
list)
git-identity-list "$@"
;;
*)
log "Error: Unknown git-identity subcommand: $subcmd"
echo "Available subcommands: create, list"
return 1
;;
esac
}

# YubiKey subcommands

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
Expand All @@ -1179,12 +1441,18 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
shift

case "$cmd" in
create | authorized-keys | push-keys | check-requirements | config | agents | hosts | keys | ssh-known-hosts)
create | authorized-keys | push-keys | check-requirements | config | agents | hosts | keys | ssh-known-hosts | setup)
"keycutter-$cmd" "$@"
;;
git-signing)
"keycutter-git-signing" "$@"
;;
git-identity)
"keycutter-git-identity" "$@"
;;
git-config)
"keycutter-git-config" "$@"
;;
update)
# Handle update subcommands
if [[ $# -eq 0 ]]; then
Expand Down
Loading