ゲートウェイが署名するパスの正式な仕様です。ゲートウェイを自作するときは、このページと PASETO v4 のライブラリがあれば足ります。Go なら、pass パッケージをインポートすれば、ここに書いたことはすべて済みます。
トークン
パスは PASETO の v4.public のトークン(Ed25519 の署名)です。
v4.public.<base64url(message || signature)>.<base64url(footer)>
| 部分 | 値 |
|---|---|
| バージョンと用途 | v4.public だけです。それ以外のヘッダーは拒否します。 |
| 暗黙のアサーション | ASCII のバイト列 tenant-pass/v1/pass そのものです。必須です。署名の計算には含めますが、トークンには含めず送りません。 |
| フッター | 必須です。JSON のオブジェクト {"kid":"<key ID>"} で、ほかには何も含めません。最大 256 バイトです。 |
| メッセージ | 下に示すクレームを持つ JSON のオブジェクトです。 |
| 全体の長さ | 最大 2048 バイトです。 |
Base64url はパディング(末尾の =)なしで書きます。正規でない書き方(使わないビットが 0 になっていないもの)は拒否します。
暗黙のアサーションは、トークンの用途を見分けるためのものです。同じ鍵で別の目的のために(あるいは将来の tenant-pass のバージョンのために)署名したトークンは、これが違うので検証に失敗します。省略しないでください。たいていの PASETO のライブラリでは、sign や verify の引数として渡します(引数の名前は「implicit assertion」や i になっていることもあります)。
フッター
{"kid":"gw1"}
kid は ^[a-z][a-z0-9_-]{0,62}$ に一致し、tenant-pass に設定した gateway_keys[].id のどれかと同じでなければなりません。フッターも署名の対象ですが、tenant-pass は署名を検証する前にも読みます。それは鍵を選ぶためで、ほかの用途には使いません。
クレーム
メッセージは、下の表のメンバーだけを持つ JSON のオブジェクトです。知らないメンバーや重複したメンバーがあれば拒否します。
| クレーム | 必須 | 形式 | 意味 |
|---|---|---|---|
aud | はい | ^[a-z][a-z0-9_-]{0,62}$ | ゲートウェイがリクエストを転送する先のアプリケーションの名前です(tenant-pass の apps[].name)。 |
host | host か tenant のどちらか一方 | 正規化済みのホスト名(後で説明します) | リクエストのホスト名です。tenant-pass は、ディレクトリの by_host でこのホスト名のテナントを調べます。 |
tenant | host か tenant のどちらか一方 | ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ | ゲートウェイがテナント ID をすでに知っている場合に使います。tenant-pass は by_tenant でテナントを調べます。 |
iat | はい | YYYY-MM-DDTHH:MM:SSZ | 発行した時刻です。UTC で、秒単位(小数なし)で書き、末尾に Z を付けます。 |
exp | はい | YYYY-MM-DDTHH:MM:SSZ | 有効期限です。形式は iat と同じです。 |
jti | はい | ^[A-Za-z0-9_-]{16,128}$ | ランダムな識別子です。128 ビット以上をおすすめします。tenant-pass は、ゲートウェイのログと突き合わせられるように、これを監査ログに残します。 |
正規化済みのホスト名
tenant-pass の側ではホスト名を正規化しません。ゲートウェイが、必ず次の形にしてから署名してください。
- 小文字の ASCII の英字、数字、
-でできたラベルを、.で区切ったもの - 国際化ドメイン名は、ASCII の形(
xn--…)に変換したもの - ポート、末尾のドット、空のラベルを含まない
- 各ラベルは 1〜63 文字で、
-で始まったり終わったりしない - 全体で 253 文字以下で、ラベルが 2 つ以上ある
- IP アドレスではない
時刻の決まり
now を tenant-pass の時計の時刻、leeway を pass.leeway(既定は 5 秒)とします。パスは次の条件をすべて満たさなければなりません。
exp > iatexp − iat ≤ pass.max_ttl(既定は 60 秒、上限は 300 秒)now < exp(有効期限には猶予を設けません)iat ≤ now + leeway
おすすめは exp = iat + 30s です。
tenant-pass が検証する順序
- 長さが 2048 バイト以下で、ヘッダーが
v4.public.で、フッターがあることを確かめます。 - フッターを厳格に解析し、
kidの鍵を探します。知らないkidなら拒否します。 - その鍵と暗黙のアサーションで署名を検証します。
- クレームを厳格に解析して検証します(形式と、
hostとtenantのどちらか一方だけがあること)。 - 有効期間と時刻の決まりを確かめます。
audが、認証したアプリケーションと一致することを確かめます。
テストベクター
| 入力 | 値 |
|---|---|
| Ed25519 のシード | 0x01 を 32 バイト並べたもの |
| 公開鍵(16 進) | 8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c |
| フッター | {"kid":"gw1"} |
| 暗黙のアサーション | tenant-pass/v1/pass |
| メッセージ | {"aud":"billing","host":"acme.example.com","iat":"2026-11-03T12:00:00Z","exp":"2026-11-03T12:00:30Z","jti":"AAAAAAAAAAAAAAAAAAAAAA"} |
正しいトークンは次のとおりです。Ed25519 の署名は同じ入力からは常に同じ結果になるので、仕様どおりに署名すれば、必ずこれと同じになります。
v4.public.eyJhdWQiOiJiaWxsaW5nIiwiaG9zdCI6ImFjbWUuZXhhbXBsZS5jb20iLCJpYXQiOiIyMDI2LTExLTAzVDEyOjAwOjAwWiIsImV4cCI6IjIwMjYtMTEtMDNUMTI6MDA6MzBaIiwianRpIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9lGRulfEQdJnx_Iagf4Fp94_8eSIifNPHT82HGA8KqWhSX_KikCQafPjF2Ntupw3XnYtRoZ6xe1-uf46uZcHoDQ.eyJraWQiOiJndzEifQ
署名の実装は、このトークンと 1 バイトも違わないものを作らなければなりません。tenant-pass は、時計が iat と exp の間にあればこのトークンを受け付けます。これは tenant-pass のテストで確かめていて、tenant-pass が使う PASETO の実装自体も、PASETO の公式の v4 のテストベクターで検証済みです。
検証する側を自分で書く場合は、位数の小さい Ed25519 の公開鍵や、正規でない形で書かれた Ed25519 の公開鍵も拒否してください。Go の ed25519.Verify は、そうした鍵に対しては簡単に偽造できる署名でも通してしまいます。pass パッケージは、こうした鍵を拒否します。
署名の例
Go
import "github.com/kobesoft-labs/tenant-pass/pass"
s := pass.Signer{KeyID: "gw1", Key: privateKey} // ed25519.PrivateKey
now := time.Now()
p, err := s.Sign(pass.Claims{
Audience: "billing",
Host: "acme.example.com",
IssuedAt: now,
Expiry: now.Add(30 * time.Second),
})
PHP(paragonie/paseto の低レベルの API)
paragonie/paseto の高レベルの Builder は、暗黙のアサーションを JSON にエンコードしてしまうので使えません。暗黙のアサーションを文字列のまま受け取る、プロトコルのクラスを直接使います。
use ParagonIE\Paseto\Keys\AsymmetricSecretKey;
use ParagonIE\Paseto\Protocol\Version4;
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$claims = json_encode([
'aud' => 'billing',
'host' => 'acme.example.com',
'iat' => $now->format('Y-m-d\TH:i:s\Z'),
'exp' => $now->modify('+30 seconds')->format('Y-m-d\TH:i:s\Z'),
'jti' => rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='),
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$token = Version4::sign(
$claims,
new AsymmetricSecretKey($secretKeyBytes, new Version4()),
'{"kid":"gw1"}', // フッター
'tenant-pass/v1/pass' // 暗黙のアサーション(このバイト列そのもの)
);
ほかのライブラリを使うとき
ライブラリによっては、頼んでいないのに独自のクレーム(形式の違う exp や nbf など)を足したり、暗黙のアサーションを JSON にエンコードしたりします。API がメジャーバージョンごとに変わることもあります。一方の tenant-pass は、知らないクレームを拒否し、暗黙のアサーションがバイト列 tenant-pass/v1/pass そのものであることを求めます。
デプロイする前に、ライブラリの出力が上のテストベクターと一致するかを必ず確かめてください。一致させられないときは、PASETO の低レベルの署名の関数を直接呼んでください。
この文書は、リポジトリにある docs/ja/pass-format.md をもとにしています(英語版は docs/en/pass-format.md)。