締結済みの契約書を確かめる手順
締結済みの契約書(PDF)には、署名と証跡が入っています。当社のサーバやプログラムを使わずに、次の手順で確かめられます。
確かめられること
- 本文が締結時から変わっていないこと
- 甲(AirSoftWare)が署名したこと。公開鍵は 当社の公開鍵の一覧 と見比べられます
- 乙(署名者)の署名が、この本文に対するものであること
- 甲の署名・乙の公開鍵・署名リンクを届けた記録が、締結時から差し替えられていないこと
- 締結の時点で、この契約が存在していたこと(OpenTimestamps。Bitcoin の時刻と照らします)
確かめられないこと
- 署名した人の身元。当社は身元を確認していません。記録されるのは「どのメールアドレスへ届けたか」と「どういう鍵で署名したか」です
- パスキーで署名した場合に、生体と暗証番号のどちらを使ったか
- PDF ファイルそのもののハッシュ(PDF は自分自身のハッシュを中に持てません。当社の確認ページの表示と見比べてください)
手順
使うもの: 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