1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UUIDv7のタイムスタンプ漏洩問題をC#で自動解決する「MaskedUUID」を作った

1
Posted at

はじめに

最近、DBのキーやAPIのIDとして UUIDv7 を採用するケースが増えています。

UUIDv7はソート可能でパフォーマンスに優れていますが、大きな問題があります。

UUIDv7には先頭48ビットにUnixタイムスタンプが埋め込まれている

つまり、UUIDをそのままAPIレスポンスやURLに露出させると、リソースがいつ作られたかがクライアント側から把握できます。

018f4e2a-b1c3-7d45-8e9f-123456789abc
^^^^^^^^^^^^^^^^
↑ ここにUnixタイムスタンプ(ミリ秒精度)が入っている

これにより、次のような情報が漏洩するリスクがあります:

  • ユーザー登録日時・注文日時などの業務情報が推測可能
  • 新規ユーザー数や成長率が外部に漏洩
  • 競合他社にビジネス状況を把握される

UUIDv47 という解決策

この問題への対処として UUIDv47 というアプローチがあります。

名前の由来は「v4のように見せて、内部ではv7を使う」から来ています。

使うもの 理由
内部(DB・サーバー) UUIDv7 ソート可能・インデックス効率が良い
外部(API・URL) UUIDv47 タイムスタンプ部分を暗号化して隠蔽

具体的には、UUIDv7のタイムスタンプ部分(先頭48ビット)のみを暗号化し、ランダム部分は保持したまま、v4として有効な形式に変換します。同じキーを使えば元のUUIDv7に復元できるため、可逆変換です。

問題点:手動変換の煩雑さと漏れのリスク

ただし、このアプローチには実装上の煩わしさがあります。

using UUIDv47Sharp;

// ❌ こういう変換を毎回手で書く必要がある
public class UserService
{
    private readonly Key _key;

    public UserService(IConfiguration config)
    {
        // キーの管理も煩雑
        var k0 = ulong.Parse(config["UUIDv47:K0"]);
        var k1 = ulong.Parse(config["UUIDv47:K1"]);
        _key = new Key(k0, k1);
    }

    public UserResponse ToResponse(User user)
    {
        return new UserResponse
        {
            Id = Uuid47Codec.Encode(user.Id, _key), // 書き忘れるとタイムスタンプが漏洩
            Name = user.Name,
        };
    }
}

変換を書き忘れたまま本番リリースすると、タイムスタンプが露出します。

また、キーの取得・管理を各サービスで行う必要があり、マルチテナント対応も自前で実装しなければなりません。レビューで気づけるとは限らず、チーム開発では特にリスクが高くなります。


そこで MaskedUUID.AspNetCore を作った

MaskedUUID.AspNetCore は、UUIDv47変換をASP.NET Coreに統合し、型レベルで自動変換するC#ライブラリです。

MaskedGuid という型を使うだけで、次のタイミングで自動的にUUIDv7 ↔ UUIDv47変換が行われます:

  • APIレスポンスMaskedGuid 型のプロパティがJSONに出力される際、UUIDv47に変換
  • APIリクエスト — UUIDv47文字列を受信時、自動的に元のGuidに復元
  • URLパラメータ/items/{id} のような URLのUUIDv47を自動復元
  • SignalR — Hubの送受信でも MaskedGuid 型のプロパティを自動変換
内部(DB・サーバー) 外部(API・URL)
形式 UUIDv7 UUIDv47(v4形式)
具体例 018f4e2a-b1c3-7d45-8e9f-123456789abc a3f2d8e1-9b4c-4e7a-a1f3-5d6e7f8a9b0c
タイムスタンプ 含まれる(先頭48ビット) 暗号化されて隠蔽

UUIDv47はタイムスタンプを暗号化しますが、鍵を持っていれば誰でも復元可能です。鍵の管理が重要で、鍵が漏洩すれば元に戻せる点に注意してください。


インストール

NuGetパッケージマネージャーからインストールできます。

.NET CLI を使用する場合:

dotnet add package MaskedUUID.AspNetCore

パッケージマネージャーコンソールを使用する場合:

Install-Package MaskedUUID.AspNetCore

または、.csproj に直接追加:

<PackageReference Include="MaskedUUID.AspNetCore" />

動作環境

  • .NET 9.0 以降
  • ASP.NET Core 9.0 以降
  • System.Text.Json(Newtonsoft.Jsonには非対応)

使い方

1. キープロバイダーの実装

暗号化に使う128bitキー(K0, K1)を提供する IMaskedUUIDKeyProvider を実装します。このクラスがキー管理の中心です。

基本形:マルチテナント対応のキープロバイダー

テナントごとに異なるキーを使うことで、テナントAのIDをテナントBで使えないようにします。IMaskedUUIDKeyProvider の実装内でテナントIDを解決します。

using MaskedUUID.AspNetCore.KeyProviders;
using Microsoft.Extensions.Caching.Memory;

public class MultiTenantKeyProvider : IMaskedUUIDKeyProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly IMemoryCache _cache;

    public MultiTenantKeyProvider(
        IHttpContextAccessor httpContextAccessor,
        IMemoryCache cache)
    {
        _httpContextAccessor = httpContextAccessor;
        _cache = cache;
    }

    public async Task<(ulong K0, ulong K1)> GetKeysAsync()
    {
        // JWTクレームやリクエストヘッダーからテナントIDを解決
        var tenantId = ResolveTenantId();

        var cacheKey = $"UUIDv47_Keys_{tenantId}";
        if (_cache.TryGetValue(cacheKey, out (ulong k0, ulong k1) cached))
            return cached;

        // 実際はDBやRedis・Key Vaultからテナントのキーを取得
        var keys = await FetchKeysFromDatabaseAsync(tenantId);

        _cache.Set(cacheKey, keys, TimeSpan.FromDays(7));
        return keys;
    }

    public (ulong K0, ulong K1) GetKeysSynchronous()
    {
        // 同期版も同様に実装(SignalRなどで使用)
        var tenantId = ResolveTenantId();
        // ...(省略)
        return FetchKeysFromDatabase(tenantId);
    }

    private Guid ResolveTenantId()
    {
        var context = _httpContextAccessor.HttpContext
            ?? throw new InvalidOperationException("HttpContextが取得できません");

        // 例: JWTのクレームからテナントIDを取得
        var tenantClaim = context.User.FindFirst("tenant_id")?.Value
            ?? throw new InvalidOperationException("tenant_idクレームがありません");

        return Guid.Parse(tenantClaim);
    }
}

シングルテナントの場合:固定キーを返すシンプルな実装

全ユーザーが同じキーで変換される場合は、固定キーを返すだけのシンプルな実装で十分です。

using MaskedUUID.AspNetCore.KeyProviders;

public class SingleTenantKeyProvider : IMaskedUUIDKeyProvider
{
    private readonly (ulong K0, ulong K1) _keys;

    public SingleTenantKeyProvider(IConfiguration config)
    {
        // 環境変数から取得する例
        var k0Str = Environment.GetEnvironmentVariable("MASKEDUUID_K0")
            ?? throw new InvalidOperationException("MASKEDUUID_K0が設定されていません");
        var k1Str = Environment.GetEnvironmentVariable("MASKEDUUID_K1")
            ?? throw new InvalidOperationException("MASKEDUUID_K1が設定されていません");

        _keys = (Convert.ToUInt64(k0Str, 16), Convert.ToUInt64(k1Str, 16));

        // または appsettings.json から取得
        // _keys = (
        //     Convert.ToUInt64(config["MaskedUUID:K0"], 16),
        //     Convert.ToUInt64(config["MaskedUUID:K1"], 16)
        // );

        // または Azure Key Vault などのシークレット管理サービスから取得
    }

    public Task<(ulong K0, ulong K1)> GetKeysAsync()
        => Task.FromResult(_keys);

    public (ulong K0, ulong K1) GetKeysSynchronous()
        => _keys;
}

キーの管理方法の選択肢:

  • 環境変数: シンプルで多くのホスティング環境に対応
  • appsettings.json: 開発環境では便利だが、本番では環境変数や User Secrets を推奨
  • Azure Key Vault / AWS Secrets Manager: エンタープライズ環境で推奨
  • User Secrets: 開発時のローカル環境で使用

DI登録は以下の通りです。

// マルチテナント対応の場合(リクエストごとにテナントが変わるため Scoped)
builder.Services.AddScoped<IMaskedUUIDKeyProvider, MultiTenantKeyProvider>();

// シングルテナントの場合(固定キーなので Singleton でOK)
builder.Services.AddSingleton<IMaskedUUIDKeyProvider, SingleTenantKeyProvider>();

2. DI登録(Program.cs)

using MaskedUUID.AspNetCore.Extensions;
using MaskedUUID.AspNetCore.Services;

// 1. キープロバイダーを登録(マルチテナント対応の例)
builder.Services.AddScoped<IMaskedUUIDKeyProvider, MultiTenantKeyProvider>();

// シングルテナントの場合は以下のように Singleton で登録
// builder.Services.AddSingleton<IMaskedUUIDKeyProvider, SingleTenantKeyProvider>();

// 2. MaskedUUIDServiceを登録
builder.Services.AddScoped<IMaskedUUIDService, MaskedUUIDService>();

// 3. ASP.NET Core統合を登録(JSONコンバーター)
builder.Services.AddMaskedUUID();

// 4. ModelBinderを登録(URLパラメータ用)
builder.Services.AddControllers().AddMaskedUUIDModelBinder();

3. DTOで MaskedGuid 型を使う

IDフィールドの型を Guid から MaskedGuid に変更するだけです。

using MaskedUUID.AspNetCore.Types;

public class ItemDto
{
    // この型にするだけで、JSON入出力が自動的にUUIDv47に変換される
    public MaskedGuid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

4. コントローラーで使う

URLパラメータも MaskedGuid 型にするだけで自動変換されます。

using MaskedUUID.AspNetCore.Types;

[ApiController]
[Route("api/[controller]")]
public class ItemsController : ControllerBase
{
    private readonly IItemService _itemService;

    public ItemsController(IItemService itemService)
    {
        _itemService = itemService;
    }

    // GET /api/items/{itemId}
    // URLのUUIDv47文字列が自動的にGuidに変換される
    [HttpGet("{itemId}")]
    public async Task<ActionResult<ItemDto>> GetById([FromRoute] MaskedGuid itemId)
    {
        Guid id = itemId; // MaskedGuid → Guid への暗黙的キャスト
        var item = await _itemService.GetItemAsync(id);
        if (item == null) return NotFound();
        return Ok(item); // ItemDtoのId(MaskedGuid型)が自動的にUUIDv47に変換される
    }

    // POST /api/items
    [HttpPost]
    public async Task<ActionResult<ItemDto>> Create([FromBody] CreateItemRequest request)
    {
        var item = await _itemService.CreateItemAsync(request);
        // item.Id は MaskedGuid 型なので、レスポンスのJSONでは自動的にUUIDv47になる
        return CreatedAtAction(nameof(GetById), new { itemId = item.Id }, item);
    }
}

MaskedGuid.ToString()生のGuid文字列を返します(マスク文字列ではありません)。マスク済み文字列はJSONシリアライズ時にのみ生成されます。また、JSONコンバーターはHTTPリクエストスコープ内でのみ動作するため、バックグラウンド処理などでは使用できません。

5. IMaskedUUIDService を直接使う

MaskedGuid 型による自動変換はHTTPリクエストスコープ内でしか動作しません。バックグラウンドサービスや、ログにマスク済みIDを出力したい場合など、手動で変換が必要なケースでは IMaskedUUIDService を直接 DI して使います。

IMaskedUUIDService のメソッドは以下の通りです。

メソッド 説明
EncodeAsync(Guid) 1件エンコード(非同期)
DecodeAsync(string) 1件デコード(非同期)
EncodeListAsync(IEnumerable<Guid>) 複数件まとめてエンコード(キー取得が1回で済むため効率的)
DecodeListAsync(IEnumerable<string>) 複数件まとめてデコード
EncodeSynchronous(Guid) 1件エンコード(同期・SignalRなどで使用)
DecodeSynchronous(string) 1件デコード(同期)

EncodeAsync / EncodeSynchronousGuid.Empty を渡すと ArgumentException が発生します。JSONコンバーター経由のときは null を返すだけですが、直接呼ぶ場合は挙動が異なります。必要に応じてガードを追加してください。

バックグラウンドサービスでの使い方

IMaskedUUIDServiceScoped サービスなので、BackgroundService のような Singleton スコープから直接 DI することはできません。IServiceScopeFactory でスコープを自前で作成して使います。

public class NotificationBackgroundService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public NotificationBackgroundService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            // Scoped サービスを使うためにスコープを都度作成
            using var scope = _scopeFactory.CreateScope();
            var maskedUuidService = scope.ServiceProvider
                .GetRequiredService<IMaskedUUIDService>();

            var internalId = Guid.CreateVersion7();

            // Guid.Empty チェックを忘れずに
            if (internalId != Guid.Empty)
            {
                var maskedId = await maskedUuidService.EncodeAsync(internalId);
                await SendNotificationAsync(maskedId, ct);
            }

            await Task.Delay(TimeSpan.FromMinutes(1), ct);
        }
    }
}

複数IDをまとめて変換する

一覧APIのレスポンスなど、複数のIDをまとめて変換したい場合は EncodeListAsync が効率的です。キーの取得が1回で済みます。

[HttpGet]
public async Task<ActionResult<List<ItemSummaryDto>>> GetAll()
{
    var items = await _repository.GetAllAsync();

    // まとめてエンコード(キー取得は1回)
    var maskedIds = await _maskedUuidService.EncodeListAsync(
        items.Select(i => i.Id)
    );

    var result = items.Zip(maskedIds, (item, maskedId) => new ItemSummaryDto
    {
        Id = maskedId,
        Name = item.Name,
    }).ToList();

    return Ok(result);
}

ログにマスク済みIDを出力する

MaskedGuid.ToString() は生のGuidを返すため、ログに生IDを出したくない場合も直接呼びます。

// ❌ ToString() は生Guidが出力される
_logger.LogInformation("作成されたID: {Id}", item.Id);

// ✅ サービスを直接使ってマスク済みをログに出す
var maskedId = _maskedUuidService.EncodeSynchronous(item.Id);
_logger.LogInformation("作成されたID: {Id}", maskedId);

6. SignalR で使う

REST APIと同様に、SignalRのHubでも MaskedGuid 型を使うだけで自動変換されます。

まず Program.csAddSignalR().AddMaskedUUID() を追加します。

// キープロバイダーは REST API と共通(追加登録不要)
builder.Services.AddScoped<IMaskedUUIDKeyProvider, SingleTenantKeyProvider>();
builder.Services.AddScoped<IMaskedUUIDService, MaskedUUIDService>();
builder.Services.AddMaskedUUID();

// SignalR に MaskedUUID を追加
builder.Services.AddSignalR().AddMaskedUUID();

開発・テスト時は .AddMaskedUUIDWithReferenceKeys() を使うと、キープロバイダーの登録を省略できます。本番では使用しないでください。

Hub側のコードは通常のSignalR実装とほぼ同じです。DTOの IdMaskedGuid 型にするだけで、クライアントへの送信・受信いずれも自動変換されます。

using MaskedUUID.AspNetCore.Types;
using Microsoft.AspNetCore.SignalR;

// Hub に送受信するメッセージの型
public class ItemNotification
{
    public MaskedGuid Id { get; set; }   // ← この型にするだけで自動変換
    public string Name { get; set; } = string.Empty;
    public string EventType { get; set; } = string.Empty; // "Created" | "Updated" | "Deleted"
}

public class ItemHub : Hub
{
    private readonly IItemService _itemService;

    public ItemHub(IItemService itemService)
    {
        _itemService = itemService;
    }

    // クライアントからアイテムIDを受け取って詳細をプッシュ
    // MaskedGuid 型なので受信時に自動的に Guid に変換される
    public async Task SubscribeItem(MaskedGuid itemId)
    {
        Guid id = itemId;
        var item = await _itemService.GetItemAsync(id);
        if (item == null) return;

        // ItemNotification.Id は MaskedGuid 型なので、送信時に自動的に UUIDv47 に変換される
        await Clients.Caller.SendAsync("ItemDetail", new ItemNotification
        {
            Id = item.Id,
            Name = item.Name,
            EventType = "Fetched"
        });
    }

    // アイテム作成をグループ全体にブロードキャスト
    public async Task NotifyItemCreated(Guid internalId, string name)
    {
        await Clients.All.SendAsync("ItemCreated", new ItemNotification
        {
            Id = internalId,   // Guid → MaskedGuid への暗黙的キャストで自動変換
            Name = name,
            EventType = "Created"
        });
    }
}

Hub の登録はいつも通りです。

app.MapHub<ItemHub>("/hubs/items");

内部の動作として、MaskedUUIDHubFilter が Hub 呼び出しのスコープを追跡しており、Scoped な IMaskedUUIDKeyProvider でもリクエストスコープ内で正しく解決されます。

7. OpenAPI / Swagger 対応

builder.Services.AddOpenApi(options =>
    options.AddMaskedGuidSchemaTransformer());

副次的なメリット:内部IDの隠蔽

タイムスタンプ問題を解決する目的で作りましたが、副次的なメリットもあります。

内部実装が透けない

生のUUIDをそのまま公開すると、次のことが外部からわかってしまいます。

  • 識別子に UUID(GUID)を使っていること
  • フォーマットからDBやフレームワークの種類がある程度推測できること

マスクした文字列はランダムなUUIDv4のように見えるため、内部実装が見えにくくなります。

エンドポイント間のID紐付けを防ぐ

同じ内部IDが複数のAPIで使われている場合、生のUUIDを返すと異なるエンドポイントのレスポンスを外部から突き合わせて関連付けられる可能性があります。

GET /users/018f4e2a-b1c3-7d45-8e9f-123456789abc
GET /orders/018f4e2a-b1c3-7d45-8e9f-123456789abc  ← 同じIDから同一人物と判断可能

UUIDv47変換はリクエストスコープのキーで行われるため、意図しない関連付けを防げます。


注意事項・制約

❌ HTTPリクエストスコープ外では動作しない

MaskedGuidConverterHttpContext.RequestServices からサービスを解決します。そのため、HTTPリクエストの外では InvalidOperationException が発生します。

具体的に使えない場所:

  • IHostedService / BackgroundService(バックグラウンドタスク)
  • IHangfireJob などのジョブスケジューラ
  • コンソールアプリや単体テストなどの非Web環境
// ❌ これはバックグラウンドサービス内なので例外になる
public class MyBackgroundService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var dto = new ItemDto { Id = someGuid }; // MaskedGuid 型
        var json = JsonSerializer.Serialize(dto); // ← InvalidOperationException!
    }
}

バックグラウンド処理でIDを扱う場合は、IMaskedUUIDService を直接 DI して使う必要があります。

MaskedGuid.ToString() はマスク文字列を返さない

ToString()内部の生Guid文字列をそのまま返します。ログやデバッグ出力に MaskedGuid を使うと、生のUUIDが出力されます。

var maskedId = new MaskedGuid(someGuid);
Console.WriteLine(maskedId.ToString()); // ← 生のGuid文字列が出力される(UUIDv47ではない)
_logger.LogInformation("ID: {Id}", maskedId); // ← 同様に生のGuidがログに残る

マスク済み文字列が必要な場合は IMaskedUUIDService.EncodeSynchronous() を直接呼ぶ必要があります。

Guid.Empty は JSON で null になる

var dto = new ItemDto { Id = Guid.Empty };
// JSON出力: { "Id": null, ... }

null を受け取った場合も Guid.Empty として扱われます。空IDを明示的に区別したい設計では注意が必要です。

Guid 型のURLパラメータは自動変換されない

ModelBinder が対象とするのは MaskedGuid / MaskedGuid? 型のみです。Guid 型のままにしていると変換が走りません。

// ❌ Guid 型のまま → MaskedUUID として受け取れない
[HttpGet("{id}")]
public IActionResult Get([FromRoute] Guid id) { ... }

// ✅ MaskedGuid 型に変える
[HttpGet("{id}")]
public IActionResult Get([FromRoute] MaskedGuid id) { ... }

❌ 属性ベースの選択的マスクはできない

[MaskedUUID] のような属性は存在しません。型(MaskedGuid)でのみマスク対象を制御します。特定のプロパティだけマスクする場合は、そのプロパティの型を MaskedGuid にします。

❌ Newtonsoft.Json には対応していない

System.Text.Json のみ対応です。プロジェクトで Newtonsoft.JsonJson.NET)を使っている場合は動作しません。

❌ SignalR の MessagePack プロトコルには対応していない

SignalR でのマスク変換は JSON プロトコルのみ対応です。AddMessagePackProtocol() を使っている場合、MaskedGuid の変換は行われません。

// ❌ MessagePack プロトコルでは MaskedGuid が変換されない
builder.Services.AddSignalR()
    .AddMaskedUUID()
    .AddMessagePackProtocol(); // ← MessagePack 通信では変換されない

まとめ

MaskedUUID.AspNetCoreは、UUIDv7のタイムスタンプ漏洩問題を型レベルで自動的に解決するライブラリです。

手動でUUIDv47変換を行う場合、変換の書き忘れやキー管理の煩雑さが課題となりますが、このライブラリを使うことで以下のメリットが得られます:

  • 型安全な変換MaskedGuid 型を使うだけで、変換漏れを型システムで防止
  • 一元的なキー管理:KeyProviderパターンでキーを一箇所で管理
  • マルチテナント対応:テナントごとに異なるキーを使う構成も標準サポート
  • 最小限の実装コスト:DI登録と型変更だけで導入可能
UUIDv7そのまま公開 UUIDv47(手動変換) MaskedUUID.AspNetCore
タイムスタンプ隠蔽
内部ID形式の隠蔽
変換漏れのリスク ⚠️ 手動のため漏れうる ✅ 型で保証
Key管理 ⚠️ 各サービスで必要 ✅ KeyProviderで一元管理
実装の手間 なし 毎回必要 DI登録+型変更のみ
テナント別キー対応 要自前実装 ✅ KeyProviderで対応

リンク

MaskedUUID.AspNetCore

UUIDv47 関連

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?