#!/bin/sh # A reference client for a swarm's values signed by their users and logins by key # (docs/access.md, Values signed by their users; Tokens), with OpenSSH's ssh-keygen, # curl and jq. What it does, a client of any language can: nothing here is secret but # the user's key, which never leaves ssh-keygen (or its agent). # # continuum-signed login a token, by key (printed) # continuum-signed put PATH JSON writes JSON at PATH, signed # continuum-signed delete PATH deletes what is at PATH, signed (a signed null) # continuum-signed get PATH the envelope at PATH, as stored # continuum-signed verify PATH checks the envelope at PATH: for PATH, signed # by one of its user's keys as the registry says # # PATH is the store's (`config/mode`), its segments without `/` or characters that need # escaping in a URL. Settings, from the environment: # # CONTINUUM_URL the node (default http://localhost:3000) # CONTINUUM_USER who signs (default $USER) # CONTINUUM_KEY the private key ssh-keygen signs with, or its public half when an # agent holds it (default ~/.ssh/id_ed25519) # CONTINUUM_TOKEN a token to use; without, each command logs in by key once # # `verify` checks one level: the envelope against the user's keys as the node shows # the registry. The registry's entries are values signed by their users too; checking # them, and theirs, up to users one trusts, is `verify` again, on each. set -eu url=${CONTINUUM_URL:-http://localhost:3000} user=${CONTINUUM_USER:-${USER:-}} key=${CONTINUUM_KEY:-$HOME/.ssh/id_ed25519} die() { echo "continuum-signed: $*" >&2 exit 1 } for tool in curl jq ssh-keygen sha256sum base64; do command -v "$tool" >/dev/null || die "needs $tool" done work=$(mktemp -d) trap 'rm -rf "$work"' EXIT # Signs file $1 for namespace $2, into $1.sig. sign() { ssh-keygen -q -Y sign -f "$key" -n "$2" "$1" >/dev/null 2>&1 || die "ssh-keygen couldn't sign with $key" } # Base64url (unpadded) on stdin, decoded to stdout. unb64url() { data=$(tr '_-' '/+') case $(( ${#data} % 4 )) in 2) data="$data==" ;; 3) data="$data=" ;; esac printf %s "$data" | base64 -d } login() { [ -n "$user" ] || die "who signs? set CONTINUUM_USER" challenge=$(curl -sf -X POST "$url/auth/challenge" | jq -r .challenge) || die "no challenge from $url" printf %s "$challenge" >"$work/challenge" sign "$work/challenge" continuum-login jq -n --arg user "$user" --arg challenge "$challenge" \ --rawfile signature "$work/challenge.sig" \ '{user: $user, challenge: $challenge, signature: $signature}' | curl -sS --fail-with-body -X POST "$url/auth/login/key" \ -H 'Content-Type: application/json' --data-binary @- | jq -r .token } # The envelope at $1, as stored; nothing if there is none. raw() { curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" -H 'Accept: text/plain' \ "$url/store/$1?raw=true" || true } # Writes JSON $2 at $1, signed: replacing the envelope there, if any (`prev`). write() { [ -n "$user" ] || die "who signs? set CONTINUUM_USER" current=$(raw "$1") case $current in cv1.*) prev=$(printf %s "$current" | sha256sum | cut -d' ' -f1) ;; *) prev= ;; esac # The exact bytes signed and sent: one line of JSON, no newline. jq -cnj --arg path "$1" --argjson value "$2" --arg user "$user" \ --argjson at "$(date +%s)000" --arg prev "$prev" \ '{path: ($path | split("/")), value: $value, user: $user, at: $at, prev: $prev}' \ >"$work/payload" || die "not JSON: $2" sign "$work/payload" continuum-value { cat "$work/payload"; echo; cat "$work/payload.sig"; } | curl -sS --fail-with-body -X PUT "$url/store/$1" \ -H "Authorization: Bearer $CONTINUUM_TOKEN" \ -H 'Content-Type: application/continuum-signed' --data-binary @- } verify() { envelope=$(raw "$1") case $envelope in cv1.*) ;; *) die "no signed value at $1" ;; esac printf %s "$envelope" | cut -d. -f2 | unb64url >"$work/payload" printf %s "$envelope" | cut -d. -f3 | unb64url | base64 | tr -d '\n' | fold -w 70 >"$work/blob" echo >>"$work/blob" { echo '-----BEGIN SSH SIGNATURE-----'; cat "$work/blob" echo '-----END SSH SIGNATURE-----'; } >"$work/payload.sig" signer=$(jq -r .user "$work/payload") signed_for=$(jq -r '.path | join("/")' "$work/payload") [ "$signed_for" = "$1" ] || die "signed for $signed_for, not $1" curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" "$url/store/_continuum/users/$signer" | jq -r --arg user "$signer" \ '(.keys // {})[] | "\($user) namespaces=\"continuum-value\" \(.)"' \ >"$work/allowed" || die "no user $signer" ssh-keygen -q -Y verify -f "$work/allowed" -I "$signer" -n continuum-value \ -s "$work/payload.sig" <"$work/payload" >/dev/null 2>&1 || die "the signature of $1 doesn't check against $signer's keys" echo "$1: signed by $signer at $(jq -r .at "$work/payload")" } command=${1:-} [ $# -gt 0 ] && shift case $command in put | delete | get | verify) # One login for all the command's requests, unless given a token. CONTINUUM_TOKEN=${CONTINUUM_TOKEN:-$(login)} [ -n "$CONTINUUM_TOKEN" ] || die "couldn't log in as $user" ;; esac case $command in login) [ $# -eq 0 ] || die "login takes nothing"; login ;; put) [ $# -eq 2 ] || die "put PATH JSON"; write "$1" "$2" ;; delete) [ $# -eq 1 ] || die "delete PATH"; write "$1" null ;; get) [ $# -eq 1 ] || die "get PATH"; raw "$1"; echo ;; verify) [ $# -eq 1 ] || die "verify PATH"; verify "$1" ;; *) sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2; exit 2 ;; esac