aboutsummaryrefslogtreecommitdiffstats
path: root/scripts/continuum-signed
blob: 6dd1f4cf9d07c5f46ddde3a79051bf801af4407e (plain)
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