Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Thin wrapper: install the optional lint/test tools and run scripts/check.
# No duplicated lint logic in YAML — anyone can run the same script locally
# and get the same result.
name: check

on:
push:
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install optional tools
run: |
sudo apt-get update
sudo apt-get install -y shellcheck bats
pipx install ruff
- name: Run checks
run: scripts/check
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.venv/
__pycache__/
*.pyc
lib/ui/xterm/
prompts/
438 changes: 438 additions & 0 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ This installs commands to `~/bin` (ensure it's in your `$PATH`) and stores key d

Options:

- `./install.sh --install-xterm` — include xterm.js assets for the web UI terminal
- `./install.sh --skip-xterm` — skip xterm.js assets
- `./install.sh uninstall` — remove all symlinks and installed files

The web UI requires Python 3. Dependencies are installed automatically in a virtual environment on first run of `ssh-ui`.
The installer performs **no network fetches**: all web-UI assets (xterm.js) are vendored in the repo under `lib/ui/vendor/` with SHA-384 hashes recorded in `SHA384SUMS` and verified by `scripts/check vendor`.

The web UI requires Python 3. Dependencies are installed automatically in a virtual environment on first run of `ssh-ui` (this dependency goes away in refactor Phase 1 — see [REFACTOR.md](REFACTOR.md)).

## Quick Start

Expand Down Expand Up @@ -78,9 +78,9 @@ Enables deprecated algorithms (DSA, RSA-SHA1, CBC ciphers) only for that specifi
| `ssh-del` | Remove keys for a user. Cleans up the host identity if no users remain. |
| `ssh-conf` | Edit host-specific SSH options (Port, Forwarding, etc.). |
| `ssh-template` | Manage key templates (standard, hardware, or OpenPubKey). |
| `ssh-rotate` | Rotate host keys when a server's host key changes. |
| `ssh-rotate` | *Disabled* — the shipped implementation was broken; rebuilt in refactor Phase 5. |
| `ssh-user-rotate` | Rotate a user's keypair for a specific host. |
| `ssh-template-rotate` | Rotate keys within a template (ed25519, ecdsa, or rsa). |
| `ssh-template-rotate` | *Disabled* — the shipped implementation was broken; rebuilt in refactor Phase 5. |
| `ssh-backup` | Create an encrypted archive of the key store. |
| `ssh-restore` | Restore keys from a backup archive. |
| `ssh-history` | View the operations log. |
Expand All @@ -99,10 +99,10 @@ Starts a local web server on a random port (localhost only). A URL with a one-ti
Features:

- **Dashboard** — overview of all host identities, their users, and keys. Connect, rotate, or delete directly from the browser.
- **Templates** — create and manage key templates. Hardware key enrollment and opkssh login run in an embedded terminal (xterm.js over websockets).
- **Templates** — create and manage key templates.
- **History** — searchable log of all operations.

The terminal integration handles interactive workflows (YubiKey touch prompts, OIDC browser login for opkssh) that would otherwise require the CLI.
The embedded xterm.js terminal (hardware key enrollment, opkssh login flows) is **temporarily disabled**: socket.io-client is no longer shipped, and the terminal returns in refactor Phase 1 on a hand-rolled WebSocket ([REFACTOR.md](REFACTOR.md)). Interactive flows fall back to plain form submissions or the CLI until then.

## Architecture

Expand Down Expand Up @@ -160,6 +160,14 @@ The `--legacy` flag injects deprecated algorithms only into the specific host's
- `generate-sk` creates FIDO2/U2F-backed keys (`ed25519-sk`) requiring physical touch.
- `generate-opk` integrates with OpenPubKey via opkssh. `ssh-new` validates certificates and triggers `opkssh login` (with optional issuer) when sessions expire, verifying the credential was updated before connecting.

## Development

Design and threat model live in [ARCHITECTURE.md](ARCHITECTURE.md); the phased refactor plan in [REFACTOR.md](REFACTOR.md).

- `scripts/check` — run all local validation (or a stage: `syntax`, `lint`, `vendor`, `tests`, `secrets`). CI runs exactly this script.
- `scripts/install-hooks` — opt-in pre-push hook that runs `scripts/check`.
- `scripts/vendor-update` — the only sanctioned way to bump vendored JS: fetches pinned versions, cross-checks two CDNs byte-for-byte, rewrites `lib/ui/vendor/SHA384SUMS`. See `lib/ui/vendor/PROVENANCE`.

## License

See [LICENSE](LICENSE) for details.
273 changes: 273 additions & 0 deletions REFACTOR.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion bin/_ssh-unique-key.inc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ get_full_host_scan() {
get_host_uuid_from_scan_data() {
local DATA="$1"
local BEST_KEY
BEST_KEY=$(echo "$DATA" | awk '/ssh-ed25519/{k=$3} /ecdsa/{if(!k)k=$3} /ssh-rsa/{if(!k)k=$3} END{print k}')
# Hash "<type> <base64>" — hostname column excluded so the identity is
# derived purely from key material (stable across hostname/IP changes).
BEST_KEY=$(echo "$DATA" | awk '/ssh-ed25519/{k=$2" "$3} /ecdsa/{if(!k)k=$2" "$3} /ssh-rsa/{if(!k)k=$2" "$3} END{print k}')
if [ -z "$BEST_KEY" ]; then err "No usable key found in scan data."; fi

local HASH
Expand Down
11 changes: 5 additions & 6 deletions bin/ssh-new
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ usage() {
echo " --template <name> Use a key template"
echo " --legacy Enable outdated algorithms (rsa/dss/cbc)"
echo " --comment \"text\" Display this message BEFORE connecting"
echo " --comment \"text\" Display this message BEFORE connecting"
echo " --key-comment \"text\" Set the public key comment (description)"
echo " -V, --verbose Enable verbose output"
echo " -h, --help Show this help message"
Expand All @@ -23,8 +22,6 @@ TEMPLATE_NAME=""
LEGACY_MODE=0
NO_CONNECT=0
COMMENT_TEXT=""
NO_CONNECT=0
COMMENT_TEXT=""
KEY_COMMENT=""
POSITIONAL_ARGS=()

Expand All @@ -37,7 +34,6 @@ while [[ $# -gt 0 ]]; do
--legacy) LEGACY_MODE=1; shift ;;
--no-connect) NO_CONNECT=1; shift ;;
--comment) COMMENT_TEXT="$2"; shift 2 ;;
--comment) COMMENT_TEXT="$2"; shift 2 ;;
--key-comment) KEY_COMMENT="$2"; shift 2 ;;
-*|--*) err "Unknown option $1"; usage ;;
*) POSITIONAL_ARGS+=("$1"); shift ;;
Expand Down Expand Up @@ -90,6 +86,9 @@ if [ -d "$CANONICAL" ]; then
err "SECURITY WARNING: Host keys have changed! Possible MITM."
else
info "Host verification passed."
# Keys verified identical; refresh the (informational) hostname column
# so known_host_keys can pin connections to the current target name.
echo "$SCAN_SORTED" > "$CANONICAL/known_host_keys"
fi
else
info "Creating new identity..."
Expand Down Expand Up @@ -244,7 +243,7 @@ chmod 600 "$TRUSTED_CONF"
if [ "$NO_CONNECT" -eq 0 ]; then
if [ -f "$KEY_FILE.pub" ]; then
info "Copying key..."
ssh-copy-id -i "$KEY_FILE.pub" -o StrictHostKeyChecking=accept-new "$USER_HOST_ARG"
ssh-copy-id -i "$KEY_FILE.pub" -o UserKnownHostsFile="$CANONICAL/known_host_keys" -o StrictHostKeyChecking=yes "$USER_HOST_ARG"
else
if [ -f "$KEY_FILE-cert.pub" ]; then
warn "Certificate found. Assuming server trusts CA. Skipping copy."
Expand Down Expand Up @@ -288,4 +287,4 @@ if [ "$NO_CONNECT" -eq 1 ]; then
fi

info "Connecting..."
exec ssh "$USER_HOST_ARG"
exec ssh -o UserKnownHostsFile="$CANONICAL/known_host_keys" -o StrictHostKeyChecking=yes "$USER_HOST_ARG"
94 changes: 10 additions & 84 deletions bin/ssh-rotate
Original file line number Diff line number Diff line change
@@ -1,86 +1,12 @@
#!/bin/bash
# Stub. The original ssh-rotate called helper functions that were never
# defined anywhere and was unrunnable as shipped (ARCHITECTURE.md W9 lists
# them). Rather than ship broken code, it is disabled until it is rebuilt
# from scratch in REFACTOR.md Phase 5.
#
# To rotate a single user's key on a host, use ssh-user-rotate.
set -e

SCRIPT_DIR=$(dirname "$0")
# shellcheck source=./_ssh-unique-key.inc.sh
source "${SCRIPT_DIR}/_ssh-unique-key.inc.sh"

[ "$#" -ne 1 ] && err "Usage: ssh-rotate <hostname>"
HOST_NAME="$1"

validate_hostname "$HOST_NAME"

CONFIG_SNIPPET_FILE="$CONFIG_DIR/$HOST_NAME"
[ ! -f "$CONFIG_SNIPPET_FILE" ] && err "No config found for '$HOST_NAME'."

# Get current UUID and paths
OLD_PATH=$(get_uuid_path_from_host "$HOST_NAME")
[ -z "$OLD_PATH" ] && exit 1
OLD_UUID=$(basename "$OLD_PATH")

# Scan for new host key
echo "Scanning host $HOST_NAME for new keys..."
RAW_SCAN_DATA=$(get_full_host_scan "$HOST_NAME")
NEW_UUID=$(get_host_uuid_from_scan_data "$RAW_SCAN_DATA")

if [ "$OLD_UUID" == "$NEW_UUID" ]; then
err "Host key has not changed. Nothing to do."
fi

# Backup existing configuration
echo "Creating backup before rotation..."
"$SCRIPT_DIR/ssh-backup"

# Create new canonical directory
NEW_PATH="$UUID_DIR/$NEW_UUID"
mkdir -p -m 700 "$NEW_PATH"

echo "Rotating from $OLD_UUID to $NEW_UUID..."

# Store new host keys
echo "$RAW_SCAN_DATA" > "$NEW_PATH/known_host_keys"
chmod 644 "$NEW_PATH/known_host_keys"

# Migrate user keys and configs
if [ -d "$OLD_PATH" ]; then
# Copy all user directories
for user_dir in "$OLD_PATH"/*; do
if [ -d "$user_dir" ]; then
user=$(basename "$user_dir")
cp -a "$user_dir" "$NEW_PATH/"
echo "Migrated keys for user: $user"
fi
done

# Copy host-specific config if it exists
if [ -f "$OLD_PATH/config" ]; then
cp "$OLD_PATH/config" "$NEW_PATH/"
chmod 600 "$NEW_PATH/config"
fi
fi

# Update symlink in by-key
if [ -L "$KEY_DIR/$OLD_UUID" ]; then
rm "$KEY_DIR/$OLD_UUID"
fi
ln -s "../host-uuid/$NEW_UUID" "$KEY_DIR/$NEW_UUID"

# Update config snippet
sed -i.bak "s/UUID: $OLD_UUID/UUID: $NEW_UUID/" "$CONFIG_SNIPPET_FILE"
rm "${CONFIG_SNIPPET_FILE}.bak"

echo "Host key rotation completed successfully"
echo "Old UUID: $OLD_UUID"
echo "New UUID: $NEW_UUID"
echo "New identity path: $NEW_PATH"

# Ask about cleaning up old identity
read -p "Delete old host identity? (y/N) " confirm
if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then
rm -rf "$OLD_PATH"
echo "Old identity removed."
else
echo "Old identity preserved at: $OLD_PATH"
fi

log "INFO" "Rotated host key for $HOST_NAME from $OLD_UUID to $NEW_UUID"
echo "ssh-rotate is not available: the shipped implementation was broken and" >&2
echo "has been removed. It will be rebuilt in refactor Phase 5 (see REFACTOR.md)." >&2
echo "To rotate one user's key on a host, use: ssh-user-rotate <uuid> <user>" >&2
exit 1
128 changes: 8 additions & 120 deletions bin/ssh-template-rotate
Original file line number Diff line number Diff line change
@@ -1,122 +1,10 @@
#!/bin/bash
# Stub. The original ssh-template-rotate called helper functions that were
# never defined anywhere and was unrunnable as shipped (ARCHITECTURE.md W9
# lists them). Rather than ship broken code, it is disabled until it is
# rebuilt from scratch in REFACTOR.md Phase 5.
set -e

SCRIPT_DIR=$(dirname "$0")
# shellcheck source=./_ssh-unique-key.inc.sh
source "${SCRIPT_DIR}/_ssh-unique-key.inc.sh"

# --- Argument Parsing ---
KEY_TYPE=""
TEMPLATE_NAME=""
FORCE=0

usage() {
echo "Usage: ssh-template-rotate [-f|--force] -t <type> <template-name>"
echo "Options:"
echo " -t, --type Key type to rotate (ed25519, ecdsa, rsa)"
echo " -f, --force Skip confirmation prompts"
echo "Example: ssh-template-rotate -t ed25519 work"
exit 1
}

while [[ $# -gt 0 ]]; do
case $1 in
-t|--type)
KEY_TYPE="$2"
shift 2
;;
-f|--force)
FORCE=1
shift
;;
-h|--help)
usage
;;
*)
if [ -z "$TEMPLATE_NAME" ]; then
TEMPLATE_NAME="$1"
shift
else
usage
fi
;;
esac
done

# --- Validation ---
[ -z "$TEMPLATE_NAME" ] && err "No template name specified"
[ -z "$KEY_TYPE" ] && err "No key type specified"

validate_template "$TEMPLATE_NAME"
validate_key_type "$KEY_TYPE"

TEMPLATE_PATH="$TEMPLATE_DIR/$TEMPLATE_NAME"
OLD_KEY="$TEMPLATE_PATH/id_$KEY_TYPE"
OLD_KEY_PUB="$OLD_KEY.pub"

# Check if key exists
[ ! -f "$OLD_KEY" ] && err "No $KEY_TYPE key found in template $TEMPLATE_NAME"

# Get affected hosts
AFFECTED_HOSTS=($(get_hosts_using_template "$TEMPLATE_NAME"))
if [ ${#AFFECTED_HOSTS[@]} -eq 0 ]; then
err "No hosts are using template $TEMPLATE_NAME"
fi

# Show affected hosts
echo "The following hosts use template '$TEMPLATE_NAME':"
printf '%s\n' "${AFFECTED_HOSTS[@]}"

if [ $FORCE -eq 0 ]; then
read -p "Rotate $KEY_TYPE key for these hosts? (y/N) " confirm
[[ "$confirm" != [yY] ]] && err "Operation cancelled"
fi

# Create backup first
echo "Creating backup..."
"$SCRIPT_DIR/ssh-backup"

# Generate new key
echo "Generating new $KEY_TYPE key for template..."
NEW_KEY_TEMP=$(mktemp)
NEW_KEY_PUB_TEMP=$(mktemp)
trap 'rm -f "$NEW_KEY_TEMP" "$NEW_KEY_PUB_TEMP"' EXIT

ssh-keygen -t "$KEY_TYPE" -f "$NEW_KEY_TEMP" -N "" -C "template:$TEMPLATE_NAME ($(date +%Y-%m-%d))"

# Process each host
for host in "${AFFECTED_HOSTS[@]}"; do
echo "Processing $host..."

# Get host path
HOST_PATH=$(get_uuid_path_from_host "$host")
[ -z "$HOST_PATH" ] && continue

# Find all user directories using this template
while IFS= read -r user_dir; do
if [ -L "$user_dir/id_$KEY_TYPE" ] && readlink "$user_dir/id_$KEY_TYPE" | grep -q "$TEMPLATE_NAME"; then
echo "Updating keys for user $(basename "$user_dir") on $host..."

# Remove old symlinks
rm -f "$user_dir/id_$KEY_TYPE" "$user_dir/id_$KEY_TYPE.pub"

# Create new symlinks
(cd "$user_dir" && \
ln -s "../../templates/$TEMPLATE_NAME/id_$KEY_TYPE" "id_$KEY_TYPE" && \
ln -s "../../templates/$TEMPLATE_NAME/id_$KEY_TYPE.pub" "id_$KEY_TYPE.pub")
fi
done < <(find "$HOST_PATH" -type d -mindepth 1 -maxdepth 1)
done

# Replace the old key with the new one
mv "$NEW_KEY_TEMP" "$OLD_KEY"
mv "$NEW_KEY_PUB_TEMP" "$OLD_KEY_PUB"
chmod 600 "$OLD_KEY"
chmod 644 "$OLD_KEY_PUB"

echo "Template key rotation completed successfully"
echo "New public key:"
cat "$OLD_KEY_PUB"
echo
echo "You may need to update this key on your remote hosts"
log "INFO" "Rotated $KEY_TYPE key for template $TEMPLATE_NAME affecting ${#AFFECTED_HOSTS[@]} hosts"
echo "ssh-template-rotate is not available: the shipped implementation was" >&2
echo "broken and has been removed. It will be rebuilt in refactor Phase 5" >&2
echo "(see REFACTOR.md)." >&2
exit 1
Loading
Loading