信頼と NAT 越えの設計
設計書 docs/trust-and-nat.md の要約。細部はそちらが正。
1. 目的と脅威モデル
| 脅威 | 本家 | この設計での扱い |
|---|---|---|
| 検索結果の捏造(どのクエリにも広告・マルウェアの URL を返す) | 形式検査のみ。検証に失敗しても remote の結果は残る | 結果の文書に作者(crawl したピア)の署名を求め、既定では作者がコーディネータの一覧に載っていることを求める |
| 他ピアの名を騙る、seed の改ざん | ピア ID は乱数。seed に署名なし | ピア ID = Ed25519 の鍵の hash。seed の中核に本人が署名し、hello のチャレンジで鍵の所有を確かめる |
| 偽ピアの大量生成(Sybil) | 3 日待てば DHT 検索先になる | 検索の信頼は一覧(入場管理)で決める。一覧に無いピアの文書は既定で使わない |
| DHT の保存ピアによる改ざん | 検出できない | 保存ピアを信頼しなくてよい。作者の署名で改ざんが分かるので、できるのは返さないことだけ |
| 信頼されたピアが広告を混ぜる、外部エンジンを中継する | ― | 禁止しないが、一覧に宣言タグとして書かせ、利用者の方針で使うか決める |
守らないもの: 保存ピアが結果を返さないこと、信頼された作者自身の虚偽(タグと監査で扱う)、検索語の秘匿。
2. 身元
- ピアは起動時に
DATA/SETTINGS/peer.key(Ed25519、PKCS#8 PEM)を読み、無ければ作る。 - ピア hash(12 文字)= SHA-256(公開鍵) の URL-safe base64 の先頭 12 文字。DHT の隙間に合わせて ID を選ぶ本家の処理はやめ、鍵で決める。
- 同じ鍵を NAT 越えの sidecar の libp2p の鍵として使う。libp2p のピア ID(
12D3KooW…)は公開鍵から計算できるので seed には載せない。
2.1 seed の署名
seed は受け取った側が IP・種別・フラグ・最終確認時刻などを書き換えて中継する。そこで本人しか決めない項目だけに署名する。
| 署名する中核 | 署名しない(観測値・受け手が書き換える) |
|---|---|
Hash, PK, Name, Port, PortSSL, BDate, SigT(署名時刻), Tags(自己申告タグ), Reach, P2PA, RDS | IP, IP6, PeerType, Flags, 各種カウンタ, LastSeen, news, TV |
Sig= Ed25519("yacy-seed-v1\n" + "Hash=<hash>\n"の後に、値のある残りの中核項目をキーの辞書順(BDate, Name, P2PA, PK, Port, PortSSL, RDS, Reach, SigT, Tags)にk=v\nで連結したもの)。PKのある seed はHash == H(PK)とSigが正しくなければ必ず拒否する。PKの無い seed はtrust.seed.acceptUnsigned=trueでない限り拒否する。- 中核が変わったか前の署名から 7 日経ったら署名し直す。
SigTが 1 時間以上未来の seed は拒否し、まだ知らないピアの seed は 30 日より古いものも拒否する。署名の対象の値に制御文字(1 つの欄を 2 つに見せる改行など)があれば無効。署名付きで知っているピアの seed を、より古い seed や署名の無い seed で置き換えない。
2.2 hello のチャレンジ
IP は署名の対象外なので、その住所で本当に鍵の持ち主が応答しているかを hello で確かめる。
- 呼び出し側は乱数の
challengeを送る。応答側は、要求が届いた相手のアドレス(challengeFor、sidecar 経由ならp2p)とSign("yacy-hello-v2|" + challenge + "|" + 自分の hash + "|" + challengeFor)を返す。 - 呼び出し側は、返ってきた seed の
PKで署名を検証し、challengeForが自分のアドレスであることを確かめる。アドレスを含めるのは、攻撃者がチャレンジを本物のピアへ転送してその答えを返す攻撃を防ぐため。本物のピアは「攻撃者から来た要求」に署名するので見分けられる。 - 応答側が呼び出し元へ接続し直して到達性を確かめる back-ping でも同じチャレンジを使う。他人の IP を名乗った seed はこれで弾かれる。
- 呼び出し側の「自分のアドレス」は、インターフェースのアドレス、設定した固定 IP、そして 3 つの異なる鍵のピアが 2 つ以上の異なる網(/24、IPv6 は /48、私設アドレスはアドレスそのもの)で 6 時間以内に一致して報告したアドレスだけ(
OwnAddresses。IPv4 と IPv6 の両方を覚える)。自分の seed の IP は使わない。署名の無い 1 つの答えのyouripで書き換わるので、中継した攻撃者が自分のアドレスを「自分のもの」と思わせられるため。答えの判定は、そのyouripを反映する前に 1 回だけ行う。 challengeForが呼び出し側の自分のアドレスでないとき(NAT の内側、起動直後、アドレスが変わった直後)は、転送された答えと見分けられない。この場合も接続としては数えるが、アドレスの証明にしない。相手の IP は知っているものを保ち、その答えを「その住所の鍵の持ち主から」とは扱わない(署名の対象外の snippet は捨てる)。back-ping は自分の住所に結び付いた答えだけを成功とする(署名の無い seed は、trust.seed.acceptUnsigned=trueのときは以前どおり通す。証明できるものが無いので)。- 署名付きで知っているピアの住所が変わるのは、back-ping 付きの hello だけ。検索要求(
/yacy/search)に付いた seed は誰でも送れるので、住所を動かさない。
3. 信頼の委譲と一覧
コーディネータの鍵(利用者が設定。複数可、先頭ほど優先)
└─ 委任書: 「このオペレータの鍵に、この網の一覧を作らせる」(版付き。失効も新しい版で出す)
└─ ピアの一覧: 「これらのピアを、この優先度・このタグで信頼する」(オペレータが署名。版付き)- 委譲は 1 段だけ。コーディネータ自身がオペレータを兼ねてもよい。
- 有効期限は持たず、版で管理する。運営者がいなくなっても最後の版で動き続ける。
- すべて同じ形の封筒で運ぶ。署名は payload のバイト列そのものに対するので、JSON の正規化は要らない。
{"payload": "<base64url(JSON のバイト列)>", "signer": "<base64url 公開鍵>", "sig": "<base64url 署名>"}
{"type": "yacy-delegation-v1", "network": "freeworld", "operator": "<pk>", "version": 3, "revoked": false}
{"type": "yacy-peerlist-v1", "network": "freeworld", "version": 12,
"peers": [{"pk": "<pk>", "priority": 100, "tags": ["ads"]}]}
- 委任書はコーディネータの署名が要る。(コーディネータ, オペレータ)ごとに最大の版を採り、
revoked:trueならそのオペレータの一覧は無効になる。一覧は有効な委任を持つオペレータの署名が要り、オペレータごとに最大の版を採る。networkはnetwork.unit.nameと一致するか*。 - 実効の信頼集合は、コーディネータの順に一覧を重ねたもの。同じピアは、先にそのピアを載せた一覧の優先度とタグを使う。照合は 72 bit のピア hash だけでなく公開鍵そのもので行う。
priorityは 0〜100。結果の点数に0.5 + 0.5 × priority / 100を掛ける。- コーディネータが無いピアは自分の文書だけを信頼する(閉じる側に倒す)。全員を知っている閉じた網では
trust.signedOnly=trueで署名されたピアをすべて信頼できる。配布物は自分のコーディネータの鍵を既定値に入れて配る想定で、このフォーク自体は鍵を同梱しない。
3.1 宣言タグ
共通の最小語彙を決め、コーディネータは x-<name>: の接頭辞で独自のタグを足せる。
| タグ | 意味 |
|---|---|
ads | 結果に広告を混ぜる |
proxy:<engine> | 外部の検索エンジンの結果を中継する |
curated | 人手で選別した索引 |
unfiltered | 選別しない |
adult | 成人向けの内容を含む |
一覧のタグが正。seed のタグは自己申告で、表示にだけ使う。trust.policy.excludeTags に当たる作者の文書は捨て、結果には作者のタグを付ける。
4. 一覧の配り方
- 各ピアは受け取った封筒を検証して
DATA/SETTINGS/trust-bundle.jsonに保存し、/yacy/trust.jsonで公開する。保存するのは、設定したコーディネータとその委任先の封筒(自分の網のもの)だけ。コーディネータの無いピアは何も保存せず、中継もしない。 - seed に
TVを載せる。コーディネータごとに 2 つの版の合計: コーディネータ自身が署名したもの(委任書と自身の一覧)と、委任先オペレータの一覧(失効したオペレータの分も数える)。新しい封筒はどちらかを増やし、失効でも減らない。 - peer ping のたびに、署名付きの seed を持つ接続中のピアの
TVが自分より新しければ(コーディネータ分が大きい、または等しくてオペレータ分が大きい)、そのピアの/yacy/trust.jsonを取って統合する(1 回に最大 3 ピア)。コーディネータ分を先に比べるので、オペレータの一覧の版を水増ししたピアにも失効が届く。TVは署名されていないので、問い合わせ先は乱数順に選び、新しいものを返さなかったピアには 10 分間問い合わせない。 - 受け付ける版は現在の Unix 時刻 + 1 日まで(最大 240)。巨大な版で以後の更新を止められないようにするため。版には Unix 時刻か小さな連番を使う。上限は新しく受け取る文にだけ当て、起動時に自分のファイルから読む文には当てない(時計が戻っても一覧を失わない)。
- コーディネータ自身の一覧に載るピアは、その項目の優先度とタグで決まる。同じコーディネータの下のオペレータの一覧どうしで同じピアを載せたときは、タグの和集合と最小の優先度をとる(あるオペレータが別のオペレータの
adsを消せず、コーディネータが選んだピアにタグを付けることもできない)。 trust.bundle.urlsからも、起動時・10 分ごと・設定が変わったときに取る。新しい版の存在を複数の経路で知ることで、古い版に留め置く攻撃をしにくくする。
5. 文書の出所(作者の署名)
- ピアは crawl した頁を索引するときに署名する。値は Solr の
provenance_sに入り、DHT の転送や単語索引の結果ではプロパティprovで運ばれる:1|<作者の鍵>|<署名>|<単語の Bloom フィルタ>。 - 署名の対象:
"yacy-doc-v1\n" + 正規化した URL + "\n" + タイトル + "\n" + Bloom。Bloom フィルタには、単語索引に入るのと同じ、その文書の単語の hash を入れる。大きさは 1 語あたり 12 bit 以上の最小の 2 の冪(512〜65536 bit)、ハッシュ関数は 4 個。他の作者の単語索引の結果では、含まれない語がたまたま一致する確率(埋まり具合4 × クエリの語数)が 1/16 以下のときだけ、フィルタが語を保証するとみなす(半分埋まったフィルタなら 1 語、71% なら 2 語から)。65536 bit は約 11,000 語で半分埋まる。
| 判定 | 条件 | 既定の扱い |
|---|---|---|
SELF | 作者が自ピア | 使う |
TRUSTED | 署名が正しく、作者が信頼集合にいる | 使う。作者の優先度とタグを付ける |
SIGNED | 署名は正しいが、作者が信頼集合にいない | 捨てる(開放モードでは「未検証」) |
UNSIGNED | 出所の署名が無い | 捨てる(開放モードでは「未検証」) |
INVALID | 署名が合わない、鍵と作者が合わない、URL かタイトルが変わっている | 必ず捨てる |
- 単語索引の結果は、クエリのすべての語が Bloom フィルタに入っていることも求める。保存ピアが信頼された文書を無関係な語に結び付けるのを防ぐため。クエリの語数に対して埋まりすぎた他の作者の Bloom フィルタ(上を参照)は、単語索引の結果には使わない(その文書も Solr からは見つかる)。
- 要約(snippet)・本文・説明・見出し・画像の代替テキストは署名の対象外。応答したピアが作者か、住所を確かめた信頼ピアのときだけ残す。そうでなければ、順位に効く署名の対象外の値(語数・サイズ・鮮度の日付)も捨て、語数の無い結果は薄い頁の閾値の半分の語数として重みを付ける。他ピアの結果を自ピアの索引に保存する(
remotesearch.result.store)のは、応答したピアが信頼された作者のときだけ。 - DHT の保存(
transferURL)はINVALIDを拒否し、UNSIGNEDは開放モードでだけ受け入れる。保存ピアは作者を信頼しなくてよい。
6. NAT 越え
NAT の内側 公開側
┌──────────────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ YaCy ── sidecar ─────┼── 外向き ─▶ relay(sidecar) ◀─ 外向き ─┼── sidecar ── YaCy │
│ :8090 :8095 /:4001│ 予約 │ :4001 │ 回線 │ :8095 :8090 │
└──────────────────────┘ └──────────────────┘ └──────────────────────┘
◀──────────────── DCUtR(穴あけ)で直結に切り替え(成功すれば)────────────────▶- sidecar(
sidecar/、Go、go-libp2p): YaCy と同じ鍵で libp2p のホストを立てる。外から届かなければ、設定された circuit relay v2 に予約を取り、DCUtR を試みる。 - トンネル。YaCy が制御 API に
GET /tunnel/<libp2p ピア ID>?addrs=…を送ると、ローカルのポートが返る。127.0.0.1:portへの普通の HTTP は、libp2p のストリーム(/yacy/http/1.0.0)で相手の sidecar へ運ばれる。普通のホスト:ポートなので、YaCy のプロトコルや Solr のクライアントはそのまま動く。 - 受け側の sidecar は
/yacy/*と Solr の select だけを YaCy の専用の接続口(127.0.0.1:8096)へ渡す。管理画面は通さず、送信元を名乗るヘッダーは取り除く。接続口は送信元アドレスを libp2p のピア ID から作った2001:db8::/32のアドレスに置き換える。トンネル経由の要求にループバックの特権を与えず、負荷制限をピアごとに効かせるため。ピア ID のヘッダーは、sidecar のトークンが一緒に来たときだけ信じる。 - 制御 API は、ループバックの Host と
Originが無いことを求める(DNS rebinding への対策)。/tunnelは YaCy がDATA/SETTINGS/sidecar.token(作成時から所有者だけが読め、一時ファイルに書いて名前を変える)に書いたトークンで認証するが、トークンそのものは送らない。YaCy は nonce(<Unix ミリ秒>.<乱数>、2 分以内に 1 回だけ有効)とHMAC-SHA256(トークン, "yacy-sidecar-req-v1|" + 方法 + "|" + パス + "|" + nonce)を送る。sidecar の停止中にポートを取ったプロセスは、使えるものを何も得られない。 - YaCy は peer ping のたびに
/status?nonce=…を問い合わせ、nonce・ピア ID・起動 ID・到達性・リレーのアドレスに peer key で署名した sidecar だけを使う。/tunnelの答え(nonce・相手・ポート・起動 ID)にも署名があり、YaCy は自分の sidecar が渡したポートだけを使う。 - sidecar は YaCy の接続口に 3 分届かなければ終了する(
-yacy-watchdog)。Docker で YaCy とネットワーク名前空間を共有していると、YaCy の再起動後も古い名前空間に残るため。 - 上限: トンネル 256 本、15 分使われなければ閉じる。閉じたトンネルはポートを 10 分保ち(YaCy がポートを覚えている時間より長く)、そうしたポートが 256 本になったら、ポートを早く手放す代わりに新しいトンネルを断る。同時要求は相手ごとに 8 本・全体で 64 本。1 要求 60 秒、応答 32 MiB。要求の本文は索引の転送だけ 32 MiB、それ以外は 1 MiB。
- seed の回線アドレスからはリレーのピア ID だけを採り、接続先は設定したリレー自身のアドレスから組み立て直す。seed で内部のホストへ接続させることはできない。その代わり、この sidecar が知らないリレーに予約したピアには届かない。
- リレーの sidecar は
-relay-allow <ファイル>か、意図して-relay-openを付けて起動する(開いたリレーはどの libp2p の通信も運ぶ)。 - YaCy は、AutoNAT が「private」と判定したとき、または判定が出ないまま 3 回続けて他ピアから junior と報告されたとき、seed を
Reach=relayにして回線のアドレスをP2PAに載せる。AutoNAT が「public」と判定するまでその状態を保つ。p2p.mode=leecherはReach=none。 - リレー経由のピアは既定で検索に応答するだけ。
p2p.relay.dhtStorage=true(seed のRDS=1)を宣言しない限り、DHT の保存先にも DHT 検索先にもならない。 - sidecar 経由の hello は、seed の鍵の libp2p の身元から来ていなければならない。向きはどちらでもよい。NAT の内側のピアがリレー経由で外へ呼ぶ場合も、公開側のピアがトンネルで NAT の内側のピアを呼ぶ場合もある。
7. 設定値
| キー | 既定 | 意味 |
|---|---|---|
trust.seed.acceptUnsigned | false | 署名の無い(旧実装の)seed を受け入れる |
trust.coordinators | 空 | 信頼するコーディネータの鍵(, 区切り、先頭ほど優先) |
trust.bundle.urls | 空 | 信頼の一覧の束を取りに行く URL |
trust.search.acceptUnverified | false | 開放モード: 信頼集合外・署名なしの文書も「未検証」として検証済みの後ろに出す |
trust.policy.excludeTags | 空 | このタグを持つ作者の文書を捨てる |
trust.selfTags | 空 | 自分の seed に載せる自己申告のタグ |
trust.signedOnly | false | コーディネータが無いとき、署名されたピアをすべて信頼する(閉じた網向け) |
p2p.sidecar.url | 空 | sidecar の制御 API(空なら NAT 越えは無効) |
p2p.sidecar.yacyPort | 8096 | sidecar が運んだ要求を受けるループバックの接続口 |
p2p.mode | auto | auto / direct / leecher |
p2p.relay.dhtStorage | false | リレー経由でも DHT の保存先になる |
remotesearch.maxtime | 5000 | 他ピアの応答を待つ時間(ms、最大 10000)。結果は届いた順に表示する |
鍵・委任書・一覧・束はコマンドラインの TrustTool で作る。FORK.md を参照。
8. 既知の制約
- 日本語・中国語の単語 hash は旧網と異なる(bigram のため)。開放モードでも旧網の CJK の索引とは突き合わない。
- とても長い文書の Bloom フィルタは埋まる。偶然の一致が 1/16 を超える場合(約 11,000 語を超える文書への 1 語のクエリなど)、他の作者の単語索引の結果は使わない。そうした文書は Solr からか、語の多いクエリで見つかる。以前のフォーク版は大きなフィルタを
INVALIDとして捨てるので、フォークのピアは同時に更新する。 OwnAddressesは 3 つの鍵と 2 つの網からの報告を求める。2 つの網にアドレスを持ち鍵を 3 つ作った攻撃者は、中継したアドレスを自分のものと思わせられる。ピアや網が少ない網では固定 IP を設定する。TVの形式(2 つの和)は以前のフォーク版と互換でない。混在の間、信頼の文はtrust.bundle.urlsからだけ広がる。CJK の分割はすべての語に NFKC を当てるので、全角・半角の文字を含む頁は索引し直すまで以前の語で残る。- 第三者から中継された seed の IP は、自分で hello するまで確かめられていない。
- 応答そのものには署名が無い。署名の対象外の部分(snippet)は、作者か、チャレンジで住所を確かめた信頼ピアからのものだけを残す。
- sidecar 経由で届くのは、この sidecar に設定したリレーに予約したピアだけ。
- 同じホストの別のユーザーは 127.0.0.1 のトンネルのポートを見つけて、このピアの libp2p の身元で要求を送れる。sidecar の停止中にトンネルのポートを取ったプロセスは、YaCy が聞き直すまで(最大 5 分)その相手として答えられる。ピア ID は費用なしで作れるので、多数の ID の遅い要求で全体 64 本の枠を埋められる。共用のホストでは YaCy と sidecar を専用のユーザーかコンテナで動かす。upstream の既定
adminAccountForLocalhost=trueではローカルのプロセスがすべて管理者になる(フォークの Docker イメージは false)。 - 信頼された作者が虚偽の内容に署名すること、保存ピアが結果を返さないことは防げない。