diff options
Diffstat (limited to 'scripts/continuum-signed')
| -rwxr-xr-x | scripts/continuum-signed | 141 |
1 files changed, 141 insertions, 0 deletions
diff --git a/scripts/continuum-signed b/scripts/continuum-signed new file mode 100755 index 0000000..073c840 --- /dev/null +++ b/scripts/continuum-signed @@ -0,0 +1,141 @@ +#!/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 |
