aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md4
-rwxr-xr-xscripts/continuum-signed141
2 files changed, 145 insertions, 0 deletions
diff --git a/docs/access.md b/docs/access.md
index d81f115..57462db 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -273,6 +273,10 @@ a list of paths as capabilities name them, `*` for any one key:
(`cv1.<payload>.<signature>`): the payload names the path, the value (`null`: deleted),
the user, when, and the envelope it replaces (`prev`), and the signature is the user's
over it, as `ssh-keygen -Y sign -n continuum-value` makes it, with one of its `keys`.
+`scripts/continuum-signed` is a reference client, with `ssh-keygen`, `curl` and `jq`:
+it logs in by key, writes and deletes signed values (`put config '{"mode": "fast"}'`),
+and checks one against its user's keys (`verify config`).
+
A node takes one only from that user, for that path, replacing what is there, by one
of the user's keys, and if the user may write there ([`api.md`](api.md), Signed values);
it writes nothing else there, nor within or above such a value (`409`), and the users
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