Skip to content

Commit 3c79d5c

Browse files
authored
Merge pull request #1 from remcovanmook/refactor/phase-0
Refactor Phase 0: security pinning, UUID derivation fix, vendored JS, scripts/check
2 parents 8125841 + 908a183 commit 3c79d5c

29 files changed

Lines changed: 1649 additions & 398 deletions

‎.github/workflows/check.yml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Thin wrapper: install the optional lint/test tools and run scripts/check.
2+
# No duplicated lint logic in YAML — anyone can run the same script locally
3+
# and get the same result.
4+
name: check
5+
6+
on:
7+
push:
8+
pull_request:
9+
10+
jobs:
11+
check:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- name: Install optional tools
16+
run: |
17+
sudo apt-get update
18+
sudo apt-get install -y shellcheck bats
19+
pipx install ruff
20+
- name: Run checks
21+
run: scripts/check

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
.venv/
22
__pycache__/
33
*.pyc
4-
lib/ui/xterm/
4+
prompts/

‎ARCHITECTURE.md‎

Lines changed: 438 additions & 0 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ This installs commands to `~/bin` (ensure it's in your `$PATH`) and stores key d
3333

3434
Options:
3535

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

40-
The web UI requires Python 3. Dependencies are installed automatically in a virtual environment on first run of `ssh-ui`.
38+
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`.
39+
40+
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)).
4141

4242
## Quick Start
4343

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

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

108-
The terminal integration handles interactive workflows (YubiKey touch prompts, OIDC browser login for opkssh) that would otherwise require the CLI.
108+
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.
109109

110110
## Architecture
111111

@@ -163,6 +163,14 @@ The `--legacy` flag injects deprecated algorithms only into the specific host's
163163
- `generate-sk` creates FIDO2/U2F-backed keys (`ed25519-sk`) requiring physical touch.
164164
- `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.
165165

166+
## Development
167+
168+
Design and threat model live in [ARCHITECTURE.md](ARCHITECTURE.md); the phased refactor plan in [REFACTOR.md](REFACTOR.md).
169+
170+
- `scripts/check` — run all local validation (or a stage: `syntax`, `lint`, `vendor`, `tests`, `secrets`). CI runs exactly this script.
171+
- `scripts/install-hooks` — opt-in pre-push hook that runs `scripts/check`.
172+
- `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`.
173+
166174
## License
167175

168176
See [LICENSE](LICENSE) for details.

‎REFACTOR.md‎

Lines changed: 273 additions & 0 deletions
Large diffs are not rendered by default.

‎bin/_ssh-unique-key.inc.sh‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ get_full_host_scan() {
9292
get_host_uuid_from_scan_data() {
9393
local DATA="$1"
9494
local BEST_KEY
95-
BEST_KEY=$(echo "$DATA" | awk '/ssh-ed25519/{k=$3} /ecdsa/{if(!k)k=$3} /ssh-rsa/{if(!k)k=$3} END{print k}')
95+
# Hash "<type> <base64>" — hostname column excluded so the identity is
96+
# derived purely from key material (stable across hostname/IP changes).
97+
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}')
9698
if [ -z "$BEST_KEY" ]; then err "No usable key found in scan data."; fi
9799

98100
local HASH

‎bin/ssh-new‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ usage() {
1111
echo " --template <name> Use a key template"
1212
echo " --legacy Enable outdated algorithms (rsa/dss/cbc)"
1313
echo " --comment \"text\" Display this message BEFORE connecting"
14-
echo " --comment \"text\" Display this message BEFORE connecting"
1514
echo " --key-comment \"text\" Set the public key comment (description)"
1615
echo " -V, --verbose Enable verbose output"
1716
echo " -h, --help Show this help message"
@@ -23,8 +22,6 @@ TEMPLATE_NAME=""
2322
LEGACY_MODE=0
2423
NO_CONNECT=0
2524
COMMENT_TEXT=""
26-
NO_CONNECT=0
27-
COMMENT_TEXT=""
2825
KEY_COMMENT=""
2926
POSITIONAL_ARGS=()
3027

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

290289
info "Connecting..."
291-
exec ssh "$USER_HOST_ARG"
290+
exec ssh -o UserKnownHostsFile="$CANONICAL/known_host_keys" -o StrictHostKeyChecking=yes "$USER_HOST_ARG"

‎bin/ssh-rotate‎

Lines changed: 10 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,12 @@
11
#!/bin/bash
2+
# Stub. The original ssh-rotate called helper functions that were never
3+
# defined anywhere and was unrunnable as shipped (ARCHITECTURE.md W9 lists
4+
# them). Rather than ship broken code, it is disabled until it is rebuilt
5+
# from scratch in REFACTOR.md Phase 5.
6+
#
7+
# To rotate a single user's key on a host, use ssh-user-rotate.
28
set -e
3-
4-
SCRIPT_DIR=$(dirname "$0")
5-
# shellcheck source=./_ssh-unique-key.inc.sh
6-
source "${SCRIPT_DIR}/_ssh-unique-key.inc.sh"
7-
8-
[ "$#" -ne 1 ] && err "Usage: ssh-rotate <hostname>"
9-
HOST_NAME="$1"
10-
11-
validate_hostname "$HOST_NAME"
12-
13-
CONFIG_SNIPPET_FILE="$CONFIG_DIR/$HOST_NAME"
14-
[ ! -f "$CONFIG_SNIPPET_FILE" ] && err "No config found for '$HOST_NAME'."
15-
16-
# Get current UUID and paths
17-
OLD_PATH=$(get_uuid_path_from_host "$HOST_NAME")
18-
[ -z "$OLD_PATH" ] && exit 1
19-
OLD_UUID=$(basename "$OLD_PATH")
20-
21-
# Scan for new host key
22-
echo "Scanning host $HOST_NAME for new keys..."
23-
RAW_SCAN_DATA=$(get_full_host_scan "$HOST_NAME")
24-
NEW_UUID=$(get_host_uuid_from_scan_data "$RAW_SCAN_DATA")
25-
26-
if [ "$OLD_UUID" == "$NEW_UUID" ]; then
27-
err "Host key has not changed. Nothing to do."
28-
fi
29-
30-
# Backup existing configuration
31-
echo "Creating backup before rotation..."
32-
"$SCRIPT_DIR/ssh-backup"
33-
34-
# Create new canonical directory
35-
NEW_PATH="$UUID_DIR/$NEW_UUID"
36-
mkdir -p -m 700 "$NEW_PATH"
37-
38-
echo "Rotating from $OLD_UUID to $NEW_UUID..."
39-
40-
# Store new host keys
41-
echo "$RAW_SCAN_DATA" > "$NEW_PATH/known_host_keys"
42-
chmod 644 "$NEW_PATH/known_host_keys"
43-
44-
# Migrate user keys and configs
45-
if [ -d "$OLD_PATH" ]; then
46-
# Copy all user directories
47-
for user_dir in "$OLD_PATH"/*; do
48-
if [ -d "$user_dir" ]; then
49-
user=$(basename "$user_dir")
50-
cp -a "$user_dir" "$NEW_PATH/"
51-
echo "Migrated keys for user: $user"
52-
fi
53-
done
54-
55-
# Copy host-specific config if it exists
56-
if [ -f "$OLD_PATH/config" ]; then
57-
cp "$OLD_PATH/config" "$NEW_PATH/"
58-
chmod 600 "$NEW_PATH/config"
59-
fi
60-
fi
61-
62-
# Update symlink in by-key
63-
if [ -L "$KEY_DIR/$OLD_UUID" ]; then
64-
rm "$KEY_DIR/$OLD_UUID"
65-
fi
66-
ln -s "../host-uuid/$NEW_UUID" "$KEY_DIR/$NEW_UUID"
67-
68-
# Update config snippet
69-
sed -i.bak "s/UUID: $OLD_UUID/UUID: $NEW_UUID/" "$CONFIG_SNIPPET_FILE"
70-
rm "${CONFIG_SNIPPET_FILE}.bak"
71-
72-
echo "Host key rotation completed successfully"
73-
echo "Old UUID: $OLD_UUID"
74-
echo "New UUID: $NEW_UUID"
75-
echo "New identity path: $NEW_PATH"
76-
77-
# Ask about cleaning up old identity
78-
read -p "Delete old host identity? (y/N) " confirm
79-
if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then
80-
rm -rf "$OLD_PATH"
81-
echo "Old identity removed."
82-
else
83-
echo "Old identity preserved at: $OLD_PATH"
84-
fi
85-
86-
log "INFO" "Rotated host key for $HOST_NAME from $OLD_UUID to $NEW_UUID"
9+
echo "ssh-rotate is not available: the shipped implementation was broken and" >&2
10+
echo "has been removed. It will be rebuilt in refactor Phase 5 (see REFACTOR.md)." >&2
11+
echo "To rotate one user's key on a host, use: ssh-user-rotate <uuid> <user>" >&2
12+
exit 1

‎bin/ssh-template-rotate‎

Lines changed: 8 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,10 @@
11
#!/bin/bash
2+
# Stub. The original ssh-template-rotate called helper functions that were
3+
# never defined anywhere and was unrunnable as shipped (ARCHITECTURE.md W9
4+
# lists them). Rather than ship broken code, it is disabled until it is
5+
# rebuilt from scratch in REFACTOR.md Phase 5.
26
set -e
3-
4-
SCRIPT_DIR=$(dirname "$0")
5-
# shellcheck source=./_ssh-unique-key.inc.sh
6-
source "${SCRIPT_DIR}/_ssh-unique-key.inc.sh"
7-
8-
# --- Argument Parsing ---
9-
KEY_TYPE=""
10-
TEMPLATE_NAME=""
11-
FORCE=0
12-
13-
usage() {
14-
echo "Usage: ssh-template-rotate [-f|--force] -t <type> <template-name>"
15-
echo "Options:"
16-
echo " -t, --type Key type to rotate (ed25519, ecdsa, rsa)"
17-
echo " -f, --force Skip confirmation prompts"
18-
echo "Example: ssh-template-rotate -t ed25519 work"
19-
exit 1
20-
}
21-
22-
while [[ $# -gt 0 ]]; do
23-
case $1 in
24-
-t|--type)
25-
KEY_TYPE="$2"
26-
shift 2
27-
;;
28-
-f|--force)
29-
FORCE=1
30-
shift
31-
;;
32-
-h|--help)
33-
usage
34-
;;
35-
*)
36-
if [ -z "$TEMPLATE_NAME" ]; then
37-
TEMPLATE_NAME="$1"
38-
shift
39-
else
40-
usage
41-
fi
42-
;;
43-
esac
44-
done
45-
46-
# --- Validation ---
47-
[ -z "$TEMPLATE_NAME" ] && err "No template name specified"
48-
[ -z "$KEY_TYPE" ] && err "No key type specified"
49-
50-
validate_template "$TEMPLATE_NAME"
51-
validate_key_type "$KEY_TYPE"
52-
53-
TEMPLATE_PATH="$TEMPLATE_DIR/$TEMPLATE_NAME"
54-
OLD_KEY="$TEMPLATE_PATH/id_$KEY_TYPE"
55-
OLD_KEY_PUB="$OLD_KEY.pub"
56-
57-
# Check if key exists
58-
[ ! -f "$OLD_KEY" ] && err "No $KEY_TYPE key found in template $TEMPLATE_NAME"
59-
60-
# Get affected hosts
61-
AFFECTED_HOSTS=($(get_hosts_using_template "$TEMPLATE_NAME"))
62-
if [ ${#AFFECTED_HOSTS[@]} -eq 0 ]; then
63-
err "No hosts are using template $TEMPLATE_NAME"
64-
fi
65-
66-
# Show affected hosts
67-
echo "The following hosts use template '$TEMPLATE_NAME':"
68-
printf '%s\n' "${AFFECTED_HOSTS[@]}"
69-
70-
if [ $FORCE -eq 0 ]; then
71-
read -p "Rotate $KEY_TYPE key for these hosts? (y/N) " confirm
72-
[[ "$confirm" != [yY] ]] && err "Operation cancelled"
73-
fi
74-
75-
# Create backup first
76-
echo "Creating backup..."
77-
"$SCRIPT_DIR/ssh-backup"
78-
79-
# Generate new key
80-
echo "Generating new $KEY_TYPE key for template..."
81-
NEW_KEY_TEMP=$(mktemp)
82-
NEW_KEY_PUB_TEMP=$(mktemp)
83-
trap 'rm -f "$NEW_KEY_TEMP" "$NEW_KEY_PUB_TEMP"' EXIT
84-
85-
ssh-keygen -t "$KEY_TYPE" -f "$NEW_KEY_TEMP" -N "" -C "template:$TEMPLATE_NAME ($(date +%Y-%m-%d))"
86-
87-
# Process each host
88-
for host in "${AFFECTED_HOSTS[@]}"; do
89-
echo "Processing $host..."
90-
91-
# Get host path
92-
HOST_PATH=$(get_uuid_path_from_host "$host")
93-
[ -z "$HOST_PATH" ] && continue
94-
95-
# Find all user directories using this template
96-
while IFS= read -r user_dir; do
97-
if [ -L "$user_dir/id_$KEY_TYPE" ] && readlink "$user_dir/id_$KEY_TYPE" | grep -q "$TEMPLATE_NAME"; then
98-
echo "Updating keys for user $(basename "$user_dir") on $host..."
99-
100-
# Remove old symlinks
101-
rm -f "$user_dir/id_$KEY_TYPE" "$user_dir/id_$KEY_TYPE.pub"
102-
103-
# Create new symlinks
104-
(cd "$user_dir" && \
105-
ln -s "../../templates/$TEMPLATE_NAME/id_$KEY_TYPE" "id_$KEY_TYPE" && \
106-
ln -s "../../templates/$TEMPLATE_NAME/id_$KEY_TYPE.pub" "id_$KEY_TYPE.pub")
107-
fi
108-
done < <(find "$HOST_PATH" -type d -mindepth 1 -maxdepth 1)
109-
done
110-
111-
# Replace the old key with the new one
112-
mv "$NEW_KEY_TEMP" "$OLD_KEY"
113-
mv "$NEW_KEY_PUB_TEMP" "$OLD_KEY_PUB"
114-
chmod 600 "$OLD_KEY"
115-
chmod 644 "$OLD_KEY_PUB"
116-
117-
echo "Template key rotation completed successfully"
118-
echo "New public key:"
119-
cat "$OLD_KEY_PUB"
120-
echo
121-
echo "You may need to update this key on your remote hosts"
122-
log "INFO" "Rotated $KEY_TYPE key for template $TEMPLATE_NAME affecting ${#AFFECTED_HOSTS[@]} hosts"
7+
echo "ssh-template-rotate is not available: the shipped implementation was" >&2
8+
echo "broken and has been removed. It will be rebuilt in refactor Phase 5" >&2
9+
echo "(see REFACTOR.md)." >&2
10+
exit 1

0 commit comments

Comments
 (0)