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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/knowledge/hestiachain_meeting_place/hestiachain_meeting_place.md +17 -7
- data/templates/knowledge/hestiachain_meeting_place_jp/hestiachain_meeting_place_jp.md +16 -7
- data/templates/skillsets/hestia/knowledge/hestia_meeting_place/hestia_meeting_place.md +22 -4
- data/templates/skillsets/hestia/lib/hestia/agent_registry.rb +144 -15
- data/templates/skillsets/hestia/lib/hestia/place_router.rb +283 -67
- data/templates/skillsets/hestia/lib/hestia/registration_nonce_store.rb +36 -0
- data/templates/skillsets/hestia/lib/hestia/skill_board.rb +23 -0
- data/templates/skillsets/hestia/lib/hestia.rb +2 -1
- data/templates/skillsets/hestia/skillset.json +2 -2
- data/templates/skillsets/hestia/tools/meeting_place_status.rb +14 -0
- data/templates/skillsets/mmp/lib/mmp/crypto.rb +6 -0
- data/templates/skillsets/mmp/lib/mmp/identity.rb +89 -2
- data/templates/skillsets/mmp/lib/mmp/meeting_session_store.rb +18 -2
- data/templates/skillsets/mmp/lib/mmp/place_client.rb +47 -6
- data/templates/skillsets/mmp/lib/mmp/registration_proof.rb +149 -0
- data/templates/skillsets/mmp/lib/mmp.rb +2 -1
- data/templates/skillsets/mmp/skillset.json +1 -1
- data/templates/skillsets/mmp/tools/meeting_attest_skill.rb +4 -1
- data/templates/skillsets/mmp/tools/meeting_connect.rb +38 -6
- data/templates/skillsets/skillset_exchange/lib/skillset_exchange/place_extension.rb +99 -13
- data/templates/skillsets/skillset_exchange/skillset.json +3 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c9e86d08fcaeb0acb8208d11bc05a5b2aea3e93b1c5ce0e51ea582e728c5b97
|
|
4
|
+
data.tar.gz: c9e04ca2f728363fe2111a04431e7615e84a8c8a4d3b4a4e2f12f6fbe92ed246
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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` |
|
|
85
|
-
| POST | `/place/v1/unregister` | Bearer | Unregister
|
|
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.
|
|
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
|
|
71
|
-
|
|
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` |
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# @
|
|
42
|
-
#
|
|
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
|
-
#
|
|
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
|