tenant-pass

パスワードの導出(tenant-pass/v1)

ウィンドウの番号とオフセット、鍵の選び方、HMAC-SHA256 によるパスワードとソルトの計算、更新のタイミングを定めた仕様です。別に書いた Python の実装で作ったテストベクター(検証用の入力と正解の組)も載せています。

このページの見出し
  1. 表記
  2. ウィンドウのオフセットと番号
  3. 鍵の選び方
  4. パスワードとソルト
  5. 返すウィンドウと更新の時刻
  6. テストベクター
  7. セキュリティ上の性質

tenant-pass がウィンドウとパスワードをどう計算するかの仕様です。方式を検証したいときや、同じ計算を別の言語で書きたいときに使ってください。末尾のテストベクター(検証用の入力と正解の組)は、本体の Go の実装とは別に Python で書いた実装で作ったもので、両者の結果が一致することを確かめてあります。

表記

  • LP(x): x のバイト長を 4 バイトのビッグエンディアン整数で表し、その後ろに x(UTF-8)を続けたもの。
  • BE64(n): n を符号付き 64 ビットのビッグエンディアン整数(2 の補数)で表したもの。
  • ||: バイト列の連結。
  • A = LP(store) || LP(node) || LP(account)。store は postgres・mysql・redis のどれか、node は設定ファイルに書いたノード名、account はアカウント名です。
  • W: ウィンドウの長さ(秒)。設定の rotation.window の値です。

ウィンドウのオフセットと番号

offset = uint64_be(SHA-256("tenant-pass/v1/offset" || A)[0:8]) mod W
n(t)   = floor((unix(t) − offset) / W)
Start(n) = n·W + offset          (Unix 時刻の秒)
End(n)   = Start(n + 1)

オフセットのおかげで、ローテーションの時刻はアカウントごとにずれ、ウィンドウの中に均等に散らばります。オフセットの計算に鍵は使いません。

鍵の選び方

ローテーションキーには、それぞれ ID と not_before(使い始める時刻)があります。ウィンドウ n では、not_before ≤ Start(n) を満たす鍵のうち、not_before がいちばん新しい鍵を使います。条件を満たす鍵がなければ、そのウィンドウのパスワードはありません。

パスワードとソルト

選んだ鍵のシークレットを K、その ID を id とすると、パスワードとソルトは次のように計算します。

password(n) = base64url_nopad( HMAC-SHA256(K, "tenant-pass/v1/password" || A || LP(id) || BE64(W) || BE64(n)) )   → 43 文字
salt(n)     =                  HMAC-SHA256(K, "tenant-pass/v1/salt"     || A || LP(id) || BE64(W) || BE64(n))     → 32 バイト

ソルトは、tenant-pass がパスワードをハッシュしてからストアに渡すときに使います。使い方はストアごとに違います。

PostgreSQL では SCRAM-SHA-256 のベリファイアを作ります。ソルトは salt(n)[0:16]、反復回数は 4096 です。

MySQL(caching_sha2_password)では、salt(n) の先頭 20 バイトを 1 バイトずつ alphabet[b & 0x3f] で crypt の文字集合 ./0-9A-Za-z の文字に置き換え、それをソルトにします。ラウンド数は 5000 です。

Redis ではソルトを使いません。ACL に登録するハッシュは hex(SHA-256(password)) です。

返すウィンドウと更新の時刻

時刻 t にどのウィンドウのパスワードを返すか(target(t))と、クライアントに更新を促す時刻(refresh_at(n))は、次の式で決まります。renew_before は、ウィンドウが終わる何秒前から次のウィンドウのパスワードに切り替えるかを表します。

target(t) = n(t) + 1   if End(n(t)) − t ≤ renew_before
          = n(t)       otherwise
refresh_at(n) = End(n) − renew_before

テストベクター

入力の条件は次のとおりです。

  • 鍵のシークレット: バイト列 00 01 02 … 1f(32 バイト)
  • 鍵 ID: k1(not_before はどのウィンドウよりも前)
  • W = 900
  • 時刻: t = 2026-11-03T12:00:00Z(Unix 時刻 1793707200)
ストア / ノード / アカウントoffsetnStartpassword(n)
postgres / pg1 / t_acme53919930071793706839lRG6msAkg88kIIjJU-N6yWZ6wzx1gQj62GYG71hcgjE
mysql / my1 / t_acme60119930071793706901e1J8ptbuAhJdMRKoN64HY-aPS_M_8qT5JBYJPCuixDg
redis / rd1 / t_acme861993007179370638610MHE6qMKh6XzzFzHwmXBvgWoezTwvsUYb91z3guUHc

各行のソルト(16 進)は次のとおりです。

  • postgres: abf9ab9a389f481fee4908dd172c9cccbadfec535752012f6409fd656d729d1a
  • mysql: b810862d689e69fa9b9e7505734ead2377cc468d2c81eeb6f48c7fc2556b8034
  • redis: 51ffc4190e3dc0774a7711bf75a433650b3d7d0235b8c879ec569274e11ff1b5

postgres の行に対応する PostgreSQL の SCRAM ベリファイアは次のとおりです。

SCRAM-SHA-256$4096:q/mrmjifSB/uSQjdFyyczA==$1vO+SFZOWRWMJtkvT14UZD23M4IOBqVfMP6JjpxyVnA=:LKeE/8nXPt7v7OoC/qRpjmb66Fd0fA5wclO1IK9ZOII=

redis の行に対応する Redis の ACL ハッシュは 181dc3a4def9e83202b8ec73eb06014c09a45f58e3637756857dd1a1b47b6278 です。

MySQL 用の SHA-crypt の計算は、OpenSSL(openssl passwd -5)が作るハッシュとも、実際の MySQL 8.4 サーバーが作るハッシュとも一致することをテストで確かめています。

セキュリティ上の性質

各要素の前に長さを付けているので、違う入力が同じ HMAC の入力にまとまってしまうことはありません。たとえば ("ab","c") と ("a","bc") は別物として扱われます。用途ごとに別のラベル(password・salt・offset)も付けているので、同じ鍵から作った値が用途をまたいで一致することもありません。

入力にはストアの種類、鍵 ID、ウィンドウの長さ W も入っています。その結果、次のことが言えます。

  • PostgreSQL のノードと Redis のノードに同じ名前のアカウントがあっても、同じ鍵から互いに無関係なパスワードができる。
  • 鍵の ID を変えると、その鍵から導出するパスワードはすべて変わる。
  • rotation.window を変えても、過去のウィンドウと同じ番号のパスワードがもう一度使われることはない。

逆に言えば、K を持っている人なら誰でも、その鍵から導出するパスワードをすべて計算できます。ローテーションキーはそれに見合うだけ厳重に保護し、漏洩が疑われたら入れ替えてください。

この文書は、リポジトリにある docs/ja/derivation.md をもとにしています(英語版は docs/en/derivation.md)。