- UUIDv47は、データベースにはソート可能なUUIDv7を保存しつつ、外部APIにはUUIDv4のように見える値を提供
- タイムスタンプフィールドのみをXORマスキングして、UUIDv7の時間情報を保護し、残りのランダムフィールドはそのまま維持
- SipHash-2-4を用いた128ビット鍵でマスキングし、鍵漏えいのリスクなしに安全に情報を保護可能
- encode/decodeは決定的かつ可逆的で、ランダム性が維持されるため衝突リスクが低い
- ベンチマーク結果では非常に高速な性能と簡単な統合方法を提供し、PostgreSQLなどのデータベースと容易に連携可能
プロジェクト概要と意義
- UUIDv47は、データベース内部にはソートおよびインデックス化に有利なUUIDv7を保存しながら、外部APIやシステムにはUUIDv4のように見える値を公開することで、プライバシー保護と高性能処理を同時に実現するオープンソースのCライブラリ
- 他のUUID変換アルゴリズムと比べて、可逆なマッピング、RFC互換、鍵の復元が不可能なセキュリティ、zero-deps、単一ヘッダーファイルを含めるだけの構成などに差別化された強みがある
主な特徴
- Header-only C (C89)、外部依存なしで簡単に統合可能
- UUIDv7のタイムスタンプフィールドだけをXORマスキングして時間情報の露出を防止し、残りのランダムフィールドは変更しない
- 鍵付きSipHash-2-4でマスキングし、128ビット鍵で安全に情報を保護可能
- encode/decode処理は決定的で完全に可逆的(元の形を正確に復元可能)
- データベース保存用(v7)と外部公開用(v4) UUID間の高速マッピングをサポート
- テストコードやベンチマークツールなど、豊富なサンプルを提供
用途と利点
- DB内のインデックスlocalityとページング効率を最大化するソート可能なUUIDv7を活用可能
- 外部にはUUIDv4のように見えるパターンだけを公開し、タイムスタンプ漏えいと追跡を防止
- SipHashを使用することで鍵の復元が不可能で、秘密鍵の安全性を保証
- RFC互換のバージョン/バリアントビット処理
- 動作速度が速く、リアルタイム処理や大量生成環境でも効率的
主な構造と内部動作原理
UUIDv7 Layout
- ts_ms_be: 48ビットbig-endianタイムスタンプ
- ver: 6バイト目の上位ニブル (0x7=DB, 0x4=外部)
- rand_a: 12ビット乱数値
- var: RFC variant (0b10)
- rand_b: 62ビット乱数値
マスキングとマッピングロジック (Façade mapping)
- エンコード: ts48 XOR mask48(R), version=4 に設定
- デコード: encTS XOR mask48(R), version=7 に設定
- ランダムフィールドは変更なし
- SipHash入力には10バイトのランダムフィールドを使用
- XORマスキングは鍵が分かれば即座に逆変換可能
セキュリティモデル
- 目標: 鍵が選択入力を与えられても漏えいしないこと
- 実装: SipHash-2-4という**鍵付き疑似ランダム関数(PRF)**を使用
- 128ビット鍵を活用し、HKDFなどによる鍵導出を推奨
- 鍵ローテーション時はUUID内部に保存せず、別途小さなkey IDを保持することを推奨
パブリックAPI (C)
- uuidv47_encode_v4facade : v7→v4変換
- uuidv47_decode_v4facade : v4→v7復元
- その他、バージョン設定、パース、フォーマット関連の関数を提供
性能とベンチマーク
- SipHashマスキング(10B)演算で14ns/op以下、encode+decodeの全ラウンドトリップで33ns/op程度(Apple M1基準)
- 大量のUUID生成・マッピング時でも高速処理を保証
-O3 -march=nativeオプションで最適性能
統合と拡張
- API境界でencode/decodeを処理することを推奨
- PostgreSQL連携時にはC拡張を作成
- シャーディング時はv4 façadeをxxh3、SipHashなどでハッシュ化可能
その他
- 他言語ポート: Go(n2p5/uuid47) などを提供
- 推奨ハッシュ: xxHashは非PRFのため情報漏えいの懸念があり、SipHashの使用を推奨
ライセンス
- MITライセンス (Stateless Limited, 2025)
1件のコメント
Hacker News の意見
こんにちは、uuidv47 の作者です。基本的なアイデアは、内部では UUIDv7 を使ってデータベースのインデックス効率と並び替え可能性を確保しつつ、外部には UUIDv4 のように見える値を見せることで、クライアントにタイミングのパターンが露出しないようにするというものです。
動作としては、48ビットのタイムスタンプを、UUID のランダムフィールドから導出した SipHash-2-4 ストリームで XOR マスキングします。
ランダムビットはそのまま維持され、バージョンは内部では 7、外部では 4 に切り替わり、RFC のバリアント値も保たれます。
マッピングは単射です:
(ts, rand) → (encTS, rand)という構造です。デコードは
encTS ⊕ mask方式なので、完全な往復変換が可能です。セキュリティ面では、SipHash は PRF なので、外部から変換済みの値を見てもキーは露出しません。
キーが違えばタイムスタンプもまったく異なるものになります。
キー ID を外部管理することで、キーのローテーションにも対応できます。
性能は 10 バイトごとに 1 回の SipHash と、48 ビットのロード/ストアを数回行う程度なので、オーバーヘッドはナノ秒レベルで、C11 のヘッダオンリー、外部依存なし、アロケーション不要です。
テストとしては、SipHash のリファレンスベクタ、往復エンコード/デコード、バージョン/バリアント不変性を確認しました。
フィードバックが気になります。
このアイデアは気に入りました。
UUID はクライアント側で生成されることも多いですが、この方式だとそれは難しそうです。
もしかして、クライアントが作った UUID を受け取ってマスキング版を返すようにしても、
tsが異なりrandが同じ 2 つの UUID を誰かが渡せてしまい、脆弱性が生まれるのではないでしょうか。結局、この方式は自前で UUIDv7 を生成する場合にだけ適しているのでしょうか。
意見は 2 つあります。
その手間に見合う価値があるかは分かりません。
最大の懸念はランダムビットのエントロピー品質です。
UUIDv7 は衝突回避をより重視しているため、予測困難性よりも衝突可能性に重点が置かれています。
そのため RFC でも非ランダム性については must ではなく should にとどまり、弱い PRNG やカウンタの使用、さらにはランダムビットの位置に追加の時計データを入れる実装さえあります(参考: RFC9562 s6.2 & s6.9)。
なので、v7 の
rand_a、rand_bを直接 PRF のシードとして使うのは、信頼境界の外から来たデータであれば、思った以上に危険かもしれません。PostgreSQL 18 の新しい
uuidv7()でさえ、高精度タイムスタンプでrand_aを丸ごと埋めますが、これも RFC 上は問題ありません。大量 import で生成された UUID を見ると、結局この v7-to-v4 方式でもグルーピングが可能になり、情報が漏れる可能性があります。
エンジン部品のテレメトリ収集のような用途なら問題ないでしょうが、人に直接結びつく識別子データなら注意が必要です。
結局のところ、信頼できるエントロピーを自前で保証しない限り、このスキームでもタイミングやシリアル、相関情報が漏れる可能性があるので、必ず v7 実装のソースを自分で確認すべきです。
良くないアイデアだと思います。
PostgreSQL 18 ではオプション引数
shiftによって、その間隔ぶんタイムスタンプをずらせます。https://www.postgresql.org/docs/18/functions-uuid.html
数年前に自分用のスキームを作って、DB では順次増加する数値 ID を使い、外部には 4〜20 文字長の短いランダム文字列として公開する方法を使ったことがあります。
そのときは Speck 暗号群のカスタムインスタンスを使っていて、堅牢でかなり筋が良いと思っていました。
完成はしていましたが、実際に使うプロジェクトを先送りしていたので公開しませんでした。
今年か来年にはその資料を正式公開する予定です。
実装方法や長所・短所をよく整理したノートもあるので、興味があればどうぞ。
https://temp.chrismorgan.info/2025-09-17-tesid/
私も以前、Speck で
bigserialPKID を難読化しようとしたことがありましたが、クロスプラットフォーム実装が不足していて、特に pgcrypto でのサポートが弱かったので、base58(AES_K1(id{8} || HMAC_K2(id{8})[0..7]))を選びました。結果は通常 22 文字ほどになって少し長くなりますが、ほぼすべての環境で実装でき、性能も十分満足できるものです。
良いアイデアです。
似た概念として sqids(旧称: hashids)も参考になるでしょう。
https://sqids.org/
以前に似た経験があり、公開用の uuid と API には出さない
bigintPK の 2 列を持たせて処理していました(uuidv7 が出るずっと前の話です)。uuid の手軽さは少し落ちますが、PK さえうまく切り出せれば、異なる DB ダンプを簡単にマージできるのが利点でした。
ハッシュベースで参照するとしても、結局 2 つの列が必要になる気がしますが、自分がハッシュの動作原理を誤解しているのかもしれません。
リクエストで渡された uuidv4 値を DB 内の uuidv7 に戻せます。
アイデア自体は興味深いですが、こうした処理をデータベースが直接サポートしてくれたらよいのにと思います。
つまり、UUIDv7 を「UUIDv4」と相互変換できて、クエリでも 2 つの形式を明示的に区別して使えるといいですね。
本当に素晴らしいプロジェクトです。
dchest の siphash ライブラリを使って Go 実装を作ってみました。
https://github.com/n2p5/uuid47
参考: https://github.com/dchest/siphash
プロジェクトは興味深いのですが、uuid v7 でタイム部分が露出する危険性を、実際の例で示せますか。
ユーザーの行動パターンや時系列が露出すると困る場面があります。
個別メッセージやリアルタイム取引なら関係ないかもしれませんが、ユーザーアカウント作成や長期データを扱う場合には、誰かが身元追跡に悪用できる可能性があります。
以前 CTF で、UUID の一部を AES キーとして brute force したことがあります。
キーがタイムソースから部分的に導出されていたため、キー生成時点の system time さえ分かれば攻撃可能でした。
もう 1 つの分かりやすい例は、ファイル共有サービスで
website.com/GUIDのような構造だけを公開し、ファイルアップロード時刻は別に公開していなくても、UUIDv7 を使っていればそれ自体からファイルのアップロード時間を推定できることです。これが必ずしも大きなセキュリティ脅威とは限りませんが、意図しない情報露出ではあります。
たとえば医療データを保存するシステムを想像してみてください。
分析のために MRI 撮影後すぐに結果をアップロードし、個人情報を削除するとしても、uuidv7 のタイムスタンプを使った外部相関分析によって、「この日に MRI を受けた人は 1 人しかいないので、これは誰の MRI か分かる」といったことが可能になります。
uuidv7 のいちばん不便な点は、一覧で人間が目視比較(diff)するのがとても難しいことです。
psql に、ランダムビットを前に出しつつ実際の並び順はタイムスタンプ基準のまま保つ可視化レイヤーがあれば、UX は大きく改善するでしょう。
私は UUID の末尾だけを見る癖をつけました。
自分で関数を 1 つ作ってクエリで使えばいいです。
たとえば 16 進表現にして文字列を反転するとか、reversed base64 で出力すれば、短くて見分けやすくなるでしょう。
この方式はかなり良さそうです。
ただ、タイムスタンプ露出を過剰に騒ぎ立てることや、シーケンシャル ID の露出がそのまま攻撃面やビジネス情報の露出につながるという主張は、本当のセキュリティ問題というより不要な心配に近いようにも見えます。
単に定期的に int 値へ大きなランダム値を加えれば、単調増加の性質は保ちつつも、外部の観察者がパターンをつかみにくくできるでしょう。
重要情報の漏えいを心配しているようで、少し大げさすぎる面もあると思います。
システムが流す情報自体には大した意味がないとしても、大量または時系列で観察すれば追加情報を推測できる可能性があります。
例として、ダーフィト・クリーゼルの SpiegelMining 講演のように、新聞記事の日付と著者だけを集めても、誰がいつ休暇に行くかというパターンが抽出できます。
複数の著者データを比較していけば、社内恋愛のようなことまで露見し得ます。
なぜセッションごとに異なる暗号鍵を使って、外部には暗号化された id だけを公開しないのでしょうか。
そうすれば DB では単純な連番 id だけを使えばよいのでは、と思います。
定期的に鍵を変えると鍵管理が非常に複雑になり、その都度どの鍵を使うかをどう特定するかも問題になります。
なぜバージョン 4 ではなくバージョン 8 を使わなかったのでしょうか。
v4 はランダムビットであることを意味しますが、実際にはそれほどランダムではありません。
v8 にはビット意味に関する制約がありません。
この方式の目的自体が外部からランダムに見えるようにすることなので、むしろ v8 のほうが目立ってしまった可能性もあると思います。