1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
|
#!/bin/sh
# A reference client for a swarm's values signed by their users, values sealed for
# their readers and logins by key (docs/access.md: Values signed by their users, Values
# sealed for their readers, Tokens), with OpenSSH's ssh-keygen, age, 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) and age.
#
# 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
# continuum-signed seal PATH JSON writes JSON at PATH sealed for its readers,
# signed
# continuum-signed open PATH the JSON sealed at PATH, opened
#
# 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
# CONTINUUM_READERS whom `seal` seals for (users, comma-separated); without, the
# readers the swarm declares for PATH, else the user alone
#
# `seal` seals for every key of each reader, as the registry has them; `open` opens with
# CONTINUUM_KEY, which must then be the private key itself (age reads no agent).
# `verify` and `seal` read the registry (`_continuum/users`) and the declarations
# (`_continuum/access`): the user needs reading them (docs/access.md).
# `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
swarm=$(curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" "$url/auth/whoami" |
jq -r .swarm) || die "no swarm from $url"
# The exact bytes signed and sent: one line of JSON, no newline.
jq -cnj --arg swarm "$swarm" --arg path "$1" --argjson value "$2" --arg user "$user" \
--argjson at "$(date +%s)000" --arg prev "$prev" \
'{swarm: $swarm, 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 @-
}
# Whom a value at $1 is sealed for, one user a line.
readers_of() {
if [ -n "${CONTINUUM_READERS:-}" ]; then
printf '%s\n' "$CONTINUUM_READERS" | tr ',' '\n'
return
fi
readers=$(curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" \
"$url/store/_continuum/access/sealed" 2>/dev/null |
jq -r --arg path "$1" '
($path | split("/")) as $p
| [.[]? | select(type == "object" and (.path | length) == ($p | length)
and ([.path, $p] | transpose | all(.[0] == "*" or .[0] == .[1])))]
| first | .readers // empty | .[]' 2>/dev/null) || readers=
if [ -n "$readers" ]; then
printf '%s\n' "$readers"
else
printf '%s\n' "$user"
fi
}
# Writes JSON $2 at $1 sealed for its readers (`readers_of`), signed.
seal() {
command -v age >/dev/null || die "needs age"
printf %s "$2" | jq -c . >"$work/plain" 2>/dev/null || die "not JSON: $2"
readers_of "$1" | while read -r reader; do
curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" \
"$url/store/_continuum/users/$reader" | jq -r '(.keys // {})[]' ||
die "no user $reader"
done >"$work/recipients"
[ -s "$work/recipients" ] || die "none of the readers has a key"
age -R "$work/recipients" -o "$work/sealed" "$work/plain" || die "age couldn't seal"
sealed="cs1.$(base64 <"$work/sealed" | tr -d '\n=' | tr '/+' '_-')"
write "$1" "$(jq -n --arg sealed "$sealed" '$sealed')"
}
# The JSON sealed at $1, opened with the user's key.
open_sealed() {
command -v age >/dev/null || die "needs age"
stored=$(raw "$1")
case $stored in
cv1.*) sealed=$(printf %s "$stored" | cut -d. -f2 | unb64url | jq -r '.value // empty') ;;
*) sealed=$stored ;;
esac
case $sealed in
cs1.*) ;;
*) die "no sealed value at $1" ;;
esac
printf %s "${sealed#cs1.}" | unb64url >"$work/sealed"
age -d -i "$key" "$work/sealed" || die "couldn't open it with $key"
echo
}
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 | seal | open)
# 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" ;;
seal) [ $# -eq 2 ] || die "seal PATH JSON"; seal "$1" "$2" ;;
open) [ $# -eq 1 ] || die "open PATH"; open_sealed "$1" ;;
*) sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2; exit 2 ;;
esac
|