#!/bin/sh { set +x +a; } 2>/dev/null # a trace would print the API key; allexport would hand it to every child # install - installs rypt, the command line for the rypt API, for the current user. # # curl -fsSL https://sh.rypt.dev/install | sh # curl -fsSL https://sh.rypt.dev/install | RYPT_API_KEY="$SECRET" sh # CI: the key from a secret # # It downloads https://sh.rypt.dev/rypt.sh and its SHA-256, checks one against the other, # and installs the script as ~/.local/bin/rypt (or $RYPT_INSTALL_DIR/rypt). It never uses # sudo. Run it again to update. # # The API key is never taken as an argument. If RYPT_API_KEY is set, that key is saved, # and an empty RYPT_API_KEY is an error, so a missing CI secret fails here. Otherwise, at a # terminal, it is asked for with the input hidden. Echo goes off before the downloads, so # a key pasted early is hidden too, and if the installer stops before reading it, what was # typed is thrown away rather than left for your shell. Otherwise the installer says how # to add the key later. It is saved to ~/.config/rypt/api-key, readable only by you, # where rypt looks for it. # # Everything is inside functions, and nothing runs until the last line, so a download cut # short runs nothing. # # Settings: # RYPT_INSTALL_DIR where to put rypt (default ~/.local/bin); an absolute path or ~/... # RYPT_API_KEY an API key to save # RYPT_API_KEY_FILE where to save it (default $XDG_CONFIG_HOME/rypt/api-key, where # XDG_CONFIG_HOME defaults to ~/.config); an absolute path or ~/... # RYPT_NO_PROMPT if set, even to nothing, never ask for the key; a non-empty CI # does the same # RYPT_FORCE if set, replace a different program already installed as rypt # RYPT_BASE_URL where to download from (default https://sh.rypt.dev; for testing) say() { printf 'rypt install: %s\n' "$*" >&2; return 0; } die() { say "$*"; exit 1; } # rypt.sh carries this line, which tells an update apart from a different program called # rypt. It is promised never to change. is_rypt() { grep -q '^# rypt-id: sh\.rypt\.dev/rypt\.sh' "$1" 2>/dev/null; } A='[A-Za-z0-9]' A8="$A$A$A$A$A$A$A$A" TOKEN_PAT="ry_${A8}_$A8$A8$A8$A8" # The pattern is unquoted on purpose: it is a glob. # shellcheck disable=SC2254 is_token() { case $1 in $TOKEN_PAT) return 0 ;; esac; return 1; } # rand_name sets R to 24 random hex characters, or stops. It runs in the current shell, # never in $(...), so a failure stops the installer instead of yielding an empty name. rand_name() { R=$(od -An -N12 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n') case $R in *[!0-9a-f]* | '') R= ;; esac [ "${#R}" -eq 24 ] || die "cannot read /dev/urandom for a temporary file name" } # sq STRING prints STRING single-quoted, safe to paste into sh, bash, zsh or fish: each ' # and \ is written outside the quotes, escaped, which all of them read the same way. sq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/[\\\\']/'\\\\&'/g")"; } # norm PATH prints PATH with repeated slashes squeezed and a trailing slash dropped ("/" # stays "/"), so two spellings of one directory compare equal. norm() { n_v=$1 while :; do case $n_v in *//*) n_v=${n_v%%//*}/${n_v#*//} ;; *) break ;; esac; done case $n_v in ?*/) n_v=${n_v%/} ;; esac printf '%s' "$n_v" } # home_path NAME VALUE prints VALUE with a leading ~/ expanded (a literal ~/ comes from a # quoted value or CI YAML), or stops unless the result is absolute. home_path() { h_v=$2 # shellcheck disable=SC2088 # the ~/ is matched as text on purpose case $h_v in '~/'*) h_v=$HOME_DIR/${h_v#'~/'} ;; esac case $h_v in /*) ;; *) die "$1 must be an absolute path (or start with ~/)" ;; esac printf '%s' "$h_v" } check_base() { case $BASE in *@* | *[!A-Za-z0-9.:/_~%-]*) die "RYPT_BASE_URL must be a plain URL: no user name, spaces or other special characters" ;; https://?*) PROTO='=https' ;; http://localhost | http://localhost[:/]* | http://127.0.0.1 | http://127.0.0.1[:/]*) PROTO='=http' ;; *) die "RYPT_BASE_URL must be https:// (plain http only for localhost)" ;; esac } # fetch URL FILE fetch() { curl -q -fsSL --proto "$PROTO" --proto-redir "$PROTO" --max-filesize 1048576 \ --connect-timeout 10 --max-time 120 -o "$2" "$1" || die "could not download $1" } # sha256 FILE prints the file's SHA-256 in hex, with whichever tool this system has. sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum < "$1" | awk '{ print $1 }' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 < "$1" | awk '{ print $1 }' elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 < "$1" | sed 's/^.*= *//' else return 1 fi } version_of() { sed -n 's/^VERSION=\([0-9][0-9A-Za-z.+-]*\)$/\1/p' "$1" 2>/dev/null | head -n 1; } verify() { v_want=$(awk 'NR == 1 { print $1 }' "$TMP/rypt.sh.sha256" | tr -d '\r') case $v_want in *[!0-9a-f]* | '') die "the published checksum is not a SHA-256. Nothing was installed." ;; esac [ "${#v_want}" -eq 64 ] || die "the published checksum is not a SHA-256. Nothing was installed." v_have=$(sha256 "$TMP/rypt.sh") || die "no sha256sum, shasum or openssl here, so the download cannot be checked. Nothing was installed." [ "$v_have" = "$v_want" ] || die "the download does not match its published SHA-256. Nothing was installed. Try again, and tell support@rypt.dev if it keeps happening." is_rypt "$TMP/rypt.sh" || die "the download is not rypt.sh. Nothing was installed." VERSION=$(version_of "$TMP/rypt.sh") [ -n "$VERSION" ] || die "the download has no version. Nothing was installed." } # check_dest looks at what is at $DEST now, before anything is downloaded. Sets WAS to the # version being replaced and NOTE to anything worth saying, or stops. check_dest() { WAS= NOTE= [ ! -d "$DEST" ] || die "$DEST is a directory. Set RYPT_INSTALL_DIR to install somewhere else." if [ -L "$DEST" ] && [ ! -e "$DEST" ]; then NOTE="it replaced a broken symlink" elif [ -e "$DEST" ]; then if is_rypt "$DEST"; then WAS=$(version_of "$DEST") WAS=${WAS:-unknown} [ ! -L "$DEST" ] || NOTE="the symlink at $DEST is now a copy" elif [ -n "${RYPT_FORCE:-}" ]; then NOTE="it replaced a different program" else die "$DEST is a different program. Set RYPT_INSTALL_DIR to install somewhere else, or RYPT_FORCE=1 to replace it." fi fi } install_script() { mkdir -p "$DIR" || die "cannot create $DIR" rand_name i_tmp=$DIR/.rypt-install-$R (set -C; cat "$TMP/rypt.sh" > "$i_tmp") 2>/dev/null || die "cannot write in $DIR" { [ -f "$i_tmp" ] && [ ! -L "$i_tmp" ]; } || die "cannot write in $DIR" if ! chmod 755 "$i_tmp" || ! mv -f "$i_tmp" "$DEST"; then rm -f "$i_tmp" die "could not install $DEST" fi } # check_kept looks at the key file that is already there the way rypt reads it, through a # symlink too. Sets HAVE_KEY if rypt can use it (and KEPT_OTHER if another user owns it), # or BAD_KEY with the reason rypt cannot. check_kept() { if [ -L "$KEY_FILE" ] && [ ! -e "$KEY_FILE" ]; then BAD_KEY="it is a symlink to nothing" elif [ ! -f "$KEY_FILE" ]; then BAD_KEY="it is not a regular file" elif [ ! -r "$KEY_FILE" ]; then BAD_KEY="you cannot read it" elif ! k_val=$( { tr -d ' \t\r\n' < "$KEY_FILE"; } 2>/dev/null) || ! is_token "$k_val"; then BAD_KEY="it does not hold an API key" else HAVE_KEY=1 [ -n "$(find -H "$KEY_FILE" -prune -user "$(id -u)" 2>/dev/null)" ] || KEPT_OTHER=1 fi k_val= } # Echo is switched off early, before the downloads, whenever a prompt will follow, so a # key pasted before the prompt appears is hidden too. show_input puts the terminal back; # the EXIT trap calls it on every way out. hide_input() { (: < /dev/tty > /dev/tty) 2>/dev/null || return 0 TTY_SAVED=$(stty -g < /dev/tty 2>/dev/null) || { TTY_SAVED=; return 0; } stty -echo < /dev/tty 2>/dev/null || { TTY_SAVED=; return 0; } } show_input() { if [ -n "$TTY_SAVED" ]; then stty "$TTY_SAVED" < /dev/tty 2>/dev/null; fi TTY_SAVED= } # drop_input throws away whatever was typed while echo was off, if the prompt never read # it, so a key pasted early does not reach the shell as a command (and its history). # shellcheck disable=SC2329 # called from the EXIT trap drop_input() { if [ -n "$TTY_SAVED" ]; then stty -icanon min 0 time 0 < /dev/tty 2>/dev/null && dd if=/dev/tty of=/dev/null bs=4096 count=256 2>/dev/null fi return 0 } # prompt_key reads the key from the terminal. Sets KEY, or leaves it empty if the user # skips. prompt_key() { p_try=0 while [ "$p_try" -lt 2 ]; do p_try=$((p_try + 1)) # Again at each prompt: ^Z and fg during the download turn echo back on. stty -echo < /dev/tty 2>/dev/null || break printf '%s' "Paste your API key from https://dashboard.rypt.dev/ (input hidden), or press Enter to skip: " > /dev/tty IFS= read -r p_in < /dev/tty || p_in= printf '\n' > /dev/tty KEY=$(printf '%s' "$p_in" | tr -d ' \t\r\n') p_in= [ -n "$KEY" ] || break is_token "$KEY" && break KEY= printf '%s\n' "That is not an API key: it should be 44 characters and start ry_." > /dev/tty done show_input } save_key() { s_dir=$(dirname "$KEY_FILE") # KEY_FILE is absolute, so dirname never sees an option (umask 077; mkdir -p "$s_dir") || die "cannot create $s_dir" # Refused when anyone may write there, as rypt.sh warns. A group-writable directory is # allowed: with a private group per user (umask 002) that group is just you. if [ -n "$(find "$s_dir" -prune -perm -002 ! -perm -1000 2>/dev/null)" ]; then die "not saving the API key in $s_dir: other users can write there. Run: chmod o-w $(sq "$s_dir")" fi s_link= [ ! -L "$KEY_FILE" ] || s_link=1 rand_name s_tmp=$s_dir/.api-key-$R (umask 077; set -C; printf '%s\n' "$KEY" > "$s_tmp") 2>/dev/null || die "cannot write in $s_dir" { [ -f "$s_tmp" ] && [ ! -L "$s_tmp" ]; } || die "cannot write in $s_dir" mv -f "$s_tmp" "$KEY_FILE" || { rm -f "$s_tmp"; die "could not save $KEY_FILE"; } KEY= say "saved your API key to $KEY_FILE" [ -z "$s_link" ] || say "note: $KEY_FILE was a symlink; it is now a file of its own, and the file it pointed to was not changed" } # find_path walks PATH as a shell looks up a command, with a leading ~ expanded as bash # does. Sets ON_PATH if DIR is there, and SHADOW to another rypt that comes before it. find_path() { ON_PATH= SHADOW= o_ifs=$IFS IFS=: set -f for o_e in $PATH; do # shellcheck disable=SC2088 # a literal ~ in PATH is matched as text on purpose case $o_e in '') o_e=. ;; '~') o_e=$HOME_DIR ;; '~/'*) o_e=$HOME_DIR/${o_e#'~/'} ;; esac o_e=$(norm "$o_e") if [ "$o_e" = "$DIR" ]; then ON_PATH=1; break; fi if [ -z "$SHADOW" ] && [ -f "$o_e/rypt" ] && [ -x "$o_e/rypt" ]; then SHADOW=$o_e/rypt; fi done set +f IFS=$o_ifs } path_advice() { find_path if [ -n "$ON_PATH" ]; then [ -z "$SHADOW" ] || say "warning: another rypt comes first on your PATH: $SHADOW" say "try: rypt keys" return 0 fi if [ "$DIR" = "$HOME_DIR/.local/bin" ]; then # shellcheck disable=SC2016 # printed for the user to paste, unexpanded a_line='export PATH="$HOME/.local/bin:$PATH"' else # shellcheck disable=SC2016 a_line="export PATH=$(sq "$DIR"):\"\$PATH\"" fi case ${SHELL##*/} in zsh) a_where="add this line to ~/.zshrc" ;; bash) if [ "$(uname -s 2>/dev/null)" = Darwin ] || [ -e "$HOME_DIR/.bash_profile" ]; then a_where="add this line to ~/.bash_profile" else a_where="add this line to ~/.bashrc" fi ;; fish) a_where="run this once in fish"; a_line="fish_add_path $(sq "$DIR")" ;; *) a_where="add this line to ~/.profile" ;; esac say "$DIR is not on your PATH. To fix that, $a_where:" printf ' %s\n' "$a_line" >&2 say "then open a new terminal (or run that line now) and try: rypt keys" } # hand_save prints a line that saves the key with the input hidden. It runs under sh from # any shell, fish included, and turns echo back on even after ^C. hand_save() { say "no API key saved. Run this installer again at a terminal to paste one, or save it by hand:" # shellcheck disable=SC2016 # printed for the user to paste, unexpanded h_sh='umask 077 && mkdir -p "$1" && trap "stty echo; echo" EXIT && trap "exit 130" INT && stty -echo && head -n 1 > "$2"' printf ' sh -c %s sh %s %s\n # then paste the key and press Enter\n' \ "$(sq "$h_sh")" "$(sq "$(dirname "$KEY_FILE")")" "$(sq "$KEY_FILE")" >&2 } main() { # A closed stdout or stderr would be handed to the next pipe the shell opens. { true 9>&1; } 2>/dev/null || exec >/dev/null true 9>&2 || exec 2>/dev/null # Drop any exported copies of this installer's variables inherited from the caller, so the # key assigned to one of them cannot reach a child's environment. unset -v KEY KEY_GIVEN p_in k_val R TTY_SAVED HAVE_KEY BAD_KEY KEPT_OTHER umask 022 # shellcheck disable=SC3045 # -c is in dash, bash, ksh, zsh and busybox ash ulimit -c 0 2>/dev/null # no core file holding the key TTY_SAVED= TMP= # Set before echo can go off: every way out puts the terminal back. trap 'drop_input; show_input; if [ -n "$TMP" ]; then rm -rf "$TMP"; fi' EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 131' QUIT trap 'exit 141' PIPE trap 'exit 143' TERM # Read the key once and take it out of the environment before anything else runs. KEY= KEY_GIVEN= if [ -n "${RYPT_API_KEY+set}" ]; then KEY_GIVEN=1; KEY=$RYPT_API_KEY; fi unset -v RYPT_API_KEY [ -n "${HOME:-}" ] || die "HOME is not set" HOME_DIR=$(norm "$HOME") DIR=$(home_path RYPT_INSTALL_DIR "${RYPT_INSTALL_DIR:-$HOME_DIR/.local/bin}") || exit 1 DIR=$(norm "$DIR") DEST=$DIR/rypt # A relative XDG_CONFIG_HOME is ignored, as the XDG spec says. case ${XDG_CONFIG_HOME:-} in /*) a_cfg=$(norm "$XDG_CONFIG_HOME") ;; *) a_cfg=$HOME_DIR/.config ;; esac KEY_FILE=$(home_path RYPT_API_KEY_FILE "${RYPT_API_KEY_FILE:-$a_cfg/rypt/api-key}") || exit 1 [ ! -d "$KEY_FILE" ] || die "RYPT_API_KEY_FILE names a directory: $KEY_FILE" BASE=${RYPT_BASE_URL:-https://sh.rypt.dev} BASE=${BASE%/} check_base # Before the key is looked at, so a missing tool is never reported as a bad key. for m_t in curl base64 tr; do command -v "$m_t" >/dev/null 2>&1 || die "rypt needs $m_t, and it is not installed" done if [ -n "$KEY_GIVEN" ]; then KEY=$(printf '%s' "$KEY" | tr -d ' \t\r\n') [ -n "$KEY" ] || die "RYPT_API_KEY is set but empty, so there is no key to save. Nothing was installed. In CI, check that the secret exists for this job." is_token "$KEY" || die "RYPT_API_KEY is not an API key: it should be 44 characters and start ry_. Nothing was installed." fi check_dest HAVE_KEY= BAD_KEY= KEPT_OTHER= if [ -z "$KEY_GIVEN" ] && { [ -e "$KEY_FILE" ] || [ -L "$KEY_FILE" ]; }; then check_kept; fi WILL_PROMPT= if [ -z "$KEY_GIVEN" ] && [ -z "$HAVE_KEY" ] && [ -z "$BAD_KEY" ] && [ -z "${RYPT_NO_PROMPT+set}" ] && [ -z "${CI:-}" ]; then hide_input [ -z "$TTY_SAVED" ] || WILL_PROMPT=1 fi TMP=$(mktemp -d 2>/dev/null || mktemp -d -t rypt) || die "cannot create a temporary directory" fetch "$BASE/rypt.sh" "$TMP/rypt.sh" fetch "$BASE/rypt.sh.sha256" "$TMP/rypt.sh.sha256" verify install_script if [ -z "$WAS" ]; then say "installed rypt $VERSION at $DEST" elif [ "$WAS" = "$VERSION" ]; then say "rypt $VERSION was already installed at $DEST; reinstalled it" else say "replaced rypt $WAS with rypt $VERSION at $DEST" fi [ -z "$NOTE" ] || say "note: $NOTE" if [ -n "$KEY_GIVEN" ]; then save_key elif [ -n "$HAVE_KEY" ]; then if [ -n "$KEPT_OTHER" ]; then say "kept the API key already in $KEY_FILE" say "note: that file belongs to another user, who can change the key in it. rypt uses it all the same; replace it with a file of your own if that is not what you want." else if [ -n "$(find -H "$KEY_FILE" -prune \( -perm -004 -o -perm -040 -o -perm -002 -o -perm -020 \) 2>/dev/null)" ]; then chmod 600 "$KEY_FILE" && say "made $KEY_FILE readable only by you" fi say "kept the API key already in $KEY_FILE" fi elif [ -n "$BAD_KEY" ]; then say "there is a file at $KEY_FILE, but $BAD_KEY, so rypt cannot use it. It was left alone; replace it with your API key." elif [ -n "$WILL_PROMPT" ] && prompt_key && [ -n "$KEY" ]; then save_key else show_input hand_save fi path_advice } main "$@" exit