#!/bin/sh # Build template. server/scripts/build.py embeds the engine and pinned signing key. set -eu umask 077 PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin export PATH ( : < /dev/tty ) 2>/dev/null || { printf '%s\n' 'エラー: 対話用TTYがありません。端末から実行してください' >&2; exit 1; } TMP_INSTALL=$(mktemp -d "${TMPDIR:-/tmp}/pubkey-install.XXXXXXXX") trap 'rm -rf "$TMP_INSTALL"' 0 trap 'exit 130' INT trap 'exit 143' TERM HUP cat > "$TMP_INSTALL/trust.pem" <<'PUBKEY_TRUST_EOF' -----BEGIN PUBLIC KEY----- MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAufQiISMbppmpIN3lr43l HngcTwCOknkKw1Z3cWGybRYfrmWlzqPztEp93f9zK0lBiz9FXLCDBR/M0XoQfhgj jnl6MSuiEfd0SsXdU24GiqHpO1b4gEaaqcrPwyWd9+T9Utw2yPSR2DV6q2dDX/kO NapncgXF8GbeIoHpq/QbtYtVLDOyciVAizHEMnCILLJocehyNvwPbGQ/WMNuVYxM y7bqyM5x1VQhlAeVj0HGxoGklVPY5VaiPqPnj/ersqOe5BdsHgmU6e0+yD3GmJqA MecJRGw+RA1VKAHNisYkskqCnmfT+zlkOM2NSWryumBzYAJyuXvba2CrNC1YY4Ph GltkLqAxdKmzJBnqhq7Sowmd01bNuuOh+z24Tn8L6grOlLOAe3BvDpfGbn/551a6 V9tz0FEy1m6hdEeQ2RC7ZS+tBt7C9PnsBL/HRj+ZOEkr6Zy4ekdcR8m+Ocbaxhwx cQygU+gWMDzJaSVaIPvcMAFeiiwZO5mEn0M9tygKb8RXAgMBAAE= -----END PUBLIC KEY----- PUBKEY_TRUST_EOF cat > "$TMP_INSTALL/keys-manager" <<'PUBKEY_ENGINE_EOF' #!/bin/sh set -eu umask 077 LC_ALL=C export LC_ALL PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin export PATH PK_BUNDLED=1 # Common POSIX sh runtime. Never source downloaded data or configuration. die() { printf 'エラー: %s\n' "$*" >&2; exit 1; } note() { printf '%s\n' "$*" >&2; } hash_file() { openssl dgst -sha256 -r "$1" | awk '{print $1}'; } hash_key() { printf '%s %s' "$1" "$2" | openssl dgst -sha256 -r | awk '{print $1}'; } need_tools() { for tool in curl openssl ssh-keygen awk sed grep sort tr wc cmp cp mv mkdir rmdir rm chmod stat id mktemp date cat tail od head cut dirname; do command -v "$tool" >/dev/null 2>&1 || die "必要なコマンドがありません: $tool" done } safe_path() { case $1 in /*) ;; *) die "絶対パスが必要です: $1";; esac case $1 in *[!a-zA-Z0-9_./-]*|*/../*|*/./*|*/..|*/.) die 'HOMEは英数字、_ . / - の安全な絶対パスを使用してください';; esac } check_owner() { [ ! -L "$1" ] || die "シンボリックリンクを拒否: $1" [ "$(stat -c %u "$1")" = "$USER_ID" ] || die "所有者が異なります: $1" mode=$(stat -c %a "$1") [ $((0$mode & 0022)) -eq 0 ] || die "他ユーザーが書き込み可能です: $1" } check_dir() { [ -d "$1" ] || die "ディレクトリではありません: $1"; check_owner "$1"; } check_file() { [ -f "$1" ] || die "通常ファイルではありません: $1" check_owner "$1" [ "$(stat -c %h "$1")" = 1 ] || die "ハードリンクを拒否: $1" } make_dir() { if [ ! -e "$1" ] && [ ! -L "$1" ]; then mkdir "$1" || die "作成失敗: $1"; fi check_dir "$1" } init_paths() { USER_ID=$(id -u) [ -n "${HOME:-}" ] || die 'HOMEが未設定です' HOME=${HOME%/} safe_path "$HOME" check_dir "$HOME" # Verify every ancestor below HOME before following it. make_dir "$HOME/.local" make_dir "$HOME/.local/share" DATA=$HOME/.local/share/pubkey-manager make_dir "$DATA" chmod 700 "$DATA" make_dir "$DATA/backups" make_dir "$HOME/.local/bin" BIN=$HOME/.local/bin SSH=$HOME/.ssh if [ -e "$SSH" ] || [ -L "$SSH" ]; then check_dir "$SSH"; fi AUTH=$SSH/authorized_keys STATE=$DATA/state.tsv CONFIG=$DATA/config.tsv TRUST=$DATA/trust.pem for p in "$AUTH" "$STATE" "$CONFIG" "$TRUST"; do if [ -e "$p" ] || [ -L "$p" ]; then check_file "$p"; fi done } cleanup() { if [ -n "${WORK:-}" ]; then rm -rf "$WORK"; fi if [ "${LOCKED:-0}" = 1 ]; then rmdir "$DATA/lock/$$" 2>/dev/null || :; rmdir "$DATA/lock" 2>/dev/null || :; fi } acquire_lock() { LOCKED=0 if ! mkdir "$DATA/lock" 2>/dev/null; then check_dir "$DATA/lock" set -- "$DATA"/lock/* [ "$#" = 1 ] && [ -d "$1" ] && [ ! -L "$1" ] || die 'ロックが初期化中または破損しています。実行中のプロセスを確認してください' pid=${1##*/} case $pid in ''|*[!0-9]*) die '不正なロック';; esac if kill -0 "$pid" 2>/dev/null; then die "同期処理が実行中です (PID $pid)"; fi # Remove only the observed PID directory: concurrent reapers cannot steal a new lock. rmdir "$DATA/lock/$pid" 2>/dev/null && rmdir "$DATA/lock" 2>/dev/null || die 'ロック回復が競合しました。再実行してください' mkdir "$DATA/lock" 2>/dev/null || die '他のプロセスがロックを取得しました' fi mkdir "$DATA/lock/$$" || die 'PIDロックの作成失敗' LOCKED=1 trap cleanup 0 trap 'exit 130' INT trap 'exit 143' TERM HUP for stale in "$DATA"/work.*; do [ -e "$stale" ] || [ -L "$stale" ] || continue check_dir "$stale" rm -rf "$stale" done WORK=$(mktemp -d "$DATA/work.XXXXXXXX") || die '作業領域作成失敗' } read_config() { [ -f "$CONFIG" ] || die '未設定です。keys-manager configure を実行してください' check_file "$CONFIG" awk -F '\t' ' NR==1 {if(NF!=2 || $1!="server" || $2!~/^https:\/\/[A-Za-z0-9][A-Za-z0-9.:\/_-]*$/ || $2~/\/\.\.?\//) exit 1} NR==2 {if(NF!=2 || $1!="tags" || $2!~/^[a-z0-9][a-z0-9_,\-]*$/) exit 1; n=split($2,a,","); for(i=1;i<=n;i++) if(a[i]!~/^[a-z0-9][a-z0-9_-]*$/ || length(a[i])>64) exit 1} NR==3 {if(NF!=2 || $1!="schedule" || $2!~/^(manual|cron|systemd)$/) exit 1} NR==4 {if(NF!=2 || $1!="frequency" || $2!~/^(daily|weekly|monthly)$/) exit 1} END {if(NR!=4) exit 1}' "$CONFIG" || die '設定ファイルが不正です' SERVER=$(awk -F '\t' 'NR==1 {print $2}' "$CONFIG") TAGS=$(awk -F '\t' 'NR==2 {print $2}' "$CONFIG") SCHEDULE=$(awk -F '\t' 'NR==3 {print $2}' "$CONFIG") FREQUENCY=$(awk -F '\t' 'NR==4 {print $2}' "$CONFIG") } write_config() { printf 'server\t%s\ntags\t%s\nschedule\t%s\nfrequency\t%s\n' "$SERVER" "$TAGS" "$SCHEDULE" "$FREQUENCY" > "$WORK/config.new" chmod 600 "$WORK/config.new" mv "$WORK/config.new" "$CONFIG" } ask() { printf '%s' "$1" > /dev/tty; IFS= read -r ANSWER < /dev/tty || die '対話入力に失敗しました'; } require_tty() { ( : < /dev/tty ) 2>/dev/null || die '対話用TTYがありません。端末から実行してください'; } fetch() { url=$1 dest=$2 limit=$3 case $url in https://*) ;; *) die 'HTTPS以外の接続は許可しません';; esac # The file-size resource limit also bounds chunked transfers on older curl. blocks=$(((limit + 1023) / 1024)) (ulimit -f "$blocks"; exec curl --proto '=https' --proto-redir '=https' --tlsv1.2 \ --fail --silent --show-error --connect-timeout 15 --max-time 120 \ --max-filesize "$limit" --output "$dest" "$url") || die "取得に失敗: $url" [ -s "$dest" ] || die "空のレスポンス: $url" [ "$(wc -c < "$dest")" -le "$limit" ] || die '取得サイズの上限超過' } fetch_manifest() { check_file "$TRUST" fetch "$SERVER/v1/current" "$WORK/current" 32 GEN=$(cat "$WORK/current") case $GEN in ''|0|0*|*[!0-9]*) die '世代番号が不正です';; esac [ "${#GEN}" -le 10 ] && [ "$GEN" -le 2147483647 ] || die '世代番号の上限超過' [ "$(wc -l < "$WORK/current")" = 1 ] || die '世代レスポンスが不正です' fetch "$SERVER/v1/generations/$GEN/manifest.tsv" "$WORK/manifest" 4194304 fetch "$SERVER/v1/generations/$GEN/manifest.tsv.sig" "$WORK/signature" 16384 openssl dgst -sha256 -verify "$TRUST" -signature "$WORK/signature" "$WORK/manifest" >/dev/null 2>&1 || die 'マニフェストの署名検証に失敗しました' validate_manifest DIGEST=$(hash_file "$WORK/manifest") } validate_manifest() { [ "$(tail -c 1 "$WORK/manifest" | od -An -tu1 | tr -d ' ')" = 10 ] || die 'マニフェストが不完全です (末尾LFなし)' # Reject NUL/CR/DEL and all ASCII controls except LF/TAB before text parsing. tr -d '\011\012\040-\176\200-\377' < "$WORK/manifest" > "$WORK/controls" [ ! -s "$WORK/controls" ] || die 'マニフェストに不正な制御文字があります' awk -F '\t' -v gen="$GEN" ' NR==1 {if(NF!=4 || $1!="pubkey-manifest" || $2!="1" || $3!=gen || $4!~/^(0|[1-9][0-9]*)$/ || $4>10000) exit 1; count=$4; next} {if(NF!=8 || $1!="key" || $2!~/^[a-z0-9][a-z0-9_-]*$/ || length($2)>64 || ids[$2]++) exit 1 if(length($3)==0 || length($3)>256 || $3!~/[^ ]/ || $3~/\302[\200-\237]|\342\200[\250\251]/) exit 1 if($4!~/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh.com|sk-ecdsa-sha2-nistp256@openssh.com)$/) exit 1 if($5!~/^[A-Za-z0-9+\/=]+$/ || length($5)>16384 || bodies[$5]++) exit 1 n=split($6,t,","); if(n<1) exit 1; for(i=1;i<=n;i++) {if(t[i]!~/^[a-z0-9][a-z0-9_-]*$/ || length(t[i])>64 || seen[$2 SUBSEP t[i]]++) exit 1} if($7!~/^(active|deleted)$/ || $8!~/^[a-f0-9]+$/ || length($8)!=64) exit 1 } END {if(NR!=count+1) exit 1}' "$WORK/manifest" || die 'マニフェスト形式が不正です' tab=$(printf '\t') tail -n +2 "$WORK/manifest" > "$WORK/rows" while IFS="$tab" read -r record kid name kind blob tags state digest; do [ "$(hash_key "$kind" "$blob")" = "$digest" ] || die "公開鍵ハッシュ不一致: $kid" printf '%s %s\n' "$kind" "$blob" > "$WORK/key.pub" ssh-keygen -l -f "$WORK/key.pub" >/dev/null 2>&1 || die "公開鍵形式が不正です: $kid" done < "$WORK/rows" # Transient tags are intentionally discarded with WORK at exit. awk -F '\t' 'NR>1 {n=split($6,t,",");for(i=1;i<=n;i++) print t[i]}' "$WORK/manifest" | sort -u > "$WORK/tags" } load_state() { if [ -f "$STATE" ]; then check_file "$STATE" cp "$STATE" "$WORK/state.old" else printf 'state\t1\t0\t-\n' > "$WORK/state.old" fi awk -F '\t' ' NR==1 {if(NF!=4 || $1!="state" || $2!="1" || $3!~/^(0|[1-9][0-9]*)$/ || $3>2147483647 || ($3>0 && ($4!~/^[a-f0-9]+$/ || length($4)!=64))) exit 1; next} {if(NF!=6 || $1!~/^(K|D)$/ || $2!~/^[a-z0-9][a-z0-9_-]*$/ || ids[$2]++ || length($3)==0 || $4!~/^[a-z0-9@.-]+$/ || $5!~/^[A-Za-z0-9+\/=]+$/ || bodies[$5]++ || $6!~/^[a-f0-9]+$/ || length($6)!=64) exit 1} END {if(NR<1) exit 1}' "$WORK/state.old" || die 'ローカル状態が破損しています' OLD_GEN=$(awk -F '\t' 'NR==1 {print $3}' "$WORK/state.old") OLD_DIGEST=$(awk -F '\t' 'NR==1 {print $4}' "$WORK/state.old") } check_generation() { [ "$GEN" -ge "$OLD_GEN" ] || die "世代の巻き戻りを拒否しました ($GEN < $OLD_GEN)" if [ "$GEN" = "$OLD_GEN" ] && [ "$DIGEST" != "$OLD_DIGEST" ]; then die '同一世代の内容変更を拒否しました'; fi } plan_state() { # Existing IDs bypass tags; unknown IDs use OR matching. D rows are permanent tombstones. awk -F '\t' -v OFS='\t' -v tags="$TAGS" -v gen="$GEN" -v digest="$DIGEST" ' FILENAME==ARGV[1] {if(FNR>1) {old[$2]=$0; body[$2]=$4 FS $5; bybody[$5]=$2; status[$2]=$1} next} FNR==1 {next} { id=$2; seen[id]=1 if((id in old) && body[id]!=$4 FS $5) {print "IDの鍵本体が変更されています: " id > "/dev/stderr"; bad=1; next} if(($5 in bybody) && bybody[$5]!=id) {print "鍵本体のID再利用を拒否: " id > "/dev/stderr"; bad=1; next} if(status[id]=="D" && $7!="deleted") {print "削除済み鍵の復活を拒否: " id > "/dev/stderr"; bad=1; next} if($7=="deleted") {out[id]="D" FS id FS "-" FS $4 FS $5 FS $8; next} matchtag=0; n=split(tags,w,","); m=split($6,t,","); for(i=1;i<=n;i++) for(j=1;j<=m;j++) if(w[i]==t[j]) matchtag=1 if((id in old) || matchtag) out[id]="K" FS id FS $3 FS $4 FS $5 FS $8 } END { if(bad) exit 1 print "state",1,gen,digest for(id in old) if(!(id in seen)) out[id]=old[id] for(id in out) print out[id] }' "$WORK/state.old" "$WORK/manifest" > "$WORK/state.unsorted" || die '鍵の同一性検証に失敗しました' head -n 1 "$WORK/state.unsorted" > "$WORK/state.new" tail -n +2 "$WORK/state.unsorted" | sort -t "$(printf '\t')" -k2,2 >> "$WORK/state.new" } BEGIN_MARK='# BEGIN pubkey.jun-suzu.net managed' END_MARK='# END pubkey.jun-suzu.net managed' render_block() { printf '%s\n' "$BEGIN_MARK" awk -F '\t' '$1=="K" {print $4 " " $5 " " $3}' "$1" printf '%s\n' "$END_MARK" } snapshot_auth() { if [ -e "$AUTH" ] || [ -L "$AUTH" ]; then check_file "$AUTH" cp "$AUTH" "$WORK/auth.old" if [ -s "$WORK/auth.old" ]; then [ "$(tail -c 1 "$WORK/auth.old" | od -An -tu1 | tr -d ' ')" = 10 ] || die 'authorized_keys の末尾改行がありません。確認してから改行を追加してください' fi else : > "$WORK/auth.old" fi awk -v begin="$BEGIN_MARK" -v end="$END_MARK" -v outside="$WORK/outside" -v managed="$WORK/managed" ' $0==begin {if(inside || blocks++) {bad=1; exit}; inside=1; print > managed; next} $0==end {if(!inside) {bad=1; exit}; inside=0; print > managed; next} /^[ \t]*#.*pubkey[.]jun-suzu[.]net managed/ {bad=1; exit} {if(inside) print > managed; else print > outside} END {if(inside || bad) exit 1}' "$WORK/auth.old" || die '管理ブロックが破損または重複しています' [ -f "$WORK/outside" ] || : > "$WORK/outside" render_block "$WORK/state.old" > "$WORK/expected" if [ -f "$WORK/managed" ]; then cmp -s "$WORK/managed" "$WORK/expected" || die '管理ブロックとローカル状態が一致しません。手動変更を確認してください' elif awk -F '\t' '$1=="K" {found=1} END {exit !found}' "$WORK/state.old"; then die '登録済み鍵があるのに管理ブロックがありません' fi } prepare_auth() { : > "$WORK/adoptable" : > "$WORK/adopt.rows" : > "$WORK/skip.ids" # Exact simple lines can be adopted. Anything else containing a known blob is left intact. awk -F '\t' -v OFS='\t' -v adopt="${ADOPT:-0}" -v candidates="$WORK/adoptable" \ -v additions="$WORK/adopt.rows" -v skips="$WORK/skip.ids" ' FILENAME==ARGV[1] {if(FNR>1) {id=$2; blob[id]=$5; kind[id]=$4; name[id]=$3; hash[id]=$8; status[id]=$7} next} FILENAME==ARGV[2] {if(FNR>1 && $1=="K") tracked[$2]=1; next} { line=$0; remove=0; trimmed=line; sub(/^[ \t]+/,"",trimmed); n=split(trimmed,parts,/[ \t]+/) for(id in blob) if(index(line,blob[id])) { simple=(n>=2 && parts[1]==kind[id] && parts[2]==blob[id]) if(id in tracked) {print "管理鍵と同じ鍵が管理領域外にあります: " id > "/dev/stderr"; bad=1; next} if(simple) { print id, name[id], status[id] > candidates if(adopt==1) { remove=1 if(!added[id]++) print (status[id]=="deleted"?"D":"K"),id,(status[id]=="deleted"?"-":name[id]),kind[id],blob[id],hash[id] > additions } else print id > skips } else { print "警告: オプション付きまたは解析できない既存鍵を保持: " id > "/dev/stderr" print id > skips } } if(!remove) print line } END {if(bad) exit 1}' "$WORK/manifest" "$WORK/state.old" "$WORK/outside" > "$WORK/outside.new" || die '既存鍵の重複を確認してください' # Do not take ownership of manual lines without consent; retain all previous managed keys. awk -F '\t' 'FILENAME==ARGV[1] {skip[$1]=1; next} FNR==1 || $1=="D" || !($2 in skip)' "$WORK/skip.ids" "$WORK/state.new" > "$WORK/state.filtered" cat "$WORK/adopt.rows" >> "$WORK/state.filtered" head -n 1 "$WORK/state.filtered" > "$WORK/state.new" tail -n +2 "$WORK/state.filtered" | sort -t "$(printf '\t')" -k2,2 -u >> "$WORK/state.new" # Adoption may repeat a D row already generated by plan_state; deduplicate by ID and verify identity. awk -F '\t' 'NR==1 || !seen[$2]++' "$WORK/state.new" > "$WORK/state.unique" mv "$WORK/state.unique" "$WORK/state.new" render_block "$WORK/state.new" > "$WORK/block.new" # Preserve external lines in place, removing only explicitly adopted simple keys. awk -F '\t' -v begin="$BEGIN_MARK" -v end="$END_MARK" -v block="$WORK/block.new" ' FILENAME==ARGV[1] {adopt[$4 " " $5]=1; next} function emit() {while((getline x < block)>0) print x; close(block)} $0==begin {emit(); inside=1; found=1; next} $0==end {inside=0; next} !inside {s=$0; sub(/^[ \t]+/,"",s); split(s,p,/[ \t]+/); if(!(p[1] " " p[2] in adopt)) print} END {if(!found) emit()}' "$WORK/adopt.rows" "$WORK/auth.old" > "$WORK/auth.new" } show_diff() { awk -F '\t' ' FILENAME==ARGV[1] {if(FNR>1 && $1=="K") {old[$2]=$3; body[$2]=$5}; next} FNR>1 && $1=="K" {seen[$2]=1; if(!($2 in old)) print " + " $3 " (" $2 ")"; else if(old[$2]!=$3) print " ~ " old[$2] " → " $3} END {for(id in old) if(!(id in seen)) print " - " old[id] " (" id ")"}' "$WORK/state.old" "$WORK/state.new" } same_or_absent() { target=$1 before=$2 absent=$3 if [ -e "$target" ] || [ -L "$target" ]; then check_file "$target"; cmp -s "$target" "$before" else [ -f "$absent" ]; fi } replace_file() { source=$1 target=$2 temp=$(mktemp "${target}.pubkey.XXXXXXXX") || die '一時ファイル作成失敗' if ! cat "$source" > "$temp" || ! chmod 600 "$temp" || ! mv "$temp" "$target"; then rm -f "$temp" die "ファイル更新失敗: $target (次回実行で復旧します)" fi } recover_transaction() { TX=$DATA/transaction [ -e "$TX" ] || [ -L "$TX" ] || return 0 check_dir "$TX" for file in auth.before auth.after state.before state.after; do check_file "$TX/$file"; done for flag in auth.absent state.absent; do if [ -e "$TX/$flag" ] || [ -L "$TX/$flag" ]; then check_file "$TX/$flag"; fi; done make_dir "$SSH" # Validate BOTH destinations before mutating either one. for part in auth state; do if [ "$part" = auth ]; then target=$AUTH; else target=$STATE; fi if ! same_or_absent "$target" "$TX/$part.before" "$TX/$part.absent"; then [ -f "$target" ] && cmp -s "$target" "$TX/$part.after" || die "未完了更新と外部編集が衝突しています: $target。$TX を確認してください" fi done for part in auth state; do if [ "$part" = auth ]; then target=$AUTH; else target=$STATE; fi if ! [ -f "$target" ] || ! cmp -s "$target" "$TX/$part.after"; then replace_file "$TX/$part.after" "$target"; fi done chmod 700 "$SSH" chmod 600 "$AUTH" "$STATE" mv "$TX" "$DATA/backups/$(date -u +%Y%m%dT%H%M%SZ)-$$" || die 'トランザクション保存失敗' } commit_plan() { make_dir "$SSH" check_dir "$SSH" # Refuse an editor race detected since the original snapshot. if [ -e "$AUTH" ] || [ -L "$AUTH" ]; then check_file "$AUTH"; cmp -s "$AUTH" "$WORK/auth.old" || die 'authorized_keys が同期中に編集されました'; else [ ! -s "$WORK/auth.old" ] || die 'authorized_keys が同期中に消失しました'; fi if [ -e "$STATE" ] || [ -L "$STATE" ]; then check_file "$STATE"; cmp -s "$STATE" "$WORK/state.old" || die 'ローカル状態が同期中に編集されました'; fi if [ -f "$AUTH" ] && [ -f "$STATE" ] && cmp -s "$AUTH" "$WORK/auth.new" && cmp -s "$STATE" "$WORK/state.new"; then chmod 700 "$SSH"; chmod 600 "$AUTH" "$STATE" note '変更なし' return fi prep=$(mktemp -d "$DATA/prepare.XXXXXXXX") || die 'ジャーナル作成失敗' cp "$WORK/auth.old" "$prep/auth.before" cp "$WORK/auth.new" "$prep/auth.after" cp "$WORK/state.old" "$prep/state.before" cp "$WORK/state.new" "$prep/state.after" [ -f "$AUTH" ] || : > "$prep/auth.absent" [ -f "$STATE" ] || : > "$prep/state.absent" mv "$prep" "$DATA/transaction" recover_transaction } cron_read() { if crontab -l > "$WORK/cron.old" 2> "$WORK/cron.error"; then return 0; fi if grep -qi 'no crontab for' "$WORK/cron.error"; then : > "$WORK/cron.old"; return 0; fi note 'crontab の読み込みに失敗しました' cat "$WORK/cron.error" >&2 return 1 } cron_strip() { awk ' $0=="# BEGIN pubkey-manager schedule" {if(inside || count++) {bad=1; exit}; inside=1; next} $0=="# END pubkey-manager schedule" {if(!inside) {bad=1; exit}; inside=0; next} !inside {print} END {if(inside || bad) exit 1}' "$WORK/cron.old" > "$WORK/cron.new" || { note 'cron管理ブロックが破損しています'; return 1; } } systemd_paths() { make_dir "$HOME/.config" make_dir "$HOME/.config/systemd" make_dir "$HOME/.config/systemd/user" UNIT=$HOME/.config/systemd/user for name in pubkey-manager.service pubkey-manager.timer; do if [ -e "$UNIT/$name" ] || [ -L "$UNIT/$name" ]; then check_file "$UNIT/$name" [ "$(head -n 1 "$UNIT/$name")" = '# Managed by pubkey-manager' ] || die "既存unitの上書きを拒否: $UNIT/$name" fi done } disable_schedule() { failed=0 if command -v crontab >/dev/null 2>&1; then if cron_read && cron_strip; then if ! cmp -s "$WORK/cron.old" "$WORK/cron.new"; then crontab "$WORK/cron.new" || failed=1 fi else failed=1; fi elif [ "${SCHEDULE:-manual}" = cron ]; then note 'crontab がないため既存cronを無効化できません'; failed=1 fi # Do not create systemd configuration just for manual operation. if [ -e "$HOME/.config/systemd/user/pubkey-manager.timer" ] || [ -L "$HOME/.config/systemd/user/pubkey-manager.timer" ]; then systemd_paths if command -v systemctl >/dev/null 2>&1 && systemctl --user disable --now pubkey-manager.timer; then rm -f "$UNIT/pubkey-manager.timer" "$UNIT/pubkey-manager.service" systemctl --user daemon-reload || failed=1 else note 'systemd timer の無効化に失敗しました'; failed=1; fi fi [ "$failed" = 0 ] } apply_schedule() { desired=$SCHEDULE disable_schedule || return 1 case $desired in manual) return 0;; cron) command -v crontab >/dev/null 2>&1 || { note 'crontab がありません'; return 1; } cron_read && cron_strip || return 1 case $FREQUENCY in daily) spec='17 3 * * *';; weekly) spec='17 3 * * 0';; monthly) spec='17 3 1 * *';; esac printf '# BEGIN pubkey-manager schedule\n%s %s/keys-sync\n# END pubkey-manager schedule\n' "$spec" "$BIN" >> "$WORK/cron.new" crontab "$WORK/cron.new" || return 1;; systemd) command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1 || { note 'systemd user manager が利用できません'; return 1; } systemd_paths printf '# Managed by pubkey-manager\n[Unit]\nDescription=Synchronize SSH public keys\n[Service]\nType=oneshot\nExecStart=%s/keys-sync\n' "$BIN" > "$WORK/service" printf '# Managed by pubkey-manager\n[Unit]\nDescription=Periodic SSH public key sync\n[Timer]\nOnCalendar=%s\nPersistent=true\nRandomizedDelaySec=15m\n[Install]\nWantedBy=timers.target\n' "$FREQUENCY" > "$WORK/timer" replace_file "$WORK/service" "$UNIT/pubkey-manager.service" replace_file "$WORK/timer" "$UNIT/pubkey-manager.timer" systemctl --user daemon-reload && systemctl --user enable --now pubkey-manager.timer || return 1;; esac } if [ "${PK_BUNDLED:-0}" != 1 ]; then CODE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) for lib in common fetch manifest authorized-keys scheduler; do . "$CODE_DIR/lib/$lib.sh"; done fi sync_keys() { [ "$#" -le 1 ] || die '使い方: keys-sync [--dry-run]' dry=0 case ${1:-} in '') ;; --dry-run) dry=1;; *) die '使い方: keys-sync [--dry-run]';; esac read_config acquire_lock if [ "$dry" = 1 ] && [ -e "$DATA/transaction" ]; then die '未完了更新があります。通常同期で復旧してください'; fi if [ "$dry" = 0 ]; then recover_transaction; fi fetch_manifest load_state check_generation snapshot_auth plan_state ADOPT=0 prepare_auth show_diff if [ "$dry" = 1 ]; then note "dry-run: 世代 $GEN (変更は適用していません)"; return; fi commit_plan note "同期完了: 世代 $GEN" } install_commands() { for name in keys-manager keys-sync; do if [ -e "$BIN/$name" ] || [ -L "$BIN/$name" ]; then check_file "$BIN/$name" grep -q '^# pubkey-manager executable v1$' "$BIN/$name" || die "既存コマンドの上書きを拒否: $BIN/$name" fi tmpbin=$(mktemp "$BIN/.pubkey-command.XXXXXXXX") cat "$BOOTSTRAP/keys-manager" > "$tmpbin" chmod 700 "$tmpbin" mv "$tmpbin" "$BIN/$name" done } configure() { require_tty BOOTSTRAP= if [ "${1:-}" = --bootstrap ] && [ "$#" = 2 ]; then BOOTSTRAP=$2 elif [ "$#" != 0 ]; then die '使い方: keys-manager configure'; fi SERVER=https://pubkey.jun-suzu.net TAGS=personal SCHEDULE=manual FREQUENCY=daily if [ -f "$CONFIG" ]; then read_config; fi acquire_lock recover_transaction if [ -n "$BOOTSTRAP" ]; then check_file "$BOOTSTRAP/trust.pem" if [ -f "$TRUST" ]; then cmp -s "$BOOTSTRAP/trust.pem" "$TRUST" || die '固定済み署名鍵と異なります。信頼できる経路で確認し keys-manager trust replace FILE を明示実行してください' else cp "$BOOTSTRAP/trust.pem" "$TRUST"; chmod 600 "$TRUST"; fi fi [ -f "$TRUST" ] || die '署名検証鍵がありません。署名鍵を埋め込んだインストーラーを使用してください' note 'SSH Public Key Manager' fetch_manifest load_state check_generation snapshot_auth note '[1/4] 配布タグを選択 (カンマ区切り、1つ以上)' cat "$WORK/tags" >&2 ask "Tags [$TAGS]: " if [ -n "$ANSWER" ]; then TAGS=$ANSWER; fi printf '%s\n' "$TAGS" | awk -F ',' '{for(i=1;i<=NF;i++) if($i!~/^[a-z0-9][a-z0-9_-]*$/ || length($i)>64 || seen[$i]++) exit 1; if(NF<1) exit 1}' || die 'タグの指定が不正です' # Existing subscription names are allowed even if currently absent from the manifest. note '[2/4] 同期方式: 1) cron 2) systemd timer 3) 手動同期のみ' ask "選択 [現在: $SCHEDULE / 空欄で保持]: " case $ANSWER in 1) SCHEDULE=cron;; 2) SCHEDULE=systemd;; 3) SCHEDULE=manual;; '') ;; *) die '1、2、3のいずれかを選択してください';; esac if [ "$SCHEDULE" != manual ]; then ask "頻度 daily / weekly / monthly [$FREQUENCY]: " case $ANSWER in daily|weekly|monthly) FREQUENCY=$ANSWER;; '') ;; *) die '頻度が不正です';; esac fi note '[3/4] 登録・変更予定の公開鍵' plan_state ADOPT=0 prepare_auth show_diff if [ -s "$WORK/adoptable" ]; then note '次の既存の単純な公開鍵行は、承認すると管理対象になります。deletedの鍵は今回削除されます:' sort -u "$WORK/adoptable" >&2 ask 'これらの既存鍵の取り込みを承認しますか? [y/N]: ' case $ANSWER in y|Y|yes) ADOPT=1; plan_state; prepare_auth; show_diff;; esac fi count=$(awk -F '\t' '$1=="K" {n++} END {print n+0}' "$WORK/state.new") note "[4/4] 設定確認: Tags=$TAGS Schedule=$SCHEDULE Frequency=$FREQUENCY Keys=$count" ask '設定を適用しますか? [y/N]: ' case $ANSWER in y|Y|yes) ;; *) note '中止しました'; return;; esac if [ -n "$BOOTSTRAP" ]; then install_commands; fi write_config commit_plan note '公開鍵と設定を保存しました' if apply_schedule; then note "同期方式を設定しました: $SCHEDULE ($FREQUENCY)" else note '定期同期の設定に失敗しました。既存スケジュールの状態を確認してください。手動同期は利用できます'; fi note "手動同期: $BIN/keys-sync" note "PATHに追加する場合: export PATH=\"\$HOME/.local/bin:\$PATH\"" } status() { read_config acquire_lock load_state note "Server: $SERVER" note "Tags: $TAGS" note "Schedule (設定値): $SCHEDULE / $FREQUENCY" note "Generation: $OLD_GEN" awk -F '\t' '$1=="K" {print " " $2 " " $3}' "$WORK/state.old" if [ -e "$DATA/transaction" ]; then note '未完了更新があります。次回通常同期で復旧します'; fi } uninstall() { remove=0 case ${1:-} in '') ;; --remove-managed-keys) remove=1;; *) die '使い方: keys-manager uninstall [--remove-managed-keys]';; esac read_config acquire_lock recover_transaction load_state snapshot_auth disable_schedule || die '定期同期を停止できないためアンインストールを中止しました' if [ "$remove" = 1 ]; then cp "$WORK/outside" "$WORK/auth.new" else awk -v begin="$BEGIN_MARK" -v end="$END_MARK" '$0!=begin && $0!=end' "$WORK/auth.old" > "$WORK/auth.new" fi # Uninstall retains a journal/backups and pinned trust so failures remain recoverable. printf 'state\t1\t%s\t%s\n' "$OLD_GEN" "$OLD_DIGEST" > "$WORK/state.new" awk -F '\t' '$1=="D"' "$WORK/state.old" >> "$WORK/state.new" commit_plan for name in keys-manager keys-sync; do if [ -e "$BIN/$name" ] || [ -L "$BIN/$name" ]; then check_file "$BIN/$name" grep -q '^# pubkey-manager executable v1$' "$BIN/$name" || die "本ツールのコマンドではありません: $BIN/$name" rm "$BIN/$name" fi done rm -f "$CONFIG" note "アンインストールしました。バックアップ・署名鍵・削除記録は $DATA に保持しています" if [ "$remove" = 0 ]; then note '公開鍵は通常の公開鍵行として保持しました'; fi } trust_replace() { [ "$#" = 1 ] || die '使い方: keys-manager trust replace FILE' require_tty check_file "$1" openssl rsa -pubin -in "$1" -noout >/dev/null 2>&1 || die 'RSA公開鍵ではありません' openssl rsa -pubin -in "$1" -text -noout 2>/dev/null | awk ' /Public-Key:/ {s=$0; sub(/^[^(]*\(/,"",s); sub(/ bit.*$/,"",s); if(s+0>=2048) ok=1} END {exit !ok}' || die '署名検証用RSA鍵は2048 bit以上が必要です' note "新しい信頼鍵 SHA256: $(hash_file "$1")" ask '別の信頼できる経路で照合しましたか? [y/N]: ' case $ANSWER in y|Y|yes) ;; *) die '交換を中止しました';; esac acquire_lock recover_transaction cp "$1" "$WORK/trust.new" replace_file "$WORK/trust.new" "$TRUST" note '署名検証鍵を交換しました。最終同期世代は維持します' } # pubkey-manager executable v1 need_tools init_paths case ${0##*/} in keys-sync|keys-sync.sh) sync_keys "$@"; exit;; esac command_name=${1:-help} if [ "$#" -gt 0 ]; then shift; fi case $command_name in sync) [ "$#" -le 1 ] || die '引数が多すぎます'; sync_keys "$@";; configure) configure "$@";; status) [ "$#" = 0 ] || die '引数が多すぎます'; status;; schedule) [ "$#" = 1 ] && [ "$1" = disable ] || die '使い方: keys-manager schedule disable'; read_config; acquire_lock; recover_transaction; disable_schedule || die '定期同期の停止に失敗'; SCHEDULE=manual; write_config; note '定期同期を停止しました';; uninstall) [ "$#" -le 1 ] || die '引数が多すぎます'; uninstall "$@";; trust) [ "${1:-}" = replace ] || die '使い方: keys-manager trust replace FILE'; shift; trust_replace "$@";; help|--help|-h) printf '%s\n' 'keys-manager sync [--dry-run] | status | configure | schedule disable | uninstall [--remove-managed-keys] | trust replace FILE';; *) die "不明なコマンド: $command_name";; esac PUBKEY_ENGINE_EOF grep -q '^-----BEGIN PUBLIC KEY-----$' "$TMP_INSTALL/trust.pem" && \ grep -q '^# pubkey-manager executable v1$' "$TMP_INSTALL/keys-manager" || { printf '%s\n' 'エラー: 未ビルドのテンプレートです。署名付きビルドで生成した server/public/install.sh を実行してください' >&2 exit 1 } sh "$TMP_INSTALL/keys-manager" configure --bootstrap "$TMP_INSTALL"