締結済みの契約書を確かめる手順

締結済みの契約書(PDF)には、署名と証跡が入っています。当社のサーバやプログラムを使わずに、次の手順で確かめられます。

確かめられること

  1. 本文が締結時から変わっていないこと
  2. 甲(AirSoftWare)が署名したこと。公開鍵は 当社の公開鍵の一覧 と見比べられます
  3. 乙(署名者)の署名が、この本文に対するものであること
  4. 甲の署名・乙の公開鍵・署名リンクを届けた記録が、締結時から差し替えられていないこと
  5. 締結の時点で、この契約が存在していたこと(OpenTimestamps。Bitcoin の時刻と照らします)

確かめられないこと

手順

使うもの: pdfdetach(poppler)・jq・openssl・python3

pdfdetach -saveall contract.pdf        # sign_trailer.json が出てきます
curl -O https://contract.airsoftware.jp/verify.sh        # 下の手順と同じ内容です。中身を読んでから実行してください
sh verify.sh sign_trailer.json

乙の署名の確かめ方は、署名の方式で変わります。

パスキー(webauthn-es256 / webauthn-rs256)署名の対象は authenticatorData ‖ SHA-256(clientDataJSON)。契約本文のハッシュは clientDataJSON の challenge に入っています。次の 6 つを順に見ます。
1 type が webauthn.get / 2 challenge が content_hash と一致 / 3 origin が https://contract.airsoftware.jp / 4 authenticatorData の先頭 32 バイトが SHA-256("contract.airsoftware.jp") / 5 フラグの UP と UV が立っている / 6 署名(DER)が合う
使い捨ての鍵(ecdsa-p256)署名の対象は content_hash の文字列。署名は r‖s の 64 バイト
鍵なし(none)署名は無く、氏名への同意の記録だけです

verify.sh の中身

#!/bin/sh
# 締結済みの契約書(PDF)を、当社のプログラムを使わずに確かめる手順。
# 使うもの: pdfdetach(poppler)・jq・openssl・python3(標準の機能だけ)
# 使い方:  pdfdetach -saveall contract.pdf  &&  sh verify.sh sign_trailer.json
T="${1:-sign_trailer.json}"
D=$(mktemp -d); NG=0
ok() { echo "OK   $1"; }
ng() { echo "NG   $1"; NG=1; }
b64() { jq -rj "$1 // \"\"" "$T" | openssl base64 -d -A; }

# 1 本文: 原文の SHA-256 が content_hash と一致するか
H=$(jq -rj .content_preimage "$T" | openssl dgst -sha256 -r | cut -d' ' -f1)
[ "$H" = "$(jq -r .content_hash "$T")" ] && ok "1 本文は締結時のまま" || ng "1 本文が締結時と違う"

# 2 甲の署名(Ed25519)。公開鍵は https://contract.airsoftware.jp/keys の一覧と見比べる
if [ "$(jq -r .payload_v "$T")" = "2" ]; then
  jq -rj '"adminoffice-sign-v2\n" + ([.content_hash,.executed_at,.counterparty_name,.counterparty_method,.self_signer] | map(. // "" | tostring) | tojson)' "$T" > "$D/payload"
else
  jq -rj '[.content_hash,.executed_at,.counterparty_name,.counterparty_method,.self_signer] | map(. // "" | tostring) | join("|")' "$T" > "$D/payload"
fi
b64 .self_sig.sig > "$D/self.sig"; b64 .self_sig.pub > "$D/self.pub"
openssl pkeyutl -verify -pubin -inkey "$D/self.pub" -keyform DER -rawin -in "$D/payload" -sigfile "$D/self.sig" >/dev/null 2>&1 \
  && ok "2 甲の署名" || ng "2 甲の署名が合わない"

# 3 乙の署名。方式(alg)で手順が違う
ALG=$(jq -r '.counterparty_sig.alg // "none"' "$T")
b64 .counterparty_sig.sig > "$D/cp.sig"; b64 .counterparty_sig.pub > "$D/cp.pub"
case "$ALG" in
webauthn-es256|webauthn-rs256)
  b64 .counterparty_sig.client_data_json > "$D/cd.json"; b64 .counterparty_sig.authenticator_data > "$D/ad.bin"
  [ "$(jq -r .type "$D/cd.json")" = "webauthn.get" ] && ok "3-1 type は webauthn.get" || ng "3-1 type が違う"
  CH=$(python3 -c "import sys,base64,json;c=json.load(open(sys.argv[1]))['challenge'];print(base64.urlsafe_b64decode(c+'='*(-len(c)%4)).hex())" "$D/cd.json")
  [ "$CH" = "$(jq -r .content_hash "$T")" ] && ok "3-2 challenge は content_hash" || ng "3-2 challenge が content_hash と違う"
  [ "$(jq -r .origin "$D/cd.json")" = "https://contract.airsoftware.jp" ] && [ "$(jq -r '.crossOrigin // false' "$D/cd.json")" != "true" ] \
    && ok "3-3 origin は https://contract.airsoftware.jp" || ng "3-3 origin が違う"
  [ "$(head -c 32 "$D/ad.bin" | xxd -p -c 64)" = "$(printf %s "contract.airsoftware.jp" | openssl dgst -sha256 -r | cut -d' ' -f1)" ] \
    && ok "3-4 rpIdHash は contract.airsoftware.jp" || ng "3-4 rpIdHash が違う"
  F=$((0x$(xxd -s 32 -l 1 -p "$D/ad.bin")))
  [ $((F & 5)) -eq 5 ] && ok "3-5 UP と UV が立っている(生体または PIN で確認)" || ng "3-5 UP か UV が立っていない"
  [ $((F & 8)) -eq 8 ] && echo "     鍵の種類: 同期される鍵(BE=1・BS=$(( (F & 16) / 16 )))" || echo "     鍵の種類: 端末に固定された鍵(BE=0)"
  { cat "$D/ad.bin"; openssl dgst -sha256 -binary "$D/cd.json"; } > "$D/signed.bin"
  openssl dgst -sha256 -verify "$D/cp.pub" -keyform DER -signature "$D/cp.sig" "$D/signed.bin" >/dev/null 2>&1 \
    && ok "3-6 乙の署名(パスキー)" || ng "3-6 乙の署名が合わない"
  ;;
ecdsa-p256)
  # 使い捨ての鍵。署名は r‖s の 64 バイトなので、openssl が読める形(DER)に直す
  python3 -c "
import sys
r=open(sys.argv[1],'rb').read(); assert len(r)==64
def i(b):
    b=b.lstrip(b'\\x00') or b'\\x00'
    if b[0]&0x80: b=b'\\x00'+b
    return b'\\x02'+bytes([len(b)])+b
s=i(r[:32])+i(r[32:]); open(sys.argv[2],'wb').write(b'\\x30'+bytes([len(s)])+s)" "$D/cp.sig" "$D/cp.der" 2>/dev/null
  jq -rj .content_hash "$T" > "$D/ch.txt"
  openssl dgst -sha256 -verify "$D/cp.pub" -keyform DER -signature "$D/cp.der" "$D/ch.txt" >/dev/null 2>&1 \
    && ok "3 乙の署名(使い捨ての鍵)" || ng "3 乙の署名が合わない"
  ;;
none) echo "--   3 乙は鍵を使わずに署名(氏名への同意のみ)" ;;
*) ng "3 知らない署名方式: $ALG" ;;
esac

# 4 甲の署名・乙の公開鍵・到達の証跡をまとめたハッシュ(これが時刻証明で固定されている)
if [ "$(jq -r '.bind.hash // ""' "$T")" != "" ]; then
  R=$(jq -cj '.reach // null' "$T" | openssl dgst -sha256 -r | cut -d' ' -f1)
  B=$(jq -rj --arg r "$R" '"adminoffice-bind-v1\n" + ([.content_hash, (.self_sig.sig // ""), (.counterparty_sig.pub // ""), $r] | tojson)' "$T" | openssl dgst -sha256 -r | cut -d' ' -f1)
  [ "$B" = "$(jq -r .bind.hash "$T")" ] && ok "4 甲の署名・乙の公開鍵・到達の証跡は締結時のまま" || ng "4 甲の署名・乙の公開鍵・到達の証跡のどれかが違う"
fi

# 5 時刻証明(OpenTimestamps)。ots コマンドがあれば、次で Bitcoin の時刻と照らせる
#   jq -r .ots.proof sign_trailer.json | openssl base64 -d -A > content.ots && ots verify -d <content_hash> content.ots
#   jq -r .bind.ots.proof sign_trailer.json | openssl base64 -d -A > bind.ots && ots verify -d <bind.hash> bind.ots
rm -rf "$D"
[ $NG -eq 0 ] && echo "結果: 確認できました" || echo "結果: 確認できませんでした"
exit $NG