#!/bin/sh # rypt-id: sh.rypt.dev/rypt.sh (the installer recognises rypt by this line; it never changes) { set +x +a; } 2>/dev/null # xtrace (sh -x, SHELLOPTS) would print the API key and plaintext; allexport would hand them to every child # rypt.sh - encrypt and decrypt with the rypt API from a shell. # # https://sh.rypt.dev this script and its SHA-256 # https://rypt.dev the service # # Needs a POSIX sh, curl and base64. Uses jq when it is installed, and never requires it. # # How it treats secrets: # - The API key comes from $RYPT_API_KEY, or from a file ($RYPT_API_KEY_FILE, default # ~/.config/rypt/api-key). It never goes on a command line: curl reads the # Authorization header on file descriptor 3, from a pipe that the shell's builtin # printf writes, so ps cannot see it. curl runs with -q, so a ~/.curlrc cannot turn on # verbose output that would print it. RYPT_API_KEY is removed from the environment # before any other program starts, so no child process inherits it. # - Plaintext travels through pipes, never through arguments. # - Every value that goes into a request is checked against an exact pattern first: # the API key, the key id, the API URL and the ciphertext read from a file. # - Output is written to a new file created exclusively (O_EXCL) under a random name # beside the destination, through one open descriptor that no other program inherits, # then linked into place and checked to be that same file. An existing file is never # replaced unless you pass --force, and nothing but /dev is written in place. # - Tracing, allexport, core dumps and curl's TLS key log are switched off. Server text # is reduced to printable characters before it reaches a terminal. # # Exit status: 0 ok, 1 the API refused the request, 2 usage error, # 3 local error (a file, a size limit, the network). VERSION=0.1.1 # The name it was run as: "rypt" once installed, "rypt.sh" when run from the download. PROG=${0##*/} case $PROG in rypt | rypt.sh) ;; *) PROG=rypt ;; esac # How to run it again, for hints: a downloaded rypt.sh is usually not executable. if [ "$PROG" = rypt.sh ]; then HINT="sh rypt.sh"; else HINT=$PROG; fi MAX_PLAINTEXT=65536 # the API's limit for encrypt MAX_AAD=65536 # the API's limit for aad MAX_BODY=131072 # the API's limit for any request body # ---------------------------------------------------------------------------- environment umask 077 LC_ALL=C # byte-wise character classes in case patterns, tr, sed and awk export LC_ALL # Drop any exported copies of this script's variables inherited from the caller, so a # secret assigned to one of them cannot leak into a child's environment. unset -v token pt pt_b64 ct body a_out a_data k_out rk_env f_msg rk_env=${RYPT_API_KEY-} unset -v RYPT_API_KEY SSLKEYLOGFILE # children never need the key; curl logs TLS secrets to SSLKEYLOGFILE # 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 or plaintext set -C # '>' creates files exclusively; nothing planted at a name is followed # A closed stdin, stdout or stderr would be handed to the next pipe the shell opens. { true 9<&0; } 2>/dev/null || exec &1; } 2>/dev/null || exec >/dev/null true 9>&2 || exec 2>/dev/null # ---------------------------------------------------------------------------- messages # Remove control characters (keeping tab and newline) from anything bound for a terminal: # server text, key names and file names cannot carry escape sequences. clean() { tr -d '\000-\010\013-\037\177' 4>&-; } say() { printf '%s: %s\n' "$PROG" "$*" | clean >&2; return 0; } # an unseen message never changes the exit status die_usage() { say "$*"; printf "Run '%s help' for usage.\n" "$HINT" >&2; exit 2; } die_local() { say "$*"; exit 3; } die_api() { say "$*"; exit 1; } usage() { printf '%s %s - encrypt and decrypt with the rypt API\n\nUsage:\n' "$PROG" "$VERSION" printf ' %s encrypt --key KEY [--aad TEXT] [-o OUT] [--force] [FILE]\n' "$PROG" printf ' %s decrypt --key KEY [--aad TEXT] [-o OUT] [--force] [FILE.enc]\n' "$PROG" printf ' %s keys\n %s help\n %s version\n' "$PROG" "$PROG" "$PROG" cat <<'EOF' KEY is a key's name or its id. It can also come from $RYPT_KEY. encrypt FILE writes FILE.enc, and decrypt FILE.enc writes FILE. With no FILE, or with FILE "-", both read stdin and write stdout. -o OUT writes to OUT instead ("-" means stdout). An existing file is never replaced unless you pass --force. --aad binds the ciphertext to a context string; decrypt needs the same one. encrypt takes at most 65,536 bytes (8,192 including aad on an hsm key), and --aad at most 65,536. The API takes 128 KiB per request, so a large input with a long --aad is refused before it is sent, because its ciphertext could not be sent back to decrypt. For anything larger, use envelope mode through the API. The API key comes from $RYPT_API_KEY, or from the file named by $RYPT_API_KEY_FILE (default ~/.config/rypt/api-key). It is never passed on a command line. Exit status: 0 ok, 1 the API refused the request, 2 usage error, 3 local error (a file, a size limit, the network). EOF } # ---------------------------------------------------------------------------- patterns # Exact patterns built from single-character classes, so a match covers the whole value # and nothing else: no newline, quote or space can ride along. LC_ALL=C above makes the # ranges byte ranges in every shell. H='[0-9a-fA-F]' UUID_PAT="$H$H$H$H$H$H$H$H-$H$H$H$H-$H$H$H$H-$H$H$H$H-$H$H$H$H$H$H$H$H$H$H$H$H" A='[A-Za-z0-9]' A8="$A$A$A$A$A$A$A$A" TOKEN_PAT="ry_${A8}_$A8$A8$A8$A8" # The patterns are unquoted on purpose: they are globs, and quoting would match them literally. # shellcheck disable=SC2254 is_uuid() { case $1 in $UUID_PAT) return 0 ;; esac; return 1; } # shellcheck disable=SC2254 is_token() { case $1 in $TOKEN_PAT) return 0 ;; esac; return 1; } # Standard base64 with padding: the alphabet only, a length that is a multiple of four, # and at most two "=" at the very end. is_b64() { case $1 in '' | *[!A-Za-z0-9+/=]* | *=[!=]* | *===) return 1 ;; esac [ $(( ${#1} % 4 )) -eq 0 ] } # ---------------------------------------------------------------------------- setup have_jq() { command -v jq >/dev/null 2>&1; } setup_api() { API=${RYPT_API_URL:-https://api.rypt.dev} API=${API%/} case $API in *@* | *[!A-Za-z0-9.:/_~%-]*) die_usage "RYPT_API_URL must be a plain URL: no user name, spaces or other special characters" ;; https://?*) ;; http://*) # Plain http only for this machine, and only as host[:port], so the key never # crosses a network in the clear. a_hp=${API#http://} a_host=${a_hp%%[:/]*} a_rest=${a_hp#"$a_host"} case $a_host in localhost | 127.0.0.1) ;; *) die_usage "RYPT_API_URL must be https:// (plain http only for localhost)" ;; esac case $a_rest in '') ;; :[0-9]*) case ${a_rest#:} in *[!0-9]*) die_usage "RYPT_API_URL: plain http takes only localhost[:PORT]" ;; esac ;; *) die_usage "RYPT_API_URL: plain http takes only localhost[:PORT]" ;; esac ;; *) die_usage "RYPT_API_URL must be https:// (plain http only for localhost)" ;; esac if [ -n "$rk_env" ]; then token=$(exec 4>&-; printf '%s' "$rk_env" | tr -d ' \t\r\n') else # As the installer saves it: a relative XDG_CONFIG_HOME is ignored, as the XDG spec # says, and a literal ~/ (from a quoted value or CI YAML) means the home directory. case ${XDG_CONFIG_HOME:-} in /*) kf=$XDG_CONFIG_HOME ;; *) kf=${HOME:-}/.config ;; esac kf=${RYPT_API_KEY_FILE:-$kf/rypt/api-key} # shellcheck disable=SC2088 # the ~/ is matched as text on purpose case $kf in '~/'*) kf=${HOME:-}/${kf#'~/'} ;; -*) kf=./$kf ;; esac [ -f "$kf" ] || die_usage "no API key: set RYPT_API_KEY, or put the key in $kf" [ -r "$kf" ] || die_local "cannot read $kf" # -H checks the file a symlink points to, not the link. if [ -n "$(exec 4>&-; find -H "$kf" -prune \( -perm -004 -o -perm -040 \) 2>/dev/null)" ]; then say "warning: $kf can be read by other users. Run: chmod 600 '$kf'" fi if [ -n "$(exec 4>&-; find -H "$kf" -prune \( -perm -002 -o -perm -020 \) 2>/dev/null)" ]; then say "warning: other users can replace $kf with their own API key. Run: chmod 600 '$kf'" fi kd=$(exec 4>&-; dirname "$kf") if [ -n "$(exec 4>&-; find -H "$kd" -prune -perm -002 ! -perm -1000 2>/dev/null)" ]; then say "warning: other users can replace files in $kd. Run: chmod o-w '$kd'" fi token=$(exec 4>&-; tr -d ' \t\r\n' < "$kf") fi is_token "$token" || die_usage "the API key should be 44 characters and start ry_ (check RYPT_API_KEY or the key file)" } # ---------------------------------------------------------------------------- http # api METHOD PATH [JSON] sets $status and $body. JSON is sent on stdin, and the # Authorization header reaches curl on fd 3 from a pipe written by the builtin printf. api() { a_method=$1 a_url=$API$2 a_data=${3-} set -- -q -sS -X "$a_method" --config /dev/fd/3 -A "rypt.sh/$VERSION" \ --max-filesize 1048576 --connect-timeout 10 --max-time 120 -w '\n%{http_code}' if [ -n "$a_data" ]; then set -- "$@" -H 'content-type: application/json' --data-binary @- fi # Plain http is only ever localhost: no proxy may carry it. For https the user's own # proxy settings, NO_PROXY included, apply unchanged. case $API in http://*) set -- "$@" --noproxy '*' ;; esac # The header pipe arrives on the group's stdin, is moved to fd 9, and reaches curl as # fd 3; curl's own stdin is the JSON. Command substitutions are subshells, so closing # fd 4 (the output file) there keeps it from every child. a_out=$(exec 4>&- printf 'header = "Authorization: Bearer %s"\n' "$token" | { printf '%s' "$a_data" | curl "$@" "$a_url" 3<&9 9<&-; } 9<&0) a_rc=$? case $a_rc in 0) ;; 63) die_local "the response from $API was larger than 1 MiB, so it was refused" ;; *) die_local "could not reach $API (curl exit $a_rc)" ;; esac status=$(exec 4>&-; printf '%s\n' "$a_out" | tail -n 1) body=$(exec 4>&-; printf '%s\n' "$a_out" | sed '$d') case $status in [0-9][0-9][0-9]) ;; *) die_local "no HTTP status from $API" ;; esac } # The awk function that undoes JSON string escapes, left to right: \" and \\ and \/ # become the character; \uXXXX, \b, \f, \n, \r and \t are dropped, because the API # escapes only control characters that way, and none belongs on a terminal. The jq paths # drop the same characters, so both paths print the same text. UNESC_AWK=' function unesc(s, o, i, c) { o = "" while ((i = index(s, "\\")) > 0) { c = substr(s, i + 1, 1) o = o substr(s, 1, i - 1) if (c == "u") s = substr(s, i + 6) else if (c == "b" || c == "f" || c == "n" || c == "r" || c == "t") s = substr(s, i + 2) else { o = o c; s = substr(s, i + 2) } } return o s }' unesc() { awk "$UNESC_AWK"' { print unesc($0) }'; } # json_str NAME prints the string value of a top-level field of $body, or nothing, # reduced to printable ASCII: every field it reads (base64, codes, messages, ids, times) # is ASCII from the real API, and nothing else belongs on a terminal. It always runs # inside $(...), so closing fd 4 affects only its own children. json_str() { exec 4>&- if have_jq; then printf '%s' "$body" | jq -r --arg f "$1" \ 'if type == "object" and (.[$f] | type) == "string" then .[$f] else empty end' 2>/dev/null | tr -cd '\040-\176' else printf '%s\n' "$body" | sed -n -E 's/.*"'"$1"'":"(([^"\\]|\\.)*)".*/\1/p' | head -n 1 | unesc | tr -cd '\040-\176' fi } ok_status() { case $status in 2??) return 0 ;; esac; return 1; } api_fail() { f_code=$(json_str error) f_msg=$(json_str message) f_req=$(json_str request_id) if [ -n "$f_code" ]; then f_reset=$(json_str resets_at) if [ -n "$f_reset" ]; then f_cap=$(exec 4>&-; printf '%s\n' "$body" | sed -n -E 's/.*"cap":([0-9]+).*/\1/p' | head -n 1) f_msg="$f_msg; the cap is ${f_cap:-?} operations a month, and it resets at $f_reset" fi f_line="$f_code: $f_msg (HTTP $status, request_id ${f_req:-none})" # A size refusal is the documented exit 3, wherever it was detected. if [ "$f_code" = payload_too_large ]; then say "$f_line"; exit 3; fi die_api "$f_line" fi die_api "HTTP $status from $API, with no rypt error in the response" } # ---------------------------------------------------------------------------- keys is_key_list() { case $body in '['*']') return 0 ;; esac; return 1; } # resolve_key NAME-OR-ID sets $key_id to a verified UUID. resolve_key() { if is_uuid "$1"; then key_id=$(exec 4>&-; printf '%s' "$1" | tr 'A-F' 'a-f') return 0 fi api GET /v1/keys ok_status || api_fail is_key_list || die_api "the API response was not a key list" if have_jq; then key_id=$(exec 4>&-; printf '%s' "$body" | RYPT_WANT=$1 jq -r \ '.[]? | select(.name == env.RYPT_WANT) | .id' 2>/dev/null | head -n 1) else # The API writes "id" first in every key object, and {"id":" cannot occur inside a # JSON string (every quote there is escaped), so it splits the list safely. Names # are JSON strings; the API escapes only backslash and double quote in them. r_esc=$(exec 4>&-; printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') key_id=$(exec 4>&-; printf '%s\n' "$body" | RYPT_WANT="\"name\":\"$r_esc\"" awk ' BEGIN { want = ENVIRON["RYPT_WANT"] } { n = split($0, obj, /\{"id":"/) for (i = 2; i <= n; i++) if (index(obj[i], want ",") || index(obj[i], want "}")) { print substr(obj[i], 1, 36); exit } }') fi if [ -z "$key_id" ]; then # Without jq only compact JSON can be read; a reformatting proxy is not "no such key". have_jq || case $body in '[]' | *'{"id":"'*) ;; *) die_api "could not read the key list from $API; installing jq would help" ;; esac die_api "no key named '$1' in this account. Run '$HINT keys' to list them." fi is_uuid "$key_id" || die_api "the API returned a key id that is not a UUID" } cmd_keys() { api GET /v1/keys ok_status || api_fail is_key_list || die_api "the API response was not a key list" if have_jq; then # explode/implode rather than gsub: no dependency on jq being built with regex support. # Names and tiers lose code points 0-31 and 127-159 (the C0 and C1 controls, and DEL) # and U+FFFD, which jq substitutes for bytes that are not UTF-8; ids keep only the # characters of a UUID. The awk path below applies the same rules. k_out=$(printf '%s' "$body" | jq -r ' def printable: explode | map(select(. > 31 and (. < 127 or . > 159) and . != 65533)) | implode; def uuidchars: explode | map(select((. >= 48 and . <= 57) or (. >= 65 and . <= 70) or (. >= 97 and . <= 102) or . == 45)) | implode; .[] | (.id | uuidchars) + " " + (((.tier | printable) + " ")[0:8]) + " " + (.name | printable)' 2>/dev/null) || die_api "the API response was not a key list" else k_out=$(printf '%s\n' "$body" | awk "$UNESC_AWK"' function field(s, f, re) { re = "\"" f "\":\"([^\"\\\\]|\\\\.)*\"" if (!match(s, re)) return "" return utf8(unesc(substr(s, RSTART + length(f) + 4, RLENGTH - length(f) - 5))) } # Keeps well-formed UTF-8 and drops everything else: stray or truncated bytes, raw # 8-bit C1 controls, and C1 controls encoded as UTF-8 (\302\200 to \302\237). function utf8(s, o, n, i, ok) { o = "" while (match(s, /[\200-\377]/)) { o = o substr(s, 1, RSTART - 1) s = substr(s, RSTART) n = 0 if (s ~ /^[\302-\337]/) n = 2 else if (s ~ /^[\340-\357]/) n = 3 else if (s ~ /^[\360-\364]/) n = 4 ok = n > 0 && length(s) >= n for (i = 2; ok && i <= n; i++) if (substr(s, i, 1) !~ /^[\200-\277]$/) ok = 0 if (!ok) { s = substr(s, 2); continue } if (substr(s, 1, 2) !~ /^\302[\200-\237]$/) o = o substr(s, 1, n) s = substr(s, n + 1) } return o s } { n = split($0, obj, /\{"id":"/) for (i = 2; i <= n; i++) { id = unesc(substr(obj[i], 1, index(obj[i], "\"") - 1)) gsub(/[^0-9A-Fa-f-]/, "", id) printf "%s %-8s %s\n", id, field(obj[i], "tier"), field(obj[i], "name") } }') fi if [ -z "$k_out" ]; then case $body in '[]') say "no keys yet. Create one at https://dashboard.rypt.dev/"; return 0 ;; *) die_api "could not read the key list from $API; installing jq would help" ;; esac fi printf '%s\n' "$k_out" | clean written "the key list" } # written WHAT checks the status of the pipeline just run: a reader that went away # (SIGPIPE, as in '| head') exits quietly with 141; any other failure is reported. written() { w_rc=$? case $w_rc in 0) ;; 141) exit 141 ;; *) die_local "could not write $1" ;; esac } # ---------------------------------------------------------------------------- files w_tmp= w_direct=0 cleanup() { if [ -n "$w_tmp" ]; then rm -f -- "$w_tmp" 4>&-; fi; } trap cleanup EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 131' QUIT trap 'exit 138' USR1 trap 'exit 140' USR2 trap 'exit 141' PIPE trap 'exit 142' ALRM trap 'exit 143' TERM # Written in place: only this process's own descriptors (/dev/fd/N, as bash's >(...) # gives), and real character devices under /dev reached without a link, such as # /dev/null and /dev/tty. Anything else at the output name, a FIFO in /dev/shm or /tmp # included, could have been planted by someone else, so it is treated as an existing # file: it needs --force, and is then replaced. is_special() { case $1 in /dev/fd/[0-9]*) [ -c "$1" ] || [ -p "$1" ] ;; /dev/*) [ ! -L "$1" ] && [ -c "$1" ] ;; *) return 1 ;; esac } # Names for this process's stderr: written through a copy of fd 2, never opened by name. is_stderr() { case $1 in /dev/stderr | /dev/fd/2 | /proc/self/fd/2) return 0 ;; esac; return 1; } # check_out PATH refuses, before any request is sent, an output that would fail or # surprise at the end. check_out() { [ "$1" = - ] && return 0 is_stderr "$1" && return 0 [ -n "$1" ] || die_usage "there is no output name. Pass -o OUT." case $1 in */) die_usage "-o $1 ends in /. Pass -o DIR/NAME." ;; esac is_special "$1" && return 0 o_base=${1##*/} [ "${#o_base}" -le 255 ] || die_local "the file name in $1 is longer than 255 bytes" [ ! -d "$1" ] || die_local "$1 is a directory. Pass -o DIR/NAME." if { [ -e "$1" ] || [ -L "$1" ]; } && [ "$force" != 1 ]; then die_local "$1 already exists. Pass --force to replace it." fi } # begin_out PATH opens fd 4 for the result, before any request is sent. For a regular # destination that is a new file, created exclusively (set -C) under a random name in the # same directory; it is written only through fd 4, so nothing can swap it for a link. begin_out() { case $1 in -*) b_path=./$1 ;; *) b_path=$1 ;; esac if is_stderr "$1"; then exec 4>&2 w_direct=1 return 0 fi if is_special "$1"; then command exec 4> "$b_path" || die_local "cannot write $1" w_direct=1 return 0 fi b_dir=$(dirname "$b_path") # The name is made here, not by mktemp -u: BSD mktemp -u creates and deletes the file, # announcing the name to anyone watching the directory before it is opened. b_rand=$(od -An -N12 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n') case $b_rand in ????????????????????????) ;; *) die_local "cannot read /dev/urandom" ;; esac b_tmp=$b_dir/.rypt-$b_rand { command exec 4> "$b_tmp"; } 2>/dev/null || die_local "cannot write in $b_dir" w_tmp=$b_tmp # set -C still opens an existing FIFO or device, without O_EXCL: make sure fd 4 is the # new regular file. { [ -f /dev/fd/4 ] && [ ! -L "$b_tmp" ]; } || die_local "cannot write in $b_dir" } # fd4_is PATH succeeds if PATH, not a symlink, names the file open on fd 4: the same # device and inode. The shell's -ef on /dev/fd/4 answers that on Linux. macOS's /dev/fd # reports numbers of its own, so there stat answers, reading fd 4 as its standard input # (fstat): BSD stat with no operand, or GNU stat with the operand -. Returns 1 for a # different file, and 2 when neither way can tell. fd4_is() { [ ! -L "$1" ] || return 1 # shellcheck disable=SC3013 [ /dev/fd/4 -ef "$1" ] && return 0 for f_how in bsd gnu; do if [ "$f_how" = bsd ]; then f_fd=$(stat -f %d:%i <&4 4>&- 2>/dev/null) || continue f_path=$(exec 4>&-; stat -f %d:%i -- "$1" 2>/dev/null) || continue else f_fd=$(stat -c %d:%i - <&4 4>&- 2>/dev/null) || continue f_path=$(exec 4>&-; stat -c %d:%i -- "$1" 2>/dev/null) || continue fi case $f_fd in *[!0-9:]* | '' | :* | *:) continue ;; esac [ "$f_fd" = "$f_path" ] return done return 2 } # finish_out PATH publishes the result. Without --force it links the file into place, # which fails if anything, even a dangling symlink, has appeared at PATH meanwhile. finish_out() { if [ "$w_direct" = 1 ]; then exec 4>&-; say "ok wrote $1"; return 0; fi # The temp file must still be the one written through fd 4, before anything is published. fd4_is "$w_tmp" case $? in 0) ;; 1) die_local "the temporary file beside $1 was replaced, so it was not used" ;; *) die_local "cannot confirm that the temporary file beside $1 is the one written (this system's /dev/fd cannot tell, and there is no stat), so it was not used" ;; esac [ ! -d "$1" ] || die_local "$1 is a directory. Pass -o DIR/NAME." if [ "$force" = 1 ]; then mv -f -- "$w_tmp" "$1" 4>&- || die_local "could not write $1" elif ln -- "$w_tmp" "$1" 2>/dev/null 4>&-; then rm -f -- "$w_tmp" 4>&- || die_local "wrote $1, but could not remove its second link $w_tmp. Remove it by hand." elif [ -e "$1" ] || [ -L "$1" ]; then die_local "$1 already exists. Pass --force to replace it." else mv -- "$w_tmp" "$1" 4>&- || die_local "could not write $1" # a filesystem without hard links fi w_tmp= # The published name must be the very file written through fd 4, not one swapped in # between the checks above and the rename. if ! fd4_is "$1"; then exec 4>&- die_local "$1 was replaced while it was being written. Do not trust its contents." fi exec 4>&- say "ok wrote $1" } # Called with stdout on the output file (or stdout); fd 4 is closed for base64 itself. b64_decode() { if printf 'YQ==\n' | base64 -d >/dev/null 2>&1 4>&-; then base64 -d 4>&- elif printf 'YQ==\n' | base64 -D >/dev/null 2>&1 4>&-; then base64 -D 4>&- else say "base64 here cannot decode (tried -d and -D)"; return 1 fi } # ---------------------------------------------------------------------------- commands parse_opts() { key_arg=${RYPT_KEY:-} aad= out= force=0 in= while [ $# -gt 0 ]; do case $1 in -k | --key) [ $# -ge 2 ] || die_usage "$1 needs a value"; key_arg=$2; shift 2 ;; --key=*) key_arg=${1#--key=}; shift ;; --aad) [ $# -ge 2 ] || die_usage "$1 needs a value"; aad=$2; shift 2 ;; --aad=*) aad=${1#--aad=}; shift ;; -o | --output) [ $# -ge 2 ] || die_usage "$1 needs a value"; out=$2; shift 2 ;; --output=*) out=${1#--output=}; shift ;; -f | --force) force=1; shift ;; -h | --help) usage; exit 0 ;; --) shift; break ;; -) [ -z "$in" ] || die_usage "give one FILE at a time"; in=-; shift ;; -*) die_usage "unknown option: $1" ;; *) [ -z "$in" ] || die_usage "give one FILE at a time"; in=$1; shift ;; esac done if [ $# -gt 0 ]; then [ -z "$in" ] && [ $# -eq 1 ] || die_usage "give one FILE at a time" in=$1 fi [ -n "$in" ] || in=- case $out in /dev/stdout | /dev/fd/1 | /proc/self/fd/1) out=- ;; esac [ -n "$key_arg" ] || die_usage "which key? Pass --key NAME or --key ID, or set RYPT_KEY." # An API key given as the key name would be echoed in "no key named ..."; refuse it # unseen, with or without text around it ("Bearer ...", a trailing newline). # shellcheck disable=SC2254 case $key_arg in *$TOKEN_PAT*) die_usage "--key (or RYPT_KEY) holds an API key, not a key name or id. Put the API key in RYPT_API_KEY or the key file." ;; esac aad_json= if [ -n "$aad" ]; then aad_len=$(printf '%s' "$aad" | wc -c | tr -d ' ') [ "$aad_len" -le "$MAX_AAD" ] || die_local "--aad is $aad_len bytes, and the API takes at most $MAX_AAD" aad_json=",\"aad\":\"$(printf '%s' "$aad" | base64 | tr -d '\n')\"" fi } check_input() { if [ "$in" = - ] && [ -t 0 ]; then die_usage "no FILE given and stdin is a terminal. Pass a file, or pipe data in." fi if [ "$in" != - ]; then [ -e "$in" ] || die_local "no such file: $in" [ -f "$in" ] || die_local "$in is not a regular file" [ -r "$in" ] || die_local "cannot read $in" fi } too_big() { die_local "$1 is $2 bytes, and encrypt takes at most $MAX_PLAINTEXT. For larger data, use envelope mode (wrap a data key) through the API." } cmd_encrypt() { parse_opts "$@" check_input if [ -z "$out" ]; then if [ "$in" = - ]; then out=-; else out=$in.enc; fi fi check_out "$out" # Read the plaintext as base64. A '!' (not in the base64 alphabet) marks a failed stage, # so a short read is never encrypted as if it were the whole input. if [ "$in" = - ]; then # One byte past the limit, so an oversized stream fails without being buffered. pt_b64=$( { { head -c $(( MAX_PLAINTEXT + 1 )) || printf '!' >&3; } | base64 || printf '!'; } 3>&1 | tr -d '\n') case $pt_b64 in *!*) die_local "could not read stdin" ;; esac else f_size=$(wc -c < "$in" | tr -d ' ') [ "$f_size" -le "$MAX_PLAINTEXT" ] || too_big "$in" "$f_size" pt_b64=$( { base64 < "$in" || printf '!'; } | tr -d '\n') case $pt_b64 in *!*) die_local "could not read $in" ;; esac fi [ -n "$pt_b64" ] || die_local "nothing to encrypt: the input is empty" case $pt_b64 in *==) e_pad=2 ;; *=) e_pad=1 ;; *) e_pad=0 ;; esac e_size=$(( ${#pt_b64} * 3 / 4 - e_pad )) [ "$e_size" -le "$MAX_PLAINTEXT" ] || too_big "the input" "$e_size" if [ "$in" != - ] && [ "$e_size" -ne "$f_size" ]; then die_local "read $e_size of the $f_size bytes in $in" fi # The decrypt request for this ciphertext will be larger than this encrypt request. If it # could not fit in the API's 128 KiB, the ciphertext could never be decrypted: refuse now. # The 1,024 bytes of headroom cover the ciphertext's own overhead. if [ $(( ${#pt_b64} + ${#aad_json} + 17 + 1024 )) -gt "$MAX_BODY" ]; then die_local "the input and --aad together are too large to decrypt again later: the API takes 128 KiB per request. Use a shorter --aad, or envelope mode." fi [ "$out" = - ] || begin_out "$out" setup_api resolve_key "$key_arg" api POST "/v1/keys/$key_id/encrypt" "{\"plaintext\":\"$pt_b64\"$aad_json}" ok_status || api_fail ct=$(json_str ciphertext) is_b64 "$ct" || die_api "the API response had no ciphertext" [ "${#ct}" -le 200000 ] || die_api "the API returned an implausibly large ciphertext" if [ $(( ${#ct} + ${#aad_json} + 17 )) -gt "$MAX_BODY" ]; then die_api "this ciphertext plus the --aad would exceed the API's 128 KiB decrypt limit, so nothing was written" fi if [ "$out" = - ]; then printf '%s\n' "$ct" 2>/dev/null || die_local "could not write the ciphertext to stdout" else printf '%s\n' "$ct" >&4 2>/dev/null || die_local "could not write $out" finish_out "$out" fi } cmd_decrypt() { parse_opts "$@" check_input if [ -z "$out" ]; then if [ "$in" = - ]; then out=- else case $in in *.enc) out=${in%.enc} ;; *) die_usage "$in does not end in .enc, so there is no default output name. Pass -o OUT, or -o - for stdout." ;; esac case $out in '' | */) die_usage "cannot name the output for $in. Pass -o OUT." ;; esac fi fi check_out "$out" # A rypt ciphertext of the largest plaintext is under 90,000 characters of base64; # read a bounded amount so a wrong, huge file fails quickly. if [ "$in" = - ]; then ct=$( { head -c 200000 || printf '!'; } | tr -d ' \t\r\n') else ct=$( { head -c 200000 < "$in" || printf '!'; } | tr -d ' \t\r\n') fi case $ct in *!*) if [ "$in" = - ]; then die_local "could not read stdin"; else die_local "could not read $in"; fi ;; esac is_b64 "$ct" || die_local "the input is not rypt ciphertext (expected one line of base64)" if [ $(( ${#ct} + ${#aad_json} + 17 )) -gt "$MAX_BODY" ]; then die_local "the ciphertext and --aad together are larger than the API's 128 KiB request limit" fi [ "$out" = - ] || begin_out "$out" setup_api resolve_key "$key_arg" api POST "/v1/keys/$key_id/decrypt" "{\"ciphertext\":\"$ct\"$aad_json}" ok_status || api_fail pt=$(json_str plaintext) is_b64 "$pt" || die_api "the API response had no plaintext" [ "${#pt}" -le 90000 ] || die_api "the API returned an implausibly large plaintext" if [ "$out" = - ]; then printf '%s\n' "$pt" | b64_decode written "the plaintext" else printf '%s\n' "$pt" | b64_decode >&4 || die_local "could not write $out" finish_out "$out" fi } # ---------------------------------------------------------------------------- main [ $# -gt 0 ] || { usage >&2; exit 2; } cmd=$1 shift case $cmd in encrypt) cmd_encrypt "$@" ;; decrypt) cmd_decrypt "$@" ;; keys) [ $# -eq 0 ] || die_usage "keys takes no arguments"; setup_api; cmd_keys ;; help | -h | --help) usage ;; version | -V | --version) printf '%s %s\n' "$PROG" "$VERSION" ;; *) die_usage "unknown command: $cmd" ;; esac