caucase.sh 36.4 KB
Newer Older
1 2
#!/bin/sh
# This file is part of caucase
3
# Copyright (C) 2017-2020  Nexedi SA
4 5
#     Vincent Pelletier <vincent@nexedi.com>
#
6 7 8
# This program is free software: you can Use, Study, Modify and Redistribute
# it under the terms of the GNU General Public License version 3, or (at your
# option) any later version, as published by the Free Software Foundation.
9
#
10 11 12 13 14
# You can also Link and Combine this program with other software covered by
# the terms of any of the Free Software licenses or any of the Open Source
# Initiative approved licenses and Convey the resulting work. Corresponding
# source of such a combination shall include the source code for all other
# software used.
15
#
16 17 18 19 20
# This program is distributed WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options.
21 22 23 24
set -u

str2json () {
  # Convert some text into a json string.
25
  # Usage: < str
26 27

  # Note: using $() to strip the trailing newline added by jq.
28
  printf '%s' "$(jq --raw-input --slurp .)"
29 30 31 32
}

pairs2obj () {
  # Convert pairs of arguments into keys & values of a json objet.
33
  # Usage: <key0> <value0> [...]
34 35 36 37 38
  # Outputs: {"key0":value0}
  # No sanity checks on keys nor values.
  # Keys are expected unquoted, as they must be strings anyway.
  # Values are expected in json.
  # If arg count is odd, last argument is ignored.
39
  # shellcheck disable=SC2039
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
  local first=1
  printf '{'
  while [ $# -ge 2 ]; do
    if [ $first -eq 1 ]; then
      first=0
    else
      printf ','
    fi
    printf '"%s":%s' "$1" "$2"
    shift 2
  done
  printf '}'
}

forEachJSONListItem () {
  # Usage: <command> [<arg> ...] < json
  # <command> is receives each item in json as input.
  # If <command> exit status is non-zero, enumeration stops.
58 59 60
  # shellcheck disable=SC2039
  local list index
  list="$(cat)"
61
  for index in $(seq 0 $(($(printf '%s\n' "$list" | jq length) - 1))); do
62
    printf '%s\n' "$list" | jq ".[$index]" | "$@" || return
63 64 65 66 67
  done
}

wrap () {
  # Wrap payload in a format suitable for caucase and sign it
68
  # Usage: <key file> <digest> < payload > wrapped
69 70 71
  # shellcheck disable=SC2039
  local digest="$2" payload
  payload="$(cat)"
72 73 74
  # Note: $() looses trailing newlines, so payload should not need to end with
  # any newline.
  pairs2obj \
75 76 77 78
    'digest' "$(printf '%s' "$digest" | str2json)" \
    'payload' "$(printf '%s' "$payload" | str2json)" \
    'signature' "$(
      printf '%s%s ' "$payload" "$digest" \
79 80 81 82 83 84 85 86 87 88 89 90 91 92
      | openssl dgst \
        -"$digest" \
        -binary \
        -sign "$1" \
        -sigopt rsa_padding_mode:pss \
        -sigopt rsa_pss_saltlen:-2 \
        -sigopt digest:"$digest" \
      | base64 -w 0 \
      | str2json
    )"
}

nullWrap () {
  # Wrap payload in a format suitable for caucase without signing it
93
  # Usage: < payload > wrapped
94 95 96 97
  pairs2obj digest null payload "$(str2json)"
}

unwrap () {
98
  # Usage: <command> [...] < wrapped > payload
99 100
  # <command> must output the x509 certificate to use to verify the signature.
  # It receives the payload being unwrapped.
101 102 103
  # shellcheck disable=SC2039
  local wrapped status json_digest digest signature_file payload pubkey_file
  wrapped="$(cat)"
104

105 106
  json_digest="$(printf '%s\n' "$wrapped" | jq .digest)"
  if [ "$json_digest" = 'null' ]; then
107 108 109
    return 1
  fi
  digest="$(
110
    printf '%s\n' "$json_digest" | jq --raw-output ascii_downcase
111 112 113 114 115 116 117 118 119 120 121 122 123
  )"
  case "$digest" in
    sha256|sha384|sha512)
    ;;
    *)
      # Note: printing json-encoded digest so it is safe to print
      # (especially, ESC is encoded as \u001b, avoiding shell escape code
      # injections).
      echo "Unhandled digest: $json_digest" >&2
      return 1
    ;;
  esac
  signature_file="$(mktemp --suffix=unwrap.sig)"
124
  printf '%s\n' "$wrapped" | jq --raw-output .signature | \
125
    base64 -d > "$signature_file"
126
  payload="$(printf '%s\n' "$wrapped" | jq --raw-output .payload)"
127
  pubkey_file="$(mktemp --suffix=unwrap.pub)"
128 129
  if printf '%s\n' "$payload" "$@" \
  | openssl x509 -pubkey -noout > "$pubkey_file"; then
130
    printf '%s%s ' "$payload" "$digest" \
131 132 133 134 135 136 137 138 139 140 141 142
    | openssl dgst \
      -"$digest" \
      -verify "$pubkey_file" \
      -sigopt rsa_padding_mode:pss \
      -sigopt rsa_pss_saltlen:-2 \
      -sigopt digest:"$digest" \
      -signature "$signature_file" > /dev/null
    status=$?
  else
    status=2
  fi
  rm "$signature_file" "$pubkey_file"
143
  test $status -eq 0 && printf '%s' "$payload"
144 145 146 147
  return $status
}

nullUnwrap () {
148
  # Usage: < wrapped > payload
149 150 151
  # shellcheck disable=SC2039
  local wrapped
  wrapped="$(cat)"
152
  if [ "$(printf '%s\n' "$wrapped" | jq '.digest')" != 'null' ]; then
153 154
    return 1
  fi
155
  printf '%s\n' "$wrapped" | jq .payload
156 157 158 159
}

writeCertKey () {
  # Write given certificate and key to file(s).
160
  # Usage: <crt data> <crt path> <key data> <key path>
161
  # shellcheck disable=SC2039
162 163 164 165 166 167 168
  local crt_path="$1" crt_data="$2" key_path="$3" key_data="$4" need_chmod
  test ! -e "$key_path"
  need_chmod=$?
  # Empty both files first, as they may be the same.
  : > "$crt_path"
  : > "$key_path"
  test $need_chmod -eq 0 && chmod go= "$key_path"
169 170
  printf '%s\n' "$key_data" >> "$key_path"
  printf '%s\n' "$crt_data" >> "$crt_path"
171 172
}

173 174 175 176 177 178 179 180 181 182 183
_curlInsecure () {
  # Because caucased https certificate does not need to be trusted.
  # Usage: ...
  curl --silent --insecure "$@"
}

_putInsecure () {
  # To PUT stdin via https.
  # Usage: ... < input
  _curlInsecure --upload-file - "$@"
}
184

185 186 187 188
_putInsecureNoOut () {
  # For when _putInsecure does not provide a response body, so the only way to
  # check for issues is checking HTTP status.
  # Usage: ... < input
189
  # shellcheck disable=SC2039
190
  local result
191
  if result="$(
192
    _putInsecure \
193
      --write-out '\n%{http_code}\n' \
194
      "$@"
195 196 197
  )"; then
    :
  else
198 199
    return 3
  fi
200
  case "$(printf '%s\n' "$result" | tail -n 1)" in
201 202 203 204
    2?? )
      return 0
    ;;
    401 )
205
      printf 'Unauthorized\n' >&2
206 207 208
      return 2
    ;;
    409 )
209
      printf 'Found\n' >&2
210 211 212
      return 4
    ;;
    * )
213
      printf '%s\n' "$result" | head -n -1 >&2
214 215 216 217 218 219
      return 1
    ;;
  esac
}

_matchCertificateBoundary () {
220
  test "$1" = '-----END CERTIFICATE-----'
221 222 223 224
}

_matchPrivateKeyBoundary () {
  case "$1" in
225
    '-----END PRIVATE KEY-----' | '-----END RSA PRIVATE KEY-----')
226 227 228 229 230 231 232 233
      return 0
    ;;
  esac
  return 1
}

_forEachPEM () {
  # Iterate over components of a PEM file, piping each to <command>
234
  # Usage: <type tester> <command> [<arg> ...] < pem
235 236 237
  # <type tester> is called with the end boundary as argument
  # <command> receives each matching PEM element as input.
  # If <command> exit status is non-zero, enumeration stops.
238
  # shellcheck disable=SC2039
239
  local tester="$1" current=''
240 241 242 243 244
  shift
  while IFS= read -r line; do
    if [ -z "$current" ]; then
      current="$line"
    else
245
      current="$(printf '%s\n%s' "$current" "$line")"
246 247
    fi
    case "$line" in
248
      '-----END '*'-----')
249
        if "$tester" "$line"; then
250
          printf '%s\n' "$current" | "$@" || return
251
        fi
252
        current=''
253 254 255 256 257
        ;;
    esac
  done
}

258
alias forEachCertificate='_forEachPEM _matchCertificateBoundary'
259
# Iterate over certificate of a PEM file, piping each to <command>
260
# Usage: <command> [<arg> ...] < pem
261

262
alias forEachPrivateKey='_forEachPEM _matchPrivateKeyBoundary'
263
# Iterate over private key of a PEM file, piping each to <command>
264
# Usage: <command> [<arg> ...] < pem
265

266
alias pem2fingerprint='openssl x509 -fingerprint -noout'
267 268

pemFingerprintIs () {
269
  # Usage: <fingerprint> < certificate
270 271 272 273 274 275
  # Return 1 when certificate's fingerprint matches argument
  test "$1" = "$(pem2fingerprint)" && return 1
}

expiresBefore () {
  # Tests whether certificate is expired at given date
276
  # Usage: <date> < certificate > certificate
277
  # <date> must be a unix timestamp (date +%s)
278 279
  # shellcheck disable=SC2039
  local enddate
280
  enddate="$(openssl x509 -enddate -noout | sed 's/^[^=]*=//')"
281 282 283 284 285 286
  test $? -ne 0 && return 1
  test "$(date --date="$enddate" +%s)" -lt "$1"
}

printIfExpiresAfter () {
  # Print certificate if it expires after given date
287
  # Usage: <date> < certificate > certificate
288
  # <date> must be a unix timestamp (date +%s)
289 290 291
  # shellcheck disable=SC2039
  local crt
  crt="$(cat)"
292
  printf '%s\n' "$crt" | expiresBefore "$1" || printf '%s\n' "$crt"
293 294
}

295 296 297 298 299 300 301 302 303 304 305 306 307
storeCertBySerial () {
  # Store certificate in a file named after its serial, in given directory
  # and using given printf format string.
  # Usage: storeCertBySerial <dir> <patterm> < certificate
  # shellcheck disable=SC2039
  local crt
  crt="$(cat)"
  serial="$(printf "%s\n" "$crt" \
    | openssl x509 -serial -noout | sed 's/^[^=]*=\(.*\)/\L\1/')"
  test $? -ne 0 && return 1
  printf "%s\n" "$crt" > "$(printf "%s/$2" "$1" "$serial")"
}

308 309 310
appendValidCA () {
  # TODO: test
  # Append CA to given file if it is signed by a CA we know of already.
311
  # Usage: <ca path> < json
312
  # Appends valid certificates to the file at <ca path>
313
  # shellcheck disable=SC2039
314
  local ca="$1" payload cert
315 316 317
  if payload=$(unwrap jq --raw-output .old_pem); then
    :
  else
318
    printf 'Bad signature, something is very wrong' >&2
319 320
    return 1
  fi
321
  cert="$(printf '%s\n' "$payload" | jq --raw-output .old_pem)"
322
  forEachCertificate \
323
    pemFingerprintIs \
324
    "$(printf '%s\n' "$cert" | pem2fingerprint)" < "$ca"
325
  if [ $? -eq 1 ]; then
326
    printf '%s\n' "$cert" >> "$ca"
327 328 329 330
  fi
}

checkCertificateMatchesKey () {
331
  # Usage: <crt> <key>
332 333 334
  # Returns 0 if certificate's public key matches private key's public key,
  # 1 otherwise.
  test "$(
335
    printf '%s\n' "$1" | openssl x509 -modulus -noout | sed 's/^Modulus=//'
336
  )" = "$(
337
    echo "$2" | openssl rsa -modulus -noout | sed 's/^Modulus=//'
338 339 340 341
  )"
}

checkDeps () {
342
  # shellcheck disable=SC2039
343
  local missingdeps='' dep
344 345 346
  # Expected builtins & keywords:
  #   alias local if then else elif fi for in do done case esac return [ test
  #   shift set
347
  for dep in jq openssl printf echo curl sed base64 cat date mktemp; do
348 349 350 351 352 353
    command -v $dep > /dev/null || missingdeps="$missingdeps $dep"
  done
  if [ -n "$missingdeps" ]; then
    echo "Missing dependencies: $missingdeps" >&2
    return 1
  fi
354
  if [ ! -r /dev/null ] || [ ! -w /dev/null ]; then
355
    echo 'Cannot read from & write to /dev/null' >&2
356 357 358 359 360 361 362 363 364 365 366 367
    return 1
  fi
}

renewCertificate () {
  # Usage: <url> <old key> <new key len> <new crt> <new key> < old crt
  # <new key> and <new crt> are created.
  # Given paths may be identical in any combination.
  # If "new" path are the same as "old" paths, old content will be overwritten
  # on success.
  # If created, key file permissions will be set so group and other have no
  # access.
368
  # shellcheck disable=SC2039
369 370
  local url="$1" oldkey="$2" bits="$3" newcrt="$4" newkey="$5" emptyreqcnf \
  newkeydata newcrtdata
371

372 373 374 375 376 377 378 379 380 381 382 383 384
  emptyreqcnf="$(mktemp --suffix=emptyreq.cnf)"
  cat > "$emptyreqcnf" << EOF
[ req ]
distinguished_name = req_distinguished_name
string_mask = utf8only
req_extensions = v3_req

[ req_distinguished_name ]
CN = Common Name

[ v3_req ]
basicConstraints = CA:FALSE
EOF
385 386 387
  newkeydata="$(
    openssl genpkey \
      -algorithm rsa \
388
      -pkeyopt "rsa_keygen_bits:$bits" \
389 390
      -outform PEM 2> /dev/null
  )"
391
  if newcrtdata="$(
392
    pairs2obj \
393 394
      'crt_pem' "$(str2json)" \
      'renew_csr_pem' "$(
395 396 397 398
        echo "$newkeydata" \
        | openssl req \
          -new \
          -key - \
399
          -subj '/CN=dummy' \
400
          -config "$emptyreqcnf" \
401 402
        | str2json
      )" \
403
    | wrap "$oldkey" 'sha256' \
404
    | _putInsecure \
405
      --header 'Content-Type: application/json' \
406
      "$url/crt/renew/"
407
  )"; then
408
    if [ \
409
      "x$(printf '%s\n' "$newcrtdata" | head -n 1)" \
410
      = \
411
      'x-----BEGIN CERTIFICATE-----' \
412 413 414
    ]; then
      if checkCertificateMatchesKey "$newcrtdata" "$newkeydata"; then
        writeCertKey "$newcrt" "$newcrtdata" "$newkey" "$newkeydata"
415
        rm "$emptyreqcnf"
416 417
        return 0
      fi
418
      printf 'Certificate does not match private key\n' >&2
419
    else
420
      printf '%s' "$newcrtdata" >&2
421 422
    fi
  fi
423
  rm "$emptyreqcnf"
424 425 426 427 428
  return 1
}

revokeCertificate () {
  # Usage: <url> <key_path> < crt
429 430
  pairs2obj 'revoke_crt_pem' "$(str2json)" \
  | wrap "$2" 'sha256' \
431
  | _putInsecureNoOut \
432
    --header 'Content-Type: application/json' \
433 434 435 436 437
    --insecure \
    "$1/crt/revoke/"
}

revokeCRTWithoutKey () {
438
  # Usage: <url> <user crt> < crt
439
  pairs2obj 'revoke_crt_pem' "$(str2json)" \
440
  | nullWrap \
441 442
  | _putInsecureNoOut \
    --cert "$2" \
443
    --header 'Content-Type: application/json' \
444 445 446 447
    "$1/crt/revoke/"
}

revokeSerial () {
448 449
  # Usage: <url> <user crt> <serial>
  pairs2obj 'revoke_serial' "$3" \
450
  | nullWrap \
451 452
  | _putInsecureNoOut \
    --cert "$2" \
453
    --header 'Content-Type: application/json' \
454 455 456 457
    "$1/crt/revoke/"
}

updateCACertificate () {
458
  # Usage: <url> <ca>
459
  # shellcheck disable=SC2039
460 461 462 463
  local url="$1" \
    ca="$2" \
    future_ca \
    status \
464 465 466
    orig_ca="" \
    ca_is_file \
    ca_file \
467
    valid_ca
468 469 470 471 472 473
  if [ -e "$ca" ]; then
    if [ -f "$ca" ]; then
      ca_is_file=1
      orig_ca="$(cat "$ca")"
    elif [ -d "$ca" ]; then
      ca_is_file=0
474
    else
475 476
      printf "%s exists and is neither a directory nor a file\n" "$ca"
      return 1
477
    fi
478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503
  else
    case "$ca" in
      *.*)
        ca_is_file=1
        ;;
      *)
        mkdir "$ca"
        ca_is_file=0
        ;;
    esac
  fi
  if [ $ca_is_file -eq 0 ]; then
    for ca_file in "$ca"/*; do
      # double use:
      # - skips non-files
      # - skips the one iteration when there is nothing in "$ca"/
      if [ -f "$ca_file" ] && [ ! -h "$ca_file" ]; then
        orig_ca="$( \
          printf "%s\n%s" "$orig_ca" "$(cat "$ca_file")" \
        )"
      fi
    done
  fi
  if [ -z "$orig_ca" ]; then
    orig_ca="$(_curlInsecure "$url/crt/ca.crt.pem")"
  fi
504 505 506
  status=$?
  test $status -ne 0 && return 1
  valid_ca="$(
507
    printf '%s\n' "$orig_ca" \
508
    | forEachCertificate printIfExpiresAfter "$(date +%s)"
509
  )" || return
510
  if [ "$ca_is_file" -eq 1 ]; then
511 512 513 514 515 516 517 518 519 520 521
    printf '%s\n' "$valid_ca" > "$ca"
  else
    for ca_file in "$ca"/*; do
      test -f "$ca_file" && rm "$ca_file"
    done
    printf '%s\n' "$valid_ca" \
    | forEachCertificate storeCertBySerial "$ca" "%s.pem"
    # other commands (openssl crl, curl) may need openssl-style subject hash
    # symlinks, so create them.
    openssl rehash "$ca" > /dev/null
  fi
522 523 524 525 526
  if [ ! -r "$cas_ca" ]; then
    # Should never be reached, as this function should be run once with
    # cas_ca == ca (to update CAS' CA), in which case cas_ca exists by this
    # point. CAU's CA should only be updated after, and by that point CAS' CA
    # already exists.
527
    printf '%s does not exist\n' "$cas_ca"
528 529
    return 1
  fi
530
  future_ca="$(_curlInsecure "$url/crt/ca.crt.json")" || return
531
  printf '%s\n' "$future_ca" | forEachJSONListItem appendValidCA "$ca"
532 533 534 535
}

getCertificateRevocationList () {
  # Usage: <url> <ca>
536 537 538 539 540 541 542
  _curlInsecure "$1/crl" | openssl crl "$(
    if [ -d "$2" ]; then
      printf -- '-CApath'
    else
      printf -- '-CAfile'
    fi
  )" "$2" 2> /dev/null
543 544 545 546
}

getCertificateSigningRequest () {
  # Usage: <url> <csr id>
547
  _curlInsecure "$1/csr/$2"
548 549 550
}

getPendingCertificateRequestList () {
551 552
  # Usage: <url> <user crt>
  _curlInsecure --cert "$2" "$1/csr"
553 554 555 556
}

createCertificateSigningRequest () {
  # Usage: <url> < csr > csr id
557
  _putInsecure --header 'Content-Type: application/pkcs10' "$1/csr" \
558 559 560 561
    --dump-header - | while IFS= read -r line; do
    # Note: $line contains trailing \r, which will not get stripped by $().
    # So strip it with sed instead.
    case "$line" in
562 563
      'Location: '*)
        printf '%s\n' "$line" | sed 's/^Location: \(\S*\).*/\1/'
564 565 566 567 568 569
      ;;
    esac
  done
}

deletePendingCertificateRequest () {
570 571
  # Usage: <url> <user crt> <csr id>
  _curlInsecure --request DELETE --cert "$2" "$1/csr/$3"
572 573 574 575
}

getCertificate () {
  # Usage: <url> <csr id>
576 577 578
  if _curlInsecure --fail "$1/crt/$2"; then
    :
  else
579
    printf 'Certificate %s not found (not signed yet or rejected)\n' "$2" >&2
580 581 582 583 584
    return 1
  fi
}

createCertificate () {
585
  # Usage: <url> <user crt> <csr id>
586
  # shellcheck disable=SC2039
587
  local result
588
  _putInsecureNoOut --cert "$2" "$1/crt/$3" < /dev/null
589 590
  result=$?
  if [ $result -ne 0 ]; then
591
    printf '%s: No such pending signing request\n' "$3" >&2
592 593 594 595 596
  fi
  return $result
}

createCertificateWith () {
597 598 599
  # Usage: <url> <user crt> <csr id> < csr
  _putInsecureNoOut --cert "$2" \
    --header 'Content-Type: application/pkcs10' "$1/crt/$3"
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618
}

if [ $# -ne 0 ]; then
  _usage () {
    cat << EOF
Usage: $0 <caucase url> [--ca-crt PATH] --ca-url URL [...]

caucase client
Certificate Authority for Users, Certificate Authority for SErvices

Arguments are taken into account in given order, options overriding any
previous occurrence and actions being executed in given order.

General options
--ca-url URL
  Required. Base URL of the caucase service to access.
  Ex: http://caucase.example.com:8000
--ca-crt PATH
  Default: cas.crt.pem
619 620 621
  Path of the service CA certificate file or directory. Updated on each run.
  If nothing with that name exists, a directory is created if given name does
  not contain a dot, and a file otherwise.
622 623
--user-ca-crt PATH
  Default: cau.crt.pem
624 625
  Path of the user CA certificate file or directory. See --update-user and
  --ca-crt.
626 627 628 629 630 631 632 633 634 635 636 637 638
--crl PATH
  Default: cas.crl.pem
  Path of the service revocation list. Updated on each run.
--user-crl PATH
  Default: cau.crl.pem
  Path of the service revocation list. See --update-user .
--threshold DAYS
  Default: 31
  Skip renewal when certificate is still valid for this many days.
  See --renew-crt .
--key-len BITS
  Default: 2048
  Size of the private key to generate. See --renew-crt .
639 640 641
--user-key PATH
  A file containing a private key and corresponding certificate, to
  authenticate as a caucase user.
642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672
--mode {service|user}
  Default: service
  Caucase personality to query.

Anonymous actions
--send-csr PATH
  Submit given certificate signing request, and print the identifier under which
  the server accepted it (see --get-crt and --get-csr).
--get-crt CSR_ID CRT_PATH
  Retrieve the certificate corresponding to the signing request with identifier
  CSR_ID, and store it in CRT_PATH.
  If CRT_PATH exists and contains a private key, verifies it matches received
  certificate, and exit before modifying the file if it does not match.
  See also --get-csr .
--revoke-crt CRT_PATH KEY_PATH
  Revoke the certificate at CRT_PATH. Revocation request must be signed with
  corresponding private key, read from KEY_PATH.
  Both paths may point at the same file.
--renew-crt CRT_PATH KEY_PATH
  Renew the certificate from CRT_PATH with a new private key. Upon success,
  write resulting certificate in CRT_PATH and key in KEY_PATH.
  Both paths may point at the same file.
--get-csr CSR_ID CSR_PATH
  Retrieve the signing request with identifier CSR_ID, and store it in CSR_PATH.
  Allows checking whether a signature request is still pending if --get-crt
  failed, or if it has been rejected.
--update-user
  In addition to updating CA certificate and revocation list for service more,
  also update the equivalents for user mode. You should not need this.

Authenticated actions
673
These options require --user-key .
674 675 676 677 678 679 680
--list-csr
  List pending certificate signing requests.
--sign-csr CSR_ID
  Sign the pending request with identifier CSR_ID.
--sign-csr-with CSR_ID CSR_PATH
  Sign the pending request with identifier CSR_ID, replacing its extensions with
  the ones from CSR_PATH.
681
--reject-csr
682 683 684 685 686 687 688 689 690 691 692
  Reject pending request with identifier CSR_ID.
--revoke-other-crt CRT_PATH
  Revoke certificate read from CRT_PATH, without requiring access to its private
  key.
--revoke-serial SERIAL
  Revoke certificate with given serial. When not even the original certificate
  is available for revocation.

Special actions
--help
  Display this help and exit.
693 694
--version
  Display command version and exit.
695 696 697 698
EOF
  }

  _argUsage () {
699
    printf '%s: %s\n' "$arg" "$1" >&2
700 701 702 703 704
    _usage >&2
  }

  _needArg () {
    if [ "$argc" -lt "$1" ]; then
705
      printf '%s\n' "$arg needs $1 arguments" >&2
706 707 708 709 710 711 712
      _usage >&2
      return 1
    fi
  }

  _needURLAndArg () {
    if [ -z "$ca_anon_url" ]; then
713
      printf '%s\n' "--ca-url must be provided before $arg" >&2
714 715 716 717 718 719 720
      return 1
    fi
    _needArg "$1" || return 1
  }

  _needAuthURLAndArg () {
    if [ -z "$user_key" ]; then
721
      printf '%s\n' "--user-key must be provided before $arg" >&2
722 723 724 725 726 727 728
      return 1
    fi
    _needURLAndArg "$1" || return 1
  }

  _checkCertficateMatchesOneKey () {
    # Called from _main, sets global "key_found".
729
    test "$key_found" -ne 0 && return 2
730 731 732 733 734 735 736
    key_found=1
    checkCertificateMatchesKey "$1" "$(cat)" || return 1
  }

  _printOneKey () {
    # Called from _main, sets global "key_found".
    if [ $key_found -ne 0 ]; then
737
      _argUsage 'Multiple private keys'
738 739 740 741 742 743 744 745
      return 1
    fi
    key_found=1
    cat
  }

  _printOneCert () {
    # Called indirectly from _main, sets global "crt_found".
746
    if [ "$crt_found" -ne 0 ]; then
747
      _argUsage 'Multiple certificates'
748 749 750 751 752 753 754 755
      return 1
    fi
    crt_found=1
    cat
  }

  _printOneMatchingCert () {
    # Called indirectly from _main, sets global "crt_found".
756 757 758
    # shellcheck disable=SC2039
    local crt
    crt="$(cat)"
759
    if [ $crt_found -ne 0 ]; then
760
      _argUsage 'Multiple certificates'
761 762 763
      return 1
    fi
    crt_found=1
764
    checkCertificateMatchesKey "$crt" "$1" && printf '%s\n' "$crt"
765 766 767
  }

  _matchOneKeyAndPrintOneMatchingCert () {
768
    # Usage: <crt path> <key path>
769
    # Sets globals "crt_found" and "key_found"
770
    # shellcheck disable=SC2039
771 772
    local crt
    key_found=0
773
    key="$(forEachPrivateKey _printOneKey < "$2")" || return
774
    crt_found=0
775
    crt="$(forEachCertificate _printOneMatchingCert "$key" < "$1")" || return
776
    if [ -z "$crt" ]; then
777
      _argUsage 'No certificate matches private key'
778 779
      return 1
    fi
780
    printf '%s\n' "$crt"
781 782 783
  }

  _printPendingCSR () {
784 785 786
    # shellcheck disable=SC2039
    local json
    json="$(cat)"
787 788 789 790
    printf '%20s | %s\n' \
      "$(printf '%s\n' "$json" | jq --raw-output .id)" \
      "$(printf '%s\n' "$json" | jq --raw-output .csr \
        | openssl req -subject -noout | sed 's/^subject=//')"
791 792 793 794 795
  }

  _main() {
    checkDeps || return 1

796
    # shellcheck disable=SC2039
797
    local ca_anon_url='' \
798
      ca_auth_url \
799 800 801 802 803 804
      mode='service' \
      mode_path='cas' \
      cas_ca='cas.crt.pem' \
      cau_ca='cau.crt.pem' \
      cas_crl='cas.crl.pem' \
      cau_crl='cau.crl.pem' \
805 806
      key_len=2048 \
      update_user=0 \
807
      user_key='' \
808 809 810
      threshold=31 \
      status arg argc \
      ca_netloc ca_address ca_port ca_path \
811
      csr_id csr csr_path crl crt crt_path crt_dir key_path key serial \
812
      csr_list_json version
813 814 815 816 817 818 819 820 821 822

    while test $# -gt 0; do
      arg="$1"
      shift
      argc=$#
      case "$arg" in
        --help)
          _usage
          return
        ;;
823 824 825 826 827 828 829 830 831 832 833 834 835
        --version)
          # shellcheck disable=SC2016
          version='$Format:%H$'
          if [ "x$(echo "$version" | base64)" = 'xJEZvcm1hdDolSCQK' ]; then
            if command -v git > /dev/null; then
              version="$(git describe --tags --dirty --always --long)"
            else
              version='unknown'
            fi
          fi
          printf '%s\n' "$version"
          return
        ;;
836 837 838 839 840 841 842 843 844 845
        --ca-url)
          _needArg 1 || return 1
          ca_anon_url="$1"
          shift
          case "$ca_anon_url" in
            https://*)
              ca_auth_url="$ca_anon_url"
            ;;
            http://*)
              ca_netloc="$(
846
                printf '%s\n' "$ca_anon_url" | sed 's!^http://\([^/?#]*\).*!\1!'
847 848
              )"
              ca_path="$(
849
                printf '%s\n' "$ca_anon_url" | sed 's!^http://[^/?#]*!!'
850 851 852 853
              )"
              ca_port=80
              # Note: too bad there is no portable case fall-through...
              case "$ca_netloc" in
854
                *\]:*)
855 856
                  # Bracket-enclosed address, which may contain colons
                  ca_address="$(
857
                    printf '%s\n' "$ca_netloc" | sed 's!^\(.*\]\).*!\1!'
858 859
                  )"
                  ca_port="$(
860
                    printf '%s\n' "$ca_netloc" | sed 's!.*\]:!!'
861 862
                  )"
                ;;
863
                *\]*)
864 865
                  # Bracket-enclosed address, which may contain colons
                  ca_address="$(
866
                    printf '%s\n' "$ca_netloc" | sed 's!^\(.*\]\).*!\1!'
867 868 869
                  )"
                ;;
                *:*)
870
                  # No bracket-enclosed address, rely on colon
871
                  ca_address="$(
872
                    printf '%s\n' "$ca_netloc" | sed 's!^\([^:]*\).*!\1!'
873 874
                  )"
                  ca_port="$(
875
                    printf '%s\n' "$ca_netloc" | sed 's!^[^:]*:!!'
876 877 878 879 880
                  )"
                ;;
                *)
                  # No bracket-encosed address, rely on colon
                  ca_address="$(
881
                    printf '%s\n' "$ca_netloc" | sed 's!^\([^:]*\).*!\1!'
882 883 884 885
                  )"
                ;;
              esac
              if [ "$ca_port" -eq 80 ]; then
886
                ca_port=''
887
              else
888
                ca_port=":$((ca_port + 1))"
889 890 891 892
              fi
              ca_auth_url="https://${ca_address}${ca_port}${ca_path}"
            ;;
            *)
893
              _argUsage 'Unrecognised URL scheme'
894 895 896
              return 1
            ;;
          esac
897
          updateCACertificate "${ca_anon_url}/cas" "$cas_ca" || return
898 899 900 901
        ;;
        --ca-crt)
          _needArg 1 || return 1
          if [ -n "$ca_anon_url" ]; then
902
            _argUsage "$arg must be provided once, before --ca-url"
903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926
            return 1
          fi
          cas_ca="$1"
          shift
        ;;
        --user-ca-crt)
          _needArg 1 || return 1
          cau_ca="$1"
          shift
        ;;
        --crl)
          _needArg 1 || return 1
          cas_crl="$1"
          shift
        ;;
        --user-crl)
          _needArg 1 || return 1
          cau_crl="$1"
          shift
        ;;
        --threshold)
          _needArg 1 || return 1
          threshold="$1"
          shift
927 928 929
          if [ "$threshold" -eq "$threshold" ] 2> /dev/null ; then
            :
          else
930
            _argUsage 'Argument must be an integer'
931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950
            return 1
          fi
        ;;
        --key-len)
          _needArg 1 || return 1
          # XXX: check key length ?
          key_len="$1"
          shift
        ;;
        --user-key)
          _needArg 1 || return 1
          user_key="$1"
          shift
        ;;
        --mode)
          _needArg 1 || return 1
          mode="$1"
          shift
          case "$mode" in
            service)
951
              mode_path='cas'
952 953
            ;;
            user)
954
              mode_path='cau'
955 956
            ;;
            *)
957
              _argUsage 'Invalid mode'
958 959 960 961 962 963 964 965 966 967 968 969
              return 1
            ;;
          esac
        ;;
        # Anonymous actions
        --send-csr)
          _needURLAndArg 1 || return 1
          if [ ! -r "$1" ]; then
            _argUsage "$1 is not readable"
            return 1
          fi
          csr_id="$(
970 971 972
            createCertificateSigningRequest "${ca_anon_url}/${mode_path}" \
            < "$1"
          )" || return
973
          printf '%s %s\n' "$csr_id" "$1"
974 975 976 977 978 979 980
          shift
        ;;
        --get-crt)
          _needURLAndArg 2 || return 1
          csr_id="$1"
          crt_path="$2"
          shift 2
981
          crt_dir="$(dirname "$crt_path")"
982 983
          if [ "x$crt_path" = 'x-' ]; then
            # stdin & stdout
984
            :
985 986
          elif [ -w "$crt_path" ] && [ -r "$crt_path" ]; then
            # existing file
987
            :
988 989
          elif [ -w "$crt_dir" ] && [ -x "$crt_dir" ]; then
            # containing directory
990 991
            :
          else
992 993 994 995
            _argUsage \
              "$crt_path is not writeable (and/or not readable if exists)"
            return 1
          fi
996 997
          crt="$(getCertificate "${ca_anon_url}/${mode_path}" "$csr_id")" \
          || return
998
          if [ "x$crt_path" = 'x-' ]; then
999
            printf '%s\n' "$crt"
1000 1001 1002 1003 1004 1005 1006
          else
            if [ -e "$crt_path" ]; then
              key_found=0
              forEachPrivateKey _checkCertficateMatchesOneKey "$crt" \
                < "$crt_path"
              status=$?
              if [ $status -eq 1 ]; then
1007
                _argUsage 'Certificate does not match private key'
1008 1009
                return 1
              elif [ $status -eq 2 ]; then
1010
                _argUsage 'Multiple private keys'
1011 1012 1013
                return 1
              fi
            fi
1014
            printf '%s\n' "$crt" >> "$crt_path"
1015 1016 1017 1018 1019 1020 1021
          fi
        ;;
        --revoke-crt)
          _needURLAndArg 2 || return 1
          crt_path="$1"
          key_path="$2"
          shift 2
1022 1023
          crt="$(_matchOneKeyAndPrintOneMatchingCert "$crt_path" \
            "$key_path")" || return
1024
          printf '%s\n' "$crt" \
1025 1026
          | revokeCertificate "${ca_anon_url}/${mode_path}" "$key_path" \
          || return
1027 1028 1029 1030 1031 1032
        ;;
        --renew-crt)
          _needURLAndArg 2 || return 1
          crt_path="$1"
          key_path="$2"
          shift 2
1033 1034 1035
          crt="$( \
            _matchOneKeyAndPrintOneMatchingCert "$crt_path" "$key_path" \
          )" || return
1036
          if printf '%s\n' "$crt" \
1037
          | expiresBefore "$(date --date="$threshold days" +%s)"; then
1038
            printf '%s\n' "$crt" \
1039 1040 1041
            | renewCertificate "${ca_anon_url}/${mode_path}" \
              "$key_path" \
              "$key_len" \
1042
              "$crt_path" "$key_path" || return
1043
          else
1044
            printf '%s did not reach renew threshold, not renewing\n' \
1045 1046 1047 1048 1049 1050 1051 1052 1053 1054
              "$crt_path" >&2
          fi
        ;;
        --get-csr)
          _needURLAndArg 2 || return 1
          csr_id="$1"
          csr_path="$2"
          shift 2
          csr="$(
            getCertificateSigningRequest "${ca_anon_url}/${mode_path}" "$csr_id"
1055
          )" || return
1056
          if [ "x$csr_path" = 'x-' ]; then
1057
            printf '%s\n' "$csr"
1058
          else
1059
            printf '%s\n' "$csr" > "$csr_path"
1060 1061 1062 1063 1064 1065 1066 1067 1068
          fi
        ;;
        --update-user)
          update_user=1
        ;;

        # Authenticated actions
        --list-csr)
          _needAuthURLAndArg 0 || return 1
1069
          printf '%s\n' "-- pending $mode CSRs --"
1070
          printf \
1071 1072
            '%20s | subject preview (fetch csr and check full content !)\n' \
            'csr_id'
1073 1074
          csr_list_json="$(
            getPendingCertificateRequestList "${ca_auth_url}/${mode_path}" \
1075
              "$user_key"
1076
          )" || return
1077 1078
          printf '%s' "$csr_list_json" | forEachJSONListItem _printPendingCSR
          printf '%s\n' "-- end of pending $mode CSRs --"
1079 1080 1081 1082 1083 1084
        ;;
        --sign-csr)
          _needAuthURLAndArg 1 || return 1
          csr_id="$1"
          shift
          createCertificate "${ca_auth_url}/${mode_path}" \
1085
            "$user_key" "$csr_id" || return
1086 1087 1088 1089 1090 1091 1092
        ;;
        --sign-csr-with)
          _needAuthURLAndArg 2 || return 1
          csr_id="$1"
          csr="$2"
          shift
          createCertificateWith "${ca_auth_url}/${mode_path}" \
1093
            "$user_key" "$csr_id" < "$csr" || return
1094 1095 1096 1097 1098 1099
        ;;
        --reject-csr)
          _needAuthURLAndArg 1 || return 1
          csr_id="$1"
          shift
          deletePendingCertificateRequest "${ca_auth_url}/${mode_path}" \
1100
            "$user_key" "$csr_id" || return
1101 1102 1103 1104 1105 1106
        ;;
        --revoke-other-crt)
          _needAuthURLAndArg 1 || return 1
          crt_path="$1"
          shift
          crt_found=0
1107
          crt="$(forEachCertificate _printOneCert < "$crt_path")" || return
1108
          printf '%s\n' "$crt" | revokeCRTWithoutKey \
1109
            "${ca_auth_url}/${mode_path}" "$user_key" || return
1110 1111 1112 1113 1114 1115
        ;;
        --revoke-serial)
          _needAuthURLAndArg 1 || return 1
          serial="$1"
          shift
          revokeSerial "${ca_auth_url}/${mode_path}" \
1116
            "$user_key" "$serial" || return
1117 1118 1119
        ;;

        *)
1120
          _argUsage 'Unknown argument'
1121 1122 1123 1124
          return 1
        ;;
      esac
    done
1125 1126
    if [ -n "$ca_anon_url" ] && [ -r "$cas_ca" ]; then
      if crl="$(
1127
        getCertificateRevocationList "${ca_anon_url}/cas" "$cas_ca"
1128
      )"; then
1129
        printf '%s\n' "$crl" > "$cas_crl"
1130 1131
      else
        printf \
1132 1133
          'Received CAS CRL was not signed by CAS CA certificate, skipping\n' \
	  1>&2
1134
      fi
1135
      if [ $update_user -eq 1 ]; then
1136
        updateCACertificate "${ca_anon_url}/cau" "$cau_ca"
1137 1138
        status=$?
        test $status -ne 0 && return $status
1139
        if crl="$(
1140
          getCertificateRevocationList "${ca_anon_url}/cau" "$cau_ca"
1141
        )"; then
1142
          printf '%s\n' "$crl" > "$cau_crl"
1143 1144
        else
          printf \
1145 1146
            'Received CAU CRL was not signed by CAU CA certificate, skipping\n' \
	    1>&2
1147 1148 1149 1150
        fi
      fi
    fi
  }
1151 1152 1153
  _test() {
    # shellcheck disable=SC2039
    local netloc="$1" \
1154
      cas_file \
1155
      cas_found \
1156
      csr_id \
1157
      status \
1158 1159
      tmp_dir \
      caucased_dir \
1160
      caucased_type \
1161
      caucased_pid
1162 1163 1164 1165 1166 1167
    _fail () {
      # shellcheck disable=SC2059
      printf "$@"
      find . -ls
      exit 1
    }
1168 1169
    echo 'Automated testing...'
    if command -v caucased > /dev/null; then
1170 1171 1172 1173
      caucased_type="path"
    elif [ "x$CAUCASE_PYTHON" != "x" ] && [ -x "$CAUCASE_PYTHON" ]; then
      # Used when ran from python caucase.test
      caucased_type="environment"
1174 1175 1176 1177 1178 1179
    else
      echo 'caucased not found in PATH, cannot run tests' >&2
      return 1
    fi
    tmp_dir="$(mktemp -d --suffix=caucase_sh)"
    caucased_dir="$tmp_dir/caucased"
1180 1181 1182 1183 1184 1185 1186
    mkdir "$caucased_dir"
    if cd "$caucased_dir"; then
      :
    else
      echo 'Could not setup caucased directory'
      return 1
    fi
1187
    echo 'Starting caucased...'
1188 1189
    case "$caucased_type" in
      path)
1190
        caucased --netloc "$netloc" &
1191 1192 1193 1194
        ;;
      environment)
        "$CAUCASE_PYTHON" \
          -c 'from caucase.http import main; main()' \
1195
          --netloc "$netloc" &
1196 1197 1198 1199 1200 1201
        ;;
      *)
        echo "Unhandled caucased_type $caucased_type"
        return 1
        ;;
    esac
1202
    caucased_pid="$!"
1203
    # shellcheck disable=SC2064
1204
    trap "kill \"$caucased_pid\"; wait; rm -rf \"$tmp_dir\"" EXIT
1205
    # wait for up to about 60 seconds for caucased to start listening (initial
1206
    # certificate generation.
1207
    echo 'Waiting for caucased to be ready...'
1208
    for _ in $(seq 600); do
1209
      _curlInsecure "http://$netloc" > /dev/null
1210 1211
      status=$?
      test $status -eq 0 && break
1212 1213 1214 1215 1216 1217 1218
      # is caucased still alive ?
      if kill -0 "$caucased_pid" 2> /dev/null; then
        :
      else
        echo "caucased exited"
        return 1
      fi
1219
      # curl status 7 means "could not connect"
1220 1221 1222 1223 1224 1225 1226 1227 1228 1229
      if [ $status -ne 7 ]; then
        echo "curl failed while accessing test caucased with status $status"
        return 1
      fi
      sleep 0.1
    done
    if [ $status -ne 0 ]; then
      echo 'Timeout while waiting for caucased to start'
      return 1
    fi
1230 1231 1232
    if cd "$tmp_dir"; then
      :
    else
1233 1234
      echo 'Could not enter test temporary directory'
      return 1
1235
    fi
1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257
    cat > "openssl.cnf" << EOF
[ req ]
distinguished_name = req_distinguished_name
string_mask = utf8only
req_extensions = v3_req

[ req_distinguished_name ]
CN = Common Name

[ v3_req ]
basicConstraints = CA:FALSE
EOF
    echo 'Generating a key and csr...'
    openssl req \
      -new \
      -keyout user_crt.pem \
      -subj "/CN=testuser" \
      -config openssl.cnf \
      -nodes \
      -out user_csr.pem 2> /dev/null
    echo 'Bootstraping trust and submitting csr for a user certificate...'
    csr_id="$(_main \
1258
      --ca-crt "cas_crt" \
1259 1260 1261 1262 1263 1264
      --ca-url "http://$netloc" \
      --update-user \
      --mode user \
      --send-csr user_csr.pem \
      | sed 's/\s.*//' \
    )"
1265
    if [ ! -d cas_crt ]; then
1266
      _fail 'cas_crt not created\n'
1267
    fi
1268
    cas_found=0
1269 1270 1271
    for cas_file in cas_crt/*; do
      if [ -r "$cas_file" ] && [ ! -h "$cas_file" ]; then
        if [ "$cas_found" -eq 1 ]; then
1272
          _fail 'Multiple CAS CA certificates found\n'
1273 1274 1275 1276 1277
        fi
        cas_found=1
      fi
    done
    if [ "$cas_found" -eq 0 ]; then
1278
      _fail 'No CAS CA certificates found, but directory exists\n'
1279
    fi
1280
    if [ ! -f cau.crt.pem ]; then
1281
      _fail 'cau.crt.pem not created\n'
1282
    fi
1283 1284
    echo 'Retrieving auto-issued user certificate...'
    if _main \
1285
      --ca-crt "cas_crt" \
1286 1287 1288 1289 1290 1291
      --ca-url "http://$netloc" \
      --mode user \
      --get-crt "$csr_id" user_crt.pem
    then
      :
    else
1292
      _fail 'Failed to receive signed user certificate.\n'
1293 1294 1295
    fi
    echo 'Using the user certificate...'
    if _main \
1296
      --ca-crt "cas_crt" \
1297 1298 1299 1300 1301 1302
      --ca-url "http://$netloc" \
      --user-key user_crt.pem \
      --list-csr \
      > /dev/null; then
      :
    else
1303
      _fail 'Failed to list pending CSR, authentication failed ?\n'
1304 1305
    fi
    echo 'Success'
1306 1307
  }
  if [ "$#" -gt 0 ] && [ "x$1" = 'x--test' ]; then
1308 1309
    if [ "$#" -le 2 ]; then
      _test "${2:-localhost:8000}"
1310 1311 1312 1313 1314 1315 1316
    else
      echo 'Usage: --test [<hostname/address>:<port>]'
      return 1
    fi
  else
    _main "$@"
  fi
1317
fi