CloudFront の署名付き URL を使ってみるメモ

CloudFront の署名付き URL を使ってみたメモ。

S3 の署名付き URL

署名付き URL といえば S3 の方もよく聞きます。いずれも同じように有効期限付きの署名付き URL を作成して認証を通すというのは変わりませんが、S3 の方は AWS IAM Role の一時クレデンシャルで署名します。その署名付き URL で可能な操作は元のアイデンティティに付与されたポリシーが適用されるため s3:GetObject などで許可したいバケットへの操作を設定しておく必要があります。また、署名の対象に Canonical URI が含まれるため元の URL ごとに署名が必要です。

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/using-presigned-url.html https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/API/sig-v4-authenticating-requests.html

CloudFront の署名付き URL

CloudFront の署名付き URL は異なります。署名鍵にはあらかじめ作成しておいた RSA 鍵を使います。ペアとなる公開鍵を CloudFront に登録し、ビヘイビアに関連付けます。公開鍵の登録時に鍵IDが払い出されます。URL には署名とともにこの鍵IDもクエリストリングで付与します。Cloudfront はそれを元に鍵IDに対応する公開鍵で署名を検証します。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-urls.html

ポリシー

署名付き URL には次のものが指定されたポリシーが必要です。

  • URL(オプション)
  • 有効期限
  • 開始日時(オプション)
  • IP アドレスレンジ(オプション)

これらを次のような JSON で指定します。ここでは見やすさのために空白や改行を入れていますが実際には空白や改行は取り除く必要があります。

{
    "Statement": [
        {
            "Resource": "https://example.com/aaa/bbb/ccc",
            "Condition": {
                "DateLessThan": {
                    "AWS:EpochTime": 1696049757
                },
                "DateGreaterThan": {
                    "AWS:EpochTime": 1696049457
                },
                "IpAddress": {
                    "AWS:SourceIp": "192.168.0.0/24"
                }
            }
        }
    ]
}

Statement が配列ですがこれは1つしか指定できません。また、IPv6 形式の IP アドレスは指定できません。URL にはワイルドカードが使用可能なので複数の URL に対して有効な署名も作成できます。むしろ URL はオプションなので未指定にもできます。

これを Base64(URL Safe にするため +=/-_~ に置換)したものをクエリストリングで指定します。また、署名はこの JSON に対して作成します。

ポリシーは省略も可能です。その場合は次のような既定のポリシーが指定されたものとみなされます。

  • 特定の URL のみに有効(ワイルドカード不可)
  • 有効期限の指定は必須
  • 開始日時やIPアドレスレンジは指定できない

これはドキュメントでは既定のポリシーと呼ばれています。一方で前述のように JSON で指定するものはカスタムポリシーと呼ばれています。既定のポリシーでもちろんポリシーの署名は必要です。ただポリシーの JSON(の Base64)を URL に含める必要が無いため、カスタムポリシーと比べれば URL が短くて済みます。

署名の作成

署名はポリシーの JSON に対して作成します。AWS SDK でも秘密鍵を指定して署名できますが、次の要領で自前でも作成可能です。

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/private-content-linux-openssl.html

試しに Node.js で署名を作成してみました。

import * as fs from 'fs/promises';
import * as crypto from 'crypto';
import * as querystring from 'querystring';

function sign(policy, privateKey) {
    const signer = crypto.createSign('RSA-SHA1');
    signer.update(policy);
    return signer.sign(privateKey);
}

function urlSafeBase64(buf) {
    return buf.toString('base64')
        .replaceAll('+', '-')
        .replaceAll('=', '_')
        .replaceAll('/', '~');
}

const policy = JSON.stringify({/* ポリシー */});

const privateKey = await fs.readFile(/* 秘密鍵*/);

const keyPairId = 'K2JCJMDEHXQW5F'; // 鍵ID

const signature = sign(policy, privateKey);

const qs = querystring.stringify({
    'Expires': expires, // 有効期限、ポリシーと一致する必要あり
    'Policy': urlSafeBase64(Buffer.from(policy)),
    'Signature': urlSafeBase64(signature),
    'Key-Pair-Id': keyPairId, // 鍵ID
});

これを元の URL のクエリストリングに付与すれば OK です。

さいごに

カスタムポリシーなら複数 URL へのワイルドカードが指定できるので、大量の URL を含むようなレスポンスでそれらすべての URL に署名付けたいときなどにも1つの署名でカバーできるので便利そうです。

最初のリクエストと、それに対するレスポンスに含まれる URL へのリクエストとで、ソース IP アドレスが一致することが確かであれば、最初のリクエストのソース IP アドレスをそのままポリシーの IP アドレスレンジに含めるような使い方も出来そうです。ただ、グローバル IP アドレスが複数あるようなNAT だと先行するリクエストと後続するリクエストでソース IP アドレスが変わることもあるだろうのでかなり限定的な環境でしか利用できないでしょうか。

また、署名は Cookie にも入れられます。例えば S3 に画像を保存しているようなシステムで、ログイン時に Set-Cookie で署名で付けるようにしておけば、未ログインでも URL に直接アクセスすれば画像だけは見えてしまう、みたいな問題も避けられそうです。

AWS のリージョンの略称

AWS でリソース名や terraform の識別子にリージョンを入れたいとき、ap-northeast-1 とかだと長すぎるのでもっと略したいときの略称の候補。

大抵はリージョンごとに〇〇環境のように作成するためリージョン名が識別子に入ることはあまりありませんが、たまにバックアップサイトとして別リージョンにも展開しておく、とかのときに悩むことがあります。

ap-northeast-1 -> an1

ハイフン区切りで先頭一文字を取り出す。元のリージョン名から導出しやすくわかりやすいと思うのですが、この方法だと一部のリージョンで重複します。

aws ec2 describe-regions --all-regions \
  | jq '.Regions[].RegionName | . as $region | split("-") | map(.[0:1]) | join("") | [$region, .] | @tsv' -r \
  | sort -k2 \
  | uniq -f1 -D
af-south-1      as1
ap-south-1      as1
ap-southeast-1  as1
ap-south-2      as2
ap-southeast-2  as2

ap-northeast-1 -> apne1

https://docs.aws.amazon.com/ja_jp/ram/latest/userguide/working-with-az-ids.html

AZ の ID でリージョン名が略されているので、それをそのまま使えばいいじゃない、という案。

aws ec2 describe-regions | jq '.Regions[].RegionName' -r \
| while read -r region; do
  aws --region="$region" ec2 describe-availability-zones \
  | jq --arg region "$region" '.AvailabilityZones[].ZoneId | split("-") | [$region, .[0]] | @tsv' -r
done \
| sort -u
ap-northeast-1  apne1
ap-northeast-2  apne2
ap-northeast-3  apne3
ap-south-1      aps1
ap-southeast-1  apse1
ap-southeast-2  apse2
ca-central-1    cac1
eu-central-1    euc1
eu-north-1      eun1
eu-west-1       euw1
eu-west-2       euw2
eu-west-3       euw3
sa-east-1       sae1
us-east-1       use1
us-east-2       use2
us-west-1       usw1
us-west-2       usw2

ap-northeast-1 -> tokyo

tokyo などの実際の地域名を使う。普段口頭では実際の地域名で喋っていると思うので、わかりやすいですね(まさか、エーピーノースイーストワン、とか喋らないですよね)。

ap-northeast-1  tokyo
ap-northeast-3  osaka
us-east-1       nvirginia
us-west-2       oregon

ap-northeast-1 -> NRT

AWS リージョンの短いエイリアスとしての空港コード - Qiita

という案もあるようです。AWS 発の文章で使われていることもあるらしいですが、なじみが無さ過ぎてぱっと見で全然わからないです。。。

ap-northeast-1

ap-northeast-1 などのリージョン名をそのまま使う。

ap-northeast-1
ap-northeast-3
us-east-1
us-west-2

昔は terraform でハイフン含む識別子を付けると aws_xxx.aaa-bbb みたいに書いたときに何か不都合があったような気がしますが、今はもうその問題は無いはずです。ただ、ちょっと見た目が気持ち悪い気がしますね・・

さいごに

これまでは tokyo とかを使うことが多かったです。ただ改めて見ると an1 とか apne1 とかもありな気もします。全リージョンにとりあえず設定しておきたい、とかなら AZ ID に基づく apne1 のような名前は使い勝手が良さそうですし、一部で重複するリージョンが必要ないなら an1 などは普段目にするリージョン名単純な省略なので覚えやすいです。

Oracle RPM の MySQL はアップデート時にサービスが enable にされる

Oracle の YUM リポジトリの RPM でインストールした MySQL だと、インストール時だけでなくアップデート時にもサービスが enable されるため、何かしらの事情でインストール後に disable していてもアップデートで enable になってしまいます。

Oracle の RPM の MySQL

適当な非最新のバージョンをインストールします。

dnf install -y https://dev.mysql.com/get/mysql80-community-release-el9-4.noarch.rpm
dnf install -y mysql-community-server-8.0.33

インストール直後は enabled になっています。

systemctl is-enabled mysqld.service
# enabled

systemctl で disable にします。

systemctl disable mysqld.service
# Removed "/etc/systemd/system/multi-user.target.wants/mysqld.service".

systemctl is-enabled mysqld.service
# disabled

アップデートすると enabled に戻ります。

dnf update -y mysql-community-server
# ...

systemctl is-enabled mysqld.service
# enabled

原因

RPM の spec ファイルの詳しいことはわかりませんがこの辺りが怪しいと思います。

https://github.com/mysql/mysql-server/blob/057f5c9509c6c9ea3ce3acdc619f3353c09e6ec6/packaging/rpm-fedora/mysql.spec.in#L659

%post セクションに以下が含まれているためインストールやアップデートのたびに enable されるのではないかと。

/usr/bin/systemctl enable mysqld >/dev/null 2>&1 || :

%systemd_post mysqld.service/usr/lib/rpm/macros.d/macros.systemd などの内容から察するに新規インストール時に systemctl --no-reload preset mysqld.service するためのマクロのようです。

rpm -q --scripts mysql-community-server でみると postinstall は次のようになっていました。

[ -e /var/log/mysqld.log ] || install -m0640 -omysql -gmysql /dev/null /var/log/mysqld.log >/dev/null 2>&1 || :

if [ $1 -eq 1 ] && [ -x "/usr/lib/systemd/systemd-update-helper" ]; then
    # Initial installation
    /usr/lib/systemd/systemd-update-helper install-system-units mysqld.service || :
fi

/usr/bin/systemctl enable mysqld >/dev/null 2>&1 || :

以下の通り $1 は新規インストール時には 1 に、アップデート時は 2 になるので、その値でもって新規インストール時にのみ enable にする方が良い気がしないでもないです。

Scriptlets :: Fedora Docs

ワークアラウンド

mask しておけばサービスが enable になるのを阻止できます。

systemctl mask mysqld.service
# Created symlink /etc/systemd/system/mysqld.service → /dev/null.

systemctl is-enabled mysqld.service
# masked

dnf update -y mysql-community-server
# ...

systemctl is-enabled mysqld.service
# masked

RHEL の mysql-server

RHEL9 の mysql-server だとそうはなっていませんでした。

cat /etc/redhat-release
# Red Hat Enterprise Linux release 9.2 (Plow)

dnf install mysql-server
# ...

systemctl is-enabled mysqld.service
# disabled

rpm -q --scripts mysql-server の postinstall の抜粋です。

if [ $1 -eq 1 ] && [ -x "/usr/lib/systemd/systemd-update-helper" ]; then
    # Initial installation
    /usr/lib/systemd/systemd-update-helper install-system-units mysqld.service || :
fi

if [ ! -e "/var/log/mysql/mysqld.log" -a ! -h "/var/log/mysql/mysqld.log" ] ; then
    install /dev/null -m0640 -omysql -gmysql "/var/log/mysql/mysqld.log"
fi

systemd.preset

という話を社内でしたところ「preset: disabled ってなんだ?」という話になりました。

これはシステムへのインストール時にデフォルトで disabled とするか enabled とするかの意味です。例えば sshd などであれば preset: enabled なのでシステムへのインストール時にデフォルトで enabled となり、不要なら明示的に disable する必要があります。

preset は以下のファイルで設定可能です。

/etc/systemd/system-preset/*.preset
/run/systemd/system-preset/*.preset
/usr/lib/systemd/system-preset/*.preset
/etc/systemd/user-preset/*.preset
/run/systemd/user-preset/*.preset
/usr/lib/systemd/user-preset/*.preset

RHEL9 で見てみると /usr/lib/systemd/system-preset/*.preset/usr/lib/systemd/user-preset/*.preset でシステム用に設定されています。この設定に基づいて rpm の %systemd_post マクロから呼ばれる systemctl preset サービス名 コマンドで enable/disable される、という仕組みのようです。

なお preset はデフォルトで enable のため /usr/lib/systemd/system-preset/99-default-disable.preset ですべて無効に設定されています。

cat /usr/lib/systemd/system-preset/99-default-disable.preset
# disable *

例えば epel-release をインストールすると /usr/lib/systemd/system-preset/90-epel.preset に次のファイルが作成されます。

# Also see:
# https://fedoraproject.org/wiki/Starting_services_by_default
#
# https://bugzilla.redhat.com/show_bug.cgi?id=1901721
enable x509watch.timer
#
# https://bugzilla.redhat.com/show_bug.cgi?id=1986205
#
enable certbot-renew.timer

新規インストール時に有効にするだけであれば同じような方法でやるのが筋のようにも思います。つまり mysql80-community-release のインストール時に /usr/lib/systemd/system-preset/90-mysql-community.preset みたいなファイルで enabled mysqld.service を指定する、とかです。

Not a bug

MySQL Bugs: #112382: Update mysql server with yum/dnf ensures service is always enabled

そうなるだろうとは思っていたけれども。

さいごに

基本的にはサービスを開始しないならインストールしなければ良いということだと思いますが、例えばサービスが Pacemaker で管理されているので systemd からは起動してほしくないような場合は注意が必要そうです。

10年ぐらい前からこうだったと思います。CentOS 7 で MySQL をインストールするために Oracle の YUM リポジトリの RPM を使う必要がありました。RHEL8 系以降では AppStream などからも MySQL がインストールできるので最新が必要とかでなければそちらのリポジトリからインストールする方が良いかもしれません。

aws-vault で gpg のパスフレーズプロンプトが表示されるのがめちゃめちゃ遅い

aws-vault でバックエンドに pass を使っているので aws-vault の実行時に gpg の秘密鍵のパスフレーズの入力プロンプトが表示されるのですが、最近になってからプロンプトが表示されるまで異様に時間がかかるようになりました。

なお、WSL2 環境で systemd は無効、かつ、homebrew(linuxbrew) で色々インストールしています。


この状況が再現した後に ps でプロセスのリストを見てみると↓のプロセスが複数立ち上がっています・・怪しい。

/home/linuxbrew/.linuxbrew/Cellar/dbus/1.14.10/bin/dbus-daemon --syslog --fork --print-pid 4 --print-address 6 --session

試しに pkill dbus-daemon してから aws-vault を実行するとプロンプトがサクッと表示されます。が、そのとき同時に↑のプロセスも作成され、次に aws-vault がプロンプトを表示するときにはめちゃくちゃ時間かかるようになります。

要するに、次の状況です。

  • aws-vault の実行時に dbus-daemon のプロセスが存在するとプロンプトの表示までめちゃめちゃ時間がかかる
  • aws-vault を実行すると dbus-daemon のプロセスが自動で開始する(ゾンビ?)

探せばすぐに原因っぽいものが見つかりました。

https://github.com/99designs/keyring/issues/103

ずいぶん前から存在してた問題のようですが・・わたしのところで発生したのはつい最近です。

そもそも以前は dbus-daemon コマンドが存在しなかったために問題が発生しなかっただけのようです。つい先日 starship をインストールしたのですが、そのとき依存で dbus が入ったことで問題が発生したようです。

which dbus-daemon
#=> /home/linuxbrew/.linuxbrew/Cellar/dbus/1.14.10/bin/dbus-daemon

brew uses dbus --installed
#=> starship

starship はおそらく cmd_duration モジュールのデスクトップ通知のために dbus を使用しています。

https://starship.rs/ja-JP/config/#command-duration

ただ、WSL 環境だとデスクトップ通知が機能しないし、そもそも cmd_duration モジュールは無効にしています。

よってこれで解決です。

brew uninstall --ignore-dependencies dbus

また別の何かを入れたときに依存で dbus が入ると再発しますが、その時は改めて考えます。

なお、環境変数 DBUS_SESSION_BUS_ADDRESS=/dev/null とかでも回避できるようです。

さいごに

dbus とかよくわからない。systemd の文脈でよく見る気がするけど、元々はデスクトップ環境におけるプロセス間通信のためのモノだったのが Linux において非デスクトップ環境でも広く利用されるようになったということなの?

aws-vault の tips

aws-vault のメモ。概ね USAGE.md を見れば十分ですが WSL で使用する場合の特有の問題などもあります。

pass バックエンド

ずっと昔は aws-vault で認証情報を保存するバックエンドとして Linux の CLI のみの環境だと aws-vault 独自の暗号化ファイルしか無く、その場合は環境変数 AWS_VAULT_FILE_PASSPHRASE にパスワードを持たせないことには使い勝手が悪く、実質のところ暗号化にあまり意味がありませんでした。

いつからかバックエンドとして pass というパスワードマネージャーが指定できるようになりました。

pass なら gpg 鍵で暗号化されるため gpg 鍵のパスフレーズがマスターパスワードとなり、より安全です(gpg-agent が一定時間記憶するので都度入力の必要はありません)。

.bash_profile で次のように環境変数を設定すれば pass をバックエンドにできます。AWS_VAULT_PASS_PREFIX は無くても良いですが pass のパスワードストア内でグループ化するために適当に指定した方が良いでしょう。

export AWS_VAULT_BACKEND=pass
export AWS_VAULT_PASS_PREFIX=aws-vault/

gpg や pass の初期設定も必要なら次のようにすればできると思います。

gpg --full-generate-key
# いろいろプロンプトが出てくるのでそれっぽく入力して進める
# 最後の方でパスフレーズの設定があります

# 鍵のIDを表示します。xxx のところにあるのが鍵のIDです
gpg -k
#=> /home/oreore/.gnupg/pubring.kbx
#=> -------------------------------
#=> pub   ed25519 2023-08-28 [SC]
#=>       xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#=> uid           [ultimate] oreore
#=> sub   cv25519 2023-08-28 [E]

# gpg 鍵のIDを指定して pass のパスワードストアを初期化します
pass init xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# aws-vault でアクセスキー・シークレットキーが追加できます
aws-vault add my-profile

# 使用時は gpg 鍵のパスフレーズの入力が求められます
aws-vault exec my-profile

なお Linux なら keyctl なるバックエンドも利用可能なはずなのですが機能してないようです。

keyctl はデバイスが対応していれば TPM とかも使えるようです。

WSL では対応していませんが。

aws-vault login と wsl-open

aws-vault login を使えば一時クレデンシャルを使ってマネジメントコンソールにログインできます。ので IAM User のパスワードは必要ありません。 ただ、WSL 環境だと xdg-open でブラウザを開こうとするため普通には使えませんが wsl-open を入れておけば OK です。

既にブラウザでログイン済みだと次のようなページが表示されてしまうのが若干煩雑ですが、 (「ここ」を Ctrl+クリック とかで開いた後 F5 リロードで OK です)

Amazon Web Services サインインページ 別の AWS アカウントにログインするには、まずログアウトする必要があります。 ログアウトするには、 ここをクリックしてください。

これが表示されたページの URL をコピーして、シークレットモードやゲストモードで URL を張り付ければ複数プロファイルを同時に操作もできます。aws-vault login my-proile -s で URL が標準出力にでるの clip.exe などを使って直接クリップボードに張り付けとかでも良いと思います。

pass-otp と mfa_process

pass-otp は pass の拡張機能のようなもので、pass のパスワードストアに TOTP のシークレットを保存しておけば pass otp コマンドでワンタイムコードが得られるようになります。

もちろんシークレットは gpg 鍵で暗号化されます。

pass otp insert aws/otp/my-profile
# プロンプトが出るので otpauth:// の形式で入力する

pass otp aws/otp/my-profile
# ワンタイムコードが表示される

さらに ~/.aws/config で次のように記述しておくと aws-vault でワンタイムコードが必要なときに pass otp で生成されたワンタイムコードが使用されるため、都度ワンタイムコードを転記する必要がなくなります。

[profile my-profile]
mfa_serial=arn:aws:iam::999999999999:mfa/my-profile
mfa_process=pass otp aws/otp/my-profile

aws-vault exec --server

aws-vault で実行時間が超長いコマンドを実行するとき(例えば terraform で作成にものすごい時間のかかるリソースを作るときなど)、一時クレデンシャルの有効期限が途中が切れてしまうことがあります。そのような場合は aws-vault exec --server <profile> を使えば OK です。

--server オプションを付けて実行すると aws-vault がバックグラウンドで一時クレデンシャルを返すためのサーバを実行し、その URL や認証用トークンを環境変数 AWS_CONTAINER_CREDENTIALS_FULL_URIAWS_CONTAINER_AUTHORIZATION_TOKEN に設定します。

この環境変数は ECS でコンテナが一時クレデンシャルを得るためのもので、次のように認証情報が得られます。

aws-vault exec --server my-profile
curl -H "Authorization:$AWS_CONTAINER_AUTHORIZATION_TOKEN" "$AWS_CONTAINER_CREDENTIALS_FULL_URI"
#=> 一時クレデンシャル

AWS SDK がクレデンシャルを要求したとき、既に発行済の一時クレデンシャルの有効期限が切れていれば aws-vault によって一時クレデンシャルが再発行されるため、長時間のコマンドでも大丈夫です。

ただし、このオプションを使用するためには aws-vault の prompt で terminal 以外が使用可能な必要があります。

prompt とは aws-vault で何かしらのユーザー入力が必要になったときにどのような方法で入力を得るかの設定です。例えば MFA のワンタイムトークの入力などです。prompt には次のものが指定可能です。

  • kdialog
  • osascript
  • terminal
  • wincredui_windows
  • ykman
  • zenity

デフォルトは terminal で、これは標準入出力が用いられますが --server オプションを付けて何かのコマンドを実行中は標準入出力はそのコマンドに取られているので、仮に一時クレデンシャルの有効期限が切れて再発行が必要になったとしてもワンタイムコードが入力できません。そのため --server オプションの利用時は terminal 以外に何かの prompt が利用可能になっている必要があります。

ただ、前述の mfa_process を使っていればワンタイムコードを手入力することは無いし、gpg 鍵のパスフレーズの入力は環境変数 GPG_TTY を設定していれば標準入出力は使いません。ので、どうせ呼ばれないことが分かっているので適当にダミーのスクリプトを用意しておけば OK です。

touch ~/bin/kdialog
chmod +x ~/bin/kdialog

わたしは次のように tty から直接入出力するコマンドに置き換えています。

#!/bin/bash

title=
inputbox=

while [ $# -ne 0 ]; do
  case "$1" in
    --inputbox)
      shift
      inputbox=$1
      ;;
    --title)
      shift
      title=$1
      ;;
    *)
      echo "unknown kdialog options [$1]" 1>&2
      exit 1
  esac
  shift
done

exec </dev/tty 2>/dev/tty
read -p "[$title] $inputbox" -r input
echo -n "$input"

GPG_TTY

前後しましたが gpg 鍵のパスフレーズの入力が必要になったとき、デフォルトでは標準入出力が使用されますが、環境変数 GPG_TTY に tty デバイスファイルを指定しておけば標準入出力は使われずに tty から直接入出力されます。

export GPG_TTY="$(tty)"

前述の --server オプションもそうですが、ProxyCommand に aws ssm start-session を記述して ssm session manager で ssh している場合などでも標準入出力が使われると困るので GPG_TTY は指定しておくと良いでしょう。

さいごに

アクセスキー・シークレットキーと、MFA の TOTP シークレットが同じパスワードストアで同じ gpg 鍵で暗号化されていると MFA が台無しになりそうですが、アクセスキー・シークレットキーを暗記しているような人は存在しないわけで、アクセスキー・シークレットキーと TOTP は結局どちらもそれらが保存されているデバイスの所有物認証でしかなく、暗号化されたパスワードストア(所有物)と gpg 鍵のパスフレーズ(知識)の方がむしろ多要素ではないでしょうか。

もしくは MFA には YubiKey とかも使えるようなので(prompt の ykman が YubiKey です)使えるならそれでも良いかもしれませんが WSL だと簡単ではなさそうです。

openssl でやっつけでクロスルート証明書を作って接続テストする

手元でクロスルート証明書を作って接続テストする手順のメモ。 openssl ca とか CA.sh だといろいろ面倒なので openssl x509 でやっつけです。

# CA1の秘密鍵と証明書(自己署名証明書)
openssl req -new -x509 -newkey rsa:2048 -nodes -sha256 -subj /CN=CA1/O=ore -days 365 -keyout ca1.key -out ca1.crt

# openssl x509 で署名時、素のままだとCA用の証明書にならないので
# X509拡張属性用のファイルを作成(中間証明書用)
#
# なお req で自己署名証明書を作るときは素のままでもCA用にx509拡張属性が付与される
# (そのように openssl.cnf で定義されている)
echo "basicConstraints=CA:true" > ca.ext

# 中間CAの秘密鍵とCSR
openssl req -new -newkey rsa:2048 -nodes -sha256 -subj /CN=inner/O=ore -keyout inner.key -out inner.csr

# 中間CA証明書をCA1で署名して発行
openssl x509 -req -days 365 -in inner.csr -out inner.crt -CA ca1.crt -CAkey ca1.key -CAcreateserial -extfile ca.ext

# サーバの秘密鍵とCSR
openssl req -new -newkey rsa:2048 -nodes -sha256 -subj /CN=localhost/O=ore -keyout sv.key -out sv.csr

# サーバ証明書を中間CAで署名して発行
openssl x509 -req -days 365 -in sv.csr -out sv.crt -CA inner.crt -CAkey inner.key -CAcreateserial

# CA1からサーバ証明書の検証
openssl verify -show_chain -CAfile ca1.crt -untrusted inner.crt sv.crt
#=> sv.crt: OK
#=> Chain:
#=> depth=0: CN = localhost, O = ore (untrusted)
#=> depth=1: CN = inner, O = ore (untrusted)
#=> depth=2: CN = CA1, O = ore

# CA2の秘密鍵と証明書(自己署名証明書)
openssl req -new -x509 -newkey rsa:2048 -nodes -sha256 -subj /CN=CA2/O=ore -days 365 -keyout ca2.key -out ca2.crt

# クロスルート用のCSR、秘密鍵にはCA1を指定する
openssl req -new -nodes -sha256 -subj /CN=CA1/O=ore -key ca1.key -out cross.csr

# クロスルート用の中間証明書をCA2で署名して発行
openssl x509 -req -days 365 -in cross.csr -out cross.crt -CA ca2.crt -CAkey ca2.key -CAcreateserial -extfile ca.ext

# CA2からサーバ証明書の検証
openssl verify -show_chain -CAfile ca2.crt -untrusted cross.crt -untrusted inner.crt sv.crt
#=> sv.crt: OK
#=> Chain:
#=> depth=0: CN = localhost, O = ore (untrusted)
#=> depth=1: CN = inner, O = ore (untrusted)
#=> depth=2: CN = CA1, O = ore (untrusted)
#=> depth=3: CN = CA2, O = ore

できました。openssl s_server でサーバを立てて curl でアクセスしてみます。 まずはクロスルート用中間証明書なしでサーバを実行した場合。

# クロスルート用中間証明書なしでサーバを実行
openssl s_server -accept 8443 -www -chainCAfile inner.crt -cert sv.crt -key sv.key

curl https://localhost:8443/ --cacert ca1.crt # OK
curl https://localhost:8443/ --cacert ca2.crt # ng

curl 側で cacert に CA1 を指定すれば OK ですが、CA2 だとクロスルート用中間証明書が無いのでダメです。

次にサーバでクロスルート用中間証明書ありでサーバを実行した場合。

# クロスルート用中間証明書ありでサーバを実行
openssl s_server -accept 8443 -www -chainCAfile <(cat cross.crt inner.crt) -cert sv.crt -key sv.key

curl https://localhost:8443/ --cacert ca1.crt # OK
curl https://localhost:8443/ --cacert ca2.crt # OK

curl 側で cacert に CA1 と CA2 のどちらの場合でも OK になりました。

PostgreSQL で関数インデックスに使っているストアドファンクションを REPLACE する

当たり前かもしれませんが PostgreSQL で関数インデックスに使っているストアドファンクションを REPLACE して中身を書き換えてしまうとクエリの結果が正しくなくなることがあります。

-- テーブルを作る
create table t (id serial not null primary key, str text);

--- 'Aa' の行と適当に100万行入れる
insert into t (str) values ('Aa');
insert into t (str) select generate_series(1, 1000000, 1)::text;

-- ストアドファンクションを作る
-- 関数インデックスで使うためには IMMUTABLE も必要
create or replace function my_func(_text text) returns text as $$ begin return upper(_text); end $$ LANGUAGE plpgsql IMMUTABLE;

-- インデックスが無いので遅い
select * from t where my_func(str) = my_func('aA');
-- (1 rows)
-- Time: 754.790 ms

-- 関数インデックスを作る
create index idx on t (my_func(str));

-- インデックスがあるので早い
select * from t where my_func(str) = my_func('aA');
-- (1 rows)
-- Time: 0.751 ms

-- ストアドファンクションを同じ内容で REPLACE する
create or replace function my_func(_text text) returns text as $$ begin return upper(_text); end $$ LANGUAGE plpgsql IMMUTABLE;

-- 特に変わらない
select * from t where my_func(str) = my_func('aA');
-- (1 rows)
-- Time: 0.565 ms

-- ストアドファンクションを異なる内容に REPLACE する
create or replace function my_func(_text text) returns text as $$ begin return lower(_text); end $$ LANGUAGE plpgsql IMMUTABLE;

-- 正しい結果が得られない
select * from t where my_func(str) = my_func('aA');
-- (0 rows)
-- Time: 0.565 ms

-- 新しい行は新しいストアドでインデックス化されるので大丈夫
insert into t (str) values ('Bb');
select my_func(str), my_func('bB') from t where my_func(str) = my_func('bB');
-- (1 rows)
-- Time: 0.963 ms

-- インデックスを再構築する
reindex index idx;

-- 正しい結果が得られる
select * from t where my_func(str) = my_func('aA');
-- (1 rows)
-- Time: 0.916 ms

IMMUTABLE としたストアドファンクションは単にその関数の参照透過性だけではなく、関数に依存するすべてのデータベースオブジェクトのライフサイクル全体にわたって不変である必要がある、ということなのかも。

https://www.postgresql.jp/document/15/html/xfunc-volatility.html https://stackoverflow.com/a/17610486

ちなみに MySQL ではどうなるかなと試そうと思ったのですが MySQL ではそもそも関数インデックスや生成列にストアドは使えませんでした。

https://dev.mysql.com/doc/refman/8.0/ja/create-index.html サブクエリー、パラメータ、変数、ストアドファンクションおよびユーザー定義関数は使用できません。

ところで PostgreSQL の pg_catalog.pg_index テーブルに indisvalid という列があるのですが、

https://www.postgresql.jp/document/15/html/catalog-pg-index.html

ストアドを REPLACE などするときっとここが false になってクエリで使用できなくなるのでは、と予想していたのですが、全然そんなことありませんでした。

↓を見るに、関数がインライン展開可能な SQL ならインデックスが不使用になるのかも?

https://www.postgresql.org/message-id/21420.1373644747%40sss.pgh.pa.us

試してみました。

-- テーブルを作る
create table t (id serial not null primary key, str text);

--- 'Aa' の行と適当に100万行入れる
insert into t (str) values ('Aa');
insert into t (str) select generate_series(1, 1000000, 1)::text;

-- ストアドファンクションを作る
create or replace function my_func(_text text) returns text as $$ select upper(_text) $$ LANGUAGE sql IMMUTABLE;

-- 関数インデックスを作る
create index idx on t (my_func(str));

-- インデックスがあるので早い
select * from t where my_func(str) = my_func('aA');
-- (1 rows)
-- Time: 0.791 ms

-- ストアドファンクションを異なる内容に REPLACE する
create or replace function my_func(_text text) returns text as $$ select lower(_text) $$ LANGUAGE sql IMMUTABLE;

-- インデックスが使われていないので遅い
select * from t where my_func(str) = my_func('aA');
-- (0 rows)
-- Time: 232.930 ms

explain select * from t where my_func(str) = my_func('aA');
--  Seq Scan on t  (cost=0.00..20405.01 rows=5000 width=10)
--    Filter: (lower(str) = 'aa'::text)

--- indisvalid は true のまま
select indisvalid from pg_index where indrelid = to_regclass('t') and indexrelid = to_regclass('idx');
-- indisvalid | t

reindex index idx;

-- インデックスが使われる
select * from t where my_func(str) = my_func('aA');
-- (0 rows)
-- Time: 0.751 ms

どうやらストアドがインライン展開可能ならインデックスが使われなくなるようなのです。 ただ、最初に予想していた indisvalid は true のままでした。これとは別のどこかに有効/無効の情報はあるのかもしれません。