kairos-chain 3.96.1 → 3.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/lib/kairos_mcp/version.rb +1 -1
  4. data/templates/knowledge/hestiachain_meeting_place/hestiachain_meeting_place.md +17 -7
  5. data/templates/knowledge/hestiachain_meeting_place_jp/hestiachain_meeting_place_jp.md +16 -7
  6. data/templates/skillsets/hestia/knowledge/hestia_meeting_place/hestia_meeting_place.md +22 -4
  7. data/templates/skillsets/hestia/lib/hestia/agent_registry.rb +144 -15
  8. data/templates/skillsets/hestia/lib/hestia/place_router.rb +283 -67
  9. data/templates/skillsets/hestia/lib/hestia/registration_nonce_store.rb +36 -0
  10. data/templates/skillsets/hestia/lib/hestia/skill_board.rb +23 -0
  11. data/templates/skillsets/hestia/lib/hestia.rb +2 -1
  12. data/templates/skillsets/hestia/skillset.json +2 -2
  13. data/templates/skillsets/hestia/tools/meeting_place_status.rb +14 -0
  14. data/templates/skillsets/mmp/lib/mmp/crypto.rb +6 -0
  15. data/templates/skillsets/mmp/lib/mmp/identity.rb +89 -2
  16. data/templates/skillsets/mmp/lib/mmp/meeting_session_store.rb +18 -2
  17. data/templates/skillsets/mmp/lib/mmp/place_client.rb +47 -6
  18. data/templates/skillsets/mmp/lib/mmp/registration_proof.rb +149 -0
  19. data/templates/skillsets/mmp/lib/mmp.rb +2 -1
  20. data/templates/skillsets/mmp/skillset.json +1 -1
  21. data/templates/skillsets/mmp/tools/meeting_attest_skill.rb +4 -1
  22. data/templates/skillsets/mmp/tools/meeting_connect.rb +38 -6
  23. data/templates/skillsets/skillset_exchange/lib/skillset_exchange/place_extension.rb +99 -13
  24. data/templates/skillsets/skillset_exchange/skillset.json +3 -3
  25. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: faa4327412fbbe72c412e3e4cccd41173a346e488366b0d4963023cb389ce97e
4
- data.tar.gz: 13767304ee185463ac1a1de1293873762c3658f81e8a5c6d007023ca29970377
3
+ metadata.gz: 7c9e86d08fcaeb0acb8208d11bc05a5b2aea3e93b1c5ce0e51ea582e728c5b97
4
+ data.tar.gz: c9e04ca2f728363fe2111a04431e7615e84a8c8a4d3b4a4e2f12f6fbe92ed246
5
5
  SHA512:
6
- metadata.gz: 657b3094d640bd4876964d3ffd88f47fd57fda12147c77d882293c68662d66a791c306d8eaae4fd4238041f641c318dc7825bb0ff6c314b2c963086146a7eb17
7
- data.tar.gz: 2f765af6b6122a2a3627678f3481a410be0bc0f359bb7b479a6e726fe6f1891264c023d42007840d1fd75e03d175109b78a164647d6402f771322fff8a3783d6
6
+ metadata.gz: a20c6536c9cae019fde8d6ae21b4c6c0a9a180e544d91186e24d1a58843a64bafcc63b030067ba5eb658ddf80a4d69a00f5bfb2f93d03e34796c3497b8497326
7
+ data.tar.gz: ce03eccb65d5d09c907b489e3ac153f1c83c9d3f13033a08324d2dad0412fe453b5fcd885ee8b243200ff67f021ec88568ff6d6222333ea2d52b94c8d1571501
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.97.0] - 2026-10-23
10
+
11
+ ### Security — Meeting Place agent ids can no longer be taken over (hestia 0.3.0, mmp 1.2.0, skillset_exchange 0.2.0)
12
+
13
+ Anyone who knew an agent id could register it again with their own key, or present the static identity
14
+ signature the agent publishes, and then withdraw or replace that agent's deposits. The Place also honoured
15
+ session tokens the core P2P introduce endpoint mints for any presented id, unregister and knowledge needs
16
+ acted on the agent id named in the request body, and SkillSet storage directories (`<name>_<id>`) could
17
+ collide across depositors.
18
+
19
+ - **Key binding.** An agent id is bound to the first key that registers it, in `agent_bindings.json` under
20
+ the storage directory. No request and no fade-out changes or removes a binding; only the operator editing
21
+ the file with the Place stopped does. New bindings are limited to `binding_rate_per_hour` (default 60).
22
+ A bindings file that cannot be read stops the Place; a missing one is accepted only on a Place that has
23
+ never run.
24
+ - **Registration proof** (`MMP::RegistrationProof`). Registration carries an RSA-PSS signature over the
25
+ https origin the client dialed, the agent id, the issue time and a single-use nonce. The Place accepts only
26
+ its own origin (`meeting_place.web_ui.place_host`), fresh proofs that no earlier process could have
27
+ accepted, each nonce once, and keys with exponent 65537 and at least 2048 bits. Static identity
28
+ signatures are refused (`registration_proof_required`): **clients must update to mmp 1.2.0**.
29
+ - **Sessions.** The Place honours only sessions it issued (marked in the session store, carrying the hash of
30
+ the binding).
31
+ - **Unbound ids holding items** from before this release are bound only by a key that signed everything held
32
+ under them (deposits, attestations, SkillSet deposits), and only while every place extension declared by
33
+ an enabled SkillSet is mounted. Place extensions must implement `holdings_evidence(agent_id)`.
34
+ - **Writes act on the session id.** Unregister and needs refuse a body `agent_id` naming another id; a signed
35
+ attestation payload must name the session id.
36
+ - **Storage.** A SkillSet directory or board content file belongs to the first deposit holding it (compared
37
+ case-insensitively); colliding deposits are refused (409 `storage_conflict`).
38
+ - **Client.** The id a client presents to Meeting Places lives in `keys/mmp_place_id`: random for keypairs
39
+ created from now on, the previous id for existing installs. `meeting_connect` takes `fresh_id: true` to
40
+ take a new id after `key_mismatch`. `meeting_place_status` reports the running Place, including
41
+ `unmounted_extensions`.
42
+ - **Upgrading a Place that has run before:** create `<storage>/agent_bindings.json` (bindings you hold, or
43
+ `{}`) before the first start; then check `/health` shows `place_started: true` and `meeting_place_status`
44
+ lists no unmounted extensions.
45
+
9
46
  ## [3.96.1] - 2026-10-22
10
47
 
11
48
  ### Fixed — agent runs convene the persona seat their reviews declared none for (agent 1.0.2)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.96.1"
2
+ VERSION = "3.97.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: hestiachain_meeting_place
3
3
  description: "HestiaChain Meeting Place — user guide for agent discovery, skill exchange, and trust anchoring"
4
- version: 2.1
4
+ version: 2.2
5
5
  layer: L1
6
6
  tags: [documentation, readme, hestia, meeting-place, p2p, deployment, trust-anchor]
7
7
  readme_order: 4.5
@@ -66,10 +66,10 @@ kairos-chain --http --port 8080
66
66
  # Place info (no auth required)
67
67
  curl -s http://localhost:8080/place/v1/info | python3 -m json.tool
68
68
 
69
- # Register an agent
70
- curl -s -X POST http://localhost:8080/place/v1/register \
71
- -H 'Content-Type: application/json' \
72
- -d '{"id":"agent-alpha","name":"Agent Alpha","capabilities":{"supported_actions":["test"]}}'
69
+ # Register an agent: registration carries a signed, single-use proof
70
+ # (hestia >= 0.3.0), so register from KairosChain with the
71
+ # meeting_connect tool (url: http://localhost:8080) rather than curl.
72
+ # The session token is in its result.
73
73
 
74
74
  # Browse the skill board (Bearer token required)
75
75
  curl -s -H "Authorization: Bearer $TOKEN" \
@@ -81,8 +81,8 @@ curl -s -H "Authorization: Bearer $TOKEN" \
81
81
  | Method | Path | Auth | Description |
82
82
  |--------|------|------|-------------|
83
83
  | GET | `/place/v1/info` | None | Place metadata and identity |
84
- | POST | `/place/v1/register` | RSA signature (optional) | Register an agent |
85
- | POST | `/place/v1/unregister` | Bearer | Unregister an agent |
84
+ | POST | `/place/v1/register` | Registration proof (required, mmp >= 1.2.0) | Register an agent; the id is bound to its key |
85
+ | POST | `/place/v1/unregister` | Bearer | Unregister the session's own agent |
86
86
  | GET | `/place/v1/agents` | Bearer | List registered agents |
87
87
  | GET | `/place/v1/board/browse` | Bearer | Browse skill board (random order) |
88
88
  | POST | `/place/v1/board/needs` | Bearer | Publish knowledge needs to board |
@@ -166,6 +166,16 @@ kairos.example.com {
166
166
  }
167
167
  ```
168
168
 
169
+ Set the public host name in the hestia config so the Place accepts registrations addressed to it (without it, only `localhost` registrations are accepted):
170
+
171
+ ```yaml
172
+ meeting_place:
173
+ web_ui:
174
+ place_host: kairos.example.com
175
+ ```
176
+
177
+ Upgrading a Place that has run before to hestia 0.3.0: the first start refuses until `agent_bindings.json` exists under the storage directory (`<data_dir>/storage/`). Create it with the bindings you hold, or as `{}`. A Place that refused to start still answers `/health` with `place_started: false`; check that it says `true`, and that `meeting_place_status` shows no `unmounted_extensions` (an installed SkillSet whose place extension is not mounted suspends registration of new ids).
178
+
169
179
  For detailed DEE protocol internals, install the hestia SkillSet and refer to its bundled knowledge (`hestia_meeting_place`).
170
180
 
171
181
  ### Interaction Policy (instance discipline)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: hestiachain_meeting_place_jp
3
3
  description: "HestiaChain Meeting Place — エージェント探索・スキル交換・信頼アンカーのユーザーガイド"
4
- version: 2.1
4
+ version: 2.2
5
5
  layer: L1
6
6
  tags: [documentation, readme, hestia, meeting-place, p2p, deployment, trust-anchor]
7
7
  readme_order: 4.5
@@ -66,10 +66,9 @@ kairos-chain --http --port 8080
66
66
  # Place 情報(認証不要)
67
67
  curl -s http://localhost:8080/place/v1/info | python3 -m json.tool
68
68
 
69
- # エージェント登録
70
- curl -s -X POST http://localhost:8080/place/v1/register \
71
- -H 'Content-Type: application/json' \
72
- -d '{"id":"agent-alpha","name":"Agent Alpha","capabilities":{"supported_actions":["test"]}}'
69
+ # エージェント登録:登録には署名つきの使い捨ての証明が要る(hestia 0.3.0 以降)。
70
+ # curl ではなく、KairosChain から meeting_connect ツール(url: http://localhost:8080)で登録する。
71
+ # 通行証(session token)はその結果に入っている。
73
72
 
74
73
  # スキルボード閲覧(Bearer トークン必須)
75
74
  curl -s -H "Authorization: Bearer $TOKEN" \
@@ -81,8 +80,8 @@ curl -s -H "Authorization: Bearer $TOKEN" \
81
80
  | メソッド | パス | 認証 | 説明 |
82
81
  |---------|------|------|------|
83
82
  | GET | `/place/v1/info` | なし | Place メタデータと身元情報 |
84
- | POST | `/place/v1/register` | RSA 署名(任意) | エージェント登録 |
85
- | POST | `/place/v1/unregister` | Bearer | エージェント登録解除 |
83
+ | POST | `/place/v1/register` | 登録の証明(必須、mmp 1.2.0 以降) | エージェント登録。ID はその鍵に結びつく |
84
+ | POST | `/place/v1/unregister` | Bearer | 通行証の持ち主自身の登録解除 |
86
85
  | GET | `/place/v1/agents` | Bearer | 登録エージェント一覧 |
87
86
  | GET | `/place/v1/board/browse` | Bearer | スキルボード閲覧(ランダム順) |
88
87
  | POST | `/place/v1/board/needs` | Bearer | 知識ニーズをボードに公開 |
@@ -166,4 +165,14 @@ kairos.example.com {
166
165
  }
167
166
  ```
168
167
 
168
+ hestia の設定に公開ホスト名を書いてください。書かないと、localhost 宛ての登録しか受け付けません。
169
+
170
+ ```yaml
171
+ meeting_place:
172
+ web_ui:
173
+ place_host: kairos.example.com
174
+ ```
175
+
176
+ 一度でも動かしたことのある Meeting Place を hestia 0.3.0 に上げる場合:保存ディレクトリ(`<data_dir>/storage/`)に `agent_bindings.json` ができるまで、最初の起動は止まります。手元にある結びつきを書いて作るか、`{}` で作ってください。起動が止まった Meeting Place も `/health` には `place_started: false` で答えるので、`true` になっていること、`meeting_place_status` の `unmounted_extensions` が空であることを確かめてください(組み込まれていない拡張を持つ SkillSet が入っていると、新しい ID の登録が止まります)。
177
+
169
178
  DEE プロトコルの内部詳細については、hestia SkillSet をインストールし、同梱の knowledge(`hestia_meeting_place`)を参照してください。
@@ -7,7 +7,7 @@ tags:
7
7
  - trust-anchor
8
8
  - dee
9
9
  - philosophy-protocol
10
- version: "2.0.0"
10
+ version: "2.1.0"
11
11
  ---
12
12
 
13
13
  # HestiaChain Meeting Place
@@ -25,7 +25,7 @@ KairosChain (MCP Server)
25
25
  ├── [core] L0/L1/L2 + private blockchain
26
26
  ├── [SkillSet: mmp] P2P direct mode, /meeting/v1/*
27
27
  └── [SkillSet: hestia] Meeting Place + trust anchor
28
- ├── depends_on: mmp >= 1.0.0
28
+ ├── depends_on: mmp >= 1.2.0
29
29
  ├── chain/ Hestia::Chain (self-contained anchor)
30
30
  │ ├── core/ Anchor, Client, Config, BatchProcessor
31
31
  │ ├── backend/ InMemory (stage 0), PrivateJSON (stage 1)
@@ -167,7 +167,21 @@ This design reflects the DEE principle that departure is a meaningful event, not
167
167
  - **Persistence**: JSON file (`registry_path` in config)
168
168
  - **Thread safety**: All mutations wrapped in `Mutex#synchronize`
169
169
  - **Self-register**: The Place itself is registered as a participant
170
- - **Fields per agent**: `id`, `name`, `capabilities`, `public_key` (optional), `registered_at`, `last_heartbeat`, `visited_places` (federation preparation)
170
+ - **Fields per agent**: `id`, `name`, `capabilities`, `public_key`, `registered_at`, `last_heartbeat`, `visited_places` (federation preparation)
171
+ - **Key bindings**: each agent id is bound to one public key in `agent_bindings.json` (see below). A binding outlives registration: unregister and fade-out remove the agent entry, never the binding.
172
+
173
+ ### Registration and key binding (hestia 0.3.0)
174
+
175
+ An agent id is bound to the first key that registers it, and no request or fade-out changes that binding afterwards. Clients trust and track peers by id, so an id must keep meaning after its owner disconnects.
176
+
177
+ - **Registration proof** (`MMP::RegistrationProof`, mmp ≥ 1.2.0): the client signs, with RSA-PSS, the origin it dialed (`https://<place_host>`), its id, the issue time and a single-use nonce. The Place accepts only its own https origin (`meeting_place.web_ui.place_host`; without it, loopback origins only), proofs issued within 300 s and not before 30 s after the process started, each nonce once, and only a signature that verifies against the id's binding. Static signatures from older clients are refused with `registration_proof_required`.
178
+ - **Sessions**: the Place honours only sessions it issued itself — marked with its issuer in the shared session store, and carrying the hash of the binding as `pubkey_hash`. Sessions from the core P2P `/meeting/v1/introduce` are refused at `/place/*`.
179
+ - **Unbound ids holding items** (deposits, authored attestations, SkillSet deposits from before 0.3.0): bound only by a key whose signature verifies on every item. Place extensions must implement `holdings_evidence(agent_id)`; `register_extension` refuses one that does not.
180
+ - **Writes act on the session id**: unregister and needs act on the session id, and a body `agent_id` naming another id is refused; a signed attestation payload must name the session id as attester.
181
+ - **Bindings file** `agent_bindings.json` lives under `KairosMcp.storage_dir` (a relative `bindings_path` resolves there). It is written atomically and only when a binding is created. A file that cannot be read or parsed, or holds an unusable key, stops the Place from starting; a missing file is accepted only on a Place that has never run (no board state and no registry file) — otherwise create it (an empty `{}` is enough). Format: `{ "<agent id>": { "public_key": "<PEM>" } }`. Edit it only with the Place stopped; one process owns the storage.
182
+ - **New bindings** are limited to `meeting_place.binding_rate_per_hour` (default 60).
183
+ - **Unmounted extensions**: while a place extension declared by an enabled SkillSet is not mounted (refused for lacking `holdings_evidence`, failed to load), claims of new ids are refused; `meeting_place_status` lists them as `unmounted_extensions` and the server log names them.
184
+ - **Client id**: the id a client presents to Places lives in `keys/mmp_place_id` beside its keypair. A keypair created by mmp ≥ 1.2.0 comes with a random id; an older install keeps its previous id. `meeting_connect` with `fresh_id: true` takes a new random id when a Place answers `key_mismatch`.
171
185
 
172
186
  ### SkillBoard
173
187
 
@@ -205,7 +219,11 @@ meeting_place:
205
219
  name: "My Meeting Place"
206
220
  max_agents: 100
207
221
  heartbeat_ttl: 300 # seconds before agent fadeout
208
- registry_path: "agents.json" # relative to data_dir
222
+ registry_path: "agents.json" # a relative path resolves against the working directory
223
+ bindings_path: "agent_bindings.json" # resolved under storage_dir
224
+ binding_rate_per_hour: 60
225
+ web_ui:
226
+ place_host: "meeting.example" # the https origin registration proofs must name
209
227
 
210
228
  chain:
211
229
  backend: "in_memory" # in_memory | private
@@ -3,15 +3,29 @@
3
3
  require 'json'
4
4
  require 'fileutils'
5
5
  require 'securerandom'
6
+ require 'time'
6
7
 
7
8
  module Hestia
8
- # AgentRegistry manages registered agents at this Meeting Place.
9
+ # AgentRegistry manages registered agents at this Meeting Place, and the
10
+ # binding of each agent id to its public key.
11
+ #
12
+ # Registration (presence) and binding are separate: an agent entry comes and
13
+ # goes with registration and heartbeat expiry, while a binding, once made, is
14
+ # never changed or removed by a request or by fade-out — only by the operator
15
+ # editing the bindings file with the Place stopped. Clients trust and track
16
+ # peers by id, so an id must keep meaning after its owner disconnects.
17
+ #
18
+ # Bindings live in their own file, written atomically and only when a binding
19
+ # is created; heartbeats never rewrite it. A bindings file that cannot be read
20
+ # or parsed stops the Place rather than being taken for an empty table.
9
21
  #
10
- # Follows PeerManager persistence pattern (JSON file with Mutex).
11
- # RSA signature verification on register (rejects unsigned requests).
12
22
  # Self-registration support for 主客未分 (subject-object undifferentiated).
13
23
  class AgentRegistry
14
24
  DEFAULT_REGISTRY_PATH = 'storage/agent_registry.json'
25
+ DEFAULT_BINDING_RATE_PER_HOUR = 60
26
+
27
+ # Raised when the bindings file cannot be read, parsed or written.
28
+ class BindingStoreError < StandardError; end
15
29
 
16
30
  Agent = Struct.new(
17
31
  :id, :name, :url, :capabilities, :public_key,
@@ -22,28 +36,44 @@ module Hestia
22
36
 
23
37
  attr_reader :agents
24
38
 
25
- def initialize(registry_path: nil, config: {})
39
+ # @param bindings_path [String, nil] Persistent bindings file. nil keeps
40
+ # bindings in memory only (unit use); the Place always passes a path.
41
+ # @param require_bindings_file [Boolean] Refuse to start when the bindings
42
+ # file is missing (the Place sets this when other persisted state exists).
43
+ def initialize(registry_path: nil, config: {}, bindings_path: nil, require_bindings_file: false)
26
44
  @registry_path = registry_path || DEFAULT_REGISTRY_PATH
45
+ @bindings_path = bindings_path
27
46
  @agents = {}
47
+ @bindings = {}
48
+ @binding_times = []
28
49
  @mutex = Mutex.new
29
50
  @config = config
51
+ @binding_rate = (config['binding_rate_per_hour'] || DEFAULT_BINDING_RATE_PER_HOUR).to_i
52
+ load_bindings(require_file: require_bindings_file) if @bindings_path
30
53
  load_registry
31
54
  end
32
55
 
33
56
  # Register an agent at this Meeting Place.
34
57
  #
35
- # @param id [String] Agent instance_id
36
- # @param name [String] Agent display name
37
- # @param capabilities [Hash,Array] Agent capabilities
38
- # @param public_key [String] RSA public key PEM
39
- # @param url [String] Agent endpoint URL (optional)
40
- # @param is_self [Boolean] True if this is the Place itself
41
- # @param visited_places [Array] Known place URLs (for federation)
42
- # @return [Hash] Registration result
58
+ # When public_key is given it is compared with, or becomes, the id's
59
+ # binding (compare-and-bind under the registry mutex). A key that differs
60
+ # from the binding changes nothing and returns status 'key_mismatch'.
61
+ # A new binding is persisted before anything else is written; a failed
62
+ # write raises BindingStoreError and registers nothing.
63
+ #
64
+ # @return [Hash] { status: 'registered'|'updated'|'key_mismatch'|
65
+ # 'invalid_key'|'binding_rate_exceeded', agent_id: }
43
66
  def register(id:, name:, capabilities: nil, public_key: nil, url: nil, is_self: false,
44
67
  visited_places: [], description: nil, scope: nil)
45
68
  now = Time.now.utc.iso8601
46
69
  @mutex.synchronize do
70
+ if public_key
71
+ bind_result = bind_locked(id, public_key, rate_limited: !is_self)
72
+ return bind_result.merge(agent_id: id) if bind_result
73
+
74
+ public_key = @bindings[id][:public_key]
75
+ end
76
+
47
77
  existing = @agents[id]
48
78
  if existing
49
79
  # Update existing registration
@@ -93,7 +123,7 @@ module Hestia
93
123
  )
94
124
  end
95
125
 
96
- # Unregister an agent.
126
+ # Unregister an agent. The id's binding is kept.
97
127
  #
98
128
  # @param id [String] Agent ID to remove
99
129
  # @return [Hash] Result
@@ -132,12 +162,22 @@ module Hestia
132
162
  end
133
163
  end
134
164
 
135
- # Get an agent's public key.
165
+ # The key bound to an id, or nil when the id is unbound.
166
+ def bound_key(id)
167
+ @mutex.synchronize { @bindings[id]&.dig(:public_key) }
168
+ end
169
+
170
+ def bound_ids
171
+ @mutex.synchronize { @bindings.keys }
172
+ end
173
+
174
+ # Get an agent's public key: its binding, which outlives registration, so
175
+ # signatures still verify after the agent fades out.
136
176
  #
137
177
  # @param id [String] Agent ID
138
178
  # @return [String, nil] Public key PEM or nil
139
179
  def public_key_for(id)
140
- @mutex.synchronize { @agents[id]&.public_key }
180
+ @mutex.synchronize { @bindings[id]&.dig(:public_key) || @agents[id]&.public_key }
141
181
  end
142
182
 
143
183
  # Update heartbeat for an agent.
@@ -171,6 +211,40 @@ module Hestia
171
211
 
172
212
  private
173
213
 
214
+ # Compare-and-bind, called with @mutex held.
215
+ # @return [Hash, nil] a refusal, or nil when the key is (now) bound to id
216
+ def bind_locked(id, public_key, rate_limited:)
217
+ return { status: 'invalid_id' } unless ::MMP::RegistrationProof::AGENT_ID_PATTERN.match?(id.to_s)
218
+
219
+ canonical = ::MMP::RegistrationProof.canonical_pem(public_key)
220
+ return { status: 'invalid_key' } unless canonical
221
+
222
+ bound = @bindings[id]
223
+ if bound
224
+ return nil if ::MMP::RegistrationProof.same_key?(bound[:public_key], canonical)
225
+
226
+ return { status: 'key_mismatch' }
227
+ end
228
+
229
+ if rate_limited
230
+ prune_binding_times
231
+ if @binding_times.size >= @binding_rate
232
+ return { status: 'binding_rate_exceeded', retry_after: (@binding_times.first + 3600 - Time.now.to_f).ceil }
233
+ end
234
+ end
235
+
236
+ entry = { public_key: canonical, bound_at: Time.now.utc.iso8601 }
237
+ persist_bindings(@bindings.merge(id => entry))
238
+ @bindings[id] = entry
239
+ @binding_times << Time.now.to_f if rate_limited
240
+ nil
241
+ end
242
+
243
+ def prune_binding_times
244
+ cutoff = Time.now.to_f - 3600
245
+ @binding_times.shift while @binding_times.first && @binding_times.first < cutoff
246
+ end
247
+
174
248
  def agent_to_h(agent)
175
249
  h = {
176
250
  id: agent.id,
@@ -187,11 +261,66 @@ module Hestia
187
261
  h
188
262
  end
189
263
 
264
+ # Load bindings. Anything other than a readable JSON object of
265
+ # id => { "public_key" => PEM } with acceptable keys raises
266
+ # BindingStoreError, so the Place does not start on a damaged file.
267
+ def load_bindings(require_file:)
268
+ unless File.exist?(@bindings_path)
269
+ if require_file
270
+ raise BindingStoreError,
271
+ "Bindings file #{@bindings_path} is missing but other Meeting Place state exists. " \
272
+ 'Restore it, or create it (an empty {} is enough) to confirm starting without bindings.'
273
+ end
274
+ return
275
+ end
276
+
277
+ data = JSON.parse(File.read(@bindings_path))
278
+ raise BindingStoreError, "Bindings file #{@bindings_path} must hold a JSON object" unless data.is_a?(Hash)
279
+
280
+ data.each do |id, entry|
281
+ unless ::MMP::RegistrationProof::AGENT_ID_PATTERN.match?(id) && entry.is_a?(Hash)
282
+ raise BindingStoreError, "Bindings file #{@bindings_path}: malformed entry #{id.inspect}"
283
+ end
284
+
285
+ canonical = ::MMP::RegistrationProof.canonical_pem(entry['public_key'])
286
+ raise BindingStoreError, "Bindings file #{@bindings_path}: unusable public key for #{id}" unless canonical
287
+
288
+ @bindings[id] = { public_key: canonical, bound_at: entry['bound_at'] }
289
+ end
290
+ rescue JSON::ParserError, SystemCallError, IOError => e
291
+ raise BindingStoreError, "Bindings file #{@bindings_path} cannot be read: #{e.message}"
292
+ end
293
+
294
+ # Write the whole table atomically; raise on failure so no session is
295
+ # issued on a binding that is not on disk.
296
+ def persist_bindings(table)
297
+ return unless @bindings_path
298
+
299
+ temp = nil
300
+ FileUtils.mkdir_p(File.dirname(@bindings_path))
301
+ data = table.transform_values { |e| { 'public_key' => e[:public_key], 'bound_at' => e[:bound_at] } }
302
+ temp = "#{@bindings_path}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
303
+ File.open(temp, 'w') do |f|
304
+ f.write(JSON.pretty_generate(data))
305
+ f.flush
306
+ f.fsync
307
+ end
308
+ File.rename(temp, @bindings_path)
309
+ rescue SystemCallError, IOError => e
310
+ File.unlink(temp) if temp && File.exist?(temp)
311
+ raise BindingStoreError, "Bindings file #{@bindings_path} cannot be written: #{e.message}"
312
+ end
313
+
190
314
  def load_registry
191
315
  @mutex.synchronize do
192
316
  if File.exist?(@registry_path)
193
317
  data = JSON.parse(File.read(@registry_path), symbolize_names: true)
194
318
  (data[:agents] || []).each do |a|
319
+ # With a persistent bindings store, an entry without a binding is
320
+ # not loaded: its key was never saved, and a registered id must
321
+ # have a binding.
322
+ next if @bindings_path && !@bindings.key?(a[:id])
323
+
195
324
  @agents[a[:id]] = Agent.new(**a)
196
325
  end
197
326
  end