diff --git a/app/installations/essential/yubikey.sh b/app/installations/essential/yubikey.sh index fefa396..65af3c9 100644 --- a/app/installations/essential/yubikey.sh +++ b/app/installations/essential/yubikey.sh @@ -9,6 +9,7 @@ install() { install_package "yubikey-personalization" repo install_package "expect" repo install_package "pamtester" aur + install_package "yubico-authenticator-bin" aur sudo systemctl enable pcscd.service sudo systemctl start pcscd.service @@ -25,4 +26,5 @@ uninstall() { uninstall_package "yubikey-personalization" repo uninstall_package "expect" repo uninstall_package "pamtester" aur + uninstall_package "yubico-authenticator-bin" aur } diff --git a/app/security/ssh/configure-git-gpg.sh b/app/security/ssh/configure-git-gpg.sh deleted file mode 100644 index ac4bd13..0000000 --- a/app/security/ssh/configure-git-gpg.sh +++ /dev/null @@ -1,25 +0,0 @@ -configure_git_gpg() { - local setup_gpg=$(gum confirm "Do you want to configure Git to use GPG for signing commits?" --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") - - if [[ $setup_gpg == "true" ]]; then - if ! command -v gpg &> /dev/null; then - status "GPG is not installed. Please install it first." - return 1 - fi - - local key_id=$(gpg --list-secret-keys --keyid-format LONG | grep sec | cut -d'/' -f2 | cut -d' ' -f1) - - if [ -z "$key_id" ]; then - status "No GPG key found. Please generate a GPG key first." - return 1 - fi - - git config --global user.signingkey $key_id - git config --global commit.gpgsign true - - echo "Git configured to use GPG key: $key_id" - echo "To add this key to your GitHub account, run:" - echo "gpg --armor --export $key_id" - echo "Then copy the output and paste it into your GitHub settings." - fi -} diff --git a/app/security/ssh/enable-ssh-agent.sh b/app/security/ssh/enable-ssh-agent.sh deleted file mode 100644 index d3b35f5..0000000 --- a/app/security/ssh/enable-ssh-agent.sh +++ /dev/null @@ -1,4 +0,0 @@ -enable_ssh_agent_service() { - systemctl --user enable ssh-agent.service - systemctl --user start ssh-agent.service -} \ No newline at end of file diff --git a/app/security/ssh/generate-gpg-key.sh b/app/security/ssh/generate-gpg-key.sh deleted file mode 100644 index a57c806..0000000 --- a/app/security/ssh/generate-gpg-key.sh +++ /dev/null @@ -1,50 +0,0 @@ -generate_gpg_key() { - local setup_gpg=$(gum confirm "Do you want to generate a new GPG key?" --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") - - if [[ $setup_gpg == "true" ]]; then - if ! command -v gpg &> /dev/null; then - status "GPG is not installed. Please install it first." - return 1 - fi - - if gpg --list-secret-keys --keyid-format LONG | grep -q "sec"; then - status "A GPG key already exists. Skipping key generation." - return 0 - fi - - local use_yubikey=$(gum confirm "Do you want to use a YubiKey for GPG?" --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") - - local name=$(gum input --prompt "Enter your name: ") - local email=$(gum input --prompt "Enter your email: ") - - if [[ $use_yubikey == "true" ]]; then - status "Generating GPG key on YubiKey..." - gpg --card-edit --command-fd 0 < -- " diff --git a/app/security/yubikey/yubikey-gpg-health.sh b/app/security/yubikey/yubikey-gpg-health.sh new file mode 100644 index 0000000..d55dbae --- /dev/null +++ b/app/security/yubikey/yubikey-gpg-health.sh @@ -0,0 +1,177 @@ +# YubiKey GPG Health Check +# Read-only diagnostic — safe to run at any time. + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BOLD='\033[1m' +DIM='\033[2m' +NC='\033[0m' + +check_pass() { echo -e " ${GREEN}✅${NC} $1"; } +check_warn() { echo -e " ${YELLOW}⚠️${NC} $1"; } +check_fail() { echo -e " ${RED}❌${NC} $1"; } +check_info() { echo -e " ${DIM}ℹ️${NC} $1"; } + +format_expiry() { + local days=$1 + if [[ $days -lt 0 ]]; then echo -e "${RED}EXPIRED ${days#-} days ago${NC}" + elif [[ $days -lt 30 ]]; then echo -e "${RED}${days} days${NC}" + elif [[ $days -lt 180 ]]; then echo -e "${YELLOW}${days} days${NC}" + else echo -e "${GREEN}${days} days${NC}" + fi +} + +yubikey_gpg_health() { + echo "" + gum style \ + --border double --border-foreground 39 \ + --padding "1 2" --margin "0 1" \ + "🔍 YubiKey GPG/SSH Health Check" + echo "" + local issues=0 warnings=0 + + # ── YubiKey presence ─────────────────────────────────────────────── + echo -e "${BOLD}YubiKey Hardware${NC}" + if ! command -v ykman &>/dev/null; then check_fail "ykman not installed"; return 1; fi + + local ykman_info + ykman_info=$(ykman info 2>/dev/null) + if [[ $? -ne 0 ]]; then check_fail "No YubiKey detected"; return 1; fi + + local yk_serial yk_firmware + yk_serial=$(echo "$ykman_info" | grep "Serial number:" | awk '{print $3}') + yk_firmware=$(echo "$ykman_info" | grep "Firmware version:" | awk '{print $3}') + check_pass "$(echo "$ykman_info" | head -1) (Serial: ${yk_serial:-?}, FW: ${yk_firmware:-?})" + + # ── OpenPGP card ─────────────────────────────────────────────────── + echo "" && echo -e "${BOLD}OpenPGP Card${NC}" + local card_status + card_status=$(gpg --card-status 2>&1) + if [[ $? -ne 0 ]]; then + check_fail "Cannot read card"; ((issues++)) + else + if echo "$card_status" | grep -q "General key info" && \ + ! echo "$card_status" | grep "Signature key" | grep -q "\[none\]"; then + check_pass "Keys present on card" + local key_fp + key_fp=$(echo "$card_status" | grep "key fingerprint" | head -1 | sed 's/.*= //') + [[ -n "$key_fp" ]] && check_info "Fingerprint: $key_fp" + else + check_fail "No keys on card"; ((issues++)) + fi + + if echo "$card_status" | grep -q "KDF setting.*on"; then + check_pass "KDF enabled" + else check_warn "KDF not enabled"; ((warnings++)); fi + + local login_data + login_data=$(echo "$card_status" | grep "^Login data" | sed 's/Login data[^:]*: *//') + if [[ -n "$login_data" && "$login_data" != "(null)" ]]; then + check_pass "Login: $login_data" + else check_warn "Login attribute not set"; ((warnings++)); fi + + local pin_retries + pin_retries=$(echo "$card_status" | grep "PIN retry counter" | sed 's/PIN retry counter[^:]*: *//') + [[ -n "$pin_retries" ]] && check_info "PIN retries: $pin_retries" + fi + + # ── Key expiry ───────────────────────────────────────────────────── + echo "" && echo -e "${BOLD}Key Expiry${NC}" + local now has_keys=false + now=$(date +%s) + while IFS=: read -r type validity length algo keyid created expires _ _ _ _ usage _; do + if [[ "$type" == "sub" ]]; then + has_keys=true + local name="Unknown" + [[ "$usage" == *s* ]] && name="Sign" + [[ "$usage" == *e* ]] && name="Encrypt" + [[ "$usage" == *a* ]] && name="Auth" + if [[ -z "$expires" || "$expires" == "0" ]]; then + check_pass "$name: never expires" + else + local days=$(( (expires - now) / 86400 )) + local date_str + date_str=$(date -d "@$expires" +%F 2>/dev/null) + local fmt + fmt=$(format_expiry "$days") + if [[ $days -lt 30 ]]; then check_fail "$name: $date_str ($fmt)"; ((issues++)) + elif [[ $days -lt 180 ]]; then check_warn "$name: $date_str ($fmt)"; ((warnings++)) + else check_pass "$name: $date_str ($fmt)"; fi + fi + fi + done < <(gpg -k --with-colons 2>/dev/null) + $has_keys || { check_fail "No GPG keys in keyring"; ((issues++)); } + + # ── Trust ────────────────────────────────────────────────────────── + echo "" && echo -e "${BOLD}Trust & Policies${NC}" + local trust + trust=$(gpg -k --with-colons 2>/dev/null | awk -F: '/^pub:/ { print $2; exit }') + case "$trust" in + u) check_pass "Trust: Ultimate" ;; + f) check_pass "Trust: Full" ;; + m) check_warn "Trust: Marginal"; ((warnings++)) ;; + *) check_warn "Trust: $trust"; ((warnings++)) ;; + esac + + # Touch policies + local openpgp_info + openpgp_info=$(ykman openpgp info 2>/dev/null) + if [[ -n "$openpgp_info" ]]; then + local t_sig t_aut t_dec + t_sig=$(echo "$openpgp_info" | grep -A2 "^Signature key:" | grep "Touch policy:" | awk '{print $NF}') + t_dec=$(echo "$openpgp_info" | grep -A2 "^Decryption key:" | grep "Touch policy:" | awk '{print $NF}') + t_aut=$(echo "$openpgp_info" | grep -A2 "^Authentication key:" | grep "Touch policy:" | awk '{print $NF}') + if [[ "$t_sig" == "On" && "$t_aut" == "On" && "$t_dec" == "On" ]]; then + check_pass "Touch: sig=on aut=on dec=on" + else check_warn "Touch: sig=${t_sig:-?} aut=${t_aut:-?} dec=${t_dec:-?}"; ((warnings++)); fi + fi + + # ── SSH ──────────────────────────────────────────────────────────── + echo "" && echo -e "${BOLD}SSH${NC}" + local expected_sock + expected_sock=$(gpgconf --list-dirs agent-ssh-socket 2>/dev/null) + if [[ "$SSH_AUTH_SOCK" == "$expected_sock" ]]; then check_pass "SSH_AUTH_SOCK → gpg-agent" + else check_fail "SSH_AUTH_SOCK: ${SSH_AUTH_SOCK:-unset} (expected: $expected_sock)"; ((issues++)); fi + + [[ -f ~/.ssh/id_rsa_yubikey.pub ]] && check_pass "Key file: ~/.ssh/id_rsa_yubikey.pub" || { check_fail "Key file missing"; ((issues++)); } + + local ssh_keys + ssh_keys=$(ssh-add -L 2>/dev/null) + if [[ $? -eq 0 && -n "$ssh_keys" ]] && ! echo "$ssh_keys" | grep -q "no identities"; then + check_pass "SSH agent: $(echo "$ssh_keys" | wc -l) key(s)" + else check_warn "SSH agent: no keys loaded"; ((warnings++)); fi + + local gk="$HOME/.config/autostart/gnome-keyring-ssh.desktop" + if [[ -f "$gk" ]] && grep -q "Hidden=true" "$gk"; then check_pass "gnome-keyring SSH: disabled" + elif [[ -f /etc/xdg/autostart/gnome-keyring-ssh.desktop ]]; then check_warn "gnome-keyring SSH: may conflict"; ((warnings++)) + else check_pass "gnome-keyring SSH: not present"; fi + + # ── Git ──────────────────────────────────────────────────────────── + echo "" && echo -e "${BOLD}Git${NC}" + local gsk + gsk=$(git config --global --get user.signingkey 2>/dev/null) + [[ -n "$gsk" ]] && check_pass "Signing key: $gsk" || { check_fail "No signing key"; ((issues++)); } + [[ "$(git config --global --get commit.gpgsign)" == "true" ]] && check_pass "Commit signing: on" || { check_warn "Commit signing: off"; ((warnings++)); } + [[ "$(git config --global --get tag.gpgSign)" == "true" ]] && check_pass "Tag signing: on" || { check_warn "Tag signing: off"; ((warnings++)); } + + # ── Systemd & Shell ──────────────────────────────────────────────── + echo "" && echo -e "${BOLD}Services & Shell${NC}" + systemctl --user is-enabled gpg-agent.socket &>/dev/null && check_pass "gpg-agent.socket: enabled" || { check_warn "gpg-agent.socket: not enabled"; ((warnings++)); } + systemctl --user is-enabled gpg-agent-ssh.socket &>/dev/null && check_pass "gpg-agent-ssh.socket: enabled" || { check_warn "gpg-agent-ssh.socket: not enabled"; ((warnings++)); } + [[ -f "$HOME/.oh-my-zsh/custom/plugins/yubikey-gpg/yubikey-gpg.plugin.zsh" ]] && check_pass "Zsh plugin: installed" || { check_fail "Zsh plugin: missing"; ((issues++)); } + + # ── Summary ──────────────────────────────────────────────────────── + echo "" + echo "──────────────────────────────────────────────────────────────────" + if [[ $issues -eq 0 && $warnings -eq 0 ]]; then + echo -e " ${GREEN}${BOLD}All checks passed!${NC}" + elif [[ $issues -eq 0 ]]; then + echo -e " ${YELLOW}${BOLD}$warnings warning(s)${NC} — functional but could be improved." + else + echo -e " ${RED}${BOLD}$issues issue(s)${NC}, ${YELLOW}$warnings warning(s)${NC}" + fi + echo "" +} + +yubikey_gpg_health diff --git a/app/security/yubikey/yubikey-gpg-restore.sh b/app/security/yubikey/yubikey-gpg-restore.sh new file mode 100644 index 0000000..da86a0c --- /dev/null +++ b/app/security/yubikey/yubikey-gpg-restore.sh @@ -0,0 +1,411 @@ +source "$MANJIKAZE_DIR/lib/bitwarden.sh" + +gpg_batch() { + gpg --command-fd=0 --pinentry-mode=loopback "$@" +} + +yubikey_gpg_restore() { + local confirm=$(gum confirm \ + "Restore GPG keys to a NEW YubiKey? + +This will: + • Retrieve key backups from Bitwarden or local files + • Import master key into a temporary keyring + • Transfer subkeys to the new YubiKey + • Update your local GPG keyring to use the new card + +Your existing ~/.gnupg configuration will be preserved." \ + --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") + + if [[ "$confirm" != "true" ]]; then return; fi + + # ── Prerequisites ────────────────────────────────────────────────── + if ! command -v ykman &>/dev/null; then + status "ykman not installed. Run the essential apps installer first." + return 1 + fi + + if ! ykman list | grep -q "YubiKey"; then + status "Please insert your NEW YubiKey and try again." + return 1 + fi + + local yk_serial + yk_serial=$(ykman info | grep "Serial number:" | awk '{print $3}') + status "Detected YubiKey (Serial: ${yk_serial:-unknown})" + + # ── Choose backup source ─────────────────────────────────────────── + local secrets_dir="$MANJIKAZE_DIR/secrets" + local source + source=$(gum choose "Bitwarden" "Local files ($secrets_dir)") + + local tmp_dir + tmp_dir=$(mktemp -d) + trap "rm -rf '$tmp_dir'" EXIT + + local certify_key="" certify_pass="" key_id="" key_fp="" + + if [[ "$source" == "Bitwarden" ]]; then + if ! unlock_bitwarden; then + status "Error: Could not unlock Bitwarden." + return 1 + fi + + # Find YubiKey GPG notes + local items + items=$(bw list items --search "YubiKey GPG Keys" --session "$BW_SESSION" 2>/dev/null) + if [[ -z "$items" || "$items" == "[]" ]]; then + status "No YubiKey GPG backup found in Bitwarden." + return 1 + fi + + # Let user choose if multiple + local note_names + note_names=$(echo "$items" | jq -r '.[].name') + local selected + selected=$(echo "$note_names" | gum choose --header "Select backup to restore:") + local item_id + item_id=$(echo "$items" | jq -r --arg name "$selected" '.[] | select(.name == $name) | .id') + + if [[ -z "$item_id" ]]; then + status "Error: Could not find selected backup." + return 1 + fi + + # Extract passphrase from note content + local note_content + note_content=$(echo "$items" | jq -r --arg name "$selected" '.[] | select(.name == $name) | .notes') + certify_pass=$(echo "$note_content" | grep "Certify Key Passphrase:" | awk '{print $NF}') + key_id=$(echo "$note_content" | grep "GPG Key ID:" | awk '{print $NF}') + + if [[ -z "$certify_pass" ]]; then + status "Warning: Passphrase not found in Bitwarden note." + certify_pass=$(gum input --password --header "Enter Certify key passphrase:") + fi + + # Download Certify.key attachment + local attachments + attachments=$(echo "$items" | jq -r --arg name "$selected" '.[] | select(.name == $name) | .attachments[]?.fileName') + + local certify_file + certify_file=$(echo "$attachments" | grep "Certify.key" | head -1) + if [[ -z "$certify_file" ]]; then + status "Error: No Certify.key attachment found." + return 1 + fi + + bw get attachment "$certify_file" --itemid "$item_id" \ + --output "$tmp_dir/Certify.key" --session "$BW_SESSION" + certify_key="$tmp_dir/Certify.key" + + else + # Local files + if [[ ! -d "$secrets_dir" ]]; then + status "Error: No secrets directory at $secrets_dir" + return 1 + fi + + # Find Certify.key files + local key_files + key_files=$(find "$secrets_dir" -name "*-Certify.key" 2>/dev/null) + if [[ -z "$key_files" ]]; then + status "Error: No Certify.key files found in $secrets_dir" + return 1 + fi + + local selected_file + selected_file=$(echo "$key_files" | gum choose --header "Select key backup:") + certify_key="$selected_file" + + # Extract key ID from filename (format: KEYID-Certify.key) + key_id=$(basename "$selected_file" | sed 's/-Certify\.key$//') + + certify_pass=$(gum input --password --header "Enter Certify key passphrase:") + fi + + if [[ ! -f "$certify_key" ]]; then + status "Error: Certify key file not found." + return 1 + fi + + if [[ -z "$certify_pass" ]]; then + status "Error: Passphrase is required." + return 1 + fi + + # ── Admin PIN ────────────────────────────────────────────────────── + local admin_pin + admin_pin=$(gum input --placeholder "12345678" \ + --header "Enter admin PIN for the NEW YubiKey (default: 12345678):") + admin_pin=${admin_pin:-12345678} + + # ── Reset new YubiKey ────────────────────────────────────────────── + if ! gum confirm "Reset OpenPGP applet on new YubiKey? (Recommended for a fresh start)" \ + --affirmative "Yes, reset" --negative "Skip" --default=false; then + status "Skipping reset. Existing keys will be overwritten." + else + status "Resetting OpenPGP applet..." + ykman openpgp reset -f + admin_pin="12345678" + status "Reset complete. Admin PIN reset to default: 12345678" + fi + + # ── Import into temporary keyring ────────────────────────────────── + status "Importing keys into temporary keyring..." + local real_gnupghome="$GNUPGHOME" + [[ -z "$real_gnupghome" ]] && real_gnupghome="$HOME/.gnupg" + + local tmp_gnupg="$tmp_dir/gnupg" + mkdir -p "$tmp_gnupg" + chmod 700 "$tmp_gnupg" + + # Copy config from real home + for cfg in gpg.conf scdaemon.conf gpg-agent.conf; do + [[ -f "$real_gnupghome/$cfg" ]] && cp "$real_gnupghome/$cfg" "$tmp_gnupg/" + done + + export GNUPGHOME="$tmp_gnupg" + export GPG_TTY=$(tty) + + # Start agent in temp context + gpgconf --kill all 2>/dev/null || true + gpg-connect-agent /bye 2>/dev/null + gpg-connect-agent "scd serialno" /bye 2>/dev/null + + # Import the full key + echo "$certify_pass" | \ + gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \ + --import "$certify_key" 2>&1 + + if [[ $? -ne 0 ]]; then + export GNUPGHOME="$real_gnupghome" + status "Error: Failed to import key. Check the passphrase." + return 1 + fi + + # Get key details from imported key + key_fp=$(gpg -k --with-colons 2>/dev/null | awk -F: '/^fpr:/ { print $10; exit }') + key_id=$(gpg -k --with-colons 2>/dev/null | awk -F: '/^pub:/ { print $5; exit }') + + if [[ -z "$key_fp" ]]; then + export GNUPGHOME="$real_gnupghome" + status "Error: Could not read imported key." + return 1 + fi + + status "Imported key: $key_id (${key_fp:0:4} ... ${key_fp: -4})" + + # ── Enable KDF ───────────────────────────────────────────────────── + status "Enabling KDF on new YubiKey..." + gpg_batch --card-edit < "$tmp_pinentry" </dev/null 2>&1 + + local -a subkey_labels=("Signature" "Encryption" "Authentication") + local -a subkey_slots=(1 2 3) + + for i in "${!subkey_labels[@]}"; do + local label="${subkey_labels[$i]}" + local slot="${subkey_slots[$i]}" + status "Transferring $label key (key $((i+1)) → slot $slot)..." + gpg --command-fd 0 --edit-key "$key_fp" </dev/null 2>&1 + rm -f "$tmp_pinentry" + + # ── Verify transfer ──────────────────────────────────────────────── + status "Verifying keys on new YubiKey..." + local card_status + card_status=$(gpg --card-status 2>&1) + if echo "$card_status" | grep -q "General key info" && \ + ! echo "$card_status" | grep "Signature key" | grep -q "\[none\]" && \ + ! echo "$card_status" | grep "Encryption key" | grep -q "\[none\]" && \ + ! echo "$card_status" | grep "Authentication key" | grep -q "\[none\]"; then + status "Keys successfully written to new YubiKey." + else + export GNUPGHOME="$real_gnupghome" + status "Error: Key transfer may have failed." + echo "$card_status" + return 1 + fi + + # ── Set touch policies ───────────────────────────────────────────── + status "Setting touch policies..." + ykman openpgp keys set-touch sig on -f -a "$admin_pin" + ykman openpgp keys set-touch aut on -f -a "$admin_pin" + ykman openpgp keys set-touch dec on -f -a "$admin_pin" + + # ── Set login attribute ──────────────────────────────────────────── + local uid + uid=$(gpg -k --with-colons 2>/dev/null | awk -F: '/^uid:/ { print $10; exit }') + if [[ -n "$uid" ]]; then + status "Setting login attribute: $uid" + gpg_batch --edit-card < "$tmp_dir/public.asc" + + # Switch back to real keyring + gpgconf --kill all 2>/dev/null || true + export GNUPGHOME="$real_gnupghome" + gpgconf --kill all 2>/dev/null || true + + # Remove old secret key stubs (they point to old card) + gpg --batch --yes --delete-secret-keys "$key_fp" 2>/dev/null || true + + # Import public key (if not already present) + gpg --import "$tmp_dir/public.asc" 2>/dev/null + + # Restart agent and pick up new card + gpg-connect-agent /bye 2>/dev/null + gpg-connect-agent "scd serialno" /bye 2>/dev/null + + # card-status auto-creates new stubs pointing to new card + gpg --card-status >/dev/null 2>&1 + + # Restore ultimate trust + gpg_batch --edit-key "$key_fp" <&1) + local ssh_key + ssh_key=$(gpg --export-ssh-key "$key_fp" 2>/dev/null) + + # Update SSH public key file + if [[ -n "$ssh_key" ]]; then + mkdir -p ~/.ssh + echo "$ssh_key" > ~/.ssh/id_rsa_yubikey.pub + chmod 644 ~/.ssh/id_rsa_yubikey.pub + status "SSH public key updated." + fi + + echo "" + gum style \ + --border double --border-foreground 76 \ + --padding "1 2" --margin "0 1" \ + "✅ YubiKey GPG restore complete" \ + "" \ + "GPG Key: $key_id" \ + "Fingerprint: $key_fp" \ + "New YubiKey: Serial ${yk_serial:-unknown}" + echo "" + echo "Verify with: gpg --card-status" + echo "Test SSH: ssh-add -L" + echo "" +} + +yubikey_gpg_restore diff --git a/app/security/yubikey/yubikey-setup-gpg.sh b/app/security/yubikey/yubikey-setup-gpg.sh index 18f1c84..ebcb71c 100644 --- a/app/security/yubikey/yubikey-setup-gpg.sh +++ b/app/security/yubikey/yubikey-setup-gpg.sh @@ -1,204 +1,511 @@ -source ./lib/bitwarden.sh +source "$MANJIKAZE_DIR/lib/bitwarden.sh" + +# ── Helper: run gpg in non-interactive batch mode ────────────────────── +# Reduces repetition of --command-fd=0 --pinentry-mode=loopback (J) +gpg_batch() { + gpg --command-fd=0 --pinentry-mode=loopback "$@" +} yubikey_setup_gpg() { - local setup_gpg_ssh=$(gum confirm "Do you want to set up GPG and SSH with your YubiKey? This will reset any existing keys and configurations." --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") + local setup_gpg_ssh=$(gum confirm \ + "Do you want to set up GPG and SSH with your YubiKey? + +This will: + • Remove ~/.gnupg (your local GPG keyring and config) + • Optionally reset the YubiKey OpenPGP applet + • Generate new GPG keys on THIS machine - if [[ $setup_gpg_ssh != "true" ]]; then +⚠ For maximum security, keys should be generated on an + air-gapped system. This script trades some security for + convenience by generating on your daily machine." \ + --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") + + if [[ "$setup_gpg_ssh" != "true" ]]; then return fi + # ── Prerequisites ────────────────────────────────────────────────── + if ! command -v ykman &> /dev/null; then + status "ykman (yubikey-manager) is not installed. Please run the essential apps installer first." + return 1 + fi + + # Detect best pinentry for the current desktop environment + local pinentry_program + if [[ "$XDG_CURRENT_DESKTOP" == *"GNOME"* ]]; then + pinentry_program=/usr/bin/pinentry-gnome3 + else + pinentry_program=/usr/bin/pinentry-gtk + fi + status "Using pinentry: $(basename $pinentry_program)" + if ! ykman list | grep -q "YubiKey"; then status "Please insert your YubiKey and try again." return 1 fi + # ── Stop existing GPG services ───────────────────────────────────── status "Stopping any running GPG agent services..." - systemctl --user stop gpg-agent.socket gpg-agent.service gpg-agent-ssh.socket gpg-agent-extra.socket gpg-agent-browser.socket - gpgconf --kill all + systemctl --user stop gpg-agent.socket gpg-agent.service gpg-agent-ssh.socket gpg-agent-extra.socket gpg-agent-browser.socket 2>/dev/null || true + gpgconf --kill all 2>/dev/null || true sudo systemctl restart pcscd sleep 2 + # Disable gnome-keyring SSH agent to prevent conflict with gpg-agent + local gk_desktop="/etc/xdg/autostart/gnome-keyring-ssh.desktop" + local gk_override="$HOME/.config/autostart/gnome-keyring-ssh.desktop" + if [[ -f "$gk_desktop" ]] && [[ ! -f "$gk_override" ]]; then + status "Disabling gnome-keyring SSH agent to avoid conflict with gpg-agent..." + mkdir -p "$HOME/.config/autostart" + cp "$gk_desktop" "$gk_override" + echo "Hidden=true" >> "$gk_override" + fi + + # ── Fresh GPG configuration ──────────────────────────────────────── status "Removing existing GPG configuration..." rm -rf ~/.gnupg mkdir -p ~/.gnupg chmod 700 ~/.gnupg status "Configuring GPG for YubiKey usage..." - wget -q https://raw.githubusercontent.com/drduh/config/master/gpg.conf -O ~/.gnupg/gpg.conf + cp "$MANJIKAZE_DIR/app/security/yubikey/gpg.conf" ~/.gnupg/gpg.conf chmod 600 ~/.gnupg/gpg.conf - gpg_agent_configuration() { - local key="$1" - local value="$2" - local line="$key${value:+ $value}" - local gpg_agent_conf=~/.gnupg/gpg-agent.conf - - if grep -q "^$key" "$gpg_agent_conf"; then - # If the key exists, replace the line - sed -i "s|^$key.*|$line|" "$gpg_agent_conf" - else - # If the key doesn't exist, append the line - echo "$line" >> "$gpg_agent_conf" + # Re-import the Code Signing CA after keyring reset (for commit verification) + local ca_key="$MANJIKAZE_DIR/assets/certs/10kb-code-signing-ca.asc" + if [[ -f "$ca_key" ]]; then + gpg --import "$ca_key" 2>/dev/null || true + local ca_fp + ca_fp=$(gpg --with-colons --import-options show-only --import "$ca_key" 2>/dev/null \ + | grep '^fpr:' | head -1 | cut -d: -f10) + if [[ -n "$ca_fp" ]]; then + echo "$ca_fp:6:" | gpg --import-ownertrust 2>/dev/null fi - } - touch ~/.gnupg/gpg-agent.conf - chmod 600 ~/.gnupg/gpg-agent.conf - gpg_agent_configuration "enable-ssh-support" - gpg_agent_configuration "default-cache-ttl" "3600" # Setting a long cache ttl to prevent frequent PIN prompts - gpg_agent_configuration "max-cache-ttl" "7200" - gpg_agent_configuration "pinentry-program" "/usr/bin/pinentry-curses" # Temporary pinentry program for unattended operations + fi + # scdaemon: disable-ccid prevents repeated prompts for an already-inserted key echo "disable-ccid" > ~/.gnupg/scdaemon.conf chmod 600 ~/.gnupg/scdaemon.conf + # gpg-agent: enable SSH support, use detected pinentry + cat > ~/.gnupg/gpg-agent.conf </dev/null | grep -q "Signature key\|Encryption key\|Authentication key" && \ - grep -qv "\[none\]" <(gpg --card-status 2>/dev/null | grep "key\.\.\.\.\."); then + if ! gpg --card-status > /dev/null 2>&1; then + status "Error: Cannot read YubiKey. Please check your YubiKey and try again." + return 1 + fi + + # Check for existing keys on the card by inspecting actual key slots + # (not "General key info" which depends on the local keyring state) + local existing_card_status + existing_card_status=$(gpg --card-status 2>/dev/null) + local has_existing_keys=false + if echo "$existing_card_status" | grep "^Signature key" | grep -qv "\[none\]" || \ + echo "$existing_card_status" | grep "^Encryption key" | grep -qv "\[none\]" || \ + echo "$existing_card_status" | grep "^Authentication key" | grep -qv "\[none\]"; then + has_existing_keys=true + fi + + if $has_existing_keys; then local reset_yubikey=$(gum confirm "Existing keys found on YubiKey. Do you want to reset the YubiKey OpenPGP applet?" --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") - if [[ $reset_yubikey == "true" ]]; then + if [[ "$reset_yubikey" == "true" ]]; then status "Resetting YubiKey OpenPGP applet..." - ykman openpgp reset + ykman openpgp reset -f gpg-connect-agent "scd serialno" /bye - gpgsm --learn else status "Operation cancelled. YubiKey must be reset to proceed." return 1 fi fi + # ── Collect user input ───────────────────────────────────────────── status "Configuring GPG keys..." local key_type="rsa4096" - local expiration=$(gum input --placeholder "5y" --header "Enter sub key expiration (e.g., 5y for 5 years):") - local name=$(gum input --header "Enter your name:") - local email=$(gum input --header "Enter your email:") - local admin_pin=$(gum input --placeholder "12345678" --header "Enter YubiKey admin PIN (default: 12345678):") + local expiration + expiration=$(gum input --placeholder "5y" --header "Enter sub key expiration (e.g., 5y for 5 years):") + expiration=${expiration:-5y} + + local name + name=$(gum input --header "Enter your name:") + if [[ -z "$name" ]]; then + status "Error: Name is required." + return 1 + fi + + local email + email=$(gum input --header "Enter your email:") + if [[ -z "$email" ]]; then + status "Error: Email is required." + return 1 + fi + + local admin_pin + admin_pin=$(gum input --placeholder "12345678" --header "Enter current YubiKey admin PIN (default: 12345678):") admin_pin=${admin_pin:-12345678} - - status "Generating GPG key..." - gpg --batch --passphrase '' --quick-generate-key "$name <$email>" $key_type cert never + + # Generate a strong passphrase for the certify key + status "Generating passphrase for the Certify (master) key..." + local certify_pass + certify_pass=$(LC_ALL=C tr -dc "A-Z2-9" < /dev/urandom | \ + tr -d "IOUS5" | \ + fold -w 4 | \ + paste -sd - - | \ + head -c 29) + echo "" + gum style \ + --border double --border-foreground 212 \ + --padding "1 2" --margin "0 1" \ + "🔑 Certify Key Passphrase (SAVE THIS!)" \ + "" \ + "$certify_pass" \ + "" \ + "This passphrase protects your master key backup." \ + "You will need it to renew or rotate subkeys." \ + "It will be stored in Bitwarden if available." + echo "" + + if ! gum confirm "Have you saved the Certify key passphrase?" --affirmative "Yes, continue" --negative "Cancel" --default=false; then + status "Setup cancelled. Please save the passphrase before continuing." + return 1 + fi + + # Enable KDF before changing PINs or transferring keys + # KDF stores the hash of PIN on YubiKey, preventing PIN from being sent as plain text + status "Enabling KDF (Key Derived Function) on YubiKey..." + gpg_batch --card-edit <" "$key_type" cert never + + local key_id key_fp key_id=$(gpg -k --with-colons "$name <$email>" | awk -F: '/^pub:/ { print $5; exit }') key_fp=$(gpg -k --with-colons "$name <$email>" | awk -F: '/^fpr:/ { print $10; exit }') - - status "Generating subkeys..." + + if [[ -z "$key_fp" ]]; then + status "Error: Failed to generate GPG key." + return 1 + fi + + status "Generating subkeys (sign, encrypt, auth)..." for subkey in sign encrypt auth; do - gpg --batch --passphrase '' --quick-add-key $key_fp $key_type $subkey $expiration + echo "$certify_pass" | \ + gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \ + --quick-add-key "$key_fp" "$key_type" "$subkey" "$expiration" done + # ── Sign developer key with 10KB CA ──────────────────────────────── + # The developer's public key must be signed by the 10KB CA so that + # manjikaze can verify their commits. The CA admin sends the CA + # private key via a Bitwarden Send link (time-limited, single use). + # This step is done BEFORE the backup, so the backup contains the + # CA-signed public key. + echo "" + gum style \ + --border rounded --border-foreground 212 \ + --padding "1 2" --margin "0 1" \ + "🔐 10KB CA Code Signing" \ + "" \ + "To verify your commits, your GPG key must be signed by the" \ + "10KB Code Signing CA. Ask your CA administrator for a" \ + "Bitwarden Send link containing the CA private key." \ + "" \ + "The administrator creates the Send with:" \ + " • Max access count: 1" \ + " • Expiration: 1 hour" \ + "" \ + "The CA key will be removed from this machine after signing." + echo "" + + local ca_signed=false + if gum confirm "Do you have a Bitwarden Send link for the CA key?" --default=false; then + local send_url + send_url=$(gum input --header "Paste the Bitwarden Send link:" --placeholder "https://vault.bitwarden.com/...") + + if [[ -n "$send_url" ]]; then + local send_password="" + if gum confirm "Does the Send link require a password?" --default=false; then + send_password=$(gum input --password --header "Enter the Send password:") + fi + + local ca_private_key + ca_private_key=$(mktemp) + + # Download the CA key via Bitwarden Send + status "Downloading CA key from Bitwarden Send..." + local receive_cmd="bw send receive \"$send_url\" --output \"$ca_private_key\"" + if [[ -n "$send_password" ]]; then + receive_cmd="$receive_cmd --password \"$send_password\"" + fi + + if eval "$receive_cmd" 2>/dev/null; then + # Import the CA key, sign the developer key, then remove the CA key + local ca_key_fp + ca_key_fp=$(gpg --with-colons --import-options show-only --import "$ca_private_key" 2>/dev/null \ + | grep '^fpr:' | head -1 | cut -d: -f10) + + gpg --batch --import "$ca_private_key" 2>/dev/null + + # Sign the developer key with the CA key (non-interactive) + if gpg --batch --yes --default-key "$ca_key_fp" --sign-key "$key_fp" 2>/dev/null; then + status "Your GPG key has been signed by the 10KB CA." + ca_signed=true + + # Upload signed key to keyserver + status "Uploading signed key to keys.openpgp.org..." + gpg --keyserver hkps://keys.openpgp.org --send-keys "$key_fp" 2>/dev/null && \ + status "Key uploaded to keyserver." || \ + status "Warning: Could not upload to keyserver. You may need to do this manually." + else + status "Warning: Failed to sign your key with the CA." + fi + + # Remove the CA private key from the local keyring + gpg --batch --yes --delete-secret-keys "$ca_key_fp" 2>/dev/null || true + else + status "Warning: Could not download CA key from Bitwarden Send." + status "The link may have expired or already been used." + fi + + # Securely remove the temporary file + shred -u "$ca_private_key" 2>/dev/null || rm -f "$ca_private_key" + fi + fi + + if [[ "$ca_signed" != "true" ]]; then + status "Your key was NOT signed by the 10KB CA." + status "Your commits will not pass manjikaze's signature verification until your key is CA-signed." + status "Ask a CA administrator to sign your key and re-run this step." + fi + + # ── Backup keys ──────────────────────────────────────────────────── status "Creating backups of GPG keys..." - gpg --output ./secrets/$key_id-Certify.key --armor --export-secret-keys $key_id - gpg --output ./secrets/$key_id-Subkeys.key --armor --export-secret-subkeys $key_id - gpg --output ./secrets/$key_id-$(date +%F).asc --armor --export $key_id - status "GPG key backups created in ./secrets directory" + local secrets_dir="$MANJIKAZE_DIR/secrets" + local backup_date + backup_date=$(date +%F) + mkdir -p "$secrets_dir" + + echo "$certify_pass" | \ + gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \ + --output "$secrets_dir/$key_id-Certify.key" \ + --armor --export-secret-keys "$key_id" + echo "$certify_pass" | \ + gpg --batch --pinentry-mode=loopback --passphrase-fd 0 \ + --output "$secrets_dir/$key_id-Subkeys.key" \ + --armor --export-secret-subkeys "$key_id" + gpg --output "$secrets_dir/$key_id-$backup_date.asc" \ + --armor --export "$key_id" + + # Generate revocation certificate + status "Generating revocation certificate..." + gpg --pinentry-mode=loopback --passphrase "$certify_pass" \ + --command-fd 0 \ + --gen-revoke --armor --output "$secrets_dir/$key_id-revoke.asc" "$key_id" <" -send "key $keynum\r" -expect "gpg>" -send "keytocard\r" -expect "Your selection?" -send "$keytype\r" -expect { - "Replace existing key?" { - send "y\r" - exp_continue - } - "PIN" { - send "$admin_pin\r" - } -} -expect "gpg>" -send "save\r" -expect eof + + # ── Transfer subkeys to YubiKey ──────────────────────────────────── + # GPG 2.x routes ALL PIN/passphrase requests through pinentry, even with + # --passphrase-fd. We use a temporary custom pinentry that inspects the + # prompt to determine which secret to provide: + # "Admin PIN" prompt → card admin PIN + # anything else → certify key passphrase + + local tmp_pinentry + tmp_pinentry=$(mktemp) + cat > "$tmp_pinentry" </dev/null 2>&1 + + local -a subkey_labels=("Signature" "Encryption" "Authentication") + local -a subkey_slots=(1 2 3) + + for i in "${!subkey_labels[@]}"; do + local label="${subkey_labels[$i]}" + local slot="${subkey_slots[$i]}" + status "Transferring $label key to YubiKey (key $((i+1)) → slot $slot)..." + + gpg --command-fd 0 --edit-key "$key_fp" </dev/null 2>&1 + rm -f "$tmp_pinentry" + # ── Verify transfer ──────────────────────────────────────────────── status "Verifying keys on YubiKey..." - card_status=$(gpg --card-status) + local card_status + card_status=$(gpg --card-status 2>&1) if echo "$card_status" | grep -q "General key info" && \ - echo "$card_status" | grep -q "Signature key" && \ - echo "$card_status" | grep -q "Encryption key" && \ - echo "$card_status" | grep -q "Authentication key"; then + ! echo "$card_status" | grep "Signature key" | grep -q "\[none\]" && \ + ! echo "$card_status" | grep "Encryption key" | grep -q "\[none\]" && \ + ! echo "$card_status" | grep "Authentication key" | grep -q "\[none\]"; then status "Keys successfully written to YubiKey." else status "Error: Keys may not have been properly written to YubiKey. Please check your YubiKey and try again." + echo "$card_status" return 1 fi - gpg --armor --export $key_fp > ./secrets/gpg-public-key.asc - status "GPG public key exported to ./secrets/gpg-public-key.asc" + # ── Set ultimate trust ───────────────────────────────────────────── + status "Setting ultimate trust on the key..." + gpg_batch --edit-key "$key_fp" < +$admin_pin +quit +EOF + + # ── Export public key and SSH public key ──────────────────────────── + gpg --armor --export "$key_fp" > "$secrets_dir/gpg-public-key.asc" + status "GPG public key exported to $secrets_dir/gpg-public-key.asc" - ssh_public_key=$(gpg --export-ssh-key $key_fp) + mkdir -p ~/.ssh + local ssh_public_key + ssh_public_key=$(gpg --export-ssh-key "$key_fp") echo "$ssh_public_key" > ~/.ssh/id_rsa_yubikey.pub - chmod 600 ~/.ssh/id_rsa_yubikey.pub + chmod 644 ~/.ssh/id_rsa_yubikey.pub status "SSH public key exported to ~/.ssh/id_rsa_yubikey.pub" + # ── Configure Git ────────────────────────────────────────────────── status "Configuring Git to use GPG key for signing..." - git config --global user.signingkey $key_fp + git config --global user.signingkey "$key_fp" git config --global commit.gpgsign true - git config --global gpg.program $(which gpg) + git config --global tag.gpgSign true + git config --global gpg.program "$(which gpg)" git config --global gpg.format openpgp - status "Configuring YubiKey to require touch for operations..." - gpg_agent_configuration "pinentry-program" "/usr/bin/pinentry-gnome3" # Set pinentry program to GUI pinentry prompts - gpg_agent_configuration "allow-loopback-pinentry" - gpg_agent_configuration "no-grab" - gpg-connect-agent /bye - - # Set touch policies using ykman with expect - for policy in sig aut enc; do - expect << EOF -spawn ykman openpgp keys set-touch $policy on -expect "Enter admin PIN:" -send "$admin_pin\r" -expect "Set touch policy of * key to on? \[y/N\]:" -send "y\r" -expect eof -EOF - done + # ── Configure touch policies ─────────────────────────────────────── + # 'cached' = touch required, but cached for 15s after use + # This avoids repeated touches during batch operations (e.g. git rebase) + status "Configuring YubiKey to require touch for operations (cached 15s)..." + ykman openpgp keys set-touch sig cached -f -a "$admin_pin" + ykman openpgp keys set-touch aut cached -f -a "$admin_pin" + ykman openpgp keys set-touch dec cached -f -a "$admin_pin" if ! gpg --card-status > /dev/null 2>&1; then status "Error: Failed to verify YubiKey configuration. Please check your YubiKey and try again." @@ -207,47 +514,76 @@ EOF status "YubiKey touch policies configured successfully." + # ── SSH config ───────────────────────────────────────────────────── status "Configuring SSH to use GPG agent..." - cat << EOF > ~/.ssh/config + # Only add YubiKey IdentityFile if not already present + if [[ ! -f ~/.ssh/config ]] || ! grep -q "id_rsa_yubikey" ~/.ssh/config; then + cat >> ~/.ssh/config << 'SSHCONF' + +# YubiKey GPG-based SSH authentication Host * IdentityFile ~/.ssh/id_rsa_yubikey.pub -EOF +SSHCONF + chmod 600 ~/.ssh/config + fi - status "Starting gpg-agent on startup..." - mkdir -p ~/.config/systemd/user/ - cat << EOF > ~/.config/systemd/user/gpg-agent-restart.service -[Unit] -Description=Restart GPG Agent -After=graphical-session.target -ConditionUser=!root - -[Service] -Type=oneshot -ExecStart=/usr/bin/systemctl --user stop gpg-agent.socket gpg-agent.service gpg-agent-ssh.socket gpg-agent-extra.socket gpg-agent-browser.socket -ExecStart=/usr/bin/gpgconf --kill all -ExecStart=/usr/bin/systemctl --user start gpg-agent.socket -ExecStart=/usr/bin/gpg-connect-agent /bye -ExecStart=/usr/bin/gpg-connect-agent updatestartuptty /bye -RemainAfterExit=yes - -[Install] -WantedBy=graphical-session.target -EOF + # ── Shell environment (SSH_AUTH_SOCK, GPG_TTY) ───────────────────── + # Install as an oh-my-zsh custom plugin so it's loaded automatically. + # This ensures SSH_AUTH_SOCK is set consistently for both terminal and + # GUI-launched applications (via systemd import-environment). + status "Configuring shell environment for GPG/SSH..." + local plugin_dir="$HOME/.oh-my-zsh/custom/plugins/yubikey-gpg" + mkdir -p "$plugin_dir" + cat > "$plugin_dir/yubikey-gpg.plugin.zsh" << 'PLUGINCONF' +# YubiKey GPG agent for SSH +# Sets SSH_AUTH_SOCK to the gpg-agent SSH socket so that ssh, git, etc. +# use the GPG agent (and thus the YubiKey) for authentication. + +export GPG_TTY=$(tty) +export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket) + +# Make sure gpg-agent is running +gpgconf --launch gpg-agent - systemctl --user daemon-reload - systemctl --user enable gpg-agent-restart.service - systemctl --user start gpg-agent-restart.service +# Update the TTY for the current session (important for pinentry) +gpg-connect-agent updatestartuptty /bye > /dev/null 2>&1 +PLUGINCONF + + # Activate the plugin via oh-my-zsh + activate_zsh_plugin "yubikey-gpg" + + # Also export to systemd user environment so GUI apps pick it up + systemctl --user import-environment SSH_AUTH_SOCK GPG_TTY 2>/dev/null || true + + # ── Systemd gpg-agent socket activation ──────────────────────────── + # Start the gpg-agent sockets — they are socket-activated (auto-started on + # demand) so no 'enable' is needed; just starting them is sufficient. + status "Starting gpg-agent socket activation..." + systemctl --user start gpg-agent.socket gpg-agent-ssh.socket 2>/dev/null || true status "YubiKey GPG and SSH setup complete." - local change_pins=$(gum confirm "Do you want to change your YubiKey OpenPGP PINs?" --affirmative "Yes" --negative "No" --default=false && echo "true" || echo "false") - - if [[ $change_pins == "true" ]]; then - gpg_agent_configuration "pinentry-program" "/usr/bin/pinentry-curses" # Temporary pinentry program for unattended operations - gpg-connect-agent /bye + # ── Change PINs (strongly recommended) ────────────────────────────── + # The User PIN is needed once per day to unlock the YubiKey for SSH, + # Git signing, and other GPG operations. Changing it from the default + # (123456) is essential. + local change_pins=$(gum confirm \ + "Change your YubiKey PINs? (Strongly recommended) - local new_user_pin=$(gum input --password --placeholder "123456" --header "Enter new User PIN (min 6 characters):") - local new_admin_pin=$(gum input --password --placeholder "12345678" --header "Enter new Admin PIN (min 8 characters):") +Your User PIN unlocks the YubiKey for daily use (SSH, Git, GPG). +The default PIN (123456) is insecure and should be changed. +Choose a PIN you can remember — you'll enter it once per day." \ + --affirmative "Yes, change PINs" --negative "Skip" --default=true && echo "true" || echo "false") + + if [[ "$change_pins" == "true" ]]; then + local current_user_pin + current_user_pin=$(gum input --password --placeholder "123456" --header "Enter CURRENT User PIN (default after reset: 123456):") + current_user_pin=${current_user_pin:-123456} + + local new_user_pin + new_user_pin=$(gum input --password --placeholder "" --header "Enter new User PIN (min 6 characters):") + local new_admin_pin + new_admin_pin=$(gum input --password --placeholder "" --header "Enter new Admin PIN (min 8 characters):") if [[ ${#new_user_pin} -lt 6 ]]; then status "Error: User PIN must be at least 6 characters long" @@ -258,64 +594,86 @@ EOF return 1 fi - expect <" -send "admin\r" -expect "gpg/card>" -send "passwd\r" -expect "Your selection?" -# Change Admin PIN first -send "3\r" -expect "Enter Admin PIN:" -send "$admin_pin\r" -expect "Enter New Admin PIN:" -send "$new_admin_pin\r" -expect "Repeat New Admin PIN:" -send "$new_admin_pin\r" -expect "PIN changed" -expect "Your selection?" -# Change User PIN -send "1\r" -expect "Enter PIN:" -send "123456\r" -expect "Enter New PIN:" -send "$new_user_pin\r" -expect "Repeat New PIN:" -send "$new_user_pin\r" -expect "PIN changed" -expect "Your selection?" -send "q\r" -expect "gpg/card>" -send "quit\r" -expect eof + # Change Admin PIN first (option 3) + status "Changing Admin PIN..." + gpg_batch --change-pin </dev/null | jq -r '.status' 2>/dev/null) - status "Bitwarden vault is locked. Attempting to unlock..." - master_password=$(gum input --password --prompt "Enter your Bitwarden master password: ") + if [[ "$bw_status" == "unlocked" ]]; then + # Already unlocked — BW_SESSION should be set from a previous call + if [[ -n "$BW_SESSION" ]]; then + return 0 + fi + fi - export BW_PASSWORD="$master_password" + if [[ "$bw_status" == "unauthenticated" ]]; then + status "Bitwarden vault is not logged in." + if ! gum confirm "Log in to Bitwarden now?" --affirmative "Yes" --negative "Skip" --default=false; then + return 1 + fi + local bw_email + bw_email=$(gum input --placeholder "user@example.com" --header "Bitwarden email address:") + if [[ -z "$bw_email" ]]; then + status "No email provided. Skipping Bitwarden." + return 1 + fi -ensure_folder() { - local folder_name="$1" + local bw_master + bw_master=$(gum input --password --header "Bitwarden master password:") + if [[ -z "$bw_master" ]]; then + status "No password provided. Skipping Bitwarden." + return 1 + fi - local folder_id=$(bw list folders | jq -r ".[] | select(.name==\"$folder_name\") | .id") - if [[ -z "$folder_id" ]]; then - status "Creating '$folder_name' folder in Bitwarden..." - folder_id=$(bw get template folder | jq --arg name "$folder_name" '.name=$name' | bw encode | bw create folder | jq -r '.id') - if [[ -z "$folder_id" ]]; then - status "Failed to create '$folder_name' folder in Bitwarden." + export BW_PASSWORD="$bw_master" + + # Check if 2FA is needed + local bw_2fa_code="" + if gum confirm "Do you have 2FA enabled on Bitwarden?" --affirmative "Yes" --negative "No" --default=true; then + bw_2fa_code=$(gum input --placeholder "123456" --header "Enter 2FA code from authenticator app:") + fi + + if [[ -n "$bw_2fa_code" ]]; then + BW_SESSION=$(bw login "$bw_email" --passwordenv BW_PASSWORD --code "$bw_2fa_code" --method 0 --raw 2>/dev/null) + else + BW_SESSION=$(bw login "$bw_email" --passwordenv BW_PASSWORD --raw 2>/dev/null) + fi + unset BW_PASSWORD + export BW_SESSION + + if [[ -z "$BW_SESSION" ]]; then + status "Failed to log in to Bitwarden. Check your credentials." return 1 fi + bw sync --session "$BW_SESSION" >/dev/null 2>&1 || true + status "Bitwarden login successful." + return 0 + fi + + status "Bitwarden vault is locked. Attempting to unlock..." + local master_password + master_password=$(gum input --password --prompt "Enter your Bitwarden master password: ") + + # Use --passwordenv per Bitwarden CLI docs + export BW_PASSWORD="$master_password" + BW_SESSION=$(bw unlock --passwordenv BW_PASSWORD --raw 2>/dev/null) + unset BW_PASSWORD + export BW_SESSION + + if [[ -z "$BW_SESSION" ]]; then + status "Failed to unlock Bitwarden vault." + return 1 + fi + + # Sync vault data after unlocking + bw sync --session "$BW_SESSION" >/dev/null 2>&1 || true +} + +# ── Organization helpers ─────────────────────────────────────────────── + +get_org_id() { + local org_name="${1:-10KB}" + bw list organizations --session "$BW_SESSION" 2>/dev/null \ + | jq -r ".[] | select(.name==\"$org_name\") | .id" +} + +get_user_collection_id() { + local org_id="$1" + local user_name="$2" + local collection_path="Medewerkers/${user_name}" + + bw list org-collections --organizationid "$org_id" --session "$BW_SESSION" 2>/dev/null \ + | jq -r ".[] | select(.name==\"$collection_path\") | .id" +} + +upsert_org_note() { + local org_id="$1" + local collection_id="$2" + local note_name="$3" + local note_content="$4" + + # Search for existing item in the organization + local existing_item + existing_item=$(bw list items --organizationid "$org_id" --collectionid "$collection_id" \ + --session "$BW_SESSION" 2>/dev/null \ + | jq -r ".[] | select(.name==\"$note_name\") | .id") + local item_id if [[ -n "$existing_item" ]]; then - bw get item "$existing_item" | \ + bw get item "$existing_item" --session "$BW_SESSION" | \ jq --arg notes "$note_content" '.notes = $notes' | \ bw encode | \ - bw edit item "$existing_item" > /dev/null + bw edit item "$existing_item" --session "$BW_SESSION" > /dev/null item_id="$existing_item" else item_id=$(bw get template item | \ jq --arg name "$note_name" \ - --arg folder_id "$folder_id" \ + --arg org_id "$org_id" \ + --arg coll_id "$collection_id" \ --arg notes "$note_content" \ - '.type = 2 | .secureNote.type = 0 | .name = $name | .folderId = $folder_id | .notes = $notes' | \ + '.type = 2 | .secureNote.type = 0 | .name = $name | .organizationId = $org_id | .collectionIds = [$coll_id] | .notes = $notes' | \ bw encode | \ - bw create item | jq -r '.id') + bw create item --session "$BW_SESSION" | jq -r '.id') fi echo "$item_id" +} +add_attachment() { local item_id="$1" local file_path="$2" @@ -45,5 +134,51 @@ ensure_folder() { fi status "Adding attachment: $(basename "$file_path")..." - bw create attachment --file "$file_path" --itemid "$item_id" > /dev/null -} \ No newline at end of file + bw create attachment --file "$file_path" --itemid "$item_id" --session "$BW_SESSION" > /dev/null +} + +# ── Legacy folder-based helpers (backward compatibility) ─────────────── + +ensure_folder() { + local folder_name="$1" + + local folder_id + folder_id=$(bw list folders --session "$BW_SESSION" | jq -r ".[] | select(.name==\"$folder_name\") | .id") + if [[ -z "$folder_id" ]]; then + status "Creating '$folder_name' folder in Bitwarden..." + folder_id=$(bw get template folder | jq --arg name "$folder_name" '.name=$name' | bw encode | bw create folder --session "$BW_SESSION" | jq -r '.id') + if [[ -z "$folder_id" ]]; then + status "Failed to create '$folder_name' folder in Bitwarden." + return 1 + fi + fi + echo "$folder_id" +} + +upsert_note() { + local folder_id="$1" + local note_name="$2" + local note_content="$3" + + local existing_item + existing_item=$(bw list items --folderid "$folder_id" --session "$BW_SESSION" | jq -r ".[] | select(.name==\"$note_name\") | .id") + + local item_id + + if [[ -n "$existing_item" ]]; then + bw get item "$existing_item" --session "$BW_SESSION" | \ + jq --arg notes "$note_content" '.notes = $notes' | \ + bw encode | \ + bw edit item "$existing_item" --session "$BW_SESSION" > /dev/null + item_id="$existing_item" + else + item_id=$(bw get template item | \ + jq --arg name "$note_name" \ + --arg folder_id "$folder_id" \ + --arg notes "$note_content" \ + '.type = 2 | .secureNote.type = 0 | .name = $name | .folderId = $folder_id | .notes = $notes' | \ + bw encode | \ + bw create item --session "$BW_SESSION" | jq -r '.id') + fi + echo "$item_id" +} diff --git a/lib/menus.sh b/lib/menus.sh index f5ce1dc..e3f2499 100644 --- a/lib/menus.sh +++ b/lib/menus.sh @@ -42,8 +42,11 @@ declare -A security_menu=( ["3:Configure Yubikey as MFA for system"]="load_module security/yubikey/yubikey-pam-authentication.sh" ["4:Auto lock on Yubikey removal"]="load_module security/yubikey/yubikey-suspend.sh" ["5:Replace faulty YubiKey"]="load_module security/yubikey/yubikey-replace.sh" - ["6:Configure YubiKey for AWS Vault MFA"]="load_module security/yubikey/yubikey-aws-vault.sh" - ["7:Configure weekly update checks"]="load_module security/updates/configure-update-checker.sh" + ["6:Configure YubiKey for GPG and SSH"]="load_module security/yubikey/yubikey-setup-gpg.sh" + ["7:YubiKey GPG health check"]="load_module security/yubikey/yubikey-gpg-health.sh" + ["8:Restore GPG keys to new YubiKey"]="load_module security/yubikey/yubikey-gpg-restore.sh" + ["9:Configure YubiKey for AWS Vault MFA"]="load_module security/yubikey/yubikey-aws-vault.sh" + ["10:Configure weekly update checks"]="load_module security/updates/configure-update-checker.sh" ) handle_menu() { diff --git a/lib/state.sh b/lib/state.sh index 08e8cfc..efd51fa 100644 --- a/lib/state.sh +++ b/lib/state.sh @@ -60,3 +60,17 @@ set_cursor_release_track() { local temp_state=$(mktemp) jq --arg track "$track" '.settings.cursor_release_track = $track' "$STATE_FILE" > "$temp_state" && mv "$temp_state" "$STATE_FILE" } + +get_security_state() { + local key="$1" + init_state_file + jq -r ".security.\"$key\" // \"\"" "$STATE_FILE" +} + +set_security_state() { + local key="$1" + local value="$2" + init_state_file + local temp_state=$(mktemp) + jq --arg key "$key" --arg val "$value" '.security += {($key): $val}' "$STATE_FILE" > "$temp_state" && mv "$temp_state" "$STATE_FILE" +} diff --git a/migrations/1771854232_setup_gpg_code_signing_ca.sh b/migrations/1771854232_setup_gpg_code_signing_ca.sh new file mode 100644 index 0000000..e93d559 --- /dev/null +++ b/migrations/1771854232_setup_gpg_code_signing_ca.sh @@ -0,0 +1,51 @@ +#!/bin/bash +set -e + +# Import the 10KB Code Signing CA into the local GPG keyring and set it +# to ultimate trust. This enables GPG-based commit signature verification +# during manjikaze updates. +# +# Also configures GPG to auto-retrieve developer keys from keyservers, +# so signed commits from any CA-certified developer can be verified +# without manually importing their keys. + +CA_KEY="$MANJIKAZE_DIR/assets/certs/10kb-code-signing-ca.asc" + +if [[ ! -f "$CA_KEY" ]]; then + status "Warning: Code Signing CA key not found at $CA_KEY. Skipping." + return 0 +fi + +# Import the CA key (idempotent) +status "Importing 10KB Code Signing CA..." +gpg --import "$CA_KEY" 2>/dev/null || true + +# Extract fingerprint and set ultimate trust +CA_FP=$(gpg --with-colons --import-options show-only --import "$CA_KEY" 2>/dev/null \ + | grep '^fpr:' | head -1 | cut -d: -f10) + +if [[ -z "$CA_FP" ]]; then + status "Warning: Could not determine CA key fingerprint. Skipping." + return 0 +fi + +# Set ultimate trust (6) if not already set +if ! gpg --with-colons --list-keys "$CA_FP" 2>/dev/null | grep -q '^uid:u:'; then + echo "$CA_FP:6:" | gpg --import-ownertrust 2>/dev/null + status "CA key set to ultimate trust." +fi + +# Configure auto-key-retrieve for on-demand developer key fetching +GPG_CONF="${GNUPGHOME:-$HOME/.gnupg}/gpg.conf" +if [[ -f "$GPG_CONF" ]] && ! grep -q 'auto-key-retrieve' "$GPG_CONF" 2>/dev/null; then + echo "" >> "$GPG_CONF" + echo "# Added by manjikaze: fetch unknown signing keys from keyserver automatically" >> "$GPG_CONF" + echo "keyserver hkps://keys.openpgp.org" >> "$GPG_CONF" + echo "auto-key-retrieve" >> "$GPG_CONF" + status "GPG auto-key-retrieve configured." +fi + +# Mark GPG CA as trusted in the state file +set_security_state "gpg_ca_trusted" "true" + +status "10KB Code Signing CA configured. Commit signature verification is now active."