diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index b877816..45fa3c7 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -479,6 +479,20 @@ reply-URI destinations, and invocations without `--host` are rejected before
reading the configuration or starting SSH. Omitting the pair preserves the
existing default SSH behavior.
+### One-time pairing links (v1.3 candidate)
+
+Both endpoints need `pairing-offer-v1` and the existing Unix Python 3.11+ relay extra. These are candidate commands, not published v1.3 support:
+
+```sh
+session-peer device rotation-status --state /private/client-state
+session-peer device offer --state /private/device-state --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+Creation confirms both identities and shows the link only on the owner terminal. `--link` takes no value: input is hidden and bounded; the client independently confirms the receiver's current fingerprint before trust staging or networking. No terminal means refusal, not echo fallback or automatic consent. Never put a link in argv, browser history, logs or issue reports. Guided setup offers link/private-file choices; existing file commands remain supported. Cancellation affects only that offer's pending trust, not completed pairing; use explicit peer revocation after completion. Expired cold links are refused; only exact saved context can reconcile an already-consumed authenticated result. Pairing never replaces the receiving target policy or native approval. See the [paired-device guide](paired-devices.md) for key-rotation restrictions, expiry, quotas, interruption and the threat model.
+
### Updating
For package-managed installs, use the same manager that installed the command:
diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json
index 9dcecb1..39e570c 100644
--- a/docs/i18n-manifest.json
+++ b/docs/i18n-manifest.json
@@ -15,7 +15,7 @@
"docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c",
"docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d",
"docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f",
- "docs/cli-reference.md": "5d4a52e5eda0e3287e45fd1d8bd852acee76af9917e759431efd84d247a73b0e",
+ "docs/cli-reference.md": "7b1e6e8d12069b45e12b65ebc02e2317f42ba0fe2b27e386b70d4616cd21c581",
"docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103",
"docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889",
"docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481",
@@ -23,7 +23,7 @@
"docs/handoff-runtime.md": "80800abed85d4af1a2e2001448b39f6cf02ae12912cde0fde90c6863124e5220",
"docs/mcp.md": "a64010cdd63f2cde6a0052450bed2d05faa0f19c93c76143cb0d585d36f8ff62",
"docs/multi-home-list.md": "f8d8e3adf727e4842ea7c4d45e8f6523b11d0513bf2509d4d1204645875e6489",
- "docs/paired-devices.md": "44c70f99d3f8f8ff9f6f86d128913ada69f4cc4e794c724cfc214f7f8ea78f20",
+ "docs/paired-devices.md": "fc174600f246292b3b877a2321e1a21861ce6e5fa5c586f56c89a0bd7181907b",
"docs/relay-auth.md": "e6da5291ce6a9da31e79e66ceeff7e9fef7e95046359acbf04d708c96ffab62f",
"docs/relay-development-plan.md": "0abf750b36275ea4e467a0020e560277f08689f2d574aed7674133705383b1ff",
"docs/relay-edge-policy.md": "c1c7f7a1c4303943c78ca334dab4034f66c0c272e40c007d07c3da2a496155eb",
diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md
index 9c58d0f..62a1d73 100644
--- a/docs/ja/cli-reference.md
+++ b/docs/ja/cli-reference.md
@@ -434,6 +434,20 @@ session-peer doctor --host worker --ssh-config '/home/alice/SSH Profiles/work.co
環境変数、MCP リクエスト、ReplyTo アドレス、保存済みプリセットからは選択できません。Windows、`setup`、ペアリング済みデバイス経路、プリセットとの併用、返信 URI 宛先、`--host` のない呼び出しは、設定を読む前および SSH 起動前に拒否します。両フラグを省略した場合は従来の既定 SSH 動作を維持します。
+### ワンタイムペアリングリンク(v1.3候補)
+
+両端で `pairing-offer-v1` と既存のUnix Python 3.11+ relay追加依存関係が必要です。以下は候補コマンドであり、公開済みのv1.3対応ではありません。
+
+```sh
+session-peer device rotation-status --state /private/client-state
+session-peer device offer --state /private/device-state --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+作成時に双方のIDを確認し、所有者の端末だけにリンクを表示します。`--link` に値は付けません。上限付きの非表示入力を使い、クライアントが独立して確認した受信端末の現在の鍵のフィンガープリントを入力してから信頼登録・接続を行います。端末がなければ入力表示や自動承認に切り替えず拒否します。リンクを引数、ブラウザー履歴、ログ、Issueに残さないでください。設定ガイドはリンク・非公開ファイルを選択でき、既存のファイル用コマンドも維持します。取消しはそのオファーの保留信頼だけに適用され、完了済みペアリングには明示的な失効操作が必要です。履歴のない期限切れリンクは拒否し、完全一致する保存記録がある場合だけ完了済みの認証結果を確認できます。ペアリングは受信対象のポリシーやネイティブCLIの承認の代わりにはなりません。鍵更新の制限、期限、回数制限、中断、脅威モデルは[ペアリングガイド](paired-devices.md)を参照してください。
+
### 更新
diff --git a/docs/ja/paired-devices.md b/docs/ja/paired-devices.md
index f2fdca4..056da07 100644
--- a/docs/ja/paired-devices.md
+++ b/docs/ja/paired-devices.md
@@ -50,6 +50,56 @@ session-peer setup --mode relay --interactive --role client --no-browser
受信側はデバイス認証前の直接接続を最大 16 本、Relay に attach された接続を最大 2 本まで許可し、認証済み接続 8 本とは別に管理します。TLS はネットワークとスケジューリングの遅延を含めて 2 秒以内に完了する必要があります。TLS 後、クライアント証明書のない初期ペアリング接続には、単一のペアリング要求と応答を完了するためにさらに 2 秒が与えられます。認証済み要求の期限は 45 秒のままです。この短い期限は正常な遅い接続も拒否する場合があります。アイドル状態または TLS が遅い直接ソケット 8 本があっても、ペアリング済みの直接接続と Relay 要求を受け付ける余地が残ります。ただし、認証前の直接接続枠 16 本がすべて埋まると、ペアリング済みの直接接続も拒否される可能性があり、予約された Relay 枠 2 本も飽和する場合があります。任意のフラッド攻撃に対する可用性は保証されません。超過ソケットや受信側のシャットダウン開始後のソケットには処理タスクを作成しません。集計した入場カウンターは ID を含めずに拒否とクリーンアップ失敗を保持します。明示的に有効化した `receiver_admission` イベントは、固定カウンターと許可リスト内の理由を 1 分に最大 1 回報告します。アプリケーションの自動再試行は有効になりません。
+## ワンタイムペアリングリンク(v1.3候補)
+
+以下は開発候補であり、公開済みのv1.3機能ではありません。両端で新しい `pairing-offer-v1` プロトコルが必要です。従来の非公開招待ファイルも引き続き使えます。既存のUnix Python 3.11+とrelay追加依存関係を使用し、Windows対応、仲介サービス、Webページ、公開待受ポートやホステッドサービスは追加しません。
+
+各端末を初期化し、信頼できる経路で公開IDを確認します。`device rotation-status` は安定した端末IDと現在の鍵のフィンガープリントを表示します。鍵の更新後には両者が異なる場合があります。受信対象のポリシーは別途確認してください。ペアリングは全セッションへのアクセス権を付与したり、ネイティブCLIの承認手順を変更したり、エージェントの実行を許可したりするものではありません。
+
+受信端末で自身のIDと接続先クライアントの現在の鍵を明示的に確認し、制御端末にリンクを表示します。
+
+```sh
+session-peer device offer --state /private/device-state \
+ --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+```
+
+長い `session-peer://pair/v1#...` リンクを非公開で渡します。クライアントでは入力内容を表示しない端末プロンプトに入力してください。リンクを `--link` の後や他のコマンド引数に入れないでください。
+
+```sh
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+クライアントは自身の現在の鍵が指定された鍵と一致するかを確認し、候補の受信者IDや経路を表示する前に、独立して確認した現在のフィンガープリントを求めます。一致した場合だけ双方のIDと経路を表示し、最終確認を受けます。未確認の画面表示をコピーするようには誘導しませんが、入力の入手先を証明できるわけではありません。リンク自身のID表記を認証根拠にはしません。信頼登録とネットワーク接続は確認後に行い、受信者も作成時に確認済みクライアントIDをトランザクション内で再検査します。制御端末を利用できない場合、秘密を表示したり自動承認したりせず拒否します。設定ガイドのリンク・非公開ファイル選択も同じ確認手順を使います。自動化には既存のファイル用コマンドを利用できます。
+
+クライアントの安定IDと鍵世代は、完全一致する既存の有効な鍵登録から取得します。新しい鍵ならフィンガープリントと世代0を使用します。既知の保留・旧世代・失効済み鍵を新しい鍵とは扱いません。必要な既存登録がない更新済み受信者・クライアント鍵は、この方式では拒否します。フィンガープリントの入力だけで別の安定IDの権限は作れません。必要に応じて従来の信頼できるファイル・回復手順を使い、秘密鍵や端末状態をコピーしないでください。
+
+### 有効期限、取消し、中断後の確認
+
+オファーには256ビットの秘密値があり、受信端末のシステム時計で10分後に失効します。指定した現在の鍵1つに結び付きます。状態はopen、reserved、consumed、cancelled、expiredです。予約、認証済みの招待別確定、取消し、失効は受信端末のDBで直列化します。別の招待が保留登録を奪ったり、ペアリング済み端末を保留に戻したりすることはできません。取消しはそのオファーの保留信頼だけを除去し、既存の鍵登録や完了したペアリングは失効させません。完了後は該当端末で既存の明示的な `device revoke --peer RECEIVER-OR-CLIENT-PRINCIPAL` を使って信頼を取り消します。
+
+中断した確定結果は、オファーID、保護されたリンク内容のダイジェスト、受信端末ID・鍵、現在のクライアント鍵が一致する保存記録がある場合だけ確認できます。一致する履歴のない期限切れリンクは、接続前に拒否します。期限後の確認は認証済み確定要求で完了済み結果を取得するだけであり、再準備、延長、失効の解除はできません。受信者が結果を削除した後は、そのpeerがペアリング済みでも元のオファーの確定を確認できません。この実装には過去の結果再構成や期限後の修復コマンドはありません。双方の状態を保持し、運用者と明示的な回復計画を立ててください。無闇な鍵失効は回復を難しくする場合があります。ネイティブメッセージの送信や自動再送は行いません。
+
+作成は毎分5件、有効なオファーは8件までです。準備要求は全体で毎分32回、各オファーで毎分8回に永続的に制限し、失敗した推測も数えます。全体の制限は従来の非公開ファイル方式にも適用します。満杯の枠は拒否された試行で更新・延長しません。未知のIDごとに状態を作りません。終了結果は7日間保持し、オファー数の上限は1,024件です。削除可能なメタデータと招待行は一緒に整理します。未認証の負荷を含め、制限で正当なペアリングも一時的に拒否され得ます。ペアリング済みのネイティブ要求にはこの準備枠を使いません。これはリソース制限であり、IDや配送の証拠ではありません。受信端末の時計が信頼できることを前提とします。信頼変更の直前に期限を検査しますが、永続化の完了は後になる場合があります。クライアントの時計ずれで早く拒否されることもあります。
+
+クライアントの完全一致処理記録も1,024件に制限し、暗黙には追い出しません。上限でも既存の一致する記録は再確認できます。初期実装には自動削除も明示的なクライアント記録整理コマンドもなく、受信結果とは異なり7日後も残るため、新しいリンクでのペアリングが将来拒否され得ます。新規記録は `pairing_intent_capacity` で拒否し、結果確認の根拠を残します。更新前のファイル方式の保留登録は、1つの有効な招待、証明書、有効鍵、有効期限から所有関係を証明できる場合だけ引き継ぎます。曖昧または証明不能な有効保留状態は保持して拒否し、別の招待に割り当てず運用者の確認を求めます。確実に失効した保留信頼はトランザクション内で整理できますが、鍵登録は保持し、ペアリング済み・失効済みpeerは削除しません。別の有効な招待は保留予約を引き継げないため、ファイルを変える場合は失効まで待つ必要があり得ます。拒否を回避するために端末DBを削除したり元のネイティブメッセージを再送したりしないでください。
+
+設定ガイドでの作成が確認後に中断した場合、非公開の設定記録にはDB作成前から公開オファーIDが残ります。未作成が確定した拒否は再試行できます。不明な場合は以下の明示的な状態確認・取消しを使います。ID指定を省略すれば保存済みIDを使います。履歴がないことは未作成の証拠ではなく、結果が削除された可能性もあるためunknownです。状態確認では保留を続けます。履歴が見つからない保存済みIDを明示的に取り消しても、終了するのはローカルの待機だけで、遠隔の権限やペアリング済みの信頼を取り消す処理は行いません。返されたIDは次の明示的に確認された作成まで現行記録に残りますが、永続監査ログではありません。既知の有効オファーは明示的に取り消すまで保留、保持された終端結果なら意図を解消します。別のIDで解除はしません。手動表示で捕捉した失敗も状態確認・取消し用の公開IDを返しますが、プロセス障害後に残るのは設定ガイドの記録だけです。新しいオファーの自動作成や秘密値の再表示は行いません。
+
+復元・隔離状態では期限整理、削除、回数記録の前に回復フェンスを適用します。そのオファー状態は過去の診断証拠であり現在の権限ではなく、設定保留を解消できません。フェンス中のペアリング変更・取消しは拒否します。回復は別の明示的な手順です。
+
+```sh
+session-peer setup --mode relay --state /private/device-state --action offer-status --offer-id OFFER-UUID --apply
+session-peer setup --mode relay --state /private/device-state --action offer-cancel --offer-id OFFER-UUID --apply
+```
+
+### 脅威モデルと秘密値の扱い
+
+専用の接続仲介サーバーや短い数字コードは使いません。サイズを制限した独自URIには既存の招待と指定公開鍵が入り、ブラウザーで開いたり取得したりせず、OSのURLハンドラーも登録しません。リンクは認証情報です。URIの `#` 以降に含まれていても、端末の録画、クリップボードやブラウザーの履歴に残ります。アドレスバー、ログ、解析ツール、信頼できない参加者のいる会話、Issueに貼らないでください。通常のJSON、診断、設定ジャーナルにはリンクや生の秘密値を入れません。非公開の処理記録に残せるのは保護されたダイジェストと公開ID情報だけです。
+
+既存の証明書固定によるエンドツーエンドTLSとクライアント秘密鍵の所有証明は引き続き必須です。侵害された中間者は接続メタデータの観測、遅延、再生、サービス拒否ができますが、端末の秘密鍵やエージェントメッセージの平文は受け取りません。受信証明書の置換は独立したフィンガープリント確認で拒否されます。正規リンクが漏れても別のクライアント鍵を承認できません。指定された公開証明書を知る者は予約による妨害を試みられますが、所有証明やネイティブ対象の権限は得られません。端末・秘密鍵またはID確認経路の侵害は保護範囲外です。ホスト側の可用性、消費・ACK、exactly-onceについて新しい保証はしません。
+
## 接続失敗の診断と安全な接続再試行
`no_authenticated_route` は `retryAllowed:false` と `consumptionConfirmed:false` を維持します。`routeFailures` は経路ごとの最後の `stage`、許可リスト内の `reason`、`attempts` を示します。限定された `attemptHistory` は各試行の段階・理由・`elapsedMs` を保持し、HTTP 拒否には `httpStatus`、WebSocket の切断には `closeCode` が含まれる場合があります。制御、入場、アップグレード、attach、ピア TLS、probe の失敗を区別し、URL・認証情報・生の例外・メッセージ本文は記録しません。2 回目で成功した場合は `setupDegraded:true`、`setupAttempts:2`、`setupFailureHistory` が付き、正常な安定性試験の合格とは見なしません。
diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md
index 1616da5..6f5d496 100644
--- a/docs/ko/cli-reference.md
+++ b/docs/ko/cli-reference.md
@@ -433,6 +433,20 @@ session-peer doctor --host worker --ssh-config '/home/alice/SSH Profiles/work.co
환경 변수, MCP 요청, ReplyTo 주소, 저장된 프리셋에서는 이 모드를 선택할 수 없습니다. Windows, `setup`, 페어링된 기기 경로, 프리셋 조합, 응답 URI 대상, `--host` 없는 호출은 설정을 읽거나 SSH를 시작하기 전에 거부됩니다. 두 플래그를 생략하면 기존 기본 SSH 동작을 유지합니다.
+### 일회용 페어링 링크(v1.3 후보)
+
+양쪽 기기에 `pairing-offer-v1`과 기존 Unix Python 3.11+ relay 추가 의존성이 필요합니다. 다음은 후보 명령이며 공개된 v1.3 지원이 아닙니다.
+
+```sh
+session-peer device rotation-status --state /private/client-state
+session-peer device offer --state /private/device-state --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+생성 시 양쪽 신원을 확인하고 소유자의 터미널에만 링크를 표시합니다. `--link`에는 값을 붙이지 않습니다. 크기가 제한되고 표시되지 않는 입력을 사용하며, 클라이언트가 별도로 확인한 수신 기기의 현재 키 지문을 입력한 뒤 신뢰를 등록하고 접속합니다. 터미널이 없으면 입력 노출이나 자동 승인 대신 거부합니다. 링크를 명령 인자, 브라우저 기록, 로그나 이슈에 남기지 마십시오. 설정 안내는 링크·비공개 파일을 선택할 수 있으며 기존 파일 명령도 유지합니다. 취소는 해당 초대의 보류 신뢰만 제거합니다. 완료된 페어링은 별도의 명시적 철회가 필요합니다. 기록 없는 만료 링크는 거부하고, 정확히 일치하는 저장 기록이 있어야 이미 완료된 인증 결과만 확인할 수 있습니다. 페어링은 수신 대상 정책이나 네이티브 CLI의 승인을 대신하지 않습니다. 키 교체 제한, 만료·횟수 제한, 중단과 위협 모델은 [기기 페어링 안내](paired-devices.md)를 참조하십시오.
+
### 업데이트
diff --git a/docs/ko/paired-devices.md b/docs/ko/paired-devices.md
index ac5895e..96ebb68 100644
--- a/docs/ko/paired-devices.md
+++ b/docs/ko/paired-devices.md
@@ -50,6 +50,56 @@ probe·목록 메타데이터만 사용하며 소비·ACK·실행 승인·가용
수신자는 기기 인증 전 직접 연결을 최대 16개, Relay에 attach된 연결을 최대 2개 허용하며, 인증된 연결 8개와 별도로 관리합니다. TLS는 네트워크 및 스케줄링 지연을 포함해 2초 안에 완료해야 합니다. TLS 이후 클라이언트 인증서가 없는 초기 페어링 연결에는 단일 페어링 요청과 응답을 완료할 추가 2초가 주어지며, 인증된 요청의 제한 시간은 45초로 유지됩니다. 이 짧은 제한 시간은 정상적인 느린 연결도 거부할 수 있습니다. 유휴 또는 느린 TLS 직접 소켓 8개가 있어도 페어링된 직접 연결과 Relay 요청을 받을 여유가 남습니다. 그러나 직접 연결의 인증 전 슬롯 16개가 모두 차면 페어링된 직접 연결도 거부될 수 있고, 예약된 Relay 슬롯 2개도 포화될 수 있으므로 임의의 홍수 공격에서 가용성을 보장하지 않습니다. 초과 소켓과 수신자 종료가 시작된 뒤의 소켓에는 처리 태스크를 만들지 않습니다. 집계 입장 카운터는 신원 없이 거부 및 정리 실패를 보존하며, 명시적으로 켠 `receiver_admission` 이벤트는 고정 카운터와 허용 목록의 사유를 분당 최대 한 번 보고합니다. 애플리케이션 자동 재시도는 활성화되지 않습니다.
+## 일회용 페어링 링크(v1.3 후보)
+
+아래 링크 방식은 개발 후보이며 아직 공개된 v1.3 기능이 아닙니다. 양쪽 기기가 새 `pairing-offer-v1` 프로토콜을 지원해야 합니다. 기존 비공개 초대장 파일 방식도 유지합니다. 기존 Unix Python 3.11+와 relay 추가 의존성을 사용하며 Windows 지원, 중계 서비스, 웹 페이지, 공개 수신 포트나 호스팅 서비스를 새로 제공하지 않습니다.
+
+먼저 각 기기를 초기화하고 신뢰할 수 있는 경로로 공개 신원을 확인합니다. `device rotation-status`는 고정 기기 식별자와 현재 키 지문을 함께 표시합니다. 키 교체 후에는 두 값이 다를 수 있습니다. 수신 대상 정책은 별도로 검토해야 합니다. 페어링은 모든 세션의 접근 권한을 주거나 네이티브 CLI의 승인 절차를 바꾸거나 에이전트 실행을 승인하지 않습니다.
+
+수신 기기에서 자신의 신원과 연결할 클라이언트의 현재 키를 명시적으로 확인한 뒤 제어 터미널에 링크를 표시합니다.
+
+```sh
+session-peer device offer --state /private/device-state \
+ --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+```
+
+긴 `session-peer://pair/v1#...` 링크를 비공개로 전달합니다. 클라이언트에서는 입력 내용이 표시되지 않는 터미널 안내에 붙여 넣습니다. 링크를 `--link` 뒤나 다른 명령 인자에 넣지 마십시오.
+
+```sh
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+클라이언트는 자신의 현재 키가 초대에 지정된 키인지 확인하고, 상대 신원이나 연결 경로를 표시하기 전에 별도로 확인한 수신 기기의 현재 키 지문을 요구합니다. 입력이 일치한 뒤에만 양쪽 신원과 경로를 보여 주고 최종 확인을 받습니다. 신뢰하지 않은 화면 값을 그대로 복사하도록 유도하지 않지만, 사용자가 실제로 어디서 입력값을 얻었는지 증명하지는 못합니다. 링크에 적힌 신원만으로 상대를 신뢰하지 않습니다. 신뢰 등록과 네트워크 접속은 확인 뒤에 이루어지며, 수신 기기도 초대 생성 시 확인받은 클라이언트 신원을 DB 트랜잭션 안에서 다시 검사합니다. 제어 터미널을 사용할 수 없으면 입력을 노출하거나 자동 승인하는 대신 거부합니다. 설정 안내의 링크·비공개 파일 선택도 같은 확인 절차를 사용합니다. 자동화에는 기존 파일 명령을 계속 사용할 수 있습니다.
+
+클라이언트의 고정 식별자와 키 세대는 정확히 일치하는 기존 활성 키 등록에서 가져옵니다. 처음 보는 키라면 키 지문과 세대 0을 사용합니다. 이미 보류·이전·철회 상태인 키를 새 키로 취급하지 않습니다. 필요한 기존 등록이 없는 교체된 수신·클라이언트 키는 이 방식에서 거부합니다. 지문을 입력하는 것만으로 다른 고정 식별자의 권한을 만들 수 없습니다. 필요한 경우 기존의 신뢰할 수 있는 파일·복구 절차를 사용하고 개인 키나 기기 상태를 복사하지 마십시오.
+
+### 만료·취소와 중단 후 확인
+
+초대에는 256비트 비밀값이 있으며 수신 기기의 시스템 시계 기준 10분 후 만료됩니다. 지정한 현재 키 하나에만 연결됩니다. 상태는 open, reserved, consumed, cancelled, expired입니다. 예약, 인증된 초대별 확정, 취소와 만료는 수신 기기 DB에서 직렬 처리합니다. 다른 초대가 보류 등록을 가로채거나 이미 페어링된 기기를 보류 상태로 되돌릴 수 없습니다. 취소는 해당 초대가 만든 보류 신뢰만 제거하며 기존 키 등록이나 완료된 페어링은 철회하지 않습니다. 완료 후에는 해당 기기에서 기존의 명시적 `device revoke --peer RECEIVER-OR-CLIENT-PRINCIPAL` 명령으로 신뢰를 철회합니다.
+
+중단된 확정 결과는 초대 ID, 보호된 링크 내용 해시, 수신 기기 식별자·키와 현재 클라이언트 키가 모두 일치하는 저장 기록이 있어야 다시 확인할 수 있습니다. 일치하는 기록 없이 입력한 만료 링크는 네트워크 접속 전에 거부합니다. 만료 후에는 인증된 확정 요청으로 이미 완료된 결과만 확인할 수 있으며, 준비를 다시 하거나 초대를 연장하거나 철회를 취소하지 않습니다. 수신 기기가 결과를 정리한 뒤에는 상대를 페어링된 상태로 알고 있더라도 해당 초대의 완료를 확인할 수 없습니다. 이번 구현은 과거 결과 재구성이나 기간이 지난 복구 명령을 제공하지 않습니다. 양쪽 상태를 보존하고 운영자와 명시적인 복구 계획을 세우십시오. 무작정 키를 철회하면 복구가 더 어려워질 수 있습니다. 네이티브 메시지를 보내거나 자동 재전송하지 않습니다.
+
+생성은 분당 5개, 활성 초대는 8개로 제한합니다. 준비 요청은 전체 분당 32회, 초대별 분당 8회로 제한하고 실패한 추측도 제한 횟수에 포함합니다. 전체 제한은 기존 비공개 파일 페어링에도 적용합니다. 한도에 도달하면 거부된 시도 때문에 기록이나 제한 기간을 더 늘리지 않습니다. 알 수 없는 ID마다 새 상태를 만들지 않습니다. 종료 결과는 7일 보관하며 전체 초대는 1,024개로 제한합니다. 정리할 때 초대 메타데이터와 초대장 행을 함께 제거합니다. 인증 전 부하를 포함해 제한 때문에 정상 페어링도 일시적으로 거부될 수 있습니다. 이미 페어링된 네이티브 요청에는 이 준비 제한을 적용하지 않습니다. 이는 자원 제한이지 신원·전달 증거가 아니며 수신 기기의 시계가 신뢰할 수 있다는 전제입니다. 신뢰 상태 변경 직전에 만료를 검사하되 영구 저장 완료는 그 뒤일 수 있습니다. 클라이언트 시계 오차로 더 일찍 거부될 수도 있습니다.
+
+클라이언트의 정확한 작업 기록도 1,024개로 제한하며 조용히 축출하지 않습니다. 한도에 도달해도 기존의 일치하는 기록은 계속 확인할 수 있습니다. 초기 구현은 클라이언트 기록의 자동 정리나 명시적 정리 명령을 제공하지 않습니다. 수신 결과와 달리 7일 후에도 남으므로 언젠가 새로운 링크를 통한 페어링이 거부될 수 있습니다. 새 기록은 `pairing_intent_capacity`로 거부하여 결과 확인의 근거를 보존합니다. 업그레이드 전 파일 방식의 보류 등록은 유효한 초대 하나, 인증서, 활성 키와 만료 시각으로 소유 관계를 증명할 수 있을 때만 이어받습니다. 모호하거나 증명할 수 없는 유효한 보류 상태는 보존한 채 거부하고 운영자가 확인하도록 하며 다른 초대에 조용히 넘기지 않습니다. 확실히 만료된 보류 신뢰는 트랜잭션 안에서 정리할 수 있으나 키 등록은 유지하고 페어링·철회 상태의 기기는 제거하지 않습니다. 다른 유효한 파일 초대로 보류 예약을 넘겨받을 수 없어 초대를 바꾸려면 만료까지 기다려야 할 수 있습니다. 거부를 우회하려고 기기 DB를 지우거나 원래 메시지를 재전송하지 마십시오.
+
+설정 안내가 신원 확인 뒤 중단되면 비공개 설정 기록에 DB 생성 전부터 공개 초대 ID가 남습니다. 생성되지 않았음이 확정된 거부는 다시 시도할 수 있습니다. 결과가 불확실하면 아래의 명시적 설정 상태 조회·취소를 사용합니다. ID 옵션을 생략하면 저장된 ID를 사용합니다. 기록이 없다는 것은 미생성의 증거가 아닙니다. 결과가 정리됐을 수도 있으므로 알 수 없음으로 표시합니다. 상태 조회는 계속 보류하고, 이력을 찾을 수 없는 저장 ID를 명시적으로 취소하면 로컬 대기만 끝내며 원격 권한이나 페어링 신뢰를 철회하는 동작은 수행하지 않습니다. 반환한 ID는 새로 명시적으로 확인한 생성 작업이 교체하기 전까지 현재 기록에 남으며 영구 감사 기록은 아닙니다. 확인된 활성 초대는 명시적으로 취소할 때까지 보류하고 보관된 종료 결과는 해당 작업을 정리합니다. 다른 ID로 해제하지 않습니다. 수동 표시 중 잡힌 오류도 상태 조회·취소에 쓸 공개 ID를 반환하지만 프로세스 장애에도 남는 것은 설정 안내의 기록뿐입니다. 새 초대를 자동 생성하거나 비밀값을 다시 표시하지 않습니다.
+
+복원·격리 상태는 만료 정리, 결과 삭제나 횟수 기록 전에 복구 차단을 적용합니다. 그 상태의 초대 조회는 과거 진단 자료이며 현재 권한이 아니므로 설정 작업을 해제하는 근거로 쓰지 않습니다. 차단 중에는 페어링 변경과 취소를 거부합니다. 복구는 별도의 명시적 절차입니다.
+
+```sh
+session-peer setup --mode relay --state /private/device-state --action offer-status --offer-id OFFER-UUID --apply
+session-peer setup --mode relay --state /private/device-state --action offer-cancel --offer-id OFFER-UUID --apply
+```
+
+### 위협 모델과 비밀값 취급
+
+별도 연결 중개 서버나 짧은 숫자 코드는 사용하지 않습니다. 크기가 제한된 전용 URI에는 기존 초대장과 지정한 공개 키가 들어갑니다. 브라우저에서 열거나 가져오지 않으며 OS URL 처리기도 설치하지 않습니다. 링크는 자격 정보입니다. URI의 `#` 뒤에 담겨 있어도 터미널 녹화, 클립보드·브라우저 기록에 남을 수 있습니다. 주소 표시줄, 로그, 분석 도구, 신뢰할 수 없는 참여자가 있는 대화나 이슈에 붙여 넣지 마십시오. 일반 JSON, 진단과 설정 기록에는 링크나 원시 비밀값을 넣지 않습니다. 비공개 작업 기록에는 보호된 해시와 공개 신원 정보만 남길 수 있습니다.
+
+기존의 인증서 고정 방식 종단 간 TLS와 클라이언트 개인 키 소유 증명은 그대로 필요합니다. 침해된 중간 서버는 연결 메타데이터를 관찰하거나 지연·재생·서비스 거부를 할 수 있지만 기기 개인 키나 에이전트 메시지 평문을 받지 않습니다. 수신 인증서를 바꾸면 별도 지문 확인에서 거부됩니다. 정상 링크가 유출되어도 다른 클라이언트 키를 승인하지 못합니다. 지정한 공개 인증서를 아는 공격자는 예약을 시도해 서비스를 방해할 수 있지만 키 소유 증명이나 원래 대상 접근 권한을 얻지는 못합니다. 기기·개인 키 또는 신원 확인 경로의 침해는 이 보호 범위 밖입니다. 호스팅 가용성, 소비·ACK 또는 정확히 한 번 처리에 대한 새 보장은 하지 않습니다.
+
## 연결 실패 진단과 안전한 연결 재시도
`no_authenticated_route`는 `retryAllowed:false`, `consumptionConfirmed:false`를 유지합니다. `routeFailures`는 경로별 마지막 `stage`, 허용 목록의 `reason`, `attempts`를 표시합니다. 제한된 `attemptHistory`는 각 시도의 단계·사유·`elapsedMs`를 보존하며 HTTP 거부에는 `httpStatus`, WebSocket 종료에는 `closeCode`가 포함될 수 있습니다. 제어, 입장, 업그레이드, attach, 피어 TLS 및 probe 실패를 구분하되 URL·자격증명·원문 예외·메시지 본문은 기록하지 않습니다. 두 번째 연결이 성공하면 `setupDegraded:true`, `setupAttempts:2`, `setupFailureHistory`가 표시되며 깨끗한 안정성 통과로 계산하지 않습니다.
diff --git a/docs/paired-devices.md b/docs/paired-devices.md
index 0828ad6..aa63b51 100644
--- a/docs/paired-devices.md
+++ b/docs/paired-devices.md
@@ -73,6 +73,56 @@ admission counters retain refusals and cleanup failures without identities;
opt-in `receiver_admission` events report fixed counters and allowlisted reasons
at most once per minute. No automatic application retry is enabled.
+## One-time pairing links (v1.3 candidate)
+
+The link workflow below is a development candidate, not a published v1.3 release. Both endpoints need the new `pairing-offer-v1` protocol. Existing private invitation files remain supported. This feature uses the existing Unix Python 3.11+ relay extra; it adds no Windows support, broker, web page, public listener or hosted service.
+
+First initialize each endpoint and exchange their public identities through a trusted channel. `device rotation-status` shows the current key fingerprint as well as the stable device principal. These may differ after rotation. Review the receiving target policy separately: pairing does not grant access to every session, change native approvals or authorize agent execution.
+
+On the receiver, explicitly confirm its identity and the intended client's current key, then display the link on the controlling terminal:
+
+```sh
+session-peer device offer --state /private/device-state \
+ --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+```
+
+Transfer the long `session-peer://pair/v1#...` link privately. On the client, enter it at the hidden terminal prompt; never place the link after `--link` or elsewhere in command arguments:
+
+```sh
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+The client verifies that its own current key matches the bound client key and asks for the receiver's current fingerprint obtained independently, before showing the candidate receiver identity or routes. Only a matching entry reveals both identities and routes for final confirmation. This avoids inviting users to copy an untrusted candidate from the prompt, but cannot prove where they obtained their input. A link cannot authenticate its own claimed identity. Confirmation precedes trust staging and network access; the receiver also rechecks the confirmed client identity atomically when creating the offer. Without a usable controlling terminal, the link workflow refuses rather than echoing a secret or accepting an automatic approval. Guided setup offers a link/private-file choice and uses the same confirmation path; the existing file commands remain available for automation.
+
+The intended principal/generation is derived from an exact active local key anchor, or fingerprint/generation zero for a new key. A known pending, retired or revoked key is not treated as new. A rotated receiver or client without the required existing binding is refused in this workflow; a typed fingerprint cannot manufacture a stable principal's authority. Use the existing trusted-file/recovery procedure where appropriate, not copied private keys or device state.
+
+### Lifecycle and interruption
+
+An offer carries a 256-bit secret, expires after ten minutes according to the receiver's system clock, and is bound to one intended current key. Its states are open, reserved, consumed, cancelled and expired. Reservation, authenticated invitation-bound commit, cancellation and expiry are serialized in the receiver database. A second invitation cannot take over an owned pending reservation, and an already paired device is never downgraded to pending. Cancellation removes only that offer's pending trust, not pre-existing key anchors or a completed pairing. After completion, use the existing explicit `device revoke --peer RECEIVER-OR-CLIENT-PRINCIPAL` on the relevant endpoint to revoke trust.
+
+Only a saved intent matching the offer ID, protected envelope digest, receiver principal/key and current client key can reconcile an interrupted completion. An expired link entered without that matching history is refused before networking. Expired reconciliation can only obtain the already-consumed result through authenticated commit; it cannot prepare again, renew the offer or undo revocation. After the receiver prunes that receipt, exact-offer reconciliation is unavailable even if the receiver still considers the peer paired. This release does not reconstruct historical receipts or provide a late-repair command. Preserve both endpoints' state and seek an explicit operator recovery plan; blindly revoking a key can make recovery harder. No native message is sent or automatically retried.
+
+Creation is limited to five offers/minute and eight active offers. Prepare attempts are persistently limited to 32/minute globally and eight/minute per offer; failed guesses count. The global limit also applies to existing private-file pairing. A full bucket is not updated or extended by rejected attempts. Unknown IDs allocate no per-ID state. Terminal receipts are retained for seven days, with a 1,024-offer cap; eligible offer metadata and invitations are pruned together. Limits can temporarily refuse legitimate pairing, including under unauthenticated load; already-paired native requests do not use these prepare buckets. These are resource limits, not identity or delivery evidence, and assume a trustworthy receiver clock. The receiver checks expiry immediately before trust-changing decisions; durable commit can finish later. Client clock skew can reject earlier.
+
+The client also caps exact-intent records at 1,024 without silent eviction; an existing matching intent may still resume at capacity. This initial implementation has no automatic pruning or explicit client-intent cleanup command: unlike receiver receipts, records are not removed after seven days, so new link pairings can eventually be refused. A full history refuses new intents with `pairing_intent_capacity` rather than discarding reconciliation evidence. During an upgrade, an old pending file-based reservation is adopted only when one live invitation, certificate, active key and expiry prove its ownership. Ambiguous or unprovable live pending state is preserved and refused for operator reconciliation, not silently reassigned. Provably expired pending trust can be retired transactionally while preserving key anchors; paired and revoked peers are not removed. A different live file invitation cannot take over a pending reservation, so changing invitations may require waiting for expiry. Do not erase the device database or retry an original native message to work around a refusal.
+
+If guided offer creation stops after confirmation, the private setup journal retains a public offer ID before database creation. A definitive creation refusal is retryable. For uncertain outcomes, use the explicit setup status/cancel actions below; they can use the saved ID if the option is omitted. Missing history is unknown, not proof of non-creation: a receipt may have been pruned. Status keeps waiting; explicit cancellation of a missing saved ID stops only local waiting, not remote authority or paired trust. The returned ID remains in the current journal until a new explicitly confirmed creation replaces it; this is not a permanent audit log. A known active offer remains pending until explicit cancellation, and a retained terminal result resolves that intent. A different ID cannot clear it. A caught manual display failure also returns its public ID for status/cancel; only the guided journal survives a process crash. No action automatically creates another offer or reprints its secret.
+
+Restored/quarantined state remains recovery-gated before expiry cleanup, pruning or quota writes. Its offer status is historical diagnostic evidence only, not current authority, and cannot resolve guided setup. Pairing mutation and cancellation are refused while the fence is present; recovery remains a separate explicit procedure.
+
+```sh
+session-peer setup --mode relay --state /private/device-state --action offer-status --offer-id OFFER-UUID --apply
+session-peer setup --mode relay --state /private/device-state --action offer-cancel --offer-id OFFER-UUID --apply
+```
+
+### Threat model and secret handling
+
+There is no rendezvous broker or short numeric code. The bounded custom-URI envelope carries the existing invitation and intended public key; it is not fetched or opened in a browser, and no OS URL handler is installed. The link is a credential: terminal recording, clipboard history and browser history can retain it even though its payload follows the URI's `#` fragment delimiter. Never paste it into an address bar, logs, analytics, chats with untrusted participants or issue reports. Ordinary JSON, diagnostics and setup journals do not contain the link or raw secret; a private intent may retain only its protected digest and public identity context.
+
+Existing pinned end-to-end TLS and client private-key possession remain mandatory. A compromised intermediary can delay, replay or deny service and observe routing metadata, but receives neither endpoint private keys nor agent-message plaintext. Replacing a receiver certificate fails independent fingerprint confirmation. A leaked genuine link cannot authorize a different client key. Knowledge of the intended public certificate may permit reservation/denial of service, but not possession proof or native access. Endpoint/private-key or trusted-confirmation-channel compromise is outside this protection. No new hosted availability, consumption, ACK or exactly-once guarantee is claimed.
+
## Connection failures and safe setup retry
`no_authenticated_route` retains `retryAllowed:false` and
diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md
index 656b219..c0731a9 100644
--- a/docs/zh-CN/cli-reference.md
+++ b/docs/zh-CN/cli-reference.md
@@ -318,6 +318,20 @@ session-peer doctor --host worker --ssh-config '/home/alice/SSH Profiles/work.co
环境变量、MCP 请求、ReplyTo 地址和已存储预设不能选择此模式。Windows、`setup`、配对设备路由、预设组合、回复 URI 目标以及未指定 `--host` 的调用,会在读取配置或启动 SSH 前被拒绝。省略这对标志时保留现有默认 SSH 行为。
+### 一次性配对链接(v1.3候选)
+
+两端都需要 `pairing-offer-v1` 及现有Unix Python 3.11+ relay额外依赖。以下为候选命令,不代表已发布的v1.3支持:
+
+```sh
+session-peer device rotation-status --state /private/client-state
+session-peer device offer --state /private/device-state --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+创建时核对双方身份,只在所有者终端显示链接。`--link` 不接值;使用长度受限的不回显输入,客户端独立核对接收端当前密钥指纹后,才登记信任并联网。无终端时拒绝,不改为回显或自动批准。不要把链接放进参数、浏览器历史、日志或Issue。设置向导提供链接/私密文件选项,现有文件命令仍受支持。取消只影响该提议的待定信任;已完成的配对须显式吊销。没有历史的过期链接会被拒绝,只有完全匹配的保存上下文才能核对已经完成的认证结果。配对不替代接收目标策略或原生CLI的审批。密钥轮换限制、有效期、频率限制、中断与威胁模型见[配对设备指南](paired-devices.md)。
+
### 更新
diff --git a/docs/zh-CN/paired-devices.md b/docs/zh-CN/paired-devices.md
index 4f128e6..86a4132 100644
--- a/docs/zh-CN/paired-devices.md
+++ b/docs/zh-CN/paired-devices.md
@@ -45,6 +45,56 @@ NAT 穿透、WireGuard 隧道或自动公开服务。
接收端在设备认证前最多允许 16 条直接连接和 2 条已 attach 的 Relay 连接,与 8 条已认证连接分别管理。TLS 必须在两秒内完成,包含网络和调度延迟。TLS 完成后,没有客户端证书的初始配对连接还有两秒用于完成唯一一次配对请求及响应;已认证请求的期限仍为 45 秒。这些较短的期限也可能拒绝合法的慢速连接。存在 8 个空闲或慢速 TLS 直接套接字时,仍有空间接收已配对设备的直接连接和 Relay 请求。但全部 16 个直接连接认证前名额耗尽时,仍可能拒绝已配对设备的直接连接,两个预留的 Relay 名额也可能饱和;不保证在任意洪水攻击下的可用性。超额套接字或接收端开始关闭后的套接字不会创建处理任务。汇总准入计数器保留拒绝和清理失败数量,不包含身份;显式启用的 `receiver_admission` 事件每分钟最多报告一次固定计数器和允许列表内的原因。不会启用应用程序自动重试。
+## 一次性配对链接(v1.3候选)
+
+以下流程是开发候选,并非已经发布的v1.3功能。两端都需要新的 `pairing-offer-v1` 协议。现有私密邀请文件仍然受支持。该功能沿用Unix Python 3.11+及relay额外依赖,不增加Windows支持、中介服务、网页、公开监听端口或托管服务。
+
+先初始化两端设备,并通过可信渠道核对公开身份。`device rotation-status` 会显示稳定设备标识和当前密钥指纹;轮换密钥后两者可能不同。接收目标策略须单独审核。配对不会授予所有会话的访问权限,不会改变原生CLI的审批流程,也不会授权代理执行。
+
+在接收端明确核对自身身份及目标客户端的当前密钥,然后在控制终端显示链接:
+
+```sh
+session-peer device offer --state /private/device-state \
+ --peer-key CLIENT-CURRENT-64-HEX-KEY --direct PRIVATE-IP:3770 --show-link
+```
+
+私下传递较长的 `session-peer://pair/v1#...` 链接。客户端在不回显的终端提示中输入,不要把链接放在 `--link` 后面或任何命令参数中:
+
+```sh
+session-peer device pair --state /private/client-state --link --route direct
+session-peer device offer-status --state /private/device-state --offer-id OFFER-UUID
+session-peer device offer-cancel --state /private/device-state --offer-id OFFER-UUID
+```
+
+客户端检查自身当前密钥是否等于指定密钥,并在显示候选接收端身份或路径之前,要求输入通过独立渠道核实的当前密钥指纹。只有匹配后才显示双方身份和路径,进行最终确认。这避免引导用户复制未核实的屏幕值,但不能证明输入的真实来源。链接不能自行证明身份。信任登记和网络连接在确认后进行;接收端创建提议时也会在事务内重新核对已确认的客户端身份。无法使用控制终端时会拒绝,不会回显秘密或自动批准。设置向导的链接/私密文件选项使用相同确认流程;自动化仍可使用现有文件命令。
+
+客户端的稳定标识和密钥代数,只能来自完全匹配的既有有效密钥绑定;首次见到的密钥使用其指纹和代数0。已知处于待定、旧代或吊销状态的密钥不能按新密钥处理。若缺少所需既有绑定,该流程会拒绝轮换后的接收端或客户端身份;输入指纹不能凭空授予其他稳定标识的权限。必要时使用原有可信文件或恢复流程,不要复制私钥或设备状态。
+
+### 过期、取消与中断后的核对
+
+配对提议带有256位秘密,按接收端系统时钟在10分钟后过期,并绑定一个指定的当前密钥。状态为open、reserved、consumed、cancelled和expired。预留、经过认证且绑定邀请的提交、取消和过期在接收端数据库中串行处理。另一份邀请不能接管待定预留,已配对设备也不会降级为待定。取消只移除该提议拥有的待定信任,不吊销既有密钥绑定或已经完成的配对。完成后,应在相应端点使用现有显式命令 `device revoke --peer RECEIVER-OR-CLIENT-PRINCIPAL` 吊销信任。
+
+只有保存的提议ID、受保护的链接内容摘要、接收端标识/密钥及当前客户端密钥全部匹配,才能核对中断的提交结果。没有匹配历史的过期链接会在联网前被拒绝。过期后的核对只能通过认证提交取得已经完成的结果,不能重新准备、延期或撤销吊销。接收端清理结果后,即使仍将该peer视为已配对,也无法再核对原提议;此实现不重建历史回执,也没有超期修复命令。保留双方状态,与操作者明确制定恢复计划;盲目吊销密钥可能使恢复更困难。流程不会发送原生消息,也不会自动重发。
+
+创建限制为每分钟5份、同时最多8份有效提议。准备请求持久限制为全局每分钟32次、每份提议每分钟8次,错误猜测也计数;全局限制同样适用于既有私密文件配对。达到上限后,拒绝尝试不会更新或延长该窗口。未知ID不会分配独立状态。终态结果保留7天,提议总数上限为1,024份;清理时将符合条件的提议元数据和邀请行一并移除。包括未认证负载在内,限制可能暂时拒绝正常配对。已配对的原生请求不使用此准备额度。这些是资源限制,不是身份或投递证据,并以接收端时钟可信为前提。接收端在信任变更决策前检查过期,持久提交可能稍后完成;客户端时钟偏差也可能使请求提前被拒绝。
+
+客户端的精确意图记录也限于1,024份,不会静默淘汰;到达上限时,既有匹配记录仍可继续核对。初始实现既不自动清理,也不提供显式客户端记录清理命令;与接收结果不同,7天后仍然保留,因此将来可能拒绝新的链接配对。新记录以 `pairing_intent_capacity` 拒绝,保留结果核对证据。升级前的文件配对待定状态,只有在唯一有效邀请、证书、有效密钥及过期时间证明归属时才接管;不明确或无法证明的有效待定状态会保留并拒绝,交由操作者核对,不会静默转交另一邀请。明确过期的待定信任可在事务内清理,但保留密钥绑定,不移除已配对或已吊销peer。另一份有效文件邀请不能接管待定预留,因此更换邀请可能需要等待过期。不要删除设备数据库或重发原始原生消息来绕过拒绝。
+
+若设置向导在确认后中断,私密设置日志会在数据库创建前保存公开提议ID。确定没有创建的拒绝可重试。结果不明确时,使用以下显式状态/取消操作;省略ID选项时使用保存的ID。没有历史不是未创建的证据,也可能是结果已清理,应视为未知。状态查询仍保持待定;对于找不到历史记录的已保存ID,显式取消操作只停止本地等待,不执行撤销远端权限或已配对信任的操作。返回ID在下一次明确确认的创建前保留为当前记录,但并非永久审计日志。已知有效提议保持待定直到明确取消;保留的终态可解除对应意图,其他ID不能清除。捕获到手动显示失败时也返回可供状态/取消使用的公开ID;只有向导日志能跨越进程崩溃保留。不会自动创建新提议或重新显示秘密。
+
+恢复或隔离状态在过期清理、删除及计数写入前就受恢复隔离保护。其提议状态只是历史诊断证据,不是当前权限,不能解除向导待定状态。隔离期间拒绝配对变更及取消,恢复仍须单独明确执行。
+
+```sh
+session-peer setup --mode relay --state /private/device-state --action offer-status --offer-id OFFER-UUID --apply
+session-peer setup --mode relay --state /private/device-state --action offer-cancel --offer-id OFFER-UUID --apply
+```
+
+### 威胁模型与秘密处理
+
+不使用会合中介或短数字码。长度受限的专用URI携带既有邀请和指定公钥,不会在浏览器中打开或抓取,也不注册操作系统URL处理器。链接属于凭据:即使内容位于URI的 `#` 后,终端录制、剪贴板或浏览器历史仍可能保留它。不要粘贴到地址栏、日志、分析工具、有不可信参与者的聊天或Issue中。常规JSON、诊断和设置日志不包含链接或原始秘密;私密意图记录只能保存受保护摘要和公开身份上下文。
+
+既有的证书固定端到端TLS与客户端私钥持有证明仍是必需条件。被攻陷的中间节点可观察连接元数据、延迟、重放或拒绝服务,但不会得到端点私钥或代理消息明文。替换接收证书会被独立指纹核验拒绝。真实链接泄漏也不能授权其他客户端密钥。知道指定公开证书的人可能通过预留干扰服务,但无法获得密钥持有证明或原生目标权限。端点、私钥或身份确认渠道被攻陷不在此保护范围内。不增加托管可用性、消费确认、ACK或恰好一次处理保证。
+
## 连接失败诊断与安全的连接重试
`no_authenticated_route` 保留 `retryAllowed:false` 和 `consumptionConfirmed:false`。`routeFailures` 给出每条失败路径最后一次的 `stage`、允许列表中的 `reason` 和 `attempts`。有界的 `attemptHistory` 保留每次尝试的阶段、原因和 `elapsedMs`;HTTP 拒绝还可能包含 `httpStatus`,WebSocket 关闭可能包含 `closeCode`。控制、准入、升级、attach、对端 TLS 和 probe 故障可以区分,但不会记录 URL、凭据、原始异常或消息正文。第二次尝试成功时会有 `setupDegraded:true`、`setupAttempts:2` 和 `setupFailureHistory`,不能算作干净的稳定性测试通过。
diff --git a/session_peer.py b/session_peer.py
index c6d457b..28fd74b 100644
--- a/session_peer.py
+++ b/session_peer.py
@@ -10317,8 +10317,20 @@ def _get_values(self, action, arg_strings):
class HumanArgumentParser(MessageArgumentParser):
+ def parse_known_args(self, args=None, namespace=None):
+ values = list(sys.argv[1:] if args is None else args)
+ # Reject accidental credential argv before argparse can quote values in
+ # unknown-option, missing-argument or invalid-choice diagnostics.
+ if (self.prog in ('session-peer', 'session-peer device', 'session-peer setup')
+ and any('session-peer://pair/' in value.lower() for value in values)
+ and (self.prog != 'session-peer' or (values and values[0] in ('device', 'setup')))):
+ self.error('pairing_link_argv_forbidden; use --link without a value')
+ return super().parse_known_args(values, namespace)
+
def error(self, message):
# Preserve both the message-value and display-only escaping contracts.
+ if 'session-peer://pair/' in message.lower():
+ message = 'pairing_link_argv_forbidden; use --link without a value'
super().error(human_text(message))
@@ -10540,7 +10552,7 @@ def add_common(sub: argparse.ArgumentParser) -> None:
add_common(setup)
setup.add_argument('--mode', choices=('local', 'ssh', 'relay'))
setup.add_argument('--interactive', action='store_true', help='ask before each setup mutation; EOF/Ctrl-C cancels')
- setup.add_argument('--action', choices=('plan', 'init', 'login', 'enroll', 'targets', 'policy', 'invite', 'pair', 'ready', 'receiver', 'cancel'), default='plan')
+ setup.add_argument('--action', choices=('plan', 'init', 'login', 'enroll', 'targets', 'policy', 'invite', 'offer', 'offer-status', 'offer-cancel', 'pair', 'ready', 'receiver', 'cancel'), default='plan')
setup.add_argument('--apply', action='store_true', help='explicitly approve the selected setup action, never a message send')
setup.add_argument('--state')
setup.add_argument('--server')
@@ -10557,7 +10569,12 @@ def add_common(sub: argparse.ArgumentParser) -> None:
setup.add_argument('--antigravity-home')
setup.add_argument('--direct')
setup.add_argument('--relay', help='receiver WSS connect URL, not an account-login URL')
- setup.add_argument('--invite', help='private existing invitation file for a client')
+ source = setup.add_mutually_exclusive_group()
+ source.add_argument('--invite', help='private existing invitation file for a client')
+ source.add_argument('--link', action='store_true', help='read private pairing link from an echo-disabled terminal; no argument value')
+ setup.add_argument('--peer-key', help='intended client current certificate fingerprint, confirmed independently')
+ setup.add_argument('--show-link', action='store_true', help='explicitly display a private offer link on the owner terminal')
+ setup.add_argument('--offer-id', help='public offer UUID for status/cancellation')
setup.add_argument('--out', help='new private invitation file for a receiver')
setup.add_argument('--route', choices=('auto', 'direct', 'relay'), default='auto')
setup.add_argument('--seconds', type=int, choices=range(1, 86401), default=3600, metavar='SECONDS')
diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py
index 88a1253..cf9cfa0 100644
--- a/session_peer_core/cli.py
+++ b/session_peer_core/cli.py
@@ -12,8 +12,20 @@ def _get_values(self, action, arg_strings):
class HumanArgumentParser(MessageArgumentParser):
+ def parse_known_args(self, args=None, namespace=None):
+ values = list(sys.argv[1:] if args is None else args)
+ # Reject accidental credential argv before argparse can quote values in
+ # unknown-option, missing-argument or invalid-choice diagnostics.
+ if (self.prog in ('session-peer', 'session-peer device', 'session-peer setup')
+ and any('session-peer://pair/' in value.lower() for value in values)
+ and (self.prog != 'session-peer' or (values and values[0] in ('device', 'setup')))):
+ self.error('pairing_link_argv_forbidden; use --link without a value')
+ return super().parse_known_args(values, namespace)
+
def error(self, message):
# Preserve both the message-value and display-only escaping contracts.
+ if 'session-peer://pair/' in message.lower():
+ message = 'pairing_link_argv_forbidden; use --link without a value'
super().error(human_text(message))
@@ -235,7 +247,7 @@ def add_common(sub: argparse.ArgumentParser) -> None:
add_common(setup)
setup.add_argument('--mode', choices=('local', 'ssh', 'relay'))
setup.add_argument('--interactive', action='store_true', help='ask before each setup mutation; EOF/Ctrl-C cancels')
- setup.add_argument('--action', choices=('plan', 'init', 'login', 'enroll', 'targets', 'policy', 'invite', 'pair', 'ready', 'receiver', 'cancel'), default='plan')
+ setup.add_argument('--action', choices=('plan', 'init', 'login', 'enroll', 'targets', 'policy', 'invite', 'offer', 'offer-status', 'offer-cancel', 'pair', 'ready', 'receiver', 'cancel'), default='plan')
setup.add_argument('--apply', action='store_true', help='explicitly approve the selected setup action, never a message send')
setup.add_argument('--state')
setup.add_argument('--server')
@@ -252,7 +264,12 @@ def add_common(sub: argparse.ArgumentParser) -> None:
setup.add_argument('--antigravity-home')
setup.add_argument('--direct')
setup.add_argument('--relay', help='receiver WSS connect URL, not an account-login URL')
- setup.add_argument('--invite', help='private existing invitation file for a client')
+ source = setup.add_mutually_exclusive_group()
+ source.add_argument('--invite', help='private existing invitation file for a client')
+ source.add_argument('--link', action='store_true', help='read private pairing link from an echo-disabled terminal; no argument value')
+ setup.add_argument('--peer-key', help='intended client current certificate fingerprint, confirmed independently')
+ setup.add_argument('--show-link', action='store_true', help='explicitly display a private offer link on the owner terminal')
+ setup.add_argument('--offer-id', help='public offer UUID for status/cancellation')
setup.add_argument('--out', help='new private invitation file for a receiver')
setup.add_argument('--route', choices=('auto', 'direct', 'relay'), default='auto')
setup.add_argument('--seconds', type=int, choices=range(1, 86401), default=3600, metavar='SECONDS')
diff --git a/session_peer_relay/app.py b/session_peer_relay/app.py
index 2d255a1..a7a8c48 100644
--- a/session_peer_relay/app.py
+++ b/session_peer_relay/app.py
@@ -88,7 +88,8 @@ async def dispatch(self, peer, value):
if value.get('op') == 'pair.prepare' and peer is None:
return self.store.prepare(value)
if value.get('op') == 'pair.commit' and peer:
- return self.store.commit(peer)
+ return self.store.commit(peer, value.get('invitation'), authenticated_key=peer,
+ offer=value.get('offer') is True)
key = peer
peer = self.store.principal(key)
self.store.authorize(peer, value.get('op'))
@@ -365,51 +366,126 @@ async def open_channel(store, certificate, routes, route, credential=None, boots
raise
-async def pair(store, invite, route, credential=None):
+class PairingWindowExpired(Exception):
+ pass
+
+
+class PairingCompletionKnown(Rejected):
+ """A validated remote receipt must never trigger another preparation."""
+
+
+async def pair(store, invite, route, credential=None, *, offer_digest=None, expected_client_key=None):
if store.recovery_required():
raise Rejected('recovery_required')
- if (type(invite.get('v')) is not int or invite['v'] not in (1, 2) or invite['expires'] < time.time()
+ link_mode = offer_digest is not None or expected_client_key is not None
+ if link_mode:
+ from .pairing_links import encode_link
+ try:
+ encode_link(invite, expected_client_key)
+ except ValueError:
+ raise Rejected('invalid_pairing_link') from None
+ expired = invite['expires'] <= time.time()
+ if (type(invite.get('v')) is not int or invite['v'] not in (1, 2) or (expired and not link_mode)
or fingerprint(invite['certificate']) != invite.get('keyFingerprint', invite['device'])):
raise Rejected('invalid_invitation')
- previous = store.peer(invite['device'])
+ saved_intent = False
+ if link_mode:
+ saved_intent = store.save_offer_intent(invite, offer_digest, expected_client_key)
+ if expired:
+ return await finish_pair(store, invite, route, credential, offer=True)
+ with store._pairing_transaction():
+ previous = store.peer(invite['device'])
if previous and previous['status'] == 'paired':
- raise Rejected('invitation_consumed')
- if previous and previous['status'] == 'pending':
+ if not link_mode or not saved_intent:
+ raise Rejected('invitation_consumed')
+ return await finish_pair(store, invite, route, credential, offer=True)
+ if (previous and previous['status'] == 'pending') or (link_mode and saved_intent):
# Resume by proving the same key, not by consuming the invitation again.
try:
- return await finish_pair(store, invite, route, credential)
+ return await finish_pair(store, invite, route, credential, offer=link_mode)
+ except PairingCompletionKnown:
+ raise
except Exception:
pass # prepare is itself idempotent for this invitation and public key
+ if link_mode and time.time() >= invite['expires']:
+ return await finish_pair(store, invite, route, credential, offer=True)
store.stage(invite)
- channel = await open_channel(store, invite['certificate'], invite['routes'], route, credential, True)
+ if link_mode and time.time() >= invite['expires']:
+ return await finish_pair(store, invite, route, credential, offer=True)
+ channel = None
try:
- preparation = {'op': 'pair.prepare', 'invitation': invite['id'],
- 'secret': invite['secret'], 'certificate': store.cert}
- if store.generation:
- preparation.update(principal=store.device, generation=store.generation)
- await channel.send(preparation)
- prepared = await channel.recv()
- if not prepared.get('ok'):
- raise Rejected(prepared.get('reason', 'pairing_failed'))
- if store.generation and 'identity-rotation-v1' not in prepared.get('features', []):
- raise Rejected('rotation_unsupported')
- finally:
- await channel.close()
- return await finish_pair(store, invite, route, credential)
+ remaining = invite['expires'] - time.time() if link_mode else None
+ if link_mode and remaining <= 0:
+ raise PairingWindowExpired()
+ try:
+ # One expiry budget covers bootstrap, preparation and its response.
+ async with asyncio.timeout(remaining):
+ if link_mode and time.time() >= invite['expires']:
+ raise PairingWindowExpired()
+ channel = await open_channel(store, invite['certificate'], invite['routes'], route, credential, True)
+ if link_mode and time.time() >= invite['expires']:
+ raise PairingWindowExpired()
+ preparation = {'op': 'pair.prepare', 'invitation': invite['id'],
+ 'secret': invite['secret'], 'certificate': store.cert}
+ if link_mode:
+ preparation['offer'] = True
+ if store.generation:
+ preparation.update(principal=store.device, generation=store.generation)
+ await channel.send(preparation)
+ prepared = await channel.recv()
+ if not prepared.get('ok'):
+ if link_mode and saved_intent:
+ # A consumed remote offer may be hidden by a transient
+ # commit-channel failure. Preserve exact recovery facts
+ # for the next explicit invocation; do not retry here.
+ raise Rejected('pairing_reconciliation_required')
+ store.discard_pairing_stage(invite['id'])
+ raise Rejected('pairing_prepare_refused' if link_mode else prepared.get('reason', 'pairing_failed'))
+ if store.generation and 'identity-rotation-v1' not in prepared.get('features', []):
+ raise Rejected('rotation_unsupported')
+ if link_mode and ('pairing-offer-v1' not in prepared.get('features', []) or
+ prepared.get('offerId') != invite['id'] or prepared.get('device') != invite['device']):
+ raise Rejected('pairing_offer_unsupported')
+ finally:
+ if channel is not None:
+ await channel.close()
+ except (PairingWindowExpired, TimeoutError):
+ if link_mode and time.time() >= invite['expires']:
+ return await finish_pair(store, invite, route, credential, offer=True)
+ raise
+ return await finish_pair(store, invite, route, credential, offer=link_mode)
-async def finish_pair(store, invite, route, credential):
+async def finish_pair(store, invite, route, credential, *, offer=False):
# No send/list access before possession of the newly proposed key is proven.
channel = await open_channel(store, invite['certificate'], invite['routes'], route, credential)
+ completion_known = False
try:
- await channel.send({'op': 'pair.commit'})
+ commitment = {'op': 'pair.commit'}
+ if offer:
+ commitment.update(invitation=invite['id'], offer=True)
+ await channel.send(commitment)
committed = await channel.recv()
if not committed.get('ok'):
raise Rejected('pairing_not_committed')
- store.remember(invite)
+ if offer and (committed.get('offerId') != invite['id'] or committed.get('device') != invite['device']
+ or 'pairing-offer-v1' not in committed.get('features', [])):
+ raise Rejected('pairing_offer_unsupported')
+ completion_known = True
+ try:
+ store.remember(invite)
+ except Exception:
+ if offer:
+ raise PairingCompletionKnown('pairing_local_reconciliation_required') from None
+ raise
return committed
finally:
- await channel.close()
+ try:
+ await channel.close()
+ except Exception:
+ if offer and completion_known:
+ raise PairingCompletionKnown('pairing_local_reconciliation_required') from None
+ raise
async def exchange(store, peer, op, body=None, ident=None, route='auto', credential=None):
diff --git a/session_peer_relay/cli.py b/session_peer_relay/cli.py
index ec34756..d904d1e 100644
--- a/session_peer_relay/cli.py
+++ b/session_peer_relay/cli.py
@@ -21,6 +21,7 @@
from .wire import validate_relay_url, direct_address
from .transport_errors import NoAuthenticatedRoute, TransportFailure
from .guidance import with_guidance
+from .pairing_ui import OfferOutcomeUnknown, offer_failure
def connection_diagnostics(exc):
@@ -259,6 +260,13 @@ async def manage(kind, args):
if args.action == 'revoke':
store.revoke(args.peer)
return {'ok': True, 'revoked': args.peer}
+ if args.action == 'offer':
+ from .pairing_ui import create_offer
+ return create_offer(store, args)
+ if args.action == 'offer-status':
+ return store.offer_status(args.offer_id)
+ if args.action == 'offer-cancel':
+ return store.cancel_offer(args.offer_id)
if args.action in ('invite', 'routes'):
routes = {}
if args.direct:
@@ -277,6 +285,12 @@ async def manage(kind, args):
private_write(args.out, json.dumps(store.invite(routes)))
return {'ok': True, 'invitationSaved': True, 'device': store.device, 'expiresInSeconds': 600}
if args.action == 'pair':
+ if args.link:
+ from .pairing_ui import receive_link
+ invitation, intended, digest, selected = receive_link(store, args.route)
+ return await pair(store, invitation, selected,
+ device_credential(args, store, invitation['device']),
+ offer_digest=digest, expected_client_key=intended)
invitation = json.loads(private_read(args.invite))
return await pair(store, invitation, args.route, device_credential(args, store, invitation['device']))
if args.action == 'status':
@@ -377,6 +391,11 @@ def server_options(item):
item.add_argument('--admission-file')
item = command('invite'); item.add_argument('--out', required=True)
item.add_argument('--direct'); item.add_argument('--relay')
+ item = command('offer'); item.add_argument('--peer-key', required=True)
+ item.add_argument('--direct'); item.add_argument('--relay')
+ item.add_argument('--show-link', action='store_true', help='explicitly display a private link only on the owner terminal')
+ for name in ('offer-status', 'offer-cancel'):
+ item = command(name); item.add_argument('--offer-id', required=True)
item = command('routes'); item.add_argument('--peer', required=True)
item.add_argument('--direct'); item.add_argument('--relay')
item = command('serve'); item.add_argument('--policy', required=True)
@@ -387,7 +406,9 @@ def server_options(item):
for name in ('pair', 'status'):
item = command(name)
if name == 'pair':
- item.add_argument('--invite', required=True)
+ source = item.add_mutually_exclusive_group(required=True)
+ source.add_argument('--invite')
+ source.add_argument('--link', action='store_true', help='read a private link from an echo-disabled terminal; never pass its value here')
else:
item.add_argument('--peer', required=True); item.add_argument('--request-id', required=True)
item.add_argument('--route', choices=('direct', 'relay') if name == 'pair' else ('auto', 'direct', 'relay'), default='direct' if name == 'pair' else 'auto')
@@ -397,11 +418,21 @@ def server_options(item):
def main(kind, argv):
+ if any('session-peer://pair/' in value.lower() for value in argv):
+ emit({'ok': False, 'reason': 'pairing_link_argv_forbidden', 'retryAllowed': False})
+ return 1
args = parser(kind).parse_args(argv)
logging.getLogger('websockets').setLevel(logging.CRITICAL+1)
os.umask(0o077)
try:
result = asyncio.run(manage(kind, args))
+ except OfferOutcomeUnknown as exc:
+ emit(offer_failure(exc) or {'ok': False, 'reason': 'pairing_offer_outcome_unknown',
+ 'status': 'unknown', 'updated': False, 'retryAllowed': False})
+ return 130 if exc.interrupted is True else 1
+ except (KeyboardInterrupt, EOFError):
+ emit({'ok': False, 'reason': 'operation_interrupted', 'retryAllowed': False})
+ return 130
except Exception as exc:
result = {'ok': False, 'reason': str(exc) if isinstance(exc, Rejected) else type(exc).__name__,
'retryAllowed': False, **connection_diagnostics(exc)}
diff --git a/session_peer_relay/pairing_links.py b/session_peer_relay/pairing_links.py
new file mode 100644
index 0000000..008be8f
--- /dev/null
+++ b/session_peer_relay/pairing_links.py
@@ -0,0 +1,134 @@
+"""Bounded, offline codec for receiver-bound pairing links.
+
+The fragment contains a bearer secret. Never include input in errors or logs.
+"""
+import base64
+import hashlib
+import json
+import math
+import re
+import uuid
+
+from .identity import fingerprint
+from .wire import direct_address, validate_relay_url
+
+
+PREFIX = 'session-peer://pair/v1#'
+MAX_LINK_BYTES = 16 * 1024
+MAX_DECODED_BYTES = 12 * 1024
+_ERROR = 'invalid_pairing_link'
+_KEY = re.compile(r'[0-9a-f]{64}\Z')
+_BASE64 = re.compile(r'[A-Za-z0-9_-]+\Z')
+_FIELDS = {'v', 'id', 'secret', 'expires', 'device', 'certificate', 'routes'}
+
+
+def _reject():
+ raise ValueError(_ERROR)
+
+
+def _key(value):
+ if not isinstance(value, str) or _KEY.fullmatch(value) is None:
+ _reject()
+
+
+def _unbase64(value):
+ if not isinstance(value, str) or _BASE64.fullmatch(value) is None:
+ _reject()
+ data = base64.b64decode(value + '=' * (-len(value) % 4), altchars=b'-_', validate=True)
+ if base64.urlsafe_b64encode(data).decode('ascii').rstrip('=') != value:
+ _reject()
+ return data
+
+
+def _pairs(pairs):
+ result = {}
+ for key, value in pairs:
+ if key in result:
+ _reject()
+ result[key] = value
+ return result
+
+
+def _validate(invitation, expected_key):
+ _key(expected_key)
+ if not isinstance(invitation, dict) or type(invitation.get('v')) is not int:
+ _reject()
+ version = invitation['v']
+ fields = _FIELDS if version == 1 else _FIELDS | {'keyFingerprint', 'keyGeneration'}
+ if version not in (1, 2) or set(invitation) != fields:
+ _reject()
+ ident = invitation['id']
+ if (not isinstance(ident, str) or len(ident) != 36
+ or str(uuid.UUID(ident)) != ident or uuid.UUID(ident).version != 4):
+ _reject()
+ _key(invitation['device'])
+ if len(_unbase64(invitation['secret'])) != 32:
+ _reject()
+ expires = invitation['expires']
+ if type(expires) not in (int, float) or not math.isfinite(expires):
+ _reject()
+ certificate = invitation['certificate']
+ if not isinstance(certificate, str):
+ _reject()
+ receiver_key = invitation['device']
+ if version == 2:
+ receiver_key = invitation['keyFingerprint']
+ _key(receiver_key)
+ generation = invitation['keyGeneration']
+ if type(generation) is not int or generation <= 0:
+ _reject()
+ if fingerprint(certificate) != receiver_key:
+ _reject()
+ routes = invitation['routes']
+ if not isinstance(routes, dict) or not routes or set(routes) - {'direct', 'relay'}:
+ _reject()
+ for kind, route in routes.items():
+ if (not isinstance(route, str) or not route or not route.isascii()
+ or any(ord(char) <= 32 or ord(char) == 127 for char in route)):
+ _reject()
+ (direct_address if kind == 'direct' else validate_relay_url)(route)
+
+
+def encode_link(invitation, expected_key):
+ """Encode an existing invitation and the intended client's certificate key."""
+ try:
+ _validate(invitation, expected_key)
+ raw = json.dumps({'v': 1, 'invitation': invitation,
+ 'clientKeyFingerprint': expected_key},
+ sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8')
+ if len(raw) > MAX_DECODED_BYTES:
+ _reject()
+ link = PREFIX + base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')
+ if len(link) > MAX_LINK_BYTES:
+ _reject()
+ return link
+ except Exception:
+ raise ValueError(_ERROR) from None
+
+
+def decode_link(link):
+ """Return (invitation, intended key), without treating expiry as a parse error."""
+ try:
+ if (not isinstance(link, str) or len(link) > MAX_LINK_BYTES
+ or not link.isascii() or not link.startswith(PREFIX)):
+ _reject()
+ raw = _unbase64(link[len(PREFIX):])
+ if len(raw) > MAX_DECODED_BYTES:
+ _reject()
+ envelope = json.loads(raw.decode('utf-8'), object_pairs_hook=_pairs,
+ parse_constant=lambda _: _reject())
+ if (not isinstance(envelope, dict)
+ or set(envelope) != {'v', 'invitation', 'clientKeyFingerprint'}
+ or type(envelope['v']) is not int or envelope['v'] != 1):
+ _reject()
+ invitation, expected_key = envelope['invitation'], envelope['clientKeyFingerprint']
+ _validate(invitation, expected_key)
+ return invitation, expected_key
+ except Exception:
+ raise ValueError(_ERROR) from None
+
+
+def link_digest(link):
+ """Hash the validated original envelope, preserving its exact byte identity."""
+ decode_link(link)
+ return hashlib.sha256(link.encode('ascii')).hexdigest()
diff --git a/session_peer_relay/pairing_ui.py b/session_peer_relay/pairing_ui.py
new file mode 100644
index 0000000..76a978c
--- /dev/null
+++ b/session_peer_relay/pairing_ui.py
@@ -0,0 +1,185 @@
+"""Explicit owner-terminal confirmation; pairing credentials never use argv/JSON."""
+import json
+import os
+import re
+import signal
+import termios
+import threading
+import uuid
+
+from .identity import fingerprint
+from .pairing_links import encode_link, decode_link, link_digest
+from .store import Rejected
+from .wire import direct_address, validate_relay_url
+
+
+class OfferOutcomeUnknown(Rejected):
+ """Only a public preallocated ID survives uncertain local completion."""
+ def __init__(self, offer_id, *, interrupted=False):
+ super().__init__('pairing_offer_outcome_unknown')
+ self.offer_id = offer_id
+ self.interrupted = interrupted
+
+
+def offer_failure(exc):
+ if type(exc) is not OfferOutcomeUnknown:
+ return None
+ try:
+ ident = uuid.UUID(exc.offer_id)
+ valid = ident.version == 4 and str(ident) == exc.offer_id
+ except (AttributeError, TypeError, ValueError):
+ valid = False
+ result = {'ok': False, 'reason': 'pairing_offer_outcome_unknown',
+ 'status': 'unknown', 'updated': False, 'retryAllowed': False}
+ if valid:
+ result['offerId'] = exc.offer_id
+ return result
+
+
+class Terminal:
+ def __enter__(self):
+ try:
+ self.fd = os.open('/dev/tty', os.O_RDWR | getattr(os, 'O_NOCTTY', 0))
+ if not os.isatty(self.fd):
+ os.close(self.fd)
+ raise OSError()
+ except OSError:
+ raise Rejected('pairing_terminal_required') from None
+ return self
+
+ def __exit__(self, *unused):
+ os.close(self.fd)
+
+ def show(self, text):
+ raw = (text + '\n').encode('utf-8')
+ while raw:
+ count = os.write(self.fd, raw)
+ if count <= 0:
+ raise Rejected('pairing_terminal_unavailable')
+ raw = raw[count:]
+
+ def read(self, prompt, *, secret=False, limit=256):
+ # Noncanonical input avoids the usual 4096-byte canonical TTY line cap.
+ # Read one byte at a time so a following confirmation is never consumed.
+ saved = termios.tcgetattr(self.fd)
+ settings = termios.tcgetattr(self.fd)
+ settings[3] &= ~(termios.ICANON | termios.ECHO | termios.ECHONL)
+ settings[6][termios.VMIN], settings[6][termios.VTIME] = 1, 0
+ raw = bytearray()
+ previous_handler = None
+ if threading.current_thread() is threading.main_thread():
+ previous_handler = signal.getsignal(signal.SIGINT)
+ # asyncio.run's cancellation handler cannot interrupt a synchronous
+ # TTY read. Raise directly so Ctrl-C restores terminal mode promptly.
+ if previous_handler != signal.SIG_IGN:
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ try:
+ termios.tcsetattr(self.fd, termios.TCSANOW, settings)
+ self.show(prompt)
+ while True:
+ value = os.read(self.fd, 1)
+ if not value or value == b'\x04':
+ raise Rejected('pairing_input_cancelled')
+ if value in (b'\n', b'\r'):
+ break
+ if value in (b'\x7f', b'\x08'):
+ if raw:
+ raw.pop()
+ continue
+ raw.extend(value)
+ if len(raw) > limit:
+ raise Rejected('pairing_input_too_large')
+ try:
+ return raw.decode('ascii')
+ except UnicodeDecodeError:
+ raise Rejected('invalid_pairing_input') from None
+ finally:
+ try:
+ try:
+ termios.tcflush(self.fd, termios.TCIFLUSH)
+ finally:
+ termios.tcsetattr(self.fd, termios.TCSANOW, saved)
+ finally:
+ if previous_handler is not None:
+ signal.signal(signal.SIGINT, previous_handler)
+ self.show('')
+
+
+def routes_from_args(args):
+ routes = {}
+ if args.direct:
+ direct_address(args.direct)
+ routes['direct'] = args.direct
+ if args.relay:
+ validate_relay_url(args.relay)
+ routes['relay'] = args.relay
+ if not routes:
+ raise Rejected('route_required')
+ return routes
+
+
+def identity_text(store, intended=None):
+ result = {'localPrincipal': store.device, 'localKeyFingerprint': store.key_id,
+ 'localGeneration': store.generation}
+ if intended is not None:
+ result['intendedClient'] = intended
+ return json.dumps(result, ensure_ascii=True, sort_keys=True)
+
+
+def create_offer(store, args, *, offer_id=None, before_create=None, created=None):
+ if not getattr(args, 'show_link', False):
+ raise Rejected('pairing_explicit_link_display_required')
+ routes = routes_from_args(args)
+ identity = store.offer_identity(args.peer_key)
+ ident, attempted, invitation = offer_id or str(uuid.uuid4()), False, None
+ try:
+ with Terminal() as terminal:
+ terminal.show(identity_text(store, identity))
+ terminal.show('Pairing preserves receiving target policy. Confirm these identities through a trusted channel.')
+ if terminal.read('Type yes to confirm your identity and this intended client key:') != 'yes':
+ raise Rejected('pairing_identity_not_confirmed')
+ if before_create is not None:
+ before_create()
+ attempted = True
+ invitation = store.create_offer(routes, args.peer_key,
+ expected_identity=identity, offer_id=ident)
+ if created is not None:
+ created(invitation['id'])
+ terminal.show('Private one-use link; expires in ten minutes. Avoid terminal recording and clipboard/browser history:')
+ terminal.show(encode_link(invitation, args.peer_key))
+ return store.offer_status(invitation['id'])
+ except (Exception, KeyboardInterrupt) as exc:
+ if not attempted or (isinstance(exc, Rejected) and invitation is None):
+ raise
+ raise OfferOutcomeUnknown(ident, interrupted=isinstance(exc, KeyboardInterrupt)) from None
+
+
+def receive_link(store, route):
+ with Terminal() as terminal:
+ raw = terminal.read('Paste private pairing link (hidden):', secret=True, limit=16 * 1024)
+ try:
+ invitation, intended_key = decode_link(raw)
+ except ValueError:
+ raise Rejected('invalid_pairing_link') from None
+ if intended_key != store.key_id:
+ raise Rejected('pairing_wrong_client_key')
+ digest = link_digest(raw)
+ routes = invitation['routes']
+ selected = ('direct' if 'direct' in routes else 'relay') if route == 'auto' else route
+ if selected not in routes:
+ raise Rejected('setup_invitation_route_unavailable')
+ receiver_key = fingerprint(invitation['certificate'])
+ receiver_identity = store.receiver_offer_identity(invitation, digest, intended_key)
+ if (receiver_identity['principal'] != invitation['device']
+ or receiver_identity['generation'] != invitation.get('keyGeneration', 0)):
+ raise Rejected('unproven_principal')
+ terminal.show(identity_text(store))
+ expected = terminal.read('Enter receiver fingerprint obtained independently through a trusted channel:')
+ if not re.fullmatch(r'[a-f0-9]{64}', expected) or expected != receiver_key:
+ raise Rejected('pairing_receiver_identity_mismatch')
+ terminal.show(json.dumps({'receiverPrincipal': invitation['device'],
+ 'receiverKeyFingerprint': receiver_key, 'receiverGeneration': invitation.get('keyGeneration', 0),
+ 'routes': routes}, ensure_ascii=True, sort_keys=True))
+ if terminal.read('Type yes to confirm your identity and pair with this receiver under its policy:') != 'yes':
+ raise Rejected('pairing_identity_not_confirmed')
+ return invitation, intended_key, digest, selected
diff --git a/session_peer_relay/setup.py b/session_peer_relay/setup.py
index 4e06d61..779cb1b 100644
--- a/session_peer_relay/setup.py
+++ b/session_peer_relay/setup.py
@@ -27,6 +27,7 @@
from .native import Policy
from .store import Store, Rejected
from .wire import direct_address, validate_relay_url
+from .pairing_ui import OfferOutcomeUnknown, offer_failure
def document(path):
@@ -377,6 +378,64 @@ async def execute(args):
work.phase('policy', 'complete')
return {'ok': True, 'policyPath': str(policy), 'policyPreview': preview,
'changed': changed, 'receiverCommand': shlex.join(receiver_command(args, root, policy))}
+ if args.action in ('offer-status', 'offer-cancel'):
+ if store.recovery_required():
+ raise Rejected('recovery_required')
+ ident = getattr(args, 'offer_id', None) or work.saved.get('offerId')
+ if not ident:
+ raise Rejected('setup_offer_id_required')
+ matches_intent = ident == work.saved.get('offerId')
+ try:
+ result = (store.offer_status(ident) if args.action == 'offer-status'
+ else store.cancel_offer(ident))
+ except Rejected as exc:
+ if str(exc) != 'pairing_offer_unknown' or not matches_intent:
+ raise
+ if work.saved['phases'].get('offer') == 'not_created':
+ # This classification comes from the original definitive
+ # rejection, never from the absence of retained history.
+ result = {'ok': True, 'offerId': ident, 'status': 'not_created'}
+ else:
+ result = {'ok': False, 'offerId': ident, 'status': 'unknown',
+ 'reason': 'pairing_offer_history_unavailable',
+ 'remoteCancellationConfirmed': False,
+ 'nextAction': 'Offer history is unavailable: it may never have been created or its receipt may have expired. Stopping local waiting does not prove non-creation, cancel a remote offer, or revoke paired trust. Inspect peers before explicitly creating a new offer.'}
+ if args.action == 'offer-cancel':
+ work.phase('offer', 'stopped_unknown')
+ result['stoppedWaiting'] = True
+ if result.get('recoveryRequired') or result.get('historical'):
+ raise Rejected('recovery_required')
+ if matches_intent and result['status'] in ('not_created', 'cancelled', 'expired', 'consumed'):
+ work.phase('offer', result['status'])
+ return result
+ if args.action == 'offer':
+ from .pairing_ui import create_offer
+ identity = store.offer_identity(args.peer_key)
+ if args.peer and args.peer != identity['principal']:
+ raise Rejected('setup_offer_policy_identity_mismatch')
+ if work.saved['phases'].get('offer') == 'pending':
+ raise Rejected('setup_offer_reconcile_required')
+ ident, commit_known = str(uuid.uuid4()), False
+ def before_create():
+ work.saved['offerId'] = ident
+ work.phase('offer', 'pending')
+ def created(ident):
+ nonlocal commit_known
+ # Set before saving: a journal failure must not classify a
+ # committed offer as absent or automatically mint another.
+ commit_known = True
+ work.save()
+ try:
+ result = create_offer(store, args, offer_id=ident,
+ before_create=before_create, created=created)
+ except OfferOutcomeUnknown:
+ raise # The durable preallocated ID remains pending.
+ except Rejected:
+ if not commit_known and work.saved.get('offerId') == ident:
+ work.phase('offer', 'not_created')
+ raise
+ work.phase('offer', 'complete')
+ return result
if args.action == 'invite':
if not args.out or not args.relay and not args.direct:
raise Rejected('setup_invitation_path_and_route_required')
@@ -396,6 +455,18 @@ async def execute(args):
return {'ok': True, 'invitationSaved': True, 'expiresInSeconds': 600,
'nextAction': 'Transfer this private file securely to the client; do not paste its secret in logs or chat.'}
if args.action == 'pair':
+ if getattr(args, 'link', False):
+ from .pairing_ui import receive_link
+ invitation, intended, envelope_digest, selected_route = receive_link(store, args.route)
+ # App owns the durable exact intent and expired reconciliation.
+ # The setup journal never stores the credential envelope.
+ credential = control.DeviceCredential(store, invitation['device'], 'client') if 'relay' in invitation['routes'] else None
+ result = await pair(store, invitation, selected_route, credential,
+ offer_digest=envelope_digest, expected_client_key=intended)
+ if result.get('ok') is True:
+ args._paired_receiver = invitation['device']
+ work.phase('pair', 'complete')
+ return result
if not args.invite:
raise Rejected('setup_invitation_required')
path = Path(args.invite).expanduser().absolute()
@@ -479,6 +550,9 @@ def ask(label, default=''):
print(json.dumps(core.human_text(await execute(args)), ensure_ascii=False))
args.role = ask('Role receiver/client', args.role)
if args.role == 'receiver':
+ method = ask('Pairing method link/file', 'file' if args.out else 'link')
+ if method not in ('link', 'file'):
+ raise Rejected('setup_pairing_method_invalid')
args.action, args.apply = 'targets', False
rows = (await execute(args))['sessions']
print(json.dumps(core.human_text(rows), ensure_ascii=False))
@@ -493,7 +567,16 @@ def ask(label, default=''):
args.antigravity_home = row.get('antigravityHome')
if row['agent'] == 'codex':
args.codex_bin = ask('Exact Codex executable path (empty uses current PATH)', args.codex_bin or '') or None
- args.peer = ask('Trusted client device principal (obtain from its init result)', args.peer or '')
+ if method == 'link':
+ args.peer_key = ask('Trusted client current certificate fingerprint (obtain independently)', args.peer_key or '')
+ state = Store(Path(args.state).expanduser())
+ try:
+ identity = state.offer_identity(args.peer_key)
+ finally:
+ state.close()
+ args.peer = identity['principal']
+ else:
+ args.peer = ask('Trusted client device principal (obtain from its init result)', args.peer or '')
args.capability = ['list', 'send'] if consent('Also allow this peer to send to this target') else ['list']
args.policy = ask('New policy path', args.policy or str(Path(args.state).expanduser()/'receiver-policy.json'))
args.relay = ask('Receiver WSS /v1/connect URL (empty for direct)', args.relay or '') or None
@@ -506,24 +589,32 @@ def ask(label, default=''):
args.apply = True
policy_result = await execute(args)
print(json.dumps(core.human_text(policy_result), ensure_ascii=False))
- args.out = ask('New private invitation file', args.out or str(Path(args.state).expanduser()/'invite.json'))
- if not consent('Create the one-time invitation (securely transfer it within 10 minutes)'):
- return {'ok': False, 'cancelled': True, 'operationCancelled': False}
- args.action = 'invite'
+ if method == 'link':
+ args.show_link, args.action = True, 'offer'
+ else:
+ args.out = ask('New private invitation file', args.out or str(Path(args.state).expanduser()/'invite.json'))
+ if not consent('Create the one-time invitation (securely transfer it within 10 minutes)'):
+ return {'ok': False, 'cancelled': True, 'operationCancelled': False}
+ args.action = 'invite'
result = await execute(args)
result['receiverCommand'] = policy_result['receiverCommand']
result['nextAction'] = 'Start this foreground receiver in another terminal; Ctrl-C stops it. No service/autostart was installed. Then the client runs setup --role client --interactive.'
return result
if args.role != 'client':
raise Rejected('invalid_setup_role')
- args.invite = ask('Securely received private invitation path', args.invite or '')
- if not consent('Pair with the device certificate and routes in this invitation'):
- return {'ok': False, 'cancelled': True, 'operationCancelled': False}
+ method = ask('Pairing method link/file', 'file' if args.invite else 'link')
+ if method not in ('link', 'file'):
+ raise Rejected('setup_pairing_method_invalid')
+ args.link = method == 'link'
+ if not args.link:
+ args.invite = ask('Securely received private invitation path', args.invite or '')
+ if not consent('Pair with the device certificate and routes in this invitation'):
+ return {'ok': False, 'cancelled': True, 'operationCancelled': False}
args.action = 'pair'
result = await execute(args)
if not result.get('ok'):
return result
- args.peer = document(Path(args.invite).expanduser())['device']
+ args.peer = args._paired_receiver if args.link else document(Path(args.invite).expanduser())['device']
if not consent('Perform metadata/probe-only readiness check (no message)'):
return {'ok': True, 'paired': True, 'readinessChecked': False}
args.action = 'ready'
@@ -534,6 +625,11 @@ def run(args):
os.umask(0o077)
try:
return asyncio.run(interactive(args) if args.interactive else execute(args))
+ except OfferOutcomeUnknown as exc:
+ public = offer_failure(exc) or {'ok': False, 'reason': 'pairing_offer_outcome_unknown',
+ 'status': 'unknown', 'updated': False, 'retryAllowed': False}
+ return {**public, '_exit': 130 if exc.interrupted is True else 1,
+ 'nextAction': 'Inspect the saved offer ID using offer-status; explicitly cancel an active offer before creating a new link. No credential is re-displayed automatically.'}
except (KeyboardInterrupt, EOFError):
return {'ok': False, 'cancelled': True, 'operationCancelled': False, '_exit': 130,
'nextAction': 'Setup stopped waiting, not rolling back committed operations. Resume enrollment with the same state/name; expired login needs explicit login again.'}
diff --git a/session_peer_relay/store.py b/session_peer_relay/store.py
index 7a17c10..e44c5a3 100644
--- a/session_peer_relay/store.py
+++ b/session_peer_relay/store.py
@@ -1,5 +1,6 @@
"""Device pairing and durable intent-before-effect journal."""
import hashlib
+import contextlib
import fcntl
import json
import os
@@ -17,6 +18,9 @@ class Rejected(ValueError):
pass
+_NO_PAIRING_ATTEMPT = object()
+
+
class Store:
def __init__(self, root, *, exclusive=False):
self.root = Path(root)
@@ -81,6 +85,14 @@ def _open(self):
generation INTEGER NOT NULL,snapshot REAL NOT NULL,reason TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS recovery_approvals(id TEXT PRIMARY KEY,hash TEXT NOT NULL,
old_peer TEXT NOT NULL,new_peer TEXT NOT NULL,response TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS pairing_offers(id TEXT PRIMARY KEY,client_key TEXT NOT NULL,
+ principal TEXT NOT NULL,generation INTEGER NOT NULL,state TEXT NOT NULL,
+ created REAL NOT NULL,terminal REAL,receipt TEXT);
+ CREATE TABLE IF NOT EXISTS pairing_reservations(peer TEXT PRIMARY KEY,invitation TEXT UNIQUE NOT NULL,
+ client_key TEXT NOT NULL,created_peer INTEGER NOT NULL,created_key INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS pairing_limits(scope TEXT PRIMARY KEY,attempts TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS pairing_intents(id TEXT PRIMARY KEY,digest TEXT NOT NULL,
+ receiver TEXT NOT NULL,receiver_key TEXT NOT NULL,client_key TEXT NOT NULL);
''')
self.db.execute('INSERT OR IGNORE INTO metadata VALUES("principal",?)', (self.device,))
self.device = self.db.execute('SELECT value FROM metadata WHERE key="principal"').fetchone()[0]
@@ -138,10 +150,24 @@ def recovery_required(self):
return bool(origin and not activated)
def invite(self, routes):
+ with self._pairing_transaction():
+ return self._invite_locked(routes)
+
+ def _invite_locked(self, routes, *, offer_id=None):
if self.recovery_required():
raise Rejected('recovery_required')
secret = secrets.token_urlsafe(32)
- ident = str(uuid.uuid4())
+ if offer_id is not None:
+ try:
+ valid = (type(offer_id) is str and str(uuid.UUID(offer_id)) == offer_id
+ and uuid.UUID(offer_id).version == 4)
+ except (ValueError, AttributeError, TypeError):
+ valid = False
+ if not valid:
+ raise Rejected('invalid_pairing_offer_id')
+ ident = offer_id if offer_id is not None else str(uuid.uuid4())
+ if self.db.execute('SELECT 1 FROM invites WHERE id=?', (ident,)).fetchone():
+ raise Rejected('pairing_offer_exists')
expires = time.time()+600
self.db.execute('INSERT INTO invites VALUES(?,?,?,NULL)',
(ident, hashlib.sha256(secret.encode()).hexdigest(), expires))
@@ -151,25 +177,205 @@ def invite(self, routes):
result.update(v=2, keyFingerprint=self.key_id, keyGeneration=self.generation)
return result
+ @contextlib.contextmanager
+ def _pairing_transaction(self, *, creation=False, attempt=_NO_PAIRING_ATTEMPT, read_only_quarantine=False):
+ self.db.execute('BEGIN IMMEDIATE')
+ savepoint = False
+ try:
+ if self.recovery_required():
+ if not read_only_quarantine:
+ raise Rejected('recovery_required')
+ # Restored history is evidence, not current lifecycle state.
+ # No expiry, pruning or rate accounting may rewrite it.
+ yield
+ self.db.execute('COMMIT')
+ return
+ now = time.time()
+ self._expire_pairings_locked(now)
+ if creation:
+ if self._pairing_charge_locked('create', 5, now):
+ raise Rejected('pairing_rate_limited')
+ if attempt is not _NO_PAIRING_ATTEMPT:
+ if self._pairing_charge_locked('prepare', 32, now):
+ raise Rejected('pairing_rate_limited')
+ offer_scope = None
+ if type(attempt) is str and self.db.execute('SELECT 1 FROM pairing_offers WHERE id=?', (attempt,)).fetchone():
+ offer_scope = 'offer:'+attempt
+ if self._pairing_charge_locked(offer_scope, 8, now):
+ raise Rejected('pairing_rate_limited')
+ self.db.execute('SAVEPOINT pairing_work')
+ savepoint = True
+ yield
+ self.db.execute('RELEASE pairing_work')
+ self.db.execute('COMMIT')
+ except Rejected:
+ # Failed guesses consume durable quota, but never partial trust.
+ if savepoint:
+ self.db.execute('ROLLBACK TO pairing_work')
+ self.db.execute('RELEASE pairing_work')
+ self.db.execute('COMMIT')
+ raise
+ except BaseException:
+ self.db.execute('ROLLBACK')
+ raise
+
+ def _pairing_charge_locked(self, scope, limit, now):
+ row = self.db.execute('SELECT attempts FROM pairing_limits WHERE scope=?', (scope,)).fetchone()
+ attempts = [value for value in json.loads(row[0]) if value > now-60] if row else []
+ if len(attempts) >= limit:
+ return True
+ attempts.append(now)
+ self.db.execute('INSERT INTO pairing_limits VALUES(?,?) ON CONFLICT(scope) DO UPDATE SET attempts=excluded.attempts',
+ (scope, json.dumps(attempts)))
+ return False
+
+ def _release_reservation_locked(self, invitation):
+ row = self.db.execute('SELECT peer,client_key,created_peer,created_key FROM pairing_reservations WHERE invitation=?',
+ (invitation,)).fetchone()
+ if row:
+ peer, key, created_peer, created_key = row
+ pending = self.db.execute('SELECT status FROM peers WHERE id=?', (peer,)).fetchone()
+ if pending == ('pending',):
+ if created_peer:
+ self.db.execute('DELETE FROM peers WHERE id=?', (peer,))
+ if created_key:
+ self.db.execute('DELETE FROM peer_keys WHERE fingerprint=? AND status="active"', (key,))
+ self.db.execute('DELETE FROM pairing_reservations WHERE invitation=?', (invitation,))
+
+ def _expire_pairings_locked(self, now):
+ for (ident,) in self.db.execute('SELECT r.invitation FROM pairing_reservations r JOIN peers p ON p.id=r.peer '
+ 'WHERE p.status="pending" AND p.expires<=? '
+ 'AND NOT EXISTS (SELECT 1 FROM invites i WHERE '
+ '(i.id=r.invitation OR i.peer=r.peer) AND i.expires>?)', (now, now)).fetchall():
+ self._release_reservation_locked(ident)
+ for ident, is_offer in self.db.execute('SELECT i.id,o.id FROM invites i LEFT JOIN pairing_offers o ON o.id=i.id '
+ 'WHERE i.expires<=? AND i.expires>0', (now,)).fetchall():
+ self._release_reservation_locked(ident)
+ self.db.execute('UPDATE pairing_offers SET state="expired",terminal=? WHERE id=? AND state IN ("open","reserved")',
+ (now, ident))
+ if is_offer is None:
+ self.db.execute('UPDATE invites SET expires=0 WHERE id=?', (ident,))
+ # Old versions had no ownership rows. Remove only expired, unreserved
+ # pending peer state with no surviving invitation authority; keep keys.
+ self.db.execute('DELETE FROM peers WHERE status="pending" AND expires<=? '
+ 'AND NOT EXISTS (SELECT 1 FROM pairing_reservations r WHERE r.peer=peers.id) '
+ 'AND NOT EXISTS (SELECT 1 FROM invites i WHERE i.peer=peers.id AND i.expires>?)', (now, now))
+ for (ident,) in self.db.execute('SELECT id FROM pairing_offers WHERE terminal', (now-7*86400,)).fetchall():
+ self.db.execute('DELETE FROM invites WHERE id=?', (ident,))
+ self.db.execute('DELETE FROM pairing_offers WHERE id=?', (ident,))
+ self.db.execute('DELETE FROM pairing_limits WHERE scope=?', ('offer:'+ident,))
+
+ def _offer_identity_locked(self, expected_key):
+ if type(expected_key) is not str or re.fullmatch(r'[a-f0-9]{64}', expected_key) is None:
+ raise Rejected('invalid_client_key')
+ row = self.db.execute('SELECT principal,generation,status FROM peer_keys WHERE fingerprint=?', (expected_key,)).fetchone()
+ if not row:
+ return {'principal': expected_key, 'keyFingerprint': expected_key, 'generation': 0}
+ peer = self.peer(row[0])
+ if row[2] != 'active' or (peer and peer['status'] in ('pending', 'revoked')):
+ raise Rejected('client_key_unavailable')
+ return {'principal': row[0], 'keyFingerprint': expected_key, 'generation': row[1]}
+
+ def offer_identity(self, expected_key):
+ with self._pairing_transaction():
+ return self._offer_identity_locked(expected_key)
+
+ def create_offer(self, routes, expected_key, *, offer_id=None, expected_identity=None):
+ with self._pairing_transaction(creation=True):
+ try:
+ identity = self._offer_identity_locked(expected_key)
+ except Rejected:
+ if expected_identity is not None:
+ raise Rejected('pairing_identity_changed') from None
+ raise
+ if expected_identity is not None and expected_identity != identity:
+ raise Rejected('pairing_identity_changed')
+ existing = self.peer(identity['principal'])
+ if existing and existing['status'] == 'paired':
+ raise Rejected('principal_already_paired')
+ if self.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0] >= 1024:
+ raise Rejected('pairing_offer_capacity')
+ if self.db.execute('SELECT COUNT(*) FROM pairing_offers WHERE state IN ("open","reserved")').fetchone()[0] >= 8:
+ raise Rejected('pairing_offer_capacity')
+ invitation = self._invite_locked(routes, offer_id=offer_id)
+ self.db.execute('INSERT INTO pairing_offers VALUES(?,?,?,?,"open",?,NULL,NULL)',
+ (invitation['id'], expected_key, identity['principal'], identity['generation'], time.time()))
+ # A committed offer must be representable by the public bounded
+ # link codec. Invalid local routes never leave an unusable offer.
+ from .pairing_links import encode_link
+ try:
+ encode_link(invitation, expected_key)
+ except ValueError:
+ raise Rejected('invalid_pairing_link') from None
+ return invitation
+
+ def _offer_status_locked(self, ident):
+ row = self.db.execute('SELECT o.state,i.expires,o.principal,o.client_key,o.generation FROM pairing_offers o '
+ 'JOIN invites i ON i.id=o.id WHERE o.id=?', (ident,)).fetchone()
+ if not row:
+ raise Rejected('pairing_offer_unknown')
+ return {'ok': True, 'offerId': ident, 'status': row[0], 'expires': row[1],
+ 'client': {'principal': row[2], 'keyFingerprint': row[3], 'generation': row[4]}}
+
+ def offer_status(self, offer_id):
+ with self._pairing_transaction(read_only_quarantine=True):
+ result = self._offer_status_locked(offer_id)
+ if self.recovery_required():
+ result.update(recoveryRequired=True, historical=True)
+ return result
+
+ def cancel_offer(self, offer_id):
+ with self._pairing_transaction():
+ status = self._offer_status_locked(offer_id)
+ if status['status'] in ('open', 'reserved'):
+ self._release_reservation_locked(offer_id)
+ self.db.execute('UPDATE pairing_offers SET state="cancelled",terminal=? WHERE id=?', (time.time(), offer_id))
+ self.db.execute('UPDATE invites SET expires=0 WHERE id=?', (offer_id,))
+ return self._offer_status_locked(offer_id)
+
def prepare(self, request):
+ attempt = request.get('invitation') if type(request) is dict else None
+ with self._pairing_transaction(attempt=attempt):
+ if type(request) is not dict:
+ raise Rejected('invalid_invitation')
+ return self._prepare_locked(request)
+
+ def _prepare_locked(self, request):
if self.recovery_required():
raise Rejected('recovery_required')
now = time.time()
+ if type(request.get('invitation')) is not str:
+ raise Rejected('invalid_invitation')
row = self.db.execute('SELECT hash,expires,peer FROM invites WHERE id=?',
(request.get('invitation'),)).fetchone()
secret = request.get('secret')
- if (not row or row[1] < now or not isinstance(secret, str)
- or not secrets.compare_digest(row[0], hashlib.sha256(secret.encode()).hexdigest())):
+ try:
+ secret_hash = hashlib.sha256(secret.encode()).hexdigest() if isinstance(secret, str) else None
+ except UnicodeError:
+ raise Rejected('invalid_invitation') from None
+ if (not row or row[1] <= now or not isinstance(secret, str)
+ or not secrets.compare_digest(row[0], secret_hash)):
raise Rejected('invalid_invitation')
pem = request.get('certificate', '')
if not isinstance(pem, str) or len(pem) > 8192:
raise Rejected('invalid_certificate')
- key = fingerprint(pem)
+ try:
+ key = fingerprint(pem)
+ except (ValueError, TypeError, UnicodeError):
+ raise Rejected('invalid_certificate') from None
+ offer = self.db.execute('SELECT client_key,principal,generation,state FROM pairing_offers WHERE id=?',
+ (request.get('invitation'),)).fetchone()
+ if bool(offer) != (request.get('offer') is True):
+ raise Rejected('pairing_offer_required')
+ if offer and (offer[0] != key or offer[3] not in ('open', 'reserved')):
+ raise Rejected('invalid_pairing_offer')
peer = request.get('principal', key)
generation = request.get('generation', 0)
if (not isinstance(peer, str) or not re.fullmatch(r'[a-f0-9]{64}', peer)
or type(generation) is not int or generation < 0):
raise Rejected('invalid_principal')
+ if offer and (peer, generation) != (offer[1], offer[2]):
+ raise Rejected('invalid_client_identity')
# An invitation permits a new key, not impersonation of a policy principal.
# TLS commit proves possession of `pem`, not ownership of a caller-chosen
# stable identity. Rotated keys require an already trusted local binding
@@ -189,19 +395,40 @@ def prepare(self, request):
raise Rejected('principal_already_paired')
if row[2] and existing and existing['status'] == 'paired':
raise Rejected('invitation_consumed')
- self.db.execute('BEGIN IMMEDIATE')
- try:
- self.db.execute('UPDATE invites SET peer=? WHERE id=?', (peer, request['invitation']))
- self.register_key(peer, pem, generation)
+ owner = self.db.execute('SELECT invitation,client_key FROM pairing_reservations WHERE peer=?', (peer,)).fetchone()
+ if existing and existing['status'] == 'pending' and not owner:
+ owner = self._adopt_legacy_reservation_locked(peer, key, existing, request['invitation'])
+ if owner and owner != (request['invitation'], key):
+ raise Rejected('peer_already_reserved')
+ if existing and existing['status'] == 'pending' and not owner:
+ raise Rejected('peer_already_reserved')
+ if existing and existing['status'] == 'paired':
+ raise Rejected('principal_already_paired')
+ key_exists = self.db.execute('SELECT principal,status FROM peer_keys WHERE fingerprint=?', (key,)).fetchone()
+ if key_exists and key_exists != (peer, 'active'):
+ raise Rejected('key_already_used')
+ # Acceptance linearizes at this final server-clock check before trust
+ # mutations. It does not make wall-clock time and durable fsync atomic.
+ if row[1] <= time.time():
+ raise Rejected('invalid_invitation')
+ self.db.execute('UPDATE invites SET peer=? WHERE id=?', (peer, request['invitation']))
+ # The certificate fingerprint and key binding were validated above;
+ # do not repeat certificate parsing after the acceptance check.
+ self.db.execute('INSERT OR IGNORE INTO peer_keys VALUES(?,?,?,?,"active",0)', (key, peer, pem, generation))
+ if not owner:
+ self.db.execute('INSERT INTO pairing_reservations VALUES(?,?,?,?,?)',
+ (peer, request['invitation'], key, int(existing is None), int(key_exists is None)))
+ if existing is None:
self.db.execute('INSERT INTO peers VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET '
'status=excluded.status,expires=excluded.expires',
(peer, pem, 'pending', row[1], '{}', '["list","send"]'))
- self.db.execute('COMMIT')
- except BaseException:
- self.db.execute('ROLLBACK')
- raise
- return {'ok': True, 'pairing': 'pending', 'device': self.device,
- 'features': ['identity-rotation-v1']}
+ if offer:
+ self.db.execute('UPDATE pairing_offers SET state="reserved" WHERE id=?', (request['invitation'],))
+ result = {'ok': True, 'pairing': 'pending', 'device': self.device,
+ 'features': ['identity-rotation-v1', 'pairing-offer-v1']}
+ if offer:
+ result['offerId'] = request['invitation']
+ return result
def peer(self, ident):
row = self.db.execute('SELECT cert,status,expires,routes,capabilities FROM peers WHERE id=?',
@@ -221,36 +448,217 @@ def trusted(self):
(time.time(),)))
return list(dict.fromkeys(certificates))
- def commit(self, peer):
+ def commit(self, peer, invitation=None, *, authenticated_key=None, offer=False):
+ with self._pairing_transaction():
+ return self._commit_locked(peer, invitation, authenticated_key=authenticated_key, offer=offer)
+
+ def _commit_locked(self, peer, invitation, *, authenticated_key=None, offer=False):
if self.recovery_required():
raise Rejected('recovery_required')
- peer = self.principal(peer)
+ key = authenticated_key if authenticated_key is not None else peer
+ principal = self.principal(key)
+ if peer not in (key, principal) or self.key_status(key) != 'active':
+ raise Rejected('invalid_client_identity')
+ peer = principal
row = self.peer(peer)
if not row or row['status'] not in ('pending', 'paired') or (
- row['status'] == 'pending' and row['expires'] < time.time()):
+ row['status'] == 'pending' and row['expires'] <= time.time()):
+ raise Rejected('pairing_not_pending')
+ supplied_invitation = invitation
+ reservation = self.db.execute('SELECT invitation,client_key FROM pairing_reservations WHERE peer=?', (peer,)).fetchone()
+ if row['status'] == 'pending' and not reservation:
+ reservation = self._adopt_legacy_reservation_locked(peer, key, row, invitation)
+ if invitation is None and reservation:
+ invitation = reservation[0]
+ bound = self.db.execute('SELECT client_key,principal,state,receipt FROM pairing_offers WHERE id=?', (invitation,)).fetchone()
+ if bound:
+ if not offer or authenticated_key is None or supplied_invitation is None or bound[:2] != (key, peer):
+ raise Rejected('pairing_offer_required')
+ if bound[2] == 'consumed' and row['status'] == 'paired':
+ return json.loads(bound[3])
+ if bound[2] != 'reserved':
+ raise Rejected('pairing_not_pending')
+ elif offer:
+ raise Rejected('invalid_pairing_offer')
+ if reservation and reservation != (invitation, key):
+ raise Rejected('pairing_not_pending')
+ if row['status'] == 'pending' and not reservation:
+ raise Rejected('pairing_not_pending')
+ if row['status'] == 'pending' and row['expires'] <= time.time():
raise Rejected('pairing_not_pending')
self.db.execute('UPDATE peers SET status="paired",expires=0 WHERE id=?', (peer,))
- self.db.execute('UPDATE invites SET expires=0 WHERE peer=?', (peer,))
- return {'ok': True, 'pairing': 'paired', 'device': self.device}
+ if invitation is not None:
+ self.db.execute('UPDATE invites SET expires=0 WHERE id=?', (invitation,))
+ self.db.execute('DELETE FROM pairing_reservations WHERE invitation=?', (invitation,))
+ result = {'ok': True, 'pairing': 'paired', 'device': self.device}
+ if bound:
+ result.update(offerId=invitation, features=['pairing-offer-v1'])
+ self.db.execute('UPDATE pairing_offers SET state="consumed",terminal=?,receipt=? WHERE id=?',
+ (time.time(), json.dumps(result), invitation))
+ return result
+
+ def _adopt_legacy_reservation_locked(self, peer, key, existing, invitation=None):
+ """Adopt only a provably unique, live pre-offer pending reservation."""
+ if existing['status'] != 'pending' or fingerprint(existing['certificate']) != key:
+ return None
+ anchor = self.db.execute('SELECT principal,cert,generation,status FROM peer_keys WHERE fingerprint=?', (key,)).fetchone()
+ if (not anchor or anchor[0] != peer or anchor[1] != existing['certificate'] or
+ type(anchor[2]) is not int or anchor[2] < 0 or anchor[3] != 'active'):
+ return None
+ candidates = self.db.execute('SELECT i.id,i.expires,o.id FROM invites i LEFT JOIN pairing_offers o ON o.id=i.id '
+ 'WHERE i.peer=? AND i.expires>?', (peer, time.time())).fetchall()
+ if (len(candidates) != 1 or candidates[0][2] is not None or
+ candidates[0][1] != existing['expires'] or
+ (invitation is not None and invitation != candidates[0][0])):
+ return None
+ ident = candidates[0][0]
+ if self.db.execute('SELECT 1 FROM pairing_reservations WHERE invitation=?', (ident,)).fetchone():
+ return None
+ self.db.execute('INSERT INTO pairing_reservations VALUES(?,?,?,?,?)', (peer, ident, key, 1, 0))
+ return ident, key
def stage(self, invite):
+ with self._pairing_transaction():
+ return self._stage_locked(invite)
+
+ def discard_pairing_stage(self, invitation):
+ with self._pairing_transaction():
+ self._release_reservation_locked(invitation)
+
+ def _stage_locked(self, invite):
existing = self.peer(invite['device'])
if existing and existing['certificate'] != invite['certificate']:
raise Rejected('principal_already_paired')
if existing and existing['status'] == 'revoked':
raise Rejected('revoked_device')
+ if existing and existing['status'] == 'paired':
+ return
+ key = fingerprint(invite['certificate'])
+ owner = self.db.execute('SELECT invitation,client_key FROM pairing_reservations WHERE peer=?', (invite['device'],)).fetchone()
+ if owner and owner != (invite['id'], key):
+ raise Rejected('peer_already_reserved')
+ if existing and existing['status'] == 'pending' and not owner:
+ raise Rejected('peer_already_reserved')
+ key_exists = self.db.execute('SELECT 1 FROM peer_keys WHERE fingerprint=?', (key,)).fetchone()
self.register_key(invite['device'], invite['certificate'], invite.get('keyGeneration', 0))
+ if not owner:
+ self.db.execute('INSERT INTO pairing_reservations VALUES(?,?,?,?,?)',
+ (invite['device'], invite['id'], key, int(existing is None), int(key_exists is None)))
self.db.execute('INSERT OR REPLACE INTO peers VALUES(?,?,?,?,?,?)',
(invite['device'], invite['certificate'], 'pending', invite['expires'],
json.dumps(invite['routes']), '["list","send"]'))
def remember(self, invite):
+ with self._pairing_transaction():
+ return self._remember_locked(invite)
+
+ def _remember_locked(self, invite):
if fingerprint(invite['certificate']) != invite.get('keyFingerprint', invite['device']):
raise Rejected('pin_mismatch')
+ existing = self.peer(invite['device'])
+ if existing and existing['status'] == 'revoked':
+ raise Rejected('revoked_device')
+ if existing and existing['certificate'] != invite['certificate']:
+ raise Rejected('principal_already_paired')
+ if existing and existing['status'] == 'pending':
+ owner = self.db.execute('SELECT invitation FROM pairing_reservations WHERE peer=?', (invite['device'],)).fetchone()
+ if owner and owner != (invite['id'],):
+ raise Rejected('peer_already_reserved')
self.register_key(invite['device'], invite['certificate'], invite.get('keyGeneration', 0))
+ if existing and existing['status'] == 'paired':
+ # A receipt query confirms pairing; it does not reset operator
+ # configured routes or capabilities from an old invitation.
+ return
self.db.execute('INSERT OR REPLACE INTO peers VALUES(?,?,?,?,?,?)',
(invite['device'], invite['certificate'], 'paired', 0,
json.dumps(invite['routes']), '["list","send"]'))
+ self.db.execute('DELETE FROM pairing_reservations WHERE peer=? AND invitation=?', (invite['device'], invite['id']))
+
+ def save_offer_intent(self, invite, digest, expected_key):
+ """Persist only public confirmation facts, before staging or networking.
+
+ Unlike lifecycle operations, cold-expired rejection performs no expiry
+ cleanup or other database writes. A saved exact intent alone permits
+ expired receipt reconciliation, never another preparation.
+ """
+ self.db.execute('BEGIN IMMEDIATE')
+ try:
+ if self.recovery_required():
+ raise Rejected('recovery_required')
+ identity, existing = self._receiver_offer_identity_locked(invite, digest, expected_key)
+ key = identity['keyFingerprint']
+ previous = self.peer(invite['device'])
+ facts = (digest, identity['principal'], key, expected_key)
+ if not existing:
+ if invite['expires'] <= time.time():
+ raise Rejected('invalid_invitation')
+ if previous and previous['status'] == 'paired':
+ raise Rejected('invitation_consumed')
+ if self.db.execute('SELECT COUNT(*) FROM pairing_intents').fetchone()[0] >= 1024:
+ raise Rejected('pairing_intent_capacity')
+ self.db.execute('INSERT INTO pairing_intents VALUES(?,?,?,?,?)', (invite['id'], *facts))
+ self.db.execute('COMMIT')
+ return bool(existing)
+ except BaseException:
+ self.db.execute('ROLLBACK')
+ raise
+
+ def _receiver_offer_identity_locked(self, invite, digest, expected_key):
+ if (type(digest) is not str or re.fullmatch(r'[a-f0-9]{64}', digest) is None
+ or expected_key != self.key_id):
+ raise Rejected('invalid_pairing_intent')
+ key = fingerprint(invite['certificate'])
+ identity = (invite['device'], key, invite.get('keyGeneration', 0))
+ facts = (digest, identity[0], key, expected_key)
+ existing = self.db.execute('SELECT digest,receiver,receiver_key,client_key FROM pairing_intents WHERE id=?',
+ (invite['id'],)).fetchone()
+ if existing and existing != facts:
+ raise Rejected('pairing_intent_conflict')
+ anchor = self.db.execute('SELECT principal,cert,generation,status FROM peer_keys WHERE fingerprint=?', (key,)).fetchone()
+ expected_anchor = (identity[0], invite['certificate'], identity[2], 'active')
+ if ((anchor and anchor != expected_anchor) or
+ ((identity[0] != key or identity[2] != 0) and anchor != expected_anchor)):
+ raise Rejected('unproven_receiver_principal')
+ previous = self.peer(identity[0])
+ if previous and previous['status'] == 'revoked':
+ raise Rejected('revoked_device')
+ if previous and previous['certificate'] != invite['certificate']:
+ raise Rejected('principal_already_paired')
+ if previous and self._expired_pending_projection_locked(identity[0], previous):
+ previous = None
+ if previous and previous['status'] == 'pending' and (not existing or anchor != expected_anchor):
+ raise Rejected('receiver_pairing_pending')
+ if invite['expires'] <= time.time() and not existing:
+ raise Rejected('invalid_invitation')
+ return {'principal': identity[0], 'keyFingerprint': key, 'generation': identity[2]}, bool(existing)
+
+ def _expired_pending_projection_locked(self, peer, existing):
+ """Read-only view of expired staging; no key or history is discarded."""
+ now = time.time()
+ if existing['status'] != 'pending' or existing['expires'] > now:
+ return False
+ if self.db.execute('SELECT 1 FROM invites WHERE peer=? AND expires>?', (peer, now)).fetchone():
+ return False
+ owner = self.db.execute('SELECT invitation,client_key FROM pairing_reservations WHERE peer=?', (peer,)).fetchone()
+ if owner:
+ if owner[1] != fingerprint(existing['certificate']):
+ return False
+ if self.db.execute('SELECT 1 FROM invites WHERE id=? AND expires>?', (owner[0], now)).fetchone():
+ return False
+ return True
+
+ def receiver_offer_identity(self, invite, digest, expected_client_key):
+ """Read-only receiver confirmation, including exact-intent recovery."""
+ self.db.execute('BEGIN IMMEDIATE')
+ try:
+ if self.recovery_required():
+ raise Rejected('recovery_required')
+ identity, unused = self._receiver_offer_identity_locked(invite, digest, expected_client_key)
+ self.db.execute('COMMIT')
+ return identity
+ except BaseException:
+ self.db.execute('ROLLBACK')
+ raise
def revoke(self, peer):
self.db.execute('UPDATE peers SET status="revoked" WHERE id=?', (peer,))
diff --git a/tests/relay/test_pairing_link_ui.py b/tests/relay/test_pairing_link_ui.py
new file mode 100644
index 0000000..fff87c0
--- /dev/null
+++ b/tests/relay/test_pairing_link_ui.py
@@ -0,0 +1,667 @@
+"""Owner-terminal pairing UX; isolated PTYs/mocks, no native or hosted calls."""
+import argparse
+import asyncio
+import contextlib
+import io
+import json
+import os
+from pathlib import Path
+import select
+import signal
+import sys
+import threading
+import tempfile
+import unittest
+from unittest import mock
+
+if os.name != 'posix' or sys.version_info < (3, 11):
+ raise unittest.SkipTest('POSIX relay terminal')
+import termios
+
+try:
+ import cryptography
+ import websockets
+except ModuleNotFoundError as exc:
+ if exc.name in ('cryptography', 'websockets'):
+ raise unittest.SkipTest('optional relay dependencies unavailable') from None
+ raise
+
+from session_peer_relay import cli, pairing_ui as ui
+from session_peer_relay.store import Rejected
+
+
+class PairingLinkUI(unittest.TestCase):
+ @contextlib.contextmanager
+ def offer_setup(self):
+ from session_peer_relay import setup
+ with tempfile.TemporaryDirectory(prefix='codex-offer-reconcile-',
+ dir=os.environ.get('SESSION_PEER_TEST_TMP')) as temporary:
+ root = Path(temporary) / 'state'
+ base = ['setup', '--mode', 'relay', '--state', str(root), '--apply']
+ self.assertTrue(setup.run(self.core_parser().parse_args(base + ['--action', 'init']))['ok'])
+ args = self.core_parser().parse_args(base + ['--action', 'offer', '--peer-key', 'c' * 64,
+ '--direct', '127.0.0.1:3770', '--show-link'])
+ yield setup, root, base, args
+
+ def offer_action(self, setup, base, action, ident=None):
+ return setup.run(self.core_parser().parse_args(base + ['--action', action] +
+ (['--offer-id', ident] if ident is not None else [])))
+
+ def core_parser(self):
+ import session_peer as core
+ namespace = dict(core.__dict__)
+ exec(Path('session_peer_core/cli.py').read_text(), namespace)
+ return namespace['build_parser']()
+
+ def assert_terminal_restored(self, fd, saved):
+ actual = termios.tcgetattr(fd)
+ # Darwin may set its kernel-maintained PENDIN bit on tcsetattr.
+ actual[3] &= ~getattr(termios, 'PENDIN', 0)
+ saved = list(saved)
+ saved[3] &= ~getattr(termios, 'PENDIN', 0)
+ self.assertEqual(actual, saved)
+
+ def store(self):
+ store = mock.Mock(device='a' * 64, key_id='b' * 64, generation=0)
+ store.offer_identity.return_value = {'principal': 'c' * 64,
+ 'keyFingerprint': 'c' * 64, 'generation': 0}
+ store.receiver_offer_identity.return_value = store.offer_identity.return_value
+ return store
+
+ def terminal(self, *answers):
+ terminal = mock.MagicMock()
+ terminal.__enter__.return_value = terminal
+ terminal.read.side_effect = answers
+ return terminal
+
+ def test_receiver_confirmation_and_link_are_terminal_only(self):
+ store = self.store()
+ store.create_offer.return_value = {'id': 'offer'}
+ store.offer_status.return_value = {'ok': True, 'offerId': 'offer', 'status': 'open'}
+ args = argparse.Namespace(peer_key='c' * 64, direct='127.0.0.1:3770', relay=None, show_link=True)
+ terminal = self.terminal('yes')
+ with mock.patch.object(ui, 'Terminal', return_value=terminal), \
+ mock.patch.object(ui, 'encode_link', return_value='session-peer://pair/v1#PRIVATE'):
+ result = ui.create_offer(store, args)
+ self.assertNotIn('PRIVATE', str(result))
+ self.assertTrue(any('PRIVATE' in str(call) for call in terminal.show.call_args_list))
+ store.create_offer.assert_called_once_with({'direct': '127.0.0.1:3770'}, 'c' * 64,
+ expected_identity=store.offer_identity.return_value, offer_id=mock.ANY)
+
+ def test_receiver_refusal_never_creates_offer(self):
+ args = argparse.Namespace(peer_key='c' * 64, direct='127.0.0.1:3770', relay=None, show_link=True)
+ for unavailable in (False, True):
+ terminal = self.terminal('no')
+ store = self.store()
+ if unavailable:
+ terminal.__enter__.side_effect = Rejected('pairing_terminal_required')
+ with mock.patch.object(ui, 'Terminal', return_value=terminal), self.assertRaises(Rejected):
+ ui.create_offer(store, args)
+ store.create_offer.assert_not_called()
+ store = self.store()
+ args.show_link = False
+ with self.assertRaisesRegex(Rejected, 'explicit_link_display'):
+ ui.create_offer(store, args)
+ store.create_offer.assert_not_called()
+
+ def test_manual_display_error_or_interrupt_reports_only_cancellable_public_id(self):
+ from session_peer_relay.store import Store
+ for interrupted in (False, True):
+ with self.subTest(interrupted=interrupted), self.offer_setup() as (setup, root, base, args):
+ terminal, private = self.terminal('yes'), []
+ def fail_link(text):
+ if text.startswith('session-peer://pair/'):
+ private.append(text)
+ if interrupted:
+ raise KeyboardInterrupt()
+ raise OSError('untrusted fixture exception: ' + text)
+ terminal.show.side_effect = fail_link
+ output = io.StringIO()
+ with mock.patch.object(ui, 'Terminal', return_value=terminal), contextlib.redirect_stdout(output):
+ code = cli.main('device', ['offer', '--state', str(root), '--peer-key', 'c' * 64,
+ '--direct', '127.0.0.1:3770', '--show-link'])
+ result = json.loads(output.getvalue())
+ self.assertEqual(code, 130 if interrupted else 1)
+ self.assertEqual(result['status'], 'unknown')
+ self.assertFalse(result['updated'])
+ self.assertFalse(result['retryAllowed'])
+ self.assertIn('offerId', result)
+ self.assertNotIn(private[0], output.getvalue())
+ invitation, unused = ui.decode_link(private[0])
+ self.assertNotIn(invitation['secret'], output.getvalue())
+ self.assertNotIn('untrusted fixture exception', output.getvalue())
+ host = Store(root)
+ try:
+ self.assertEqual(host.offer_status(result['offerId'])['status'], 'open')
+ finally:
+ host.close()
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ self.assertEqual(cli.main('device', ['offer-cancel', '--state', str(root),
+ '--offer-id', result['offerId']]), 0)
+ self.assertEqual(json.loads(output.getvalue())['status'], 'cancelled')
+
+ def test_unknown_offer_metadata_accepts_only_exact_class_and_canonical_public_id(self):
+ self.assertIsNone(ui.offer_failure(Rejected('fixture')))
+ result = ui.offer_failure(ui.OfferOutcomeUnknown('session-peer://pair/v1#PRIVATE'))
+ self.assertNotIn('offerId', result)
+ self.assertNotIn('PRIVATE', str(result))
+
+ def test_client_requires_own_key_and_independent_receiver_confirmation(self):
+ invitation = {'device': 'c' * 64, 'certificate': 'public', 'routes': {'direct': '127.0.0.1:3770'}}
+ for intended, answers, error in (
+ ('d' * 64, ('private',), 'wrong_client_key'),
+ ('b' * 64, ('private', 'd' * 64), 'receiver_identity_mismatch'),
+ ('b' * 64, ('private', 'c' * 64, 'no'), 'identity_not_confirmed')):
+ terminal = self.terminal(*answers)
+ with mock.patch.object(ui, 'Terminal', return_value=terminal), \
+ mock.patch.object(ui, 'decode_link', return_value=(invitation, intended)), \
+ mock.patch.object(ui, 'link_digest', return_value='d' * 64), \
+ mock.patch.object(ui, 'fingerprint', return_value='c' * 64), \
+ self.assertRaisesRegex(Rejected, error):
+ ui.receive_link(self.store(), 'direct')
+ if error == 'receiver_identity_mismatch':
+ self.assertNotIn('c' * 64, str(terminal.show.call_args_list))
+ self.assertNotIn('receiverPrincipal', str(terminal.show.call_args_list))
+
+ def test_offer_creation_rechecks_confirmed_identity_after_prompt(self):
+ from session_peer_relay.store import Store
+ with self.offer_setup() as (setup, root, base, args):
+ host, client = Store(root), Store(root.parent / 'client')
+ try:
+ args.peer_key = client.key_id
+ terminal = self.terminal()
+ def changed_before_yes(*unused, **kwargs):
+ host.register_key('d' * 64, client.cert, 2)
+ return 'yes'
+ terminal.read.side_effect = changed_before_yes
+ with mock.patch.object(ui, 'Terminal', return_value=terminal), self.assertRaises(Rejected):
+ ui.create_offer(host, args)
+ self.assertEqual(host.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0], 0)
+ self.assertEqual(host.db.execute('SELECT COUNT(*) FROM invites').fetchone()[0], 0)
+ finally:
+ client.close()
+ host.close()
+
+ def test_pty_receiver_identity_is_hidden_until_independent_fingerprint_match(self):
+ import time
+ master, slave = os.openpty()
+ candidate = 'c' * 64
+ link = 'session-peer://pair/v1#' + candidate
+ invitation = {'device': candidate, 'certificate': 'public',
+ 'routes': {'direct': '127.0.0.1:3770'}}
+ observed, errors, results = bytearray(), [], []
+ def read_until(marker):
+ cutoff = time.monotonic() + 2
+ while marker not in observed:
+ self.assertLess(time.monotonic(), cutoff)
+ if select.select([master], [], [], .05)[0]:
+ observed.extend(os.read(master, 32768))
+ def receive():
+ try:
+ results.append(ui.receive_link(self.store(), 'direct'))
+ except BaseException as exc:
+ errors.append(exc)
+ try:
+ with mock.patch.object(ui.os, 'open', return_value=os.dup(slave)), \
+ mock.patch.object(ui, 'decode_link', return_value=(invitation, 'b' * 64)), \
+ mock.patch.object(ui, 'link_digest', return_value='d' * 64), \
+ mock.patch.object(ui, 'fingerprint', return_value=candidate):
+ thread = threading.Thread(target=receive)
+ thread.start()
+ read_until(b'Paste private pairing link')
+ os.write(master, (link + '\n').encode())
+ read_until(b'Enter receiver fingerprint obtained independently')
+ self.assertNotIn(candidate.encode(), observed)
+ self.assertNotIn(b'receiverPrincipal', observed)
+ self.assertNotIn(b'127.0.0.1:3770', observed)
+ os.write(master, (candidate + '\n').encode())
+ read_until(b'Type yes to confirm your identity and pair')
+ self.assertIn(candidate.encode(), observed)
+ self.assertIn(b'receiverPrincipal', observed)
+ self.assertIn(b'127.0.0.1:3770', observed)
+ os.write(master, b'yes\n')
+ thread.join(2)
+ self.assertFalse(thread.is_alive())
+ self.assertEqual(errors, [])
+ self.assertEqual(len(results), 1)
+ finally:
+ os.close(master)
+ os.close(slave)
+
+ def test_cli_confirmation_precedes_credential_or_pair(self):
+ args = cli.parser('device').parse_args(['pair', '--link', '--state', '/unused'])
+ with mock.patch.object(cli, 'Store', return_value=self.store()), \
+ mock.patch.object(ui, 'receive_link', side_effect=Rejected('pairing_identity_not_confirmed')), \
+ mock.patch.object(cli, 'device_credential') as credential, \
+ mock.patch.object(cli, 'pair') as pair, self.assertRaises(Rejected):
+ asyncio.run(cli.manage('device', args))
+ credential.assert_not_called()
+ pair.assert_not_called()
+
+ def test_accidental_link_argv_never_reaches_diagnostics(self):
+ secret = 'session-peer://pair/v1#PRIVATE-CREDENTIAL'
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ self.assertEqual(cli.main('device', ['pair', '--link', secret]), 1)
+ self.assertNotIn('PRIVATE-CREDENTIAL', output.getvalue())
+ for argv in (['device', 'pair', '--link', secret],
+ ['setup', '--mode', 'relay', '--link', secret],
+ ['setup', '--route', secret]):
+ error = io.StringIO()
+ with contextlib.redirect_stderr(error), self.assertRaises(SystemExit):
+ self.core_parser().parse_args(argv)
+ self.assertNotIn('PRIVATE-CREDENTIAL', error.getvalue())
+
+ def test_ordinary_send_payload_can_contain_literal_link_and_device_or_setup(self):
+ payload = 'session-peer://pair/v1#LITERAL-MESSAGE'
+ for target in ('device', 'setup', 'somebody'):
+ args = self.core_parser().parse_args(['send', '--to', target, '--message', payload])
+ self.assertEqual(args.message_option, payload)
+ self.assertEqual(args.to, target)
+ for word in ('device', 'setup'):
+ message = payload + ' ' + word
+ args = self.core_parser().parse_args(['send', '--to', 'somebody', '--message', message])
+ self.assertEqual(args.message_option, message)
+
+ def test_setup_offer_journal_contains_public_id_only_and_refusal_can_retry(self):
+ from session_peer_relay import setup
+ with tempfile.TemporaryDirectory(prefix='codex-link-setup-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as temporary:
+ root = Path(temporary) / 'state'
+ base = ['setup', '--mode', 'relay', '--state', str(root), '--apply']
+ self.assertTrue(setup.run(self.core_parser().parse_args(base + ['--action', 'init']))['ok'])
+ args = self.core_parser().parse_args(base + ['--action', 'offer', '--peer-key', 'c' * 64,
+ '--direct', '127.0.0.1:3770', '--show-link'])
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('no')):
+ refused = setup.run(args)
+ self.assertFalse(refused['ok'])
+ self.assertNotIn('offer', json.loads((root / 'setup.json').read_text())['phases'])
+ terminal = self.terminal('yes')
+ with mock.patch.object(ui, 'Terminal', return_value=terminal):
+ result = setup.run(args)
+ self.assertTrue(result['ok'], result)
+ journal = (root / 'setup.json').read_text()
+ self.assertNotIn('session-peer://', journal)
+ self.assertNotIn('secret', journal)
+ self.assertIn(result['offerId'], journal)
+ self.assertEqual(json.loads(journal)['phases']['offer'], 'complete')
+
+ def test_setup_link_uses_confirmed_input_before_pair_and_records_no_secret(self):
+ from session_peer_relay import setup
+ invitation = {'device': 'c' * 64, 'routes': {'direct': '127.0.0.1:3770'}}
+ with tempfile.TemporaryDirectory(prefix='codex-link-setup-', dir=os.environ.get('SESSION_PEER_TEST_TMP')) as temporary:
+ root = Path(temporary) / 'state'
+ base = ['setup', '--mode', 'relay', '--state', str(root), '--apply']
+ self.assertTrue(setup.run(self.core_parser().parse_args(base + ['--action', 'init']))['ok'])
+ args = self.core_parser().parse_args(base + ['--action', 'pair', '--link'])
+ with mock.patch.object(ui, 'receive_link', return_value=(invitation, 'b' * 64, 'd' * 64, 'direct')), \
+ mock.patch.object(setup, 'pair', new_callable=mock.AsyncMock, return_value={'ok': True}) as pair:
+ result = setup.run(args)
+ self.assertTrue(result['ok'], result)
+ self.assertEqual(pair.call_args.kwargs, {'offer_digest': 'd' * 64, 'expected_client_key': 'b' * 64})
+ self.assertEqual(args._paired_receiver, 'c' * 64)
+ self.assertNotIn('secret', (root / 'setup.json').read_text())
+
+ def test_definitive_creation_refusal_is_retryable_without_pending_wedge(self):
+ from session_peer_relay.store import Store
+ for reason in ('pairing_rate_limited', 'pairing_offer_capacity'):
+ with self.subTest(reason=reason), self.offer_setup() as (setup, root, base, args):
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')), \
+ mock.patch.object(Store, 'create_offer', side_effect=Rejected(reason)):
+ result = setup.run(args)
+ self.assertEqual(result['reason'], reason)
+ saved = json.loads((root / 'setup.json').read_text())
+ self.assertEqual(saved['phases']['offer'], 'not_created')
+ self.assertEqual(self.offer_action(setup, base, 'offer-status')['status'], 'not_created')
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')):
+ self.assertTrue(setup.run(args)['ok'])
+
+ def test_unknown_creation_failure_preserves_id_until_explicit_status(self):
+ from session_peer_relay.store import Store
+ with self.offer_setup() as (setup, root, base, args):
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')), \
+ mock.patch.object(Store, 'create_offer', side_effect=OSError('fixture unavailable')):
+ self.assertFalse(setup.run(args)['ok'])
+ saved = json.loads((root / 'setup.json').read_text())
+ self.assertEqual(saved['phases']['offer'], 'pending')
+ self.assertEqual(setup.run(args)['reason'], 'setup_offer_reconcile_required')
+ result = self.offer_action(setup, base, 'offer-status', saved['offerId'])
+ self.assertFalse(result['ok'])
+ self.assertEqual(result['status'], 'unknown')
+ self.assertEqual(result['reason'], 'pairing_offer_history_unavailable')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'pending')
+ stopped = self.offer_action(setup, base, 'offer-cancel', saved['offerId'])
+ self.assertFalse(stopped['ok'])
+ self.assertEqual(stopped['offerId'], saved['offerId'])
+ self.assertTrue(stopped['stoppedWaiting'])
+ self.assertFalse(stopped['remoteCancellationConfirmed'])
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'stopped_unknown')
+ with mock.patch.object(ui, 'Terminal', side_effect=AssertionError('status must not create')):
+ self.assertEqual(self.offer_action(setup, base, 'offer-status')['status'], 'unknown')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['offerId'], saved['offerId'])
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')):
+ fresh = setup.run(args)
+ self.assertTrue(fresh['ok'], fresh)
+ self.assertNotEqual(fresh['offerId'], saved['offerId'])
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['offerId'], fresh['offerId'])
+
+ def test_committed_offer_survives_created_journal_callback_failure(self):
+ with self.offer_setup() as (setup, root, base, args):
+ save = setup.Workflow.save
+ pending_saves = []
+ def fail_created(work):
+ if work.saved['phases'].get('offer') == 'pending':
+ pending_saves.append(True)
+ if len(pending_saves) == 2:
+ raise OSError('fixture journal failure after DB commit')
+ return save(work)
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')), \
+ mock.patch.object(setup.Workflow, 'save', new=fail_created):
+ self.assertFalse(setup.run(args)['ok'])
+ saved = json.loads((root / 'setup.json').read_text())
+ self.assertEqual(saved['phases']['offer'], 'pending')
+ self.assertEqual(self.offer_action(setup, base, 'offer-status', saved['offerId'])['status'], 'open')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'pending')
+ self.assertEqual(self.offer_action(setup, base, 'offer-cancel', saved['offerId'])['status'], 'cancelled')
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')):
+ fresh = setup.run(args)
+ self.assertTrue(fresh['ok'], fresh)
+ self.assertNotEqual(fresh['offerId'], saved['offerId'])
+
+ def test_display_failure_requires_status_cancel_without_secret_reissue(self):
+ from session_peer_relay.store import Store
+ with self.offer_setup() as (setup, root, base, args):
+ terminal = self.terminal('yes')
+ def fail_link(text):
+ if text.startswith('session-peer://pair/'):
+ raise OSError('fixture display failure')
+ terminal.show.side_effect = fail_link
+ with mock.patch.object(ui, 'Terminal', return_value=terminal):
+ self.assertFalse(setup.run(args)['ok'])
+ saved = json.loads((root / 'setup.json').read_text())
+ self.assertEqual(saved['phases']['offer'], 'pending')
+ host = Store(root)
+ try:
+ other = host.create_offer({'direct': '127.0.0.1:3770'}, 'd' * 64)
+ finally:
+ host.close()
+ self.assertEqual(self.offer_action(setup, base, 'offer-cancel', other['id'])['status'], 'cancelled')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'pending')
+ self.assertEqual(self.offer_action(setup, base, 'offer-status')['status'], 'open')
+ with mock.patch.object(ui, 'Terminal', side_effect=AssertionError('must not reemit credential')):
+ self.assertEqual(self.offer_action(setup, base, 'offer-cancel')['status'], 'cancelled')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'cancelled')
+
+ def test_expired_status_resolves_pending_creation(self):
+ import time
+ with self.offer_setup() as (setup, root, base, args):
+ terminal = self.terminal('yes')
+ terminal.show.side_effect = lambda text: (_ for _ in ()).throw(OSError()) if text.startswith('session-peer://pair/') else None
+ with mock.patch.object(ui, 'Terminal', return_value=terminal):
+ self.assertFalse(setup.run(args)['ok'])
+ with mock.patch('time.time', return_value=time.time() + 601):
+ self.assertEqual(self.offer_action(setup, base, 'offer-status')['status'], 'expired')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'expired')
+
+ def test_reserved_status_preserves_pending_and_consumed_status_resolves_it(self):
+ from session_peer_relay.store import Store
+ with self.offer_setup() as (setup, root, base, args):
+ client = Store(root.parent / 'client')
+ try:
+ args.peer_key = client.key_id
+ terminal = self.terminal('yes')
+ def fail_link(text):
+ if text.startswith('session-peer://pair/'):
+ raise OSError('fixture display failure')
+ terminal.show.side_effect = fail_link
+ with mock.patch.object(ui, 'Terminal', return_value=terminal):
+ self.assertFalse(setup.run(args)['ok'])
+ link = next(call.args[0] for call in terminal.show.call_args_list
+ if call.args[0].startswith('session-peer://pair/'))
+ invite, unused = ui.decode_link(link)
+ host = Store(root)
+ try:
+ host.prepare({'invitation': invite['id'], 'secret': invite['secret'],
+ 'certificate': client.cert, 'offer': True})
+ finally:
+ host.close()
+ self.assertEqual(self.offer_action(setup, base, 'offer-status')['status'], 'reserved')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'pending')
+ host = Store(root)
+ try:
+ host.commit(client.key_id, invite['id'], authenticated_key=client.key_id, offer=True)
+ finally:
+ host.close()
+ self.assertEqual(self.offer_action(setup, base, 'offer-status')['status'], 'consumed')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'consumed')
+ finally:
+ client.close()
+
+ def test_pruned_consumed_receipt_is_unknown_and_stop_never_revokes_pairing(self):
+ import time
+ from session_peer_relay.store import Store
+ with self.offer_setup() as (setup, root, base, args):
+ client = Store(root.parent / 'client')
+ try:
+ args.peer_key = client.key_id
+ terminal = self.terminal('yes')
+ def fail_link(text):
+ if text.startswith('session-peer://pair/'):
+ raise OSError('fixture display failure')
+ terminal.show.side_effect = fail_link
+ with mock.patch.object(ui, 'Terminal', return_value=terminal):
+ self.assertFalse(setup.run(args)['ok'])
+ link = next(call.args[0] for call in terminal.show.call_args_list
+ if call.args[0].startswith('session-peer://pair/'))
+ invite, unused = ui.decode_link(link)
+ host = Store(root)
+ try:
+ host.prepare({'invitation': invite['id'], 'secret': invite['secret'],
+ 'certificate': client.cert, 'offer': True})
+ host.commit(client.key_id, invite['id'], authenticated_key=client.key_id, offer=True)
+ paired = host.peer(client.device)
+ finally:
+ host.close()
+ with mock.patch('time.time', return_value=time.time() + 7 * 86400 + 1):
+ missing = self.offer_action(setup, base, 'offer-status')
+ self.assertFalse(missing['ok'])
+ self.assertEqual(missing['status'], 'unknown')
+ self.assertEqual(missing['reason'], 'pairing_offer_history_unavailable')
+ self.assertEqual(json.loads((root / 'setup.json').read_text())['phases']['offer'], 'pending')
+ stopped = self.offer_action(setup, base, 'offer-cancel')
+ self.assertFalse(stopped['ok'])
+ self.assertTrue(stopped['stoppedWaiting'])
+ self.assertFalse(stopped['remoteCancellationConfirmed'])
+ journal = json.loads((root / 'setup.json').read_text())
+ self.assertEqual(journal['offerId'], invite['id'])
+ self.assertEqual(journal['phases']['offer'], 'stopped_unknown')
+ host = Store(root)
+ try:
+ self.assertEqual(host.peer(client.device), paired)
+ finally:
+ host.close()
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')):
+ duplicate = setup.run(args)
+ self.assertFalse(duplicate['ok'])
+ self.assertEqual(duplicate['reason'], 'principal_already_paired')
+ finally:
+ client.close()
+
+ def test_historical_restored_status_does_not_resolve_setup_pending(self):
+ from session_peer_relay.store import Store
+ with self.offer_setup() as (setup, root, base, args):
+ with mock.patch.object(ui, 'Terminal', return_value=self.terminal('yes')), \
+ mock.patch.object(Store, 'create_offer', side_effect=OSError('fixture unknown')):
+ self.assertFalse(setup.run(args)['ok'])
+ before = (root / 'setup.json').read_bytes()
+ with mock.patch.object(Store, 'offer_status', return_value={'ok': True,
+ 'status': 'consumed', 'recoveryRequired': True, 'historical': True}):
+ result = self.offer_action(setup, base, 'offer-status')
+ self.assertEqual(result['reason'], 'recovery_required')
+ self.assertEqual((root / 'setup.json').read_bytes(), before)
+ with mock.patch.object(Store, 'recovery_required', return_value=True), \
+ mock.patch.object(Store, 'cancel_offer', side_effect=AssertionError('no historical cancellation')):
+ result = self.offer_action(setup, base, 'offer-cancel')
+ self.assertEqual(result['reason'], 'recovery_required')
+ self.assertEqual((root / 'setup.json').read_bytes(), before)
+
+ def test_terminal_reads_beyond_canonical_limit_without_echo_and_restores(self):
+ master, slave = os.openpty()
+ saved = termios.tcgetattr(slave)
+ result, errors = [], []
+ def read():
+ try:
+ with ui.Terminal() as terminal:
+ result.append(terminal.read('hidden:', secret=True, limit=16384))
+ except BaseException as exc:
+ errors.append(exc)
+ try:
+ with mock.patch.object(ui.os, 'open', return_value=os.dup(slave)):
+ thread = threading.Thread(target=read)
+ thread.start()
+ self.assertTrue(select.select([master], [], [], 2)[0])
+ os.read(master, 1024)
+ # Wait for the input mode switch, without relying on scheduling.
+ import time
+ cutoff = time.monotonic() + 2
+ while termios.tcgetattr(slave)[3] & termios.ICANON:
+ self.assertLess(time.monotonic(), cutoff)
+ time.sleep(.001)
+ raw = b'x' * 12000 + b'\n'
+ while raw:
+ raw = raw[os.write(master, raw):]
+ thread.join(2)
+ self.assertFalse(thread.is_alive())
+ self.assertEqual(errors, [])
+ self.assertEqual(result, ['x' * 12000])
+ self.assert_terminal_restored(slave, saved)
+ self.assertNotIn(b'x', os.read(master, 1024))
+ finally:
+ os.close(master)
+ os.close(slave)
+
+
+class PairingLinkUILoopback(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ from session_peer_relay import app
+ from session_peer_relay.store import Store
+ self.directory = tempfile.TemporaryDirectory(prefix='codex-link-ui-tls-',
+ dir=os.environ.get('SESSION_PEER_TEST_TMP'))
+ root = Path(self.directory.name)
+ self.host, self.client = Store(root / 'host'), Store(root / 'client')
+ self.receiver = app.Receiver(self.host, {'targets': {'unused': {
+ 'agent': 'claude', 'target': 'offline-fixture'}}, 'peers': {
+ self.client.device: {'capabilities': ['list'], 'targets': ['unused']}}})
+ self.listener = await self.receiver.listen('127.0.0.1', 0)
+ routes = {'direct': '127.0.0.1:' + str(self.listener.sockets[0].getsockname()[1])}
+ self.invitation = self.host.create_offer(routes, self.client.key_id)
+ self.link = ui.encode_link(self.invitation, self.client.key_id)
+
+ async def asyncTearDown(self):
+ self.listener.close()
+ await self.listener.wait_closed()
+ await self.receiver.close()
+ self.client.close()
+ self.host.close()
+ self.directory.cleanup()
+
+ async def pair_through_ui(self):
+ from session_peer_relay import app
+ terminal = mock.MagicMock()
+ terminal.__enter__.return_value = terminal
+ terminal.read.side_effect = [self.link, self.host.key_id, 'yes']
+ changes = self.client.db.total_changes
+ with mock.patch.object(ui, 'Terminal', return_value=terminal):
+ invitation, intended, digest, route = ui.receive_link(self.client, 'direct')
+ self.assertEqual(self.client.db.total_changes, changes, 'confirmation mutated trust/intent')
+ return await app.pair(self.client, invitation, route,
+ offer_digest=digest, expected_client_key=intended)
+
+ async def lose_response(self, pairing):
+ from session_peer_relay.wire import Secure
+ receive = Secure.recv
+ async def lost(channel):
+ value = await receive(channel)
+ if value.get('pairing') == pairing:
+ raise OSError('fixture lost response')
+ return value
+ with mock.patch.object(Secure, 'recv', new=lost), self.assertRaises(OSError):
+ await self.pair_through_ui()
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'pending')
+
+ async def test_ui_reentry_reconciles_lost_prepare_response(self):
+ await self.lose_response('pending')
+ self.assertEqual(self.host.offer_status(self.invitation['id'])['status'], 'reserved')
+ result = await self.pair_through_ui()
+ self.assertTrue(result['ok'])
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'paired')
+
+ async def test_ui_reentry_reconciles_lost_commit_response_after_expiry(self):
+ await self.lose_response('paired')
+ self.assertEqual(self.host.offer_status(self.invitation['id'])['status'], 'consumed')
+ with mock.patch('time.time', return_value=self.invitation['expires'] + 1), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('expired prepare')):
+ result = await self.pair_through_ui()
+ self.assertTrue(result['ok'])
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'paired')
+
+ async def test_pending_ui_reentry_rejects_changed_link_without_writes_or_network(self):
+ from session_peer_relay import app
+ await self.lose_response('pending')
+ self.link = ui.encode_link({**self.invitation,
+ 'routes': {'direct': '127.0.0.1:9'}}, self.client.key_id)
+ changes = self.client.db.total_changes
+ with mock.patch.object(app, 'open_channel', side_effect=AssertionError('unexpected network')), \
+ self.assertRaisesRegex(Rejected, 'intent_conflict'):
+ await self.pair_through_ui()
+ self.assertEqual(self.client.db.total_changes, changes)
+
+ async def test_cold_expired_ui_input_has_no_writes_or_network(self):
+ from session_peer_relay import app
+ changes = self.client.db.total_changes
+ with mock.patch('time.time', return_value=self.invitation['expires'] + 1), \
+ mock.patch.object(app, 'open_channel', side_effect=AssertionError('unexpected network')), \
+ self.assertRaisesRegex(Rejected, 'invalid_invitation'):
+ await self.pair_through_ui()
+ self.assertEqual(self.client.db.total_changes, changes)
+
+
+class PairingLinkUITerminal(unittest.TestCase):
+ assert_terminal_restored = PairingLinkUI.assert_terminal_restored
+
+ def test_terminal_interrupt_restores_input_mode(self):
+ master, slave = os.openpty()
+ saved = termios.tcgetattr(slave)
+ try:
+ with mock.patch.object(ui.os, 'open', return_value=os.dup(slave)), \
+ mock.patch.object(ui.os, 'read', side_effect=KeyboardInterrupt), \
+ self.assertRaises(KeyboardInterrupt):
+ with ui.Terminal() as terminal:
+ terminal.read('hidden:', secret=True)
+ self.assert_terminal_restored(slave, saved)
+ finally:
+ os.close(master)
+ os.close(slave)
+
+ def test_one_sigint_interrupts_synchronous_terminal_read_inside_asyncio(self):
+ master, slave = os.openpty()
+ saved = termios.tcgetattr(slave)
+ original_handler = signal.getsignal(signal.SIGINT)
+ timer = None
+ async def read():
+ with ui.Terminal() as terminal:
+ terminal.read('hidden:', secret=True)
+ try:
+ with mock.patch.object(ui.os, 'open', return_value=os.dup(slave)):
+ timer = threading.Timer(.03, lambda: os.kill(os.getpid(), signal.SIGINT))
+ timer.start()
+ with self.assertRaises(KeyboardInterrupt):
+ asyncio.run(read())
+ self.assert_terminal_restored(slave, saved)
+ self.assertEqual(signal.getsignal(signal.SIGINT), original_handler)
+ finally:
+ if timer is not None:
+ timer.cancel()
+ timer.join()
+ os.close(master)
+ os.close(slave)
diff --git a/tests/relay/test_pairing_links.py b/tests/relay/test_pairing_links.py
new file mode 100644
index 0000000..e7726fb
--- /dev/null
+++ b/tests/relay/test_pairing_links.py
@@ -0,0 +1,143 @@
+"""Offline pairing envelope validation; no link or secret enters diagnostics."""
+import base64
+import copy
+import hashlib
+import json
+import os
+import pathlib
+import sys
+import tempfile
+import unittest
+
+if os.name != 'posix' or sys.version_info < (3, 11):
+ raise unittest.SkipTest('optional relay requires POSIX and Python 3.11+')
+try:
+ import cryptography
+ import websockets
+except ModuleNotFoundError as exc:
+ if exc.name not in ('cryptography', 'websockets'):
+ raise
+ raise unittest.SkipTest('optional relay dependencies unavailable') from None
+
+from session_peer_relay.pairing_links import (
+ PREFIX, MAX_LINK_BYTES, MAX_DECODED_BYTES, encode_link, decode_link, link_digest,
+)
+from session_peer_relay.store import Store
+
+
+class PairingLinks(unittest.TestCase):
+ def setUp(self):
+ directory = tempfile.TemporaryDirectory(prefix='codex-pair-link-', dir=os.environ.get('SESSION_PEER_TEST_TMP'))
+ self.addCleanup(directory.cleanup)
+ self.receiver = Store(pathlib.Path(directory.name) / 'receiver')
+ self.addCleanup(self.receiver.close)
+ self.invite = self.receiver.invite({'direct': '127.0.0.1:12345',
+ 'relay': 'wss://relay.example/v1/connect'})
+ self.key = 'a' * 64
+
+ def envelope(self, invitation=None, **extra):
+ return dict(v=1, invitation=self.invite if invitation is None else invitation,
+ clientKeyFingerprint=self.key, **extra)
+
+ def raw_link(self, value):
+ raw = value if isinstance(value, bytes) else json.dumps(value).encode()
+ return PREFIX + base64.urlsafe_b64encode(raw).decode().rstrip('=')
+
+ def rejected(self, link):
+ with self.assertRaisesRegex(ValueError, '^invalid_pairing_link$') as error:
+ decode_link(link)
+ self.assertIsNone(error.exception.__cause__)
+ self.assertNotIn(self.invite['secret'], str(error.exception))
+
+ def test_round_trip_and_digest_of_original_link(self):
+ link = encode_link(self.invite, self.key)
+ self.assertEqual(decode_link(link), (self.invite, self.key))
+ self.assertEqual(link_digest(link), hashlib.sha256(link.encode()).hexdigest())
+ formatted = self.raw_link(self.envelope())
+ self.assertEqual(decode_link(formatted), (self.invite, self.key))
+ self.assertNotEqual(link_digest(link), link_digest(formatted))
+
+ def test_rotated_invitation_checks_key_not_principal(self):
+ invite = dict(self.invite, v=2, device='b'*64,
+ keyFingerprint=self.receiver.key_id, keyGeneration=2)
+ self.assertEqual(decode_link(encode_link(invite, self.key)), (invite, self.key))
+ invite['keyFingerprint'] = invite['device']
+ self.rejected(self.raw_link(self.envelope(invite)))
+
+ def test_expired_invitation_remains_decodable_for_reconciliation(self):
+ invite = dict(self.invite, expires=0)
+ self.assertEqual(decode_link(encode_link(invite, self.key))[0], invite)
+
+ def test_prefix_ascii_padding_and_alphabet_are_strict(self):
+ link = encode_link(self.invite, self.key)
+ for bad in (link+'=', link+'\n', link+'é', link.replace('pair/v1', 'pair/v2'),
+ link.replace('session-peer:', 'https:', 1), PREFIX, None,
+ PREFIX+'a', PREFIX+'abc+', PREFIX+'abc/'):
+ with self.subTest(bad_type=type(bad).__name__):
+ self.rejected(bad)
+ # Nonzero unused padding bits must not alias a canonical encoding.
+ self.rejected(PREFIX+'Zh')
+
+ def test_bounds_apply_to_decoded_bytes_and_whole_ascii_link(self):
+ self.rejected(PREFIX + 'a' * MAX_LINK_BYTES)
+ self.rejected(self.raw_link(b' ' * (MAX_DECODED_BYTES+1)))
+ invite = dict(self.invite, certificate='x' * MAX_DECODED_BYTES)
+ with self.assertRaisesRegex(ValueError, '^invalid_pairing_link$'):
+ encode_link(invite, self.key)
+
+ def test_duplicate_fields_rejected_at_all_depths(self):
+ raw = json.dumps(self.envelope())
+ for bad in (raw.replace('"v": 1', '"v": 1, "v": 1', 1),
+ raw.replace('"secret":', '"v": 1, "secret":', 1),
+ raw.replace('"direct":', '"direct":"localhost:1", "direct":', 1)):
+ self.rejected(self.raw_link(bad.encode()))
+
+ def test_exact_wrapper_and_invitation_fields(self):
+ for envelope in (self.envelope(extra='secret'),
+ {'v': 1, 'invitation': self.invite},
+ dict(self.envelope(), v=True), dict(self.envelope(), v=2), []):
+ self.rejected(self.raw_link(envelope))
+ for invite in (dict(self.invite, extra='secret'), dict(self.invite, v=True),
+ dict(self.invite, v=2), dict(self.invite, keyGeneration=0)):
+ self.rejected(self.raw_link(self.envelope(invite)))
+
+ def test_invalid_ids_secret_certificate_and_route_are_sanitized(self):
+ for field, bad in (
+ ('id', self.invite['id'].upper()), ('id', '0'*36),
+ ('device', self.receiver.device.upper()), ('device', self.key),
+ ('secret', 'a'*43), ('secret', self.invite['secret']+'='),
+ ('certificate', 'PRIVATE SECRET'), ('routes', {}), ('routes', {'unknown': 'value'}),
+ ('routes', {'direct': 'user:pass@localhost:1'}),
+ ('routes', {'relay': 'https://relay.example/v1/connect'}),
+ ('routes', {'relay': 'wss://relay.example/v1/connect?secret=value'}),
+ ('routes', {'direct': 'localhost:1\n'}), ('routes', []),
+ ):
+ with self.subTest(field=field):
+ self.rejected(self.raw_link(self.envelope(dict(self.invite, **{field: bad}))))
+ for bad in (self.key.upper(), 'a'*63, None):
+ self.rejected(self.raw_link(dict(self.envelope(), clientKeyFingerprint=bad)))
+
+ def test_numeric_diagnostics_and_generation_are_finite_exact_types(self):
+ for expires in (True, '1', float('nan'), float('inf'), -float('inf')):
+ self.rejected(self.raw_link(self.envelope(dict(self.invite, expires=expires))))
+ # An exponent overflowing float is not handled by parse_constant.
+ raw = json.dumps(self.envelope()).replace(str(self.invite['expires']), '1e9999')
+ self.rejected(self.raw_link(raw.encode()))
+ for generation in (True, 0, -1, 1.0, '1'):
+ invite = dict(self.invite, v=2, keyFingerprint=self.receiver.key_id,
+ keyGeneration=generation)
+ self.rejected(self.raw_link(self.envelope(invite)))
+
+ def test_malformed_json_unicode_and_deep_input_have_fixed_errors(self):
+ for raw in (b'{', b'\xff', b'[]', b'null', b'['*1100+b']'*1100):
+ self.rejected(self.raw_link(raw))
+
+ def test_encoding_does_not_mutate_invitation_and_sanitizes_errors(self):
+ original = copy.deepcopy(self.invite)
+ encode_link(self.invite, self.key)
+ self.assertEqual(original, self.invite)
+ for invite in (None, dict(self.invite, expires=float('nan'))):
+ with self.assertRaisesRegex(ValueError, '^invalid_pairing_link$'):
+ encode_link(invite, self.key)
+ with self.assertRaisesRegex(ValueError, '^invalid_pairing_link$'):
+ link_digest('secret-bad-link')
diff --git a/tests/relay/test_pairing_offer_restore.py b/tests/relay/test_pairing_offer_restore.py
new file mode 100644
index 0000000..07a5d60
--- /dev/null
+++ b/tests/relay/test_pairing_offer_restore.py
@@ -0,0 +1,110 @@
+"""Offline snapshot fencing for new pairing tables; no native or network calls."""
+import json
+import time
+import unittest
+from unittest import mock
+
+# Reuse the platform/dependency guard and private state fixture, not its tests.
+from tests.relay import test_pairing_offers as offers
+from session_peer_relay.identity import private_write
+from session_peer_relay.lifecycle import backup, restore
+from session_peer_relay.pairing_links import encode_link, link_digest
+from session_peer_relay.store import Store, Rejected
+
+
+class OfferRestore(unittest.TestCase):
+ setUp = offers.Offers.setUp
+ prepare = offers.Offers.prepare
+ commit = offers.Offers.commit
+
+ def test_eight_day_restored_pairing_history_is_immutable_under_lifecycle_calls(self):
+ routes = {'direct': '127.0.0.1:1'}
+ consumed = self.host.create_offer(routes, self.client.key_id)
+ self.prepare(consumed)
+ self.commit(consumed)
+ incoming = self.other.create_offer(routes, self.host.key_id)
+ digest = link_digest(encode_link(incoming, self.host.key_id))
+ self.host.save_offer_intent(incoming, digest, self.host.key_id)
+ reserved = self.host.create_offer(routes, self.other.key_id)
+ self.prepare(reserved, client=self.other)
+ policy = self.root/'forensic-policy.json'
+ private_write(policy, json.dumps({'targets': {
+ 'fixture': {'agent': 'claude', 'target': 'fixture'}}, 'peers': {}}))
+ snapshot = self.root/'forensic-snapshot'
+ backup(self.host, snapshot, policy)
+ destination = self.root/'forensic-restored'
+ restore(snapshot, destination)
+ recovered = Store(destination)
+ self.addCleanup(recovered.close)
+ before = recovered.db.serialize()
+ changes = recovered.db.total_changes
+ actions = (
+ lambda: recovered.prepare({'invitation': reserved['id'], 'secret': reserved['secret'],
+ 'certificate': self.other.cert, 'offer': True}),
+ lambda: recovered.commit(self.client.key_id, consumed['id'], authenticated_key=self.client.key_id, offer=True),
+ lambda: recovered.create_offer(routes, 'a'*64),
+ lambda: recovered.cancel_offer(reserved['id']),
+ lambda: recovered.stage(incoming),
+ lambda: recovered.remember(incoming),
+ lambda: recovered.discard_pairing_stage(reserved['id']),
+ lambda: recovered.invite(routes),
+ lambda: recovered.offer_identity('a'*64),
+ lambda: recovered.save_offer_intent(incoming, digest, recovered.key_id),
+ lambda: recovered.receiver_offer_identity(incoming, digest, recovered.key_id),
+ )
+ with mock.patch('time.time', return_value=time.time()+8*86400):
+ for action in actions:
+ with self.assertRaisesRegex(Rejected, '^recovery_required$'):
+ action()
+ self.assertEqual(recovered.db.total_changes, changes)
+ self.assertTrue(recovered.db.serialize() == before, 'restored pairing evidence changed')
+ for invite, expected in ((consumed, 'consumed'), (reserved, 'reserved')):
+ result = recovered.offer_status(invite['id'])
+ self.assertEqual(result['status'], expected)
+ self.assertTrue(result['historical'])
+ self.assertTrue(result['recoveryRequired'])
+ self.assertEqual(recovered.db.total_changes, changes)
+ self.assertTrue(recovered.db.serialize() == before, 'diagnostics rewrote restored pairing evidence')
+
+ def test_snapshot_keeps_all_new_tables_but_never_reactivates_authority(self):
+ routes = {'direct': '127.0.0.1:1'}
+ consumed = self.host.create_offer(routes, self.client.key_id)
+ self.prepare(consumed)
+ self.commit(consumed)
+ cancelled = self.host.create_offer(routes, self.other.key_id)
+ self.host.cancel_offer(cancelled['id'])
+ incoming = self.other.create_offer(routes, self.host.key_id)
+ digest = link_digest(encode_link(incoming, self.host.key_id))
+ self.host.save_offer_intent(incoming, digest, self.host.key_id)
+ reserved = self.host.create_offer(routes, self.other.key_id)
+ self.prepare(reserved, client=self.other)
+ tables = ('pairing_offers', 'pairing_reservations', 'pairing_limits', 'pairing_intents')
+ counts = {table: self.host.db.execute('SELECT COUNT(*) FROM '+table).fetchone()[0]
+ for table in tables}
+ policy = self.root/'policy.json'
+ private_write(policy, json.dumps({'targets': {
+ 'fixture': {'agent': 'claude', 'target': 'fixture'}}, 'peers': {}}))
+ snapshot = self.root/'snapshot'
+ backup(self.host, snapshot, policy)
+ # The live receiver commits after the snapshot; restoring must not make
+ # the older reserved invitation usable again, even with the right key.
+ self.commit(reserved, client=self.other)
+ destination = self.root/'restored'
+ restore(snapshot, destination)
+ recovered = Store(destination)
+ self.addCleanup(recovered.close)
+ self.assertTrue(recovered.recovery_required())
+ for table, count in counts.items():
+ self.assertEqual(recovered.db.execute('SELECT COUNT(*) FROM '+table).fetchone()[0], count)
+ self.assertEqual(recovered.offer_status(consumed['id'])['status'], 'consumed')
+ self.assertEqual(recovered.offer_status(cancelled['id'])['status'], 'cancelled')
+ self.assertEqual(recovered.offer_status(reserved['id'])['status'], 'reserved')
+ for invitation, client in ((consumed, self.client), (reserved, self.other)):
+ with self.assertRaisesRegex(Rejected, 'recovery_required'):
+ recovered.commit(client.key_id, invitation['id'],
+ authenticated_key=client.key_id, offer=True)
+ with self.assertRaisesRegex(Rejected, 'recovery_required'):
+ recovered.prepare({'invitation': invitation['id'], 'secret': invitation['secret'],
+ 'certificate': client.cert, 'offer': True})
+ with self.assertRaisesRegex(Rejected, 'recovery_required'):
+ recovered.create_offer(routes, 'a'*64)
diff --git a/tests/relay/test_pairing_offers.py b/tests/relay/test_pairing_offers.py
new file mode 100644
index 0000000..cd36d43
--- /dev/null
+++ b/tests/relay/test_pairing_offers.py
@@ -0,0 +1,786 @@
+"""Offline offers and loopback TLS; no external or native agent is contacted."""
+import asyncio
+import concurrent.futures
+import hashlib
+import json
+import os
+from pathlib import Path
+import sys
+import tempfile
+import threading
+import time
+import unittest
+import uuid
+from unittest import mock
+
+if os.name != 'posix' or sys.version_info < (3, 11):
+ raise unittest.SkipTest('optional relay requires POSIX and Python 3.11+')
+try:
+ import cryptography
+ import websockets
+except ModuleNotFoundError as exc:
+ if exc.name not in ('cryptography', 'websockets'):
+ raise
+ raise unittest.SkipTest('optional relay dependencies unavailable') from None
+
+from session_peer_relay import app
+from session_peer_relay.pairing_links import encode_link, link_digest
+from session_peer_relay.store import Store, Rejected
+
+
+class Offers(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory(prefix='codex-offer-', dir=os.environ.get('SESSION_PEER_TEST_TMP'))
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ self.host, self.client, self.other = [Store(self.root/name) for name in ('host', 'client', 'other')]
+ for store in (self.host, self.client, self.other):
+ self.addCleanup(store.close)
+
+ def offer(self):
+ return self.host.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id)
+
+ def prepare(self, invite, *, client=None, **overrides):
+ client = client or self.client
+ request = {'invitation': invite['id'], 'secret': invite['secret'], 'certificate': client.cert, 'offer': True}
+ request.update(overrides)
+ return self.host.prepare(request)
+
+ def commit(self, invite, *, client=None):
+ client = client or self.client
+ return self.host.commit(client.key_id, invite['id'], authenticated_key=client.key_id, offer=True)
+
+ def test_offer_binds_exact_client_and_never_stores_secret(self):
+ invite = self.offer()
+ self.assertEqual(self.host.offer_identity(self.client.key_id),
+ {'principal': self.client.key_id, 'keyFingerprint': self.client.key_id, 'generation': 0})
+ with self.assertRaises(Rejected):
+ self.prepare(invite, client=self.other)
+ self.assertIsNone(self.host.peer(self.other.device))
+ prepared = self.prepare(invite)
+ self.assertEqual(prepared['offerId'], invite['id'])
+ self.assertIn('pairing-offer-v1', prepared['features'])
+ self.assertEqual(self.host.offer_status(invite['id'])['status'], 'reserved')
+ dump = '\n'.join(self.host.db.iterdump())
+ self.assertNotIn(invite['secret'], dump)
+ self.assertNotIn('secret', json.dumps(self.host.offer_status(invite['id'])))
+
+ def test_preallocated_offer_id_is_canonical_unique_and_status_reconcilable(self):
+ ident = str(uuid.uuid4())
+ invite = self.host.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id, offer_id=ident)
+ self.assertEqual(invite['id'], ident)
+ self.assertEqual(self.host.offer_status(ident)['status'], 'open')
+ with self.assertRaisesRegex(Rejected, '^pairing_offer_exists$'):
+ self.host.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id, offer_id=ident)
+ for invalid in ('not-an-id', str(uuid.uuid4()).upper(), str(uuid.uuid1())):
+ with self.assertRaisesRegex(Rejected, '^invalid_pairing_offer_id$'):
+ self.host.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id, offer_id=invalid)
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0], 1)
+
+ def test_creation_identity_cas_and_already_paired_client_refuse_before_mint(self):
+ snapshot = self.host.offer_identity(self.client.key_id)
+ self.host.register_key('a'*64, self.client.cert, 2)
+ with self.assertRaisesRegex(Rejected, '^pairing_identity_changed$'):
+ self.host.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id, expected_identity=snapshot)
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM invites').fetchone()[0], 0)
+ snapshot = self.host.offer_identity(self.client.key_id)
+ self.host.db.execute('UPDATE peer_keys SET status="revoked" WHERE fingerprint=?', (self.client.key_id,))
+ with self.assertRaisesRegex(Rejected, '^pairing_identity_changed$'):
+ self.host.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id, expected_identity=snapshot)
+ self.host.db.execute('DELETE FROM peer_keys WHERE fingerprint=?', (self.client.key_id,))
+ invite = self.offer()
+ self.prepare(invite)
+ self.commit(invite)
+ with self.assertRaisesRegex(Rejected, '^principal_already_paired$'):
+ self.offer()
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0], 1)
+
+ def test_unencodable_offer_routes_roll_back_invitation_and_metadata(self):
+ routes = ({}, {'direct': '호스트.invalid:1'}, {'direct': 'x'*17000+':1'})
+ for value in routes:
+ with self.assertRaisesRegex(Rejected, '^invalid_pairing_link$') as error:
+ self.host.create_offer(value, self.client.key_id)
+ self.assertIsNone(error.exception.__cause__)
+ self.assertEqual(str(error.exception), 'invalid_pairing_link')
+ for table in ('invites', 'pairing_offers', 'pairing_reservations', 'peers', 'peer_keys'):
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM '+table).fetchone()[0], 0)
+ self.assertTrue(self.offer())
+
+ def test_no_legacy_prepare_or_commit_downgrade(self):
+ invite = self.offer()
+ with self.assertRaises(Rejected):
+ self.prepare(invite, offer=False)
+ self.prepare(invite)
+ with self.assertRaises(Rejected):
+ self.host.commit(self.client.key_id)
+ with self.assertRaises(Rejected):
+ self.host.commit(self.client.key_id, invite['id'], authenticated_key=self.client.key_id)
+ with self.assertRaises(Rejected):
+ self.host.commit(self.client.key_id, authenticated_key=self.client.key_id, offer=True)
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'pending')
+ self.assertTrue(self.commit(invite)['ok'])
+ legacy = self.host.invite({})
+ with self.assertRaises(Rejected):
+ self.prepare(legacy, client=self.other)
+
+ def test_consumed_receipt_exact_key_idempotent_after_expiry_without_undoing_revoke(self):
+ invite = self.offer()
+ self.prepare(invite)
+ result = self.commit(invite)
+ with mock.patch('time.time', return_value=invite['expires']+1):
+ self.assertEqual(self.commit(invite), result)
+ with self.assertRaises(Rejected):
+ self.commit(invite, client=self.other)
+ self.host.revoke(self.client.device)
+ with self.assertRaises(Rejected):
+ self.commit(invite)
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'revoked')
+
+ def test_cancellation_only_removes_owned_pending_trust(self):
+ invite = self.offer()
+ self.prepare(invite)
+ self.assertEqual(self.host.cancel_offer(invite['id'])['status'], 'cancelled')
+ self.assertIsNone(self.host.peer(self.client.device))
+ self.assertIsNone(self.host.db.execute('SELECT 1 FROM peer_keys WHERE fingerprint=?', (self.client.key_id,)).fetchone())
+ with self.assertRaises(Rejected):
+ self.commit(invite)
+ self.host.register_key(self.client.device, self.client.cert)
+ second = self.offer()
+ self.prepare(second)
+ self.host.cancel_offer(second['id'])
+ self.assertIsNone(self.host.peer(self.client.device))
+ self.assertEqual(self.host.key_status(self.client.key_id), 'active')
+ self.assertIsNotNone(self.host.db.execute('SELECT 1 FROM peer_keys WHERE fingerprint=?', (self.client.key_id,)).fetchone())
+
+ def test_consumed_cancel_preserves_completed_pair_and_anchor(self):
+ invite = self.offer()
+ self.prepare(invite)
+ self.commit(invite)
+ self.assertEqual(self.host.cancel_offer(invite['id'])['status'], 'consumed')
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'paired')
+ self.assertEqual(self.host.key_status(self.client.key_id), 'active')
+
+ def test_expiry_atomically_releases_pending_and_refuses_commit(self):
+ invite = self.offer()
+ self.prepare(invite)
+ with mock.patch('time.time', return_value=invite['expires']+1):
+ with self.assertRaises(Rejected):
+ self.commit(invite)
+ self.assertEqual(self.host.offer_status(invite['id'])['status'], 'expired')
+ self.assertIsNone(self.host.peer(self.client.device))
+
+ def test_expiry_during_certificate_validation_cannot_create_pending_authority(self):
+ invite = self.offer()
+ clock = [invite['expires']-1]
+ from session_peer_relay import store as store_module
+ original = store_module.fingerprint
+ def parse_then_expire(pem):
+ result = original(pem)
+ clock[0] = invite['expires']
+ return result
+ with mock.patch('time.time', side_effect=lambda: clock[0]), \
+ mock.patch.object(store_module, 'fingerprint', side_effect=parse_then_expire):
+ with self.assertRaisesRegex(Rejected, '^invalid_invitation$'):
+ self.prepare(invite)
+ self.assertIsNone(self.host.peer(self.client.device))
+ self.assertIsNone(self.host.db.execute('SELECT 1 FROM peer_keys WHERE fingerprint=?', (self.client.key_id,)).fetchone())
+ self.assertIsNone(self.host.db.execute('SELECT 1 FROM pairing_reservations').fetchone())
+ self.assertEqual(self.host.db.execute('SELECT peer FROM invites WHERE id=?', (invite['id'],)).fetchone(), (None,))
+ self.assertEqual(self.host.offer_status(invite['id'])['status'], 'open')
+
+ def test_expiry_during_commit_binding_validation_cannot_promote_pending_peer(self):
+ invite = self.offer()
+ self.prepare(invite)
+ clock = [invite['expires']-1]
+ database = self.host.db
+ proxy = mock.Mock(wraps=database)
+ def query_then_expire(sql, *args, **kwargs):
+ result = database.execute(sql, *args, **kwargs)
+ if sql.startswith('SELECT client_key,principal,state,receipt FROM pairing_offers'):
+ clock[0] = invite['expires']
+ return result
+ proxy.execute.side_effect = query_then_expire
+ with mock.patch('time.time', side_effect=lambda: clock[0]), \
+ mock.patch.object(self.host, 'db', proxy):
+ with self.assertRaisesRegex(Rejected, '^pairing_not_pending$'):
+ self.commit(invite)
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'pending')
+ self.assertEqual(self.host.db.execute('SELECT state,receipt FROM pairing_offers WHERE id=?', (invite['id'],)).fetchone(), ('reserved', None))
+ self.assertIsNotNone(self.host.db.execute('SELECT 1 FROM pairing_reservations WHERE invitation=?', (invite['id'],)).fetchone())
+
+ def test_offer_identity_does_not_fallback_for_pending_retired_or_revoked(self):
+ invite = self.offer()
+ self.prepare(invite)
+ with self.assertRaises(Rejected):
+ self.host.offer_identity(self.client.key_id)
+ self.host.cancel_offer(invite['id'])
+ self.host.register_key(self.client.device, self.client.cert)
+ for status in ('pending', 'retired', 'revoked'):
+ self.host.db.execute('UPDATE peer_keys SET status=? WHERE fingerprint=?', (status, self.client.key_id))
+ with self.assertRaises(Rejected):
+ self.host.offer_identity(self.client.key_id)
+
+ def test_rotated_identity_comes_only_from_exact_active_anchor(self):
+ principal = 'a'*64
+ self.host.register_key(principal, self.client.cert, 4)
+ self.assertEqual(self.host.offer_identity(self.client.key_id)['principal'], principal)
+ invite = self.offer()
+ with self.assertRaises(Rejected):
+ self.prepare(invite)
+ self.prepare(invite, principal=principal, generation=4)
+ self.assertTrue(self.commit(invite)['ok'])
+
+ def test_invitation_reservation_cannot_overwrite_another_or_downgrade_paired(self):
+ first, second = self.offer(), self.offer()
+ self.prepare(first)
+ with self.assertRaises(Rejected):
+ self.prepare(second)
+ self.assertEqual(self.host.offer_status(first['id'])['status'], 'reserved')
+ self.commit(first)
+ with self.assertRaises(Rejected):
+ self.prepare(second)
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'paired')
+
+ def test_creation_quota_active_quota_and_persistence(self):
+ now = time.time()
+ with mock.patch('time.time', return_value=now):
+ for unused in range(5):
+ self.offer()
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.offer()
+ reopened = Store(self.host.root)
+ try:
+ with mock.patch('time.time', return_value=now):
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ reopened.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id)
+ with mock.patch('time.time', return_value=now+61):
+ for unused in range(3):
+ reopened.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id)
+ with self.assertRaisesRegex(Rejected, 'capacity'):
+ reopened.create_offer({'direct': '127.0.0.1:1'}, self.client.key_id)
+ finally:
+ reopened.close()
+
+ def test_full_quota_rejects_without_writes_or_extending_window(self):
+ now = time.time()
+ with mock.patch('time.time', return_value=now):
+ for unused in range(5):
+ self.offer()
+ before = self.host.db.total_changes
+ attempts = self.host.db.execute('SELECT attempts FROM pairing_limits WHERE scope="create"').fetchone()[0]
+ with mock.patch('time.time', return_value=now+59):
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.offer()
+ self.assertEqual(self.host.db.total_changes, before)
+ self.assertEqual(self.host.db.execute('SELECT attempts FROM pairing_limits WHERE scope="create"').fetchone()[0], attempts)
+ with mock.patch('time.time', return_value=now+60):
+ self.assertIsNotNone(self.offer())
+
+ def test_full_global_quota_never_allocates_or_updates_offer_bucket(self):
+ invite = self.offer()
+ for unused in range(32):
+ with self.assertRaises(Rejected):
+ self.host.prepare({'invitation': str(uuid.uuid4())})
+ before = self.host.db.total_changes
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.prepare(invite)
+ self.assertEqual(self.host.db.total_changes, before)
+ self.assertIsNone(self.host.db.execute('SELECT 1 FROM pairing_limits WHERE scope=?', ('offer:'+invite['id'],)).fetchone())
+
+ def test_full_offer_bucket_does_not_extend_but_denials_charge_remaining_global_quota(self):
+ invite = self.offer()
+ now = time.time()
+ with mock.patch('time.time', return_value=now):
+ for unused in range(8):
+ with self.assertRaises(Rejected):
+ self.prepare(invite, secret='incorrect')
+ scope = 'offer:'+invite['id']
+ attempts = self.host.db.execute('SELECT attempts FROM pairing_limits WHERE scope=?', (scope,)).fetchone()[0]
+ with mock.patch('time.time', return_value=now+59):
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.prepare(invite)
+ self.assertEqual(self.host.db.execute('SELECT attempts FROM pairing_limits WHERE scope=?', (scope,)).fetchone()[0], attempts)
+ global_attempts = json.loads(self.host.db.execute('SELECT attempts FROM pairing_limits WHERE scope="prepare"').fetchone()[0])
+ self.assertEqual(len(global_attempts), 9)
+ with mock.patch('time.time', return_value=now+60):
+ self.assertTrue(self.prepare(invite)['ok'])
+
+ def test_expired_premigration_pending_state_releases_peer_but_preserves_anchor(self):
+ invitation = self.host.invite({})
+ self.host.prepare({'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert})
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ with mock.patch('time.time', return_value=invitation['expires']+1):
+ identity = self.host.offer_identity(self.client.key_id)
+ self.assertEqual(identity['principal'], self.client.device)
+ self.assertIsNone(self.host.peer(self.client.device))
+ self.assertEqual(self.host.key_status(self.client.key_id), 'active')
+ fresh = self.offer()
+ self.assertTrue(self.prepare(fresh)['ok'])
+ self.assertEqual(self.host.db.execute('SELECT expires FROM invites WHERE id=?', (invitation['id'],)).fetchone(), (0,))
+
+ def test_expired_premigration_pending_with_live_ambiguous_authority_stays_protected(self):
+ invitation = self.host.invite({})
+ self.host.prepare({'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert})
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ second = self.host.invite({})
+ self.host.db.execute('UPDATE invites SET peer=?,expires=? WHERE id=?',
+ (self.client.device, invitation['expires']+100, second['id']))
+ with mock.patch('time.time', return_value=invitation['expires']+1):
+ with self.assertRaises(Rejected):
+ self.host.offer_identity(self.client.key_id)
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'pending')
+ self.assertEqual(self.host.key_status(self.client.key_id), 'active')
+
+ def test_expired_premigration_legacy_pending_can_prepare_a_fresh_file_invitation(self):
+ invitation = self.host.invite({})
+ self.host.prepare({'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert})
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ with mock.patch('time.time', return_value=invitation['expires']+1):
+ fresh = self.host.invite({})
+ self.assertTrue(self.host.prepare({'invitation': fresh['id'], 'secret': fresh['secret'],
+ 'certificate': self.client.cert})['ok'])
+ self.assertEqual(self.host.db.execute('SELECT invitation FROM pairing_reservations').fetchone(), (fresh['id'],))
+
+ def test_failed_guesses_persist_offer_quota_and_unknown_ids_allocate_no_rows(self):
+ invite = self.offer()
+ for unused in range(8):
+ with self.assertRaises(Rejected):
+ self.prepare(invite, secret='incorrect')
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.prepare(invite)
+ before = self.host.db.execute('SELECT COUNT(*) FROM pairing_limits').fetchone()[0]
+ for unused in range(4):
+ with self.assertRaises(Rejected):
+ self.host.prepare({'invitation': str(uuid.uuid4()), 'secret': 'incorrect'})
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_limits').fetchone()[0], before)
+ self.assertIsNone(self.host.peer(self.client.device))
+
+ def test_global_prepare_limit_is_durable_even_without_known_invitation(self):
+ for unused in range(32):
+ with self.assertRaises(Rejected):
+ self.host.prepare({'invitation': str(uuid.uuid4()), 'secret': 'incorrect'})
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.prepare(self.offer())
+
+ def test_every_malformed_prepare_including_false_id_consumes_global_quota(self):
+ invalid = ({'invitation': False}, {'invitation': []}, None, {}, True)
+ for index in range(32):
+ with self.assertRaises(Rejected):
+ self.host.prepare(invalid[index % len(invalid)])
+ self.assertEqual(self.host.db.execute('SELECT scope FROM pairing_limits').fetchall(), [('prepare',)])
+ with self.assertRaisesRegex(Rejected, 'rate_limited'):
+ self.host.prepare({'invitation': False})
+
+ def test_unambiguous_premigration_legacy_reservation_is_adopted_for_commit(self):
+ invitation = self.host.invite({})
+ self.host.prepare({'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert})
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ self.assertTrue(self.host.commit(self.client.key_id)['ok'])
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'paired')
+ self.assertEqual(self.host.db.execute('SELECT expires FROM invites WHERE id=?', (invitation['id'],)).fetchone(), (0,))
+
+ def test_premigration_legacy_prepare_adopts_only_its_exact_live_invitation(self):
+ invitation = self.host.invite({})
+ request = {'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert}
+ self.host.prepare(request)
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ self.assertTrue(self.host.prepare(request)['ok'])
+ self.assertEqual(self.host.db.execute('SELECT invitation FROM pairing_reservations').fetchone(), (invitation['id'],))
+
+ def test_ambiguous_premigration_reservations_remain_unmodified_and_refused(self):
+ invitation = self.host.invite({})
+ self.host.prepare({'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert})
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ other = self.host.invite({})
+ self.host.db.execute('UPDATE invites SET peer=? WHERE id=?', (self.client.device, other['id']))
+ with self.assertRaises(Rejected):
+ self.host.commit(self.client.key_id)
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'pending')
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_reservations').fetchone()[0], 0)
+
+ def test_premigration_offer_ownership_is_never_inferred(self):
+ invitation = self.offer()
+ self.prepare(invitation)
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ with self.assertRaises(Rejected):
+ self.commit(invitation)
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_reservations').fetchone()[0], 0)
+
+ def test_premigration_expiry_or_certificate_mismatch_cannot_be_adopted(self):
+ invitation = self.host.invite({})
+ self.host.prepare({'invitation': invitation['id'], 'secret': invitation['secret'], 'certificate': self.client.cert})
+ self.host.db.execute('DELETE FROM pairing_reservations')
+ self.host.db.execute('UPDATE peers SET expires=expires+1 WHERE id=?', (self.client.device,))
+ with self.assertRaises(Rejected):
+ self.host.commit(self.client.key_id)
+ with self.assertRaises(Rejected):
+ self.host.commit(self.other.key_id)
+
+ def test_terminal_retention_prunes_bound_invitation_together(self):
+ invite = self.offer()
+ self.host.cancel_offer(invite['id'])
+ with mock.patch('time.time', return_value=time.time()+7*86400+1):
+ with self.assertRaisesRegex(Rejected, 'unknown'):
+ self.host.offer_status(invite['id'])
+ self.assertIsNone(self.host.db.execute('SELECT 1 FROM invites WHERE id=?', (invite['id'],)).fetchone())
+ self.assertIsNone(self.host.db.execute('SELECT 1 FROM pairing_offers WHERE id=?', (invite['id'],)).fetchone())
+
+ def test_cross_connection_prepare_race_has_single_owner(self):
+ invitations = [self.offer(), self.offer()]
+ barrier = threading.Barrier(2)
+ def attempt(invite):
+ store = Store(self.host.root)
+ try:
+ barrier.wait(timeout=2)
+ try:
+ store.prepare({'invitation': invite['id'], 'secret': invite['secret'],
+ 'certificate': self.client.cert, 'offer': True})
+ return True
+ except Rejected:
+ return False
+ finally:
+ store.close()
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
+ results = list(executor.map(attempt, invitations))
+ self.assertEqual(sorted(results), [False, True])
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_reservations').fetchone()[0], 1)
+
+ def test_cancel_commit_race_has_one_terminal_state_without_partial_trust(self):
+ invite = self.offer()
+ self.prepare(invite)
+ barrier = threading.Barrier(2)
+ def operation(commit):
+ store = Store(self.host.root)
+ try:
+ barrier.wait(timeout=2)
+ try:
+ if commit:
+ return store.commit(self.client.key_id, invite['id'], authenticated_key=self.client.key_id, offer=True)
+ return store.cancel_offer(invite['id'])
+ except Rejected:
+ return None
+ finally:
+ store.close()
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
+ list(executor.map(operation, (True, False)))
+ status = self.host.offer_status(invite['id'])['status']
+ self.assertIn(status, ('cancelled', 'consumed'))
+ peer = self.host.peer(self.client.device)
+ if status == 'consumed':
+ self.assertEqual(peer['status'], 'paired')
+ else:
+ self.assertIsNone(peer)
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_reservations').fetchone()[0], 0)
+
+ def test_terminal_cap_fails_closed_until_safe_paired_pruning(self):
+ invite = self.offer()
+ self.host.cancel_offer(invite['id'])
+ now = time.time()
+ ids = [str(uuid.uuid4()) for unused in range(1023)]
+ self.host.db.execute('BEGIN IMMEDIATE')
+ try:
+ self.host.db.executemany('INSERT INTO invites VALUES(?,?,0,NULL)', [(ident, 'a'*64) for ident in ids])
+ self.host.db.executemany('INSERT INTO pairing_offers VALUES(?,?,?,?,"cancelled",?,?,NULL)',
+ [(ident, self.client.key_id, self.client.device, 0, now, now) for ident in ids])
+ self.host.db.execute('COMMIT')
+ except BaseException:
+ self.host.db.execute('ROLLBACK')
+ raise
+ with self.assertRaisesRegex(Rejected, 'capacity'):
+ self.offer()
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0], 1024)
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM invites').fetchone()[0], 1024)
+ with mock.patch('time.time', return_value=now+7*86400+1):
+ fresh = self.offer()
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0], 1)
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM invites').fetchone()[0], 1)
+ self.assertNotIn(fresh['id'], ids)
+
+
+class OfferTLS(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self):
+ self.directory = tempfile.TemporaryDirectory(prefix='codex-offer-tls-', dir=os.environ.get('SESSION_PEER_TEST_TMP'))
+ self.root = Path(self.directory.name)
+ self.host, self.client = Store(self.root/'h'), Store(self.root/'c')
+ self.receiver = app.Receiver(self.host, {'targets': {'unused': {'agent': 'claude', 'target': 'offline-fixture'}},
+ 'peers': {self.client.device: {'capabilities': ['list'], 'targets': ['unused']}}})
+ self.listener = await self.receiver.listen('127.0.0.1', 0)
+ self.routes = {'direct': '127.0.0.1:'+str(self.listener.sockets[0].getsockname()[1])}
+ self.invite = self.host.create_offer(self.routes, self.client.key_id)
+ self.digest = link_digest(encode_link(self.invite, self.client.key_id))
+
+ async def asyncTearDown(self):
+ self.listener.close()
+ await self.listener.wait_closed()
+ await self.receiver.close()
+ self.client.close()
+ self.host.close()
+ self.directory.cleanup()
+
+ async def pair(self, invite=None, digest=None):
+ return await app.pair(self.client, invite or self.invite, 'direct',
+ offer_digest=digest or self.digest, expected_client_key=self.client.key_id)
+
+ async def test_loopback_tls_success_and_expired_exact_intent_receipt(self):
+ result = await self.pair()
+ self.assertEqual(result['offerId'], self.invite['id'])
+ self.assertEqual(self.host.offer_status(self.invite['id'])['status'], 'consumed')
+ with mock.patch('time.time', return_value=self.invite['expires']+1):
+ self.assertEqual(await self.pair(), result)
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'paired')
+
+ async def test_cold_expiry_wrong_client_and_unanchored_receiver_have_no_writes_or_network(self):
+ before = self.client.db.total_changes
+ bad = [({'expires': time.time()-1}, self.client.key_id), ({}, 'b'*64),
+ ({'v': 2, 'device': 'a'*64, 'keyFingerprint': self.host.key_id, 'keyGeneration': 1}, self.client.key_id)]
+ with mock.patch.object(app, 'open_channel', side_effect=AssertionError('unexpected network')):
+ for changes, expected in bad:
+ with self.assertRaises(Rejected):
+ await app.pair(self.client, {**self.invite, **changes}, 'direct',
+ offer_digest=self.digest, expected_client_key=expected)
+ self.assertEqual(self.client.db.total_changes, before)
+ self.assertIsNone(self.client.peer(self.host.device))
+
+ async def test_commit_receipt_lost_reconciles_after_expiry_without_preparing(self):
+ with mock.patch.object(self.client, 'remember', side_effect=OSError('offline fixture')):
+ with self.assertRaisesRegex(Rejected, '^pairing_local_reconciliation_required$'):
+ await self.pair()
+ self.assertEqual(self.host.offer_status(self.invite['id'])['status'], 'consumed')
+ with mock.patch('time.time', return_value=self.invite['expires']+1), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('expired prepare')):
+ self.assertTrue((await self.pair())['ok'])
+
+ async def test_expired_saved_intent_cannot_prepare_unconsumed_offer(self):
+ self.client.save_offer_intent(self.invite, self.digest, self.client.key_id)
+ with mock.patch('time.time', return_value=self.invite['expires']+1), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('expired prepare')):
+ with self.assertRaises(Exception):
+ await self.pair()
+ self.assertIsNone(self.host.peer(self.client.device))
+
+ async def test_digest_change_and_local_revocation_never_reconcile(self):
+ await self.pair()
+ with mock.patch.object(app, 'open_channel', side_effect=AssertionError('unexpected network')):
+ with self.assertRaisesRegex(Rejected, 'intent_conflict'):
+ await self.pair(digest='a'*64)
+ self.client.revoke(self.host.device)
+ with self.assertRaises(Rejected):
+ await self.pair()
+
+ async def test_intent_public_facts_exist_before_any_connection(self):
+ def unavailable(*args, **kwargs):
+ row = self.client.db.execute('SELECT digest,receiver,receiver_key,client_key FROM pairing_intents WHERE id=?',
+ (self.invite['id'],)).fetchone()
+ self.assertEqual(row, (self.digest, self.host.device, self.host.key_id, self.client.key_id))
+ raise OSError('fixture unavailable')
+ with mock.patch.object(app, 'open_channel', side_effect=unavailable):
+ with self.assertRaises(OSError):
+ await self.pair()
+ self.assertNotIn(self.invite['secret'], '\n'.join(self.client.db.iterdump()))
+
+ async def test_old_receiver_cannot_downgrade_offer(self):
+ original = self.host.prepare
+ def legacy(request):
+ result = original(request)
+ result['features'] = ['identity-rotation-v1']
+ return result
+ with mock.patch.object(self.host, 'prepare', side_effect=legacy):
+ with self.assertRaisesRegex(Rejected, 'unsupported'):
+ await self.pair()
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'pending')
+
+ async def test_receiver_confirmation_is_readonly_and_allows_only_exact_pending_intent(self):
+ self.client.save_offer_intent(self.invite, self.digest, self.client.key_id)
+ self.client.stage(self.invite)
+ before = self.client.db.total_changes
+ identity = self.client.receiver_offer_identity(self.invite, self.digest, self.client.key_id)
+ self.assertEqual(identity, {'principal': self.host.device, 'keyFingerprint': self.host.key_id, 'generation': 0})
+ self.assertEqual(self.client.db.total_changes, before)
+ with self.assertRaises(Rejected):
+ self.client.receiver_offer_identity(self.invite, 'a'*64, self.client.key_id)
+ self.client.db.execute('DELETE FROM pairing_intents')
+ before = self.client.db.total_changes
+ with self.assertRaisesRegex(Rejected, 'pending'):
+ self.client.receiver_offer_identity(self.invite, self.digest, self.client.key_id)
+ self.assertEqual(self.client.db.total_changes, before)
+
+ async def test_premigration_pending_file_flow_resumes_over_real_tls(self):
+ legacy = self.host.invite(self.routes)
+ self.client.stage(legacy)
+ self.host.prepare({'invitation': legacy['id'], 'secret': legacy['secret'], 'certificate': self.client.cert})
+ for store in (self.client, self.host):
+ store.db.execute('DELETE FROM pairing_reservations')
+ result = await app.pair(self.client, legacy, 'direct')
+ self.assertTrue(result['ok'])
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'paired')
+ self.assertEqual(self.host.peer(self.client.device)['status'], 'paired')
+
+ async def test_client_intent_cap_preserves_exact_resume_without_eviction(self):
+ self.client.save_offer_intent(self.invite, self.digest, self.client.key_id)
+ rows = [(str(uuid.uuid4()), self.digest, self.host.device, self.host.key_id, self.client.key_id)
+ for unused in range(1023)]
+ self.client.db.execute('BEGIN IMMEDIATE')
+ try:
+ self.client.db.executemany('INSERT INTO pairing_intents VALUES(?,?,?,?,?)', rows)
+ self.client.db.execute('COMMIT')
+ except BaseException:
+ self.client.db.execute('ROLLBACK')
+ raise
+ fresh = self.host.create_offer(self.routes, self.client.key_id)
+ fresh_digest = link_digest(encode_link(fresh, self.client.key_id))
+ before = self.client.db.total_changes
+ with self.assertRaisesRegex(Rejected, '^pairing_intent_capacity$'):
+ self.client.save_offer_intent(fresh, fresh_digest, self.client.key_id)
+ self.assertEqual(self.client.db.total_changes, before)
+ self.assertEqual(self.client.db.execute('SELECT COUNT(*) FROM pairing_intents').fetchone()[0], 1024)
+ self.assertTrue(self.client.save_offer_intent(self.invite, self.digest, self.client.key_id))
+ with mock.patch('time.time', return_value=self.invite['expires']+1):
+ self.assertTrue(self.client.save_offer_intent(self.invite, self.digest, self.client.key_id))
+ self.assertEqual(self.client.db.execute('SELECT COUNT(*) FROM pairing_intents').fetchone()[0], 1024)
+ self.assertIsNotNone(self.client.db.execute('SELECT 1 FROM pairing_intents WHERE id=?', (rows[0][0],)).fetchone())
+
+ async def test_expiry_during_pending_finish_failure_never_falls_back_to_prepare(self):
+ self.client.save_offer_intent(self.invite, self.digest, self.client.key_id)
+ self.client.stage(self.invite)
+ clock = [self.invite['expires']-1]
+ calls = []
+ async def finish(*args, **kwargs):
+ calls.append(True)
+ clock[0] = self.invite['expires']+1
+ raise Rejected('pairing_not_committed')
+ with mock.patch('time.time', side_effect=lambda: clock[0]), \
+ mock.patch.object(app, 'finish_pair', side_effect=finish), \
+ mock.patch.object(app, 'open_channel', side_effect=AssertionError('expired bootstrap')), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('expired prepare')):
+ with self.assertRaises(Rejected):
+ await self.pair()
+ self.assertEqual(len(calls), 2)
+ self.assertIsNone(self.host.peer(self.client.device))
+
+ async def test_expiry_during_bootstrap_connection_closes_without_preparing(self):
+ clock = [self.invite['expires']-1]
+ channel = mock.Mock()
+ channel.send = mock.AsyncMock()
+ channel.close = mock.AsyncMock()
+ async def connect(*args, **kwargs):
+ clock[0] = self.invite['expires']+1
+ return channel
+ with mock.patch('time.time', side_effect=lambda: clock[0]), \
+ mock.patch.object(app, 'open_channel', side_effect=connect) as opened, \
+ mock.patch.object(app, 'finish_pair', side_effect=Rejected('pairing_not_committed')), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('expired prepare')):
+ with self.assertRaises(Rejected):
+ await self.pair()
+ opened.assert_awaited_once()
+ channel.send.assert_not_awaited()
+ channel.close.assert_awaited_once()
+ self.assertIsNone(self.host.peer(self.client.device))
+
+ async def test_known_commit_with_local_remember_failure_never_reprepares(self):
+ await self.pair()
+ self.client.db.execute('UPDATE peers SET status="pending",expires=? WHERE id=?',
+ (self.invite['expires'], self.host.device))
+ with mock.patch.object(self.client, 'remember', side_effect=OSError('offline fixture')), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('known completion reprepared')):
+ with self.assertRaisesRegex(Rejected, '^pairing_local_reconciliation_required$'):
+ await self.pair()
+ self.assertEqual(self.host.offer_status(self.invite['id'])['status'], 'consumed')
+
+ async def test_consumed_offer_transient_resume_failure_preserves_next_manual_reconciliation(self):
+ native = mock.AsyncMock(side_effect=AssertionError('pairing invoked native effect'))
+ with mock.patch.object(self.receiver.native, 'invoke', native):
+ with mock.patch.object(self.client, 'remember', side_effect=OSError('offline fixture')):
+ with self.assertRaises(app.PairingCompletionKnown):
+ await self.pair()
+ self.assertEqual(self.host.offer_status(self.invite['id'])['status'], 'consumed')
+ original_open = app.open_channel
+ calls = []
+ async def transient_once(*args, **kwargs):
+ bootstrap = args[5] if len(args) > 5 else kwargs.get('bootstrap', False)
+ calls.append(bootstrap)
+ if calls == [False]:
+ raise ConnectionError('offline fixture')
+ return await original_open(*args, **kwargs)
+ original_prepare = self.host.prepare
+ with mock.patch.object(app, 'open_channel', side_effect=transient_once), \
+ mock.patch.object(self.host, 'prepare', wraps=original_prepare) as prepared:
+ with self.assertRaisesRegex(Rejected, '^pairing_reconciliation_required$'):
+ await self.pair()
+ self.assertEqual(calls, [False, True])
+ self.assertEqual(prepared.call_count, 1)
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'pending')
+ self.assertEqual(self.client.key_status(self.host.key_id), 'active')
+ self.assertEqual(self.client.db.execute('SELECT COUNT(*) FROM pairing_intents').fetchone()[0], 1)
+ with mock.patch.object(self.host, 'prepare', side_effect=AssertionError('manual receipt retry prepared')):
+ self.assertTrue((await self.pair())['ok'])
+ native.assert_not_awaited()
+ self.assertEqual(self.client.peer(self.host.device)['status'], 'paired')
+ self.assertEqual(self.host.db.execute('SELECT COUNT(*) FROM pairing_offers').fetchone()[0], 1)
+
+ async def test_saved_intent_without_local_stage_still_attempts_authenticated_receipt_first(self):
+ with mock.patch.object(self.client, 'remember', side_effect=OSError('offline fixture')):
+ with self.assertRaises(app.PairingCompletionKnown):
+ await self.pair()
+ self.client.discard_pairing_stage(self.invite['id'])
+ self.assertIsNone(self.client.peer(self.host.device))
+ with mock.patch.object(self.host, 'prepare', side_effect=AssertionError('saved receipt retry prepared')):
+ self.assertTrue((await self.pair())['ok'])
+
+ async def test_consumed_receipt_query_preserves_operator_updated_routes(self):
+ await self.pair()
+ configured = {'direct': '127.0.0.1:54321'}
+ self.client.db.execute('UPDATE peers SET routes=? WHERE id=?', (json.dumps(configured), self.host.device))
+ original_open = app.open_channel
+ routes_used = []
+ async def observe(*args, **kwargs):
+ routes_used.append(args[2])
+ return await original_open(*args, **kwargs)
+ with mock.patch.object(app, 'open_channel', side_effect=observe), \
+ mock.patch.object(self.host, 'prepare', side_effect=AssertionError('receipt query prepared')):
+ self.assertTrue((await self.pair())['ok'])
+ self.assertEqual(routes_used, [self.invite['routes']])
+ self.assertEqual(self.client.peer(self.host.device)['routes'], configured)
+
+ async def test_expired_pending_confirmation_is_readonly_and_fresh_staging_preserves_intents(self):
+ for legacy in (False, True):
+ with self.subTest(legacy=legacy):
+ # Independent roots make both ownership variants explicit.
+ client = Store(self.root/('old-client' if legacy else 'owned-client'))
+ try:
+ old = self.host.invite(self.routes)
+ digest = link_digest(encode_link(old, client.key_id))
+ client.save_offer_intent(old, digest, client.key_id)
+ client.stage(old)
+ if legacy:
+ client.db.execute('DELETE FROM pairing_reservations')
+ with mock.patch('time.time', return_value=old['expires']+1):
+ fresh = self.host.invite(self.routes)
+ fresh_digest = link_digest(encode_link(fresh, client.key_id))
+ before = client.db.total_changes
+ identity = client.receiver_offer_identity(fresh, fresh_digest, client.key_id)
+ self.assertEqual(identity['principal'], self.host.device)
+ self.assertEqual(client.db.total_changes, before)
+ client.save_offer_intent(fresh, fresh_digest, client.key_id)
+ client.stage(fresh)
+ self.assertEqual(client.peer(self.host.device)['expires'], fresh['expires'])
+ self.assertEqual(client.db.execute('SELECT invitation FROM pairing_reservations').fetchone(), (fresh['id'],))
+ self.assertIsNotNone(client.db.execute('SELECT 1 FROM pairing_intents WHERE id=?', (old['id'],)).fetchone())
+ self.assertEqual(client.key_status(self.host.key_id), 'active')
+ finally:
+ client.close()
+
+ async def test_old_completion_cannot_overwrite_new_owned_pending_routes(self):
+ self.client.save_offer_intent(self.invite, self.digest, self.client.key_id)
+ self.client.stage(self.invite)
+ self.client.discard_pairing_stage(self.invite['id'])
+ fresh = self.host.invite({'direct': '127.0.0.1:54321'})
+ self.client.stage(fresh)
+ before = self.client.peer(self.host.device)
+ with self.assertRaisesRegex(Rejected, '^peer_already_reserved$'):
+ self.client.remember(self.invite)
+ self.assertEqual(self.client.peer(self.host.device), before)
+ self.assertEqual(self.client.db.execute('SELECT invitation FROM pairing_reservations').fetchone(), (fresh['id'],))
+
+
+if __name__ == '__main__':
+ unittest.main()