# ライセンスチェーンと委任

Rediaccはライセンス発行に改ざん検知可能なハッシュチェーンを使用し、オンプレミスデプロイメントには委任証明書モデルを採用しています。このページでは、システムが改ざん、リプレイ攻撃、ライセンス共有からどのように保護されるかを説明します。

## なぜチェーンが必要か？

アカウントサーバーが発行するすべてのライセンスは、追記専用の台帳に記録されます。各エントリはSHA-256ハッシュによって前のエントリにリンクされ、チェーンを形成します。このチェーンには改ざんを検知可能にする3つの特性があります：

1. **シーケンス番号**はサブスクリプションごとにグローバルかつ単調増加します。エントリのスキップや並び替えはチェーンを壊します。
2. **チェーンハッシュ**は各エントリを前のエントリすべてに結びつけます。過去のエントリを変更すると、それ以降のエントリがすべて無効になります。
3. **renetはサブスクリプションごとに確認した最高シーケンス番号を保存します。** シーケンスをロールバックするサーバーは即座に検出されます。

## ライセンスの発行方法

CLIがマシンアクティベーションやリポジトリライセンスをリクエストすると、アカウントサーバーは：

1. サブスクリプションの現在のチェーンヘッド（最後のシーケンス番号とハッシュ）を読み取ります。
2. 次のシーケンス番号と前のチェーンハッシュを組み込んだライセンスペイロードを構築します。
3. Ed25519でペイロードに署名します。
4. `chainHash = SHA256(prevChainHash + ":" + signedPayload)`を計算します。
5. エントリを発行台帳にアトミックに追加します。2つの同時リクエストが同じシーケンスで衝突した場合、負けた側が次のシーケンスを再取得して再署名します。
6. 署名済みblobとチェーンハッシュをCLIに返します。

`sequence`と`prevChainHash`は署名済みペイロード内にあります（署名を無効にしなければ変更できません）。`chainHash`はエンベロープ上にあります（循環依存を避けるため署名後に計算されます）。

## Renetの検証方法

Renetを実行する各マシンは`{licenseDir}/chain-state.json`に最後に確認したチェーン状態を保存します。ライセンス検証のたびにRenetは以下を確認します：

| 確認項目 | 失敗の意味 |
|---|---|
| Ed25519署名が有効 | ライセンスが偽造または改ざんされた |
| `sequence > lastKnownSequence` | サーバーがチェーンをロールバックした（リプレイ攻撃） |
| `chainHash == SHA256(prevChainHash + ":" + payload)` | チェーンエントリが変更された |
| `issuedAt >= lastKnownIssuedAt` | 時刻操作（サーバーの時計が巻き戻された） |

いずれかの確認が失敗した場合、ライセンスは拒否され、失敗理由が報告されます。

## 委任証明書（オンプレミス）

エアギャップまたはセルフホストのデプロイメントの場合、上流アカウントサーバーは**委任証明書**を発行し、オンプレミスサーバーが独自のEd25519キーでライセンスに署名することを許可します。証明書はオンプレミスサーバーができることを制限します。

### 証明書の構造

委任証明書には以下が含まれます：

- `subscriptionId` - この証明書が適用されるサブスクリプション
- `planCode`、`maxMachines`、`maxRepositorySizeGb`、`maxRepoLicenseIssuancesPerMonth` - 組み込まれたプラン制限
- `maxTotalIssuances` - チェーンシーケンス番号の上限
- `delegatedPublicKey` - オンプレミスサーバーのEd25519公開鍵（SPKI base64）
- `genesisHash` - チェーンの起点（前の証明書からの継続、または「genesis」）
- `genesisSequence` - 発行時のチェーンシーケンス番号。転送中にチェーンが進んだ場合、`/onprem/cert-upload`がローカル発行台帳の既知エントリに新しい証明書がリンクしているかを検証するために使用します。後方互換性のためオプションです（欠落した場合は0として扱われます）。
- `validFrom`、`validUntil` - 有効期間（以下の有効ポリシーによって決まる）
- 上流マスターEd25519キーで署名済み

### 委任の仕組み

1. EnterpriseアドミンがオンプレミスサーバーでEd25519キーペアを生成します。
2. アドミンが上流に委任証明書をリクエストします：
   ```
   POST /admin/delegation-certs
   { subscriptionId, validDays: 90, delegatedPublicKey: "MCowBQYDK2VwAyEA..." }
   ```
3. 上流がマスターキーで証明書に署名して返します。
4. オンプレミスサーバーが証明書と秘密鍵を保存し、ライセンスへの署名準備完了です。
5. CLIがオンプレミスサーバーにライセンスをリクエストすると、サーバーは委任キーで署名し、証明書への参照を含めます。
6. Renetが**2レベルの検証**を実行します：
   - 組み込まれた上流マスターキーに対して証明書の署名を検証します。
   - 証明書の委任キーに対してblobの署名を検証します。
   - `blob.sequence <= cert.maxTotalIssuances`を確認します。
   - すべての標準チェーン確認を適用します。

オンプレミスサーバーは以下ができません：
- 委任証明書のプラン制限外のライセンスを偽造すること（renetが拒否します）。
- `maxTotalIssuances`を超える操作を発行すること（renetがシーケンスオーバーフローを拒否します）。
- 証明書を変更すること（上流の署名が壊れます）。

## 有効ポリシー

委任証明書の有効期間は共有ポリシーヘルパー（`computeDelegationCertValidity()`）によって計算されます。このヘルパーは上流バックエンドとカスタマーポータルフロントエンドの両方で実行されます。同じ入力は常に同じ`validUntil`を生成するため、顧客は送信前に作成モーダルで有効期間のプレビューを確認できます。

### プランごとのデフォルトと上限

| プラン | デフォルト有効期間 | プラン上限 |
|---|---|---|
| COMMUNITY | 15日 | 30日 |
| PROFESSIONAL | 60日 | 120日 |
| BUSINESS | 90日 | 180日 |
| ENTERPRISE | 120日 | 365日 |

デフォルトは呼び出し元が`validDays`を省略した場合に作成エンドポイントが選択する値です。上限は呼び出し元がリクエストできる最大値です。

### サブスクリプションごとの上書き

アドミンはアドミンのSubscription Detailページを通じて特定のサブスクリプションに`delegationCertDefaultDays`のカスタム値を設定できます。**上書きはそのサブスクリプションのデフォルトと上限の両方を置き換えます** - これは特定の顧客向けの逃げ道です（例：COMMUNITYプランで200日の証明書が必要なエンタープライズ契約）。ZodスキーマはなおABSOLUTE範囲の`1..365`を強制します。

### ハードキャップ：サブスクリプション終了+3日の猶予

プランの上限と上書きに関わらず、すべての証明書は`subscription.expiresAt + 3日`（既存の`SUBSCRIPTION_CONFIG.gracePeriodDays`）にハードキャップされます：

- 永続的なサブスクリプション（`expiresAt = null`）の場合、有効期限のキャップは適用されません（プラン上限のみ）。
- Stripe課金の月次サブスクリプションの場合、キャップはおおよそ次の請求日+3日です。Stripeが毎月`expiresAt`を更新すると、キャップもそれに合わせて移動します。
- トライアルサブスクリプションの場合、キャップはトライアル終了+3日です。

### 有効日数と理由

すべての作成/更新レスポンスには`effectiveDays`と`reason`が含まれるため、証明書がその有効期間になった正確な理由を確認できます：

| 理由 | 意味 |
|---|---|
| `plan_default` | リクエストなし、上書きなし - プランのデフォルトを使用 |
| `subscription_override` | リクエストなし - サブスクリプションの上書きをデフォルトとして使用 |
| `requested` | 呼び出し元のリクエストがすべてのキャップ内で承認された |
| `plan_max_clamp` | 呼び出し元のリクエストがプラン上限を超えた - 切り下げされた |
| `override_max_clamp` | 呼び出し元のリクエストがサブスクリプション上書きを超えた - 切り下げされた |
| `subscription_cap_clamp` | 有効なはずのターゲットがサブスクリプションの`expiresAt + 3日`を超える |

カスタマーポータルの作成モーダルはこれらの理由を使用してライブプレビューを表示します（例：「18日間の証明書が発行されます。証明書はサブスクリプション終了日から3日以上先を超えられないためクランプされました。」）。これにより顧客は盲目的に送信せずに済みます。

### 適応型更新しきい値

オンプレミスの自動更新ループはLet's Encryptをモデルにした適応型しきい値を使用します：

```
effectiveThresholdDays = min(env.RENEW_THRESHOLD_DAYS, ceil(certValidityDays / 3))
```

15日間のCOMMUNITY証明書は残り5日で更新されます。90日間のBUSINESS証明書は残り14日で更新されます（環境設定の上限が適用）。120日間のENTERPRISE証明書も残り14日で更新されます。これにより短期の証明書が即座に更新をトリガーすることなく、長期の証明書には十分なバッファが確保されます。

## シングルアクティブ強制

サブスクリプションは**同時に最大1つのアクティブな委任証明書**を持てます（`MAX_ACTIVE_DELEGATION_CERTS_PER_SUBSCRIPTION = 1`）。

### なぜ1つか？

各オンプレミスインストールは独自のローカル発行台帳に対して`maxRepoLicenseIssuancesPerMonth`、`maxActivations`、チェーン整合性を強制します。オンプレミスは使用カウントを上流に同期しません - それがオフライン対応委任の利点です。

サブスクリプションが複数のアクティブな証明書（インストールごとに1つ）を持つ場合、各インストールが独立して制限を強制することになります：

- 月500回のサブスクリプションで3つのアクティブな証明書がある場合、実際には最大**1,500回/月**の発行が可能になります。
- 3つの並行するチェーンがそれぞれgenesisに紐づき、監査上の調整が不可能です。

オンプレミスはオフラインで動作するよう設計されているため、上流はこのバイパスを検出できません。**シングルアクティブは唯一強制可能なモデルです。** マルチインストール顧客（本番+ステージング+DR）はインストールごとに1つのサブスクリプションを購入する必要があります。

### 衝突時の動作

`POST /admin/delegation-certs`と`POST /portal/delegation-certs`は2つ目の作成リクエストを以下で拒否します：

```json
HTTP/1.1 409 Conflict
{
  "code": "DELEGATION_CERT_ALREADY_ACTIVE",
  "existingCertId": "...",
  "actions": {
    "renew": "POST /portal/delegation-certs/process-renewal-request (preserves chain)",
    "revokeAndCreate": "POST /portal/delegation-certs/{existingCertId}/revoke then retry create"
  }
}
```

カスタマーポータルは専用のダイアログで影響を説明します：

- **Renew（推奨）** - 既存のチェーンを延長します。以前に発行されたリポジトリライセンスはすべて引き続き機能します。
- **Revoke and Create** - 既存のチェーンを破棄し、genesisから新たに始めます。古い証明書の`validUntil`が過ぎると、以前に発行されたリポジトリライセンスは検証不可能になります。異なる署名キーを持つ新しいオンプレミスに移行した場合、または侵害されたキーから復旧する場合のみ使用してください。

`renew()`はシングルアクティブを保持するアトミックスワップであり、409の衝突確認の対象では**ありません**。

### レート制限

シングルアクティブでも、悪意ある呼び出し元が`revoke → create → revoke → create`をループして上流マスターキーの署名サイクルを消費できます。両方の作成エンドポイントは既存の`rateLimits`テーブルを使用して、サブスクリプションごとに**ローリング24時間あたり10回**に制限します：

```
HTTP/1.1 429 Too Many Requests
Retry-After: 78234
{ "code": "DELEGATION_CERT_RATE_LIMITED", "retryAfterSec": 78234 }
```

カウンターは結果に関わらずすべての試行で増加します（衝突スパムループも制限されます）。

## フォーク検出

顧客が委任証明書を別の当事者と共有した場合（または同じ証明書から2つのオンプレミスサーバーを実行した場合）、チェーンは分岐します。上流は更新時にこれを検出します。

### 更新フロー

1. オンプレミスアドミンが現在のチェーンヘッドを指定して`POST /admin/delegation-certs/renew`を呼び出します：
   ```
   { subscriptionId, currentChainHash, currentSequence, delegatedPublicKey }
   ```
2. 上流が自身の台帳記録に対してチェーンエントリを走査します。
3. `currentChainHash`が`currentSequence`での上流の記録済みチェーンと一致しない場合、フーク検出：
   ```
   409 { code: 'CHAIN_FORK_DETECTED', divergedAtSequence: N }
   ```
4. 新しい証明書の`genesisHash`は現在のチェーンハッシュに設定されるため、古いチェーン状態を持つマシンは中断した場所から続けられます。

証明書が非顧客と共有された場合：
- 証明書の有効期間中は使用できます。
- 最初の更新時に、上流は1つのチェーン（正規のもの）のみを確認します。
- 新しい証明書の`genesisHash`は正規のチェーンとのみ一致します。
- 共有チェーン上のマシンは、保存された`chainHash`が新しい証明書の`genesisHash`に接続しないため、新しいライセンスを即座に拒否します。

## エアギャップ更新

上流へのアウトバウンドHTTPSアクセスがないオンプレミスインストールの場合、更新フローは完全にオフラインです。ループを閉じる3つの新しいエンドポイントがあります：

**オンプレミス（`auth, root, requireElevated()`）：**
- `GET /onprem/cert-current` - 現在ロードされている署名済み証明書をダウンロード（バックアップ、監査、再インポート）
- `GET /onprem/renewal-request` - ローカルチェーンヘッドと委任公開鍵を含む署名済みマニフェストを生成（オンプレミス秘密鍵で署名）

**上流（アドミンまたはorg-scopedポータル）：**
- `POST /admin/delegation-certs/process-renewal-request`（クロスカスタマーのシステムルート）
- `POST /portal/delegation-certs/process-renewal-request`（orgオーナー/アドミン）

### 更新リクエストマニフェスト

更新リクエストは小さなJSONドキュメントです：

```json
{
  "manifest": {
    "schemaVersion": 1,
    "generatedAt": "2026-04-15T12:00:00.000Z",
    "subscriptionId": "...",
    "currentChainHash": "...",
    "currentSequence": 42,
    "delegatedPublicKey": "MCowBQYDK2VwAyEA...",
    "currentCertValidUntil": "...",
    "currentCertPublicKeyId": "...",
    "currentCertId": null
  },
  "signature": "<base64 Ed25519>",
  "publicKeyId": "..."
}
```

署名はオンプレミス秘密鍵を使用してマニフェストの正規エンコーディング（キーをアルファベット順にソートした後、`JSON.stringify`）に対して計算されます。これにより、オブジェクト構造の順序に関わらず両側が同じバイト列を計算することが保証されます。

### 上流での検証

`processRenewalManifest()`は5つの確認を実行します：

1. **アクティブな証明書が存在する**かをマニフェストのサブスクリプションに対して確認します。存在しない場合は`404 NO_ACTIVE_CERT`を返します - 顧客は更新ではなく作成フローを使用する必要があります。
2. **委任公開鍵がアクティブな証明書と一致する**かを確認します。一致しない場合は`400 DELEGATED_KEY_MISMATCH`を返します - 異なるオンプレミスからのリプレイを防ぎます。
3. **マニフェストの署名がアクティブな証明書の`delegatedPublicKey`に対して検証できる**かを確認します。できない場合は`400 MANIFEST_SIGNATURE_INVALID`を返します - マニフェストがオンプレミス秘密鍵の保持者から来たことを証明します。
4. **マニフェストの経過時間**が7日以内（`RENEWAL_MANIFEST_MAX_AGE_MS`）かを確認します。超過した場合は`400 MANIFEST_EXPIRED`を返します - リプレイ対策のアンカー。
5. **マニフェストの`currentSequence`でのチェーンハッシュリンク**が上流の台帳と一致するかを確認します。一致しない場合は`409 CHAIN_FORK_DETECTED`を返します - フォークしたチェーンを防ぎます。

すべての確認が通過すると、`processRenewalManifest`は既存の`renew()`フローを呼び出します。これは古い証明書をアトミックに失効させ、新しい証明書を挿入します。**これは作成側のシングルアクティブ409の対象ではありません** - アトミックスワップであり、2ステップのrevoke+createではないためです。

### 転送中のシーケンス進行

更新リクエストマニフェストは生成時のチェーンヘッドをキャプチャします。マニフェストが転送中（USB、暗号化メール）の間も、オンプレミスはリポジトリライセンスを発行し続け、ローカルチェーンを進めることができます。

新しい証明書がオンプレミスにアップロードされると、`/onprem/cert-upload`は新しい証明書の`genesisSequence`がローカル発行台帳の既知エントリにまだリンクしているかを検証します：

- `cert.genesisSequence > localHead.sequence`の場合は`409 CHAIN_HEAD_BEHIND`を返します（上流がフォークしたチェーンにある）。
- `cert.genesisSequence > 0`で、そのシーケンスのローカル台帳エントリの`chainHash`が`cert.genesisHash`と異なる場合は`409 CHAIN_FORK_ON_UPLOAD`を返します（ローカルチェーンが分岐した）。
- それ以外の場合、証明書は受け入れられます。将来の発行は`localHead.sequence + 1`から続きます。

これは**転送中に書き込みフリーズが不要**であることを意味します。チェーンは両側で自然に拡張されます。X.509証明書の更新が転送中のシリアル番号を処理する方法と同様です。

## 定期監査

上流は証明書を更新せずにチェーン整合性を検証するための監査エンドポイントを提供します：

```
POST /admin/delegation-certs/audit
{ subscriptionId, chainEntries: [{ sequence, chainHash }, ...] }
```

上流はエントリを走査し、`{ valid: true }`または`{ valid: false, divergedAtSequence: N, expected, actual }`を返します。

オンプレミスサーバーはフォークを早期に検出するために定期的にこのエンドポイントを呼び出す必要があります（デフォルト：`UPSTREAM_AUDIT_URL`環境変数で週次）。

### マシン側の監査証明

Renetは`VerifyAuditProof`を使用してチェーンの連続性をローカルで検証できます。マシンが長い間隔を経てライセンスを更新する場合、サーバーは証明として中間チェーンエントリを返すことができます。マシンはSHA-256でSHA-256の証明を走査し、各`chainHash`が前の`prevHash + blobHash`から導出されることを検証し、上流への問い合わせなしに改ざんを検出します。

## 並行処理の安全性

D1（CloudflareのデータベースI）はインタラクティブトランザクションをサポートしません。同じサブスクリプションに対する同時ライセンス発行はシーケンス番号で衝突する可能性があります。アカウントサーバーはこれを以下のように処理します：

1. 次のシーケンス番号と前のチェーンハッシュを読み取ります。
2. そのシーケンスを組み込んでblobを構築して署名します。
3. `onConflictDoNothing`で台帳エントリを挿入します。
4. 挿入で0行が変更された場合、別のリクエストがシーケンスを取得した - シーケンスを再取得し、再構築し、**再署名**してリトライします。
5. 10回失敗した後、エラーで失敗します。

重要な詳細：リトライは**blobを再署名します**。台帳エントリのみを更新する単純なリトライは、署名済みblobに古いシーケンス番号が残り、チェーンが壊れることになります。

## メールトランスポート

アカウントサーバーは2つのプラガブルなトランスポートを通じてトランザクションメール（マジックリンク、パスワードリセット、セキュリティ通知）を送信できます：

| トランスポート | 設定 |
|---|---|
| `ses`（デフォルト） | `AWS_SES_ACCESS_KEY_ID`、`AWS_SES_SECRET_ACCESS_KEY`、`AWS_SES_REGION`、`AWS_SES_FROM` |
| `smtp` | `EMAIL_TRANSPORT=smtp`、`SMTP_HOST`、`SMTP_PORT`、`SMTP_USER`、`SMTP_PASSWORD`、`SMTP_SECURE`、`SMTP_FROM` |

どちらのトランスポートもクラウドとオンプレミスのデプロイメントで機能します。インフラに合ったものを選択してください：独自のAWSアカウントでAWS SESを使用するか、任意のSMTPサーバー（Microsoft Exchange、Postfix、SendGrid、Mailgunなど）を使用するかです。

トランスポートは`EMAIL_TRANSPORT`環境変数によって起動時に選択されます。SMTPは接続プーリングと遅延ロードを使用するため、SMTPクライアントライブラリはSMTPが選択された場合のみ初期化されます。

すべてのメールテンプレートと公開メールAPIはトランスポート間で同一です。

## 関連ドキュメント

- [オンプレミスインストール](/en/docs/on-premise) - オンプレミスサーバーのデプロイ方法
- [サブスクリプションとライセンス](/en/docs/subscription-licensing) - プラン制限とマシンスロット
- [リリースチャネル](/en/docs/release-channels) - edgeとstableチャネル
- [データリージョン](/en/docs/data-regions) - リージョン別データレジデンシー