mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
web: federation signature v2 migration (#805)
* web: federation signature v2 migration * web: address review comments
This commit is contained in:
15 files changed
+428
-173
No files matched your search
+54
-26
@@ -1,37 +1,65 @@
|
||||
---
|
||||
name: api-casing-and-federation-signature
|
||||
description: API JSON casing map + the camelCase federation-signature landmine
|
||||
description: API JSON casing map + the federation signature v2 (snake) migration
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
**Federation instance signatures are computed over camelCase canonical keys.**
|
||||
`web/lib/potato_mesh/application/federation/signature.rb` `canonical_instance_payload`
|
||||
signs `JSON.generate({contactLink, id, domain, pubkey, name, version, channel,
|
||||
frequency, latitude, longitude, lastUpdateTime, isPrivate}, sort_keys: true)`. So
|
||||
the federation wire — `/.well-known/potato-mesh`, `GET /api/instances`
|
||||
(`instances.rb` `normalize_instance_row`), the announcement payload
|
||||
(`self_instance.rb`) — **must stay camelCase**. Renaming any signed key breaks
|
||||
cross-version signature verification **bilaterally** (no "emit both keys" shim
|
||||
fixes a signature). Treat this as immovable without a signature-version scheme.
|
||||
**Federation wire is snake_case "signature v2" since the 0.7.0 line (FS1–FS6 in
|
||||
SPEC.md).** It used to be camelCase-and-immovable (BF3, now superseded). The whole
|
||||
federation surface — `/.well-known/potato-mesh`, `GET /api/instances`
|
||||
(`instances.rb` `normalize_instance_row`), and the announcement payload
|
||||
(`self_instance.rb` `instance_announcement_payload`) — now emits snake_case:
|
||||
`public_key` (was `publicKey`/`pubkey`), `last_update` (was
|
||||
`lastUpdate`/`lastUpdateTime`), `is_private`, `contact_link`, `nodes_count`,
|
||||
`meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`
|
||||
(forward-compat stub, always 0), plus `signature_algorithm`, `signed_payload`,
|
||||
`signature_version`. Single-token keys (`id`, `domain`, `name`, `version`,
|
||||
`channel`, `frequency`, `latitude`, `longitude`, `signature`) unchanged. DB
|
||||
columns (`pubkey`, `last_update_time`) stay internal, mapped at the wire boundary.
|
||||
|
||||
**HTTP API casing map (as of 0.7.0):**
|
||||
**How the migration works (`federation/signature.rb`):**
|
||||
- One shared `canonical_signed_payload(fields)` builds the snake canonical and
|
||||
stamps `signature_version` *inside the signed bytes* (anti-downgrade). Used by
|
||||
both signers (instance announcement + well-known) — option U0.
|
||||
- `canonical_instance_payload` signs the **v2** (snake) form, now including ALL
|
||||
node counts (FS2 — no unsigned attribute remains).
|
||||
- `verify_instance_signature` tries **v2 (snake) then v1 (camel)** canonical →
|
||||
accept-both. `canonical_instance_payload_v1` is kept verbatim for the v1 path.
|
||||
- The break is **one-way** (FS5): old peers can't verify our v2 signature (stop
|
||||
accepting us until they upgrade); we still accept their v1 → mixed fleet
|
||||
converges. `config.federation_signature_version` = 2.
|
||||
- Parsers read both casings: `crawl.rb` `remote_instance_attributes_from_payload`
|
||||
+ `routes/ingest.rb` POST `/api/instances` + `federation/validation.rb`
|
||||
`validate_well_known_document` (e.g. `payload["public_key"] || payload["publicKey"]`).
|
||||
|
||||
**Signed counts are stored/served/relayed verbatim (decision (a)).** Because the
|
||||
crawl re-verifies relayed `/api/instances` entries' signatures over their counts
|
||||
(`crawl.rb`), the old recompute-on-store would break relay re-verification when
|
||||
the recomputed value diverged from the signed snapshot. So the recompute in
|
||||
`ingest.rb` and `crawl.rb` is now a **count-absent fallback only** — present
|
||||
(signed) counts are kept so the canonical re-builds identically downstream.
|
||||
|
||||
**HTTP API casing map (0.7.0 line):**
|
||||
- Read collections (`/api/nodes`, `/api/messages`, `/api/positions`,
|
||||
`/api/telemetry`, `/api/traces`, `/api/neighbors`) and `/api/stats`: **snake_case**
|
||||
already (`query_nodes` selects snake columns; matrix reads `rx_time`).
|
||||
- `/version`: **snake_case** since 0.7.0 (was the lone camelCase read response).
|
||||
- `POST /api/nodes` **input**: Meshtastic camelCase, but `upsert_node` now also
|
||||
accepts snake via nil-aware `pick_alias` (camel preferred → ingestor unaffected).
|
||||
- Federation wire: **camelCase** (signed — see above).
|
||||
`/api/telemetry`, `/api/traces`, `/api/neighbors`) and `/api/stats`,
|
||||
`/version`: **snake_case**.
|
||||
- `POST /api/nodes` / `POST /api/instances` **input**: accept camelCase AND
|
||||
snake (nil-aware `pick_alias` / `a || b`).
|
||||
- Federation wire: **snake_case v2** (signed — see above).
|
||||
|
||||
**Gotchas that cost time:**
|
||||
- `/api/nodes` camelCase you see in the frontend is the POST input, the client
|
||||
`normalizeNodeCollection` internal model, and `?? shortName` fallbacks — NOT the
|
||||
GET response. Don't "snake-case /api/nodes"; it already is.
|
||||
- The dashboard frontend reads config from the server-rendered `data-app-config`
|
||||
attribute (`views/layouts/app.erb`, helper `frontend_app_config`), **not** from
|
||||
`/version`. The matrix bridge only pings `/version` for liveness (no key reads).
|
||||
The Flutter app *does* read `/version` config keys (`app/lib/main.dart`) — the
|
||||
one consumer that breaks on a `/version` casing change.
|
||||
**Gotchas:**
|
||||
- Frontend `federation-page.js` reads `/api/instances` dual (`x.snake ?? x.camel`)
|
||||
so it survives either casing; `federation-instance-display.js` only reads
|
||||
`name`/`domain` (casing-safe).
|
||||
- RSpec `stub_const(name, value)` does **NOT** take a block — `stub_const(...) do
|
||||
… end` silently skips the block body (vacuous test). `app_spec.rb` had several
|
||||
such blocks (`build_well_known_document` + the `.self_instance_domain` /
|
||||
`.self_instance_registration_decision` / `.ensure_self_instance_record!`
|
||||
describes); **all now converted to inline `stub_const`** and genuinely run — no
|
||||
block-form `stub_const` remains in the specs. Use the value form
|
||||
`stub_const("X", Class.new do … end)` only when the block belongs to `Class.new`.
|
||||
- The Flutter app reads `/version` config keys (one consumer that breaks on a
|
||||
`/version` casing change); it does NOT consume the federation wire.
|
||||
|
||||
See [[web-app-local-run]] for running the server to curl these.
|
||||
@@ -669,3 +669,56 @@ canonical payload are unchanged.
|
||||
```
|
||||
**Expected:** all green. POST `be_ok` assertions were updated to `201` (not
|
||||
removed); the ingestor is unaffected (2xx success); the matrix bridge is GET-only.
|
||||
|
||||
---
|
||||
|
||||
## Bugfix/Migration: Federation signature v2
|
||||
|
||||
Maps to SPEC **FS1–FS6** — federation wire migrated to snake_case with signed
|
||||
counts and v1-backward-compatible verification.
|
||||
|
||||
### FS-A1 — v2 sign/verify round-trip + v1 backward-accept
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/federation_spec.rb -e "signature" )
|
||||
```
|
||||
**Expected:** pass. A v2 (snake) instance signature verifies; a legacy v1
|
||||
(camelCase, no `signature_version`) signature still verifies via fallback.
|
||||
`verify_instance_signature` accepts both; instances sign/send v2.
|
||||
|
||||
### FS-A2 — all announced counts are signed (tamper-evident)
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/federation_spec.rb -e "signed counts" )
|
||||
```
|
||||
**Expected:** pass. The announcement canonical covers `nodes_count`,
|
||||
`meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`;
|
||||
altering any count invalidates the v2 signature. Nothing in the announced payload
|
||||
sits outside the signed canonical except `signature` / `signature_version`.
|
||||
|
||||
### FS-A3 — well-known v2 snake + version marker, accepts v1+v2
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "well-known" )
|
||||
```
|
||||
**Expected:** pass. `/.well-known/potato-mesh` emits snake_case (`public_key`,
|
||||
`last_update`, `signature_algorithm`, `signed_payload`, `signature_version`); the
|
||||
validator accepts both v2 and legacy v1 documents.
|
||||
|
||||
### FS-A4 — wire surfaces are snake_case
|
||||
```bash
|
||||
( cd web && bundle exec rspec spec/app_spec.rb -e "/api/instances" )
|
||||
```
|
||||
**Expected:** pass. `GET /api/instances` and the announce payload use
|
||||
`public_key`, `last_update`, `is_private`, `contact_link`, `*_nodes_count` — no
|
||||
camelCase keys.
|
||||
|
||||
### FS-A5 — activity gate is intended behavior (not a regression)
|
||||
**Expected (covered by `federation_spec`):** an instance with **0 nodes active in
|
||||
7 days** is **not** federated — `validate_remote_nodes` rejects it ("node data is
|
||||
stale" / below `remote_instance_min_node_count`). By design.
|
||||
|
||||
### FS-R1 — Regression
|
||||
```bash
|
||||
( cd web && bundle exec rspec ) && ( cd web && npm test )
|
||||
( . .venv/bin/activate && pytest -q tests/ ) && ( cd matrix && cargo test --all --all-features )
|
||||
```
|
||||
**Expected:** all green. The pre-existing camelCase federation specs are
|
||||
retargeted to v2 or kept as the v1-backward-accept proof, not removed.
|
||||
@@ -262,9 +262,31 @@ to standardise on snake_case while preserving compatibility where it is load-bea
|
||||
| --- | --- | --- |
|
||||
| **BF1** | **`/version` response is snake_case.** Top-level `last_node_update` and the `config` block (`site_name`, `map_center` {`lat`,`lon`}, `private_mode`, `instance_domain`, `contact_link`, `contact_link_url`, `max_distance_km`, `refresh_interval_seconds`) replace the prior camelCase keys. A versioned breaking change (0.7.0); consumers are the Flutter app and external clients. | interview |
|
||||
| **BF2** | **`POST /api/nodes` additionally accepts snake_case** node fields (`last_heard`, `user.short_name`/`long_name`/`hw_model`, `device_metrics.battery_level`, `position.location_source`, …) via a **nil-aware** `pick_alias` (a `false` camelCase value is never overridden by a snake_case alias). **Additive** — the Python ingestor's camelCase output keeps working, so no ingestor change is required. | interview |
|
||||
| **BF3** | **The signed federation wire is unchanged** (Invariant III). `/.well-known/potato-mesh` and `/api/instances` keep their camelCase keys (`isPrivate`, `lastUpdateTime`, `nodesCount`, …) because those keys are inside the instance **signature** (`federation/signature.rb`); renaming them would break cross-version signature verification bilaterally. | code |
|
||||
| **BF3** | **The signed federation wire is unchanged** (Invariant III). `/.well-known/potato-mesh` and `/api/instances` keep their camelCase keys (`isPrivate`, `lastUpdateTime`, `nodesCount`, …) because those keys are inside the instance **signature** (`federation/signature.rb`); renaming them would break cross-version signature verification bilaterally. **Superseded by FS1–FS6** (next release): the wire is deliberately migrated to snake_case v2 with a `signature_version` marker and v1-backward-accept, so the break is one-way and versioned rather than silent. | code |
|
||||
| **BF4** | **Out of scope (deferred).** The Flutter app's `/version` reader (`app/lib/main.dart`) and the server→frontend `data-app-config` DOM channel (`frontend_app_config`) keep camelCase for now and are tracked as separate follow-ups; the frontend dashboard is unaffected (it reads `data-app-config`, not `/version`). | interview |
|
||||
| **BF5** | **`POST /api/instances` accepts snake_case aliases** for its optional fields (`contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`) in addition to camelCase (third-party / cross-version compat); `id`/`lastUpdateTime`/`isPrivate` were already dual-keyed. The signed canonical payload (camelCase) is unchanged. (I6) | interview |
|
||||
| **BF5** | **`POST /api/instances` accepts snake_case aliases** for its optional fields (`contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`) in addition to camelCase (third-party / cross-version compat); `id`/`lastUpdateTime`/`isPrivate` were already dual-keyed. The signed canonical payload (camelCase) is unchanged. (I6) **Superseded by FS1** (the signed canonical and announced payload are now snake_case v2; the dual-key acceptance remains as the v1-backward path). | interview |
|
||||
| **BF6** | **Position time is exposed only as `position_time`** (unix int) on GET responses; the redundant ISO twin (`pos_time_iso` on `/api/nodes`, `position_time_iso` on `/api/positions`) is removed — clients format it themselves. (I2) | interview |
|
||||
| **BF7** | **All `POST /api/*` ingest routes return `201 Created`** (was `200`), matching `/api/instances`. The Python ingestor accepts any 2xx (`queue.py` urlopen); the matrix bridge is GET-only. (I3) | interview |
|
||||
| **BF8** | **List POST routes validate the top-level payload.** `/api/messages`, `/positions`, `/telemetry`, `/neighbors`, `/traces` reject a non-Array/non-Hash body with `400 {"error":"invalid payload"}`, matching `/api/nodes` strictness. (I5) | interview |
|
||||
|
||||
---
|
||||
|
||||
## Bugfix/Migration: Federation signature v2 (snake_case wire, signed counts)
|
||||
|
||||
Migrates the federation wire (instance announcement, `GET /api/instances`,
|
||||
`/.well-known/potato-mesh`) from camelCase to snake_case and closes the
|
||||
unsigned-field gap, as a **deliberate, versioned break to Invariant III** with
|
||||
receiver-side backward compatibility. The two signed surfaces keep distinct roles
|
||||
(well-known = fetched-from-origin identity anchor; announcement = relayable
|
||||
attribute bundle) but share one snake_case canonicalizer, one `signature_version`
|
||||
marker, and one fallback chain (option **U0**).
|
||||
|
||||
| # | Decision | Source |
|
||||
| --- | --- | --- |
|
||||
| **FS1** | **Snake_case federation wire** via one shared canonicalizer: `public_key` (was `publicKey`/`pubkey`), `last_update` (was `lastUpdate`/`lastUpdateTime`), `is_private`, `contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`, `signature_algorithm`, `signed_payload`, `signature_version`. Single-token keys (`id`, `domain`, `name`, `version`, `channel`, `frequency`, `latitude`, `longitude`, `signature`) unchanged. DB columns (`pubkey`, `last_update_time`) stay internal, mapped at the wire boundary. | interview |
|
||||
| **FS2** | **Every announced count is signed** — the announcement canonical includes `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, and a forward-compat `reticulum_nodes_count` (0 until a Reticulum ingestor exists); **no unsigned attribute remains** in the announcement. Counts are still **recomputed** from the peer's live `/api/nodes` on receipt — the signature authenticates the sender's snapshot (integrity), the recompute keeps the displayed figure fresh. | interview |
|
||||
| **FS3** | **`signature_version` is stamped inside the signed canonical** (not only the envelope), so the format cannot be silently downgraded. Current version = `2`; legacy payloads without it are treated as `v1`. | interview |
|
||||
| **FS4** | **Send-snake, accept-both (U0).** Instances sign and send **v2 (snake)**. `verify_instance_signature` and the well-known validator try the **v2 snake** canonical, then fall back to the **v1 camel** canonical, each composed with the existing `contact_link`-strip and domain-casing fallbacks. One canonicalizer + fallback chain is shared by both signers (their field sets differ; the mechanism/casing/marker/fallback are shared). | interview |
|
||||
| **FS5** | **Versioned one-way break** (amends Invariant III, mirrors S7): old peers cannot verify a v2 signature and stop accepting this instance until upgraded (accepted cost); new instances accept old peers' v1 signatures, so a mixed fleet converges. | interview |
|
||||
| **FS6** | **`last_update`** is the sole wire name for the instance update time across both signed surfaces. | interview |
|
||||
| **FS7** | **Flutter `/api/instances` reader deferred** (extends BF4). `app/lib/main.dart` `MeshInstance.fromJson` still reads `isPrivate`/`lastUpdateTime` (camelCase) and is intentionally **not** migrated, consistent with the standing decision to defer all Flutter work. Low impact: `GET /api/instances` never serves an `is_private: true` entry (private instances 404 the endpoint via `federation_enabled?`; remote private peers are rejected at registration/crawl), so privacy stays enforced server-side — the stale reads only blank the app's `last_update`/`is_private` display until the client is updated. | review |
|
||||
@@ -92,16 +92,20 @@ module PotatoMesh
|
||||
domain = sanitize_instance_domain(payload["domain"])
|
||||
return [nil, nil, "missing instance domain"] unless domain
|
||||
|
||||
pubkey = sanitize_public_key_pem(payload["pubkey"])
|
||||
# Accept both the v2 (snake_case) and legacy v1 (camelCase) wire keys so
|
||||
# the verifier can reconstruct either canonical, and so the parsed
|
||||
# attributes carry the signed counts needed to rebuild the v2 blob
|
||||
# (SPEC FS1/FS2/FS4).
|
||||
pubkey = sanitize_public_key_pem(payload["public_key"] || payload["pubkey"])
|
||||
return [nil, nil, "missing instance public key"] unless pubkey
|
||||
|
||||
signature = string_or_nil(payload["signature"])
|
||||
return [nil, nil, "missing instance signature"] unless signature
|
||||
|
||||
private_value = if payload.key?("isPrivate")
|
||||
payload["isPrivate"]
|
||||
else
|
||||
private_value = if payload.key?("is_private")
|
||||
payload["is_private"]
|
||||
elsif payload.key?("isPrivate")
|
||||
payload["isPrivate"]
|
||||
end
|
||||
private_flag = coerce_boolean(private_value)
|
||||
if private_flag.nil?
|
||||
@@ -119,9 +123,13 @@ module PotatoMesh
|
||||
frequency: string_or_nil(payload["frequency"]),
|
||||
latitude: coerce_float(payload["latitude"]),
|
||||
longitude: coerce_float(payload["longitude"]),
|
||||
last_update_time: coerce_integer(payload["lastUpdateTime"]),
|
||||
last_update_time: coerce_integer(payload["last_update"] || payload["lastUpdateTime"]),
|
||||
is_private: private_flag,
|
||||
contact_link: string_or_nil(payload["contactLink"]),
|
||||
contact_link: string_or_nil(payload["contactLink"] || payload["contact_link"]),
|
||||
nodes_count: coerce_integer(payload["nodes_count"] || payload["nodesCount"]),
|
||||
meshcore_nodes_count: coerce_integer(payload["meshcore_nodes_count"] || payload["meshcoreNodesCount"]),
|
||||
meshtastic_nodes_count: coerce_integer(payload["meshtastic_nodes_count"] || payload["meshtasticNodesCount"]),
|
||||
reticulum_nodes_count: coerce_integer(payload["reticulum_nodes_count"]),
|
||||
}
|
||||
|
||||
[attributes, signature, nil]
|
||||
@@ -322,15 +330,16 @@ module PotatoMesh
|
||||
stats_payload,
|
||||
max_age_seconds: PotatoMesh::Config.remote_instance_max_node_age,
|
||||
)
|
||||
attributes[:nodes_count] = stats_count if stats_count
|
||||
# Keep the SIGNED counts from the verified entry verbatim (SPEC FS2/(a)
|
||||
# — relay-consistent); only fall back to the peer's live /api/stats when
|
||||
# the entry carried no count.
|
||||
attributes[:nodes_count] = stats_count if stats_count && attributes[:nodes_count].nil?
|
||||
|
||||
# Extract per-protocol 24h counts (informational, not signed). Reads
|
||||
# the 0.7.0 nodes sub-hash, falling back to the pre-0.7.0 flat shape.
|
||||
if stats_payload.is_a?(Hash)
|
||||
mc_day = remote_stats_protocol_day(stats_payload, "meshcore")
|
||||
mt_day = remote_stats_protocol_day(stats_payload, "meshtastic")
|
||||
attributes[:meshcore_nodes_count] = mc_day if mc_day
|
||||
attributes[:meshtastic_nodes_count] = mt_day if mt_day
|
||||
attributes[:meshcore_nodes_count] = mc_day if mc_day && attributes[:meshcore_nodes_count].nil?
|
||||
attributes[:meshtastic_nodes_count] = mt_day if mt_day && attributes[:meshtastic_nodes_count].nil?
|
||||
end
|
||||
|
||||
nodes_since_path = "/api/nodes?since=#{recent_cutoff}&limit=1000"
|
||||
|
||||
@@ -108,6 +108,9 @@ module PotatoMesh
|
||||
nodes_count: nodes_count,
|
||||
meshcore_nodes_count: mc_count,
|
||||
meshtastic_nodes_count: mt_count,
|
||||
# Forward-compat stub (SPEC FS2): always 0 until a Reticulum ingestor
|
||||
# exists, but signed now so the shape need not change again.
|
||||
reticulum_nodes_count: 0,
|
||||
}
|
||||
ensure
|
||||
db&.close
|
||||
@@ -133,20 +136,22 @@ module PotatoMesh
|
||||
payload = {
|
||||
"id" => attributes[:id],
|
||||
"domain" => attributes[:domain],
|
||||
"pubkey" => attributes[:pubkey],
|
||||
"public_key" => attributes[:pubkey],
|
||||
"name" => attributes[:name],
|
||||
"version" => attributes[:version],
|
||||
"channel" => attributes[:channel],
|
||||
"frequency" => attributes[:frequency],
|
||||
"latitude" => attributes[:latitude],
|
||||
"longitude" => attributes[:longitude],
|
||||
"lastUpdateTime" => attributes[:last_update_time],
|
||||
"isPrivate" => attributes[:is_private],
|
||||
"contactLink" => attributes[:contact_link],
|
||||
"nodesCount" => attributes[:nodes_count],
|
||||
"meshcoreNodesCount" => attributes[:meshcore_nodes_count],
|
||||
"meshtasticNodesCount" => attributes[:meshtastic_nodes_count],
|
||||
"last_update" => attributes[:last_update_time],
|
||||
"is_private" => attributes[:is_private],
|
||||
"contact_link" => attributes[:contact_link],
|
||||
"nodes_count" => attributes[:nodes_count],
|
||||
"meshcore_nodes_count" => attributes[:meshcore_nodes_count],
|
||||
"meshtastic_nodes_count" => attributes[:meshtastic_nodes_count],
|
||||
"reticulum_nodes_count" => attributes[:reticulum_nodes_count],
|
||||
"signature" => signature,
|
||||
"signature_version" => PotatoMesh::Config.federation_signature_version,
|
||||
}
|
||||
payload.reject { |_, value| value.nil? }
|
||||
end
|
||||
|
||||
@@ -17,13 +17,55 @@
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Federation
|
||||
# Build the canonical JSON payload that gets signed for instance
|
||||
# announcements. Keys are emitted in deterministic order and only
|
||||
# populated when the corresponding attribute is non-nil.
|
||||
# Build a canonical signed JSON payload: snake_case keys in deterministic
|
||||
# order (+sort_keys+), with +signature_version+ stamped inside so the format
|
||||
# cannot be silently downgraded. Shared by the instance-announcement and
|
||||
# well-known signers (SPEC FS1/FS3/FS4, option U0). Nil fields are omitted.
|
||||
#
|
||||
# @param fields [Hash{String,Symbol => Object}] snake_case wire fields.
|
||||
# @param signature_version [Integer] format marker baked into the payload.
|
||||
# @return [String] canonical JSON string suitable for signing/verifying.
|
||||
def canonical_signed_payload(fields, signature_version: PotatoMesh::Config.federation_signature_version)
|
||||
data = {}
|
||||
fields.each { |key, value| data[key.to_s] = value unless value.nil? }
|
||||
data["signature_version"] = signature_version
|
||||
JSON.generate(data, sort_keys: true)
|
||||
end
|
||||
|
||||
# v2 (snake_case) canonical for an instance announcement. Signs every
|
||||
# announced attribute, including all node counts (SPEC FS1/FS2) — nothing
|
||||
# in the announced payload sits outside this blob except the signature.
|
||||
#
|
||||
# @param attributes [Hash] instance attributes hash.
|
||||
# @return [String] canonical JSON string suitable for signing.
|
||||
def canonical_instance_payload(attributes)
|
||||
# @return [String] canonical JSON string.
|
||||
def canonical_instance_payload_v2(attributes)
|
||||
canonical_signed_payload({
|
||||
"channel" => attributes[:channel],
|
||||
"contact_link" => attributes[:contact_link],
|
||||
"domain" => attributes[:domain],
|
||||
"frequency" => attributes[:frequency],
|
||||
"id" => attributes[:id],
|
||||
"is_private" => attributes[:is_private],
|
||||
"last_update" => attributes[:last_update_time],
|
||||
"latitude" => attributes[:latitude],
|
||||
"longitude" => attributes[:longitude],
|
||||
"meshcore_nodes_count" => attributes[:meshcore_nodes_count],
|
||||
"meshtastic_nodes_count" => attributes[:meshtastic_nodes_count],
|
||||
"name" => attributes[:name],
|
||||
"nodes_count" => attributes[:nodes_count],
|
||||
"public_key" => attributes[:pubkey],
|
||||
"reticulum_nodes_count" => attributes[:reticulum_nodes_count],
|
||||
"version" => attributes[:version],
|
||||
})
|
||||
end
|
||||
|
||||
# v1 (legacy camelCase) canonical — retained verbatim so a pre-0.7.0 peer's
|
||||
# signature still verifies (SPEC FS4 backward-accept). MUST reproduce the
|
||||
# exact bytes older instances signed; do not modify.
|
||||
#
|
||||
# @param attributes [Hash] instance attributes hash.
|
||||
# @return [String] canonical JSON string.
|
||||
def canonical_instance_payload_v1(attributes)
|
||||
data = {}
|
||||
data["contactLink"] = attributes[:contact_link] if attributes[:contact_link]
|
||||
data["id"] = attributes[:id] if attributes[:id]
|
||||
@@ -41,19 +83,34 @@ module PotatoMesh
|
||||
JSON.generate(data, sort_keys: true)
|
||||
end
|
||||
|
||||
# Verify a base64 RSA-SHA256 signature for an instance attribute set.
|
||||
# Canonical used when SIGNING our own announcement — always the current
|
||||
# (v2) form (SPEC FS4 send-snake).
|
||||
#
|
||||
# @param attributes [Hash] instance attributes hash.
|
||||
# @return [String] canonical JSON string suitable for signing.
|
||||
def canonical_instance_payload(attributes)
|
||||
canonical_instance_payload_v2(attributes)
|
||||
end
|
||||
|
||||
# Verify a base64 RSA-SHA256 instance signature, accepting both the v2
|
||||
# (snake) and legacy v1 (camel) canonical forms (SPEC FS4 accept-both).
|
||||
#
|
||||
# @param attributes [Hash] canonical instance attributes.
|
||||
# @param signature [String, nil] base64-encoded signature bytes.
|
||||
# @param public_key_pem [String, nil] PEM-encoded RSA public key.
|
||||
# @return [Boolean] true when the signature validates against the public key.
|
||||
# @return [Boolean] true when the signature validates under either form.
|
||||
def verify_instance_signature(attributes, signature, public_key_pem)
|
||||
return false unless signature && public_key_pem
|
||||
|
||||
canonical = canonical_instance_payload(attributes)
|
||||
signature_bytes = Base64.strict_decode64(signature)
|
||||
key = OpenSSL::PKey::RSA.new(public_key_pem)
|
||||
key.verify(OpenSSL::Digest::SHA256.new, signature_bytes, canonical)
|
||||
candidates = [
|
||||
canonical_instance_payload_v2(attributes),
|
||||
canonical_instance_payload_v1(attributes),
|
||||
]
|
||||
candidates.any? do |canonical|
|
||||
key.verify(OpenSSL::Digest::SHA256.new, signature_bytes, canonical)
|
||||
end
|
||||
rescue ArgumentError, OpenSSL::PKey::PKeyError
|
||||
false
|
||||
end
|
||||
|
||||
@@ -30,7 +30,7 @@ module PotatoMesh
|
||||
return [false, "document is not an object"]
|
||||
end
|
||||
|
||||
remote_pubkey = sanitize_public_key_pem(document["publicKey"])
|
||||
remote_pubkey = sanitize_public_key_pem(document["public_key"] || document["publicKey"])
|
||||
return [false, "public key missing"] unless remote_pubkey
|
||||
return [false, "public key mismatch"] unless remote_pubkey == pubkey
|
||||
|
||||
@@ -38,12 +38,12 @@ module PotatoMesh
|
||||
return [false, "domain missing"] unless remote_domain
|
||||
return [false, "domain mismatch"] unless remote_domain.casecmp?(domain)
|
||||
|
||||
algorithm = string_or_nil(document["signatureAlgorithm"])
|
||||
algorithm = string_or_nil(document["signature_algorithm"] || document["signatureAlgorithm"])
|
||||
unless algorithm&.casecmp?(PotatoMesh::Config.instance_signature_algorithm)
|
||||
return [false, "unsupported signature algorithm"]
|
||||
end
|
||||
|
||||
signed_payload_b64 = string_or_nil(document["signedPayload"])
|
||||
signed_payload_b64 = string_or_nil(document["signed_payload"] || document["signedPayload"])
|
||||
signature_b64 = string_or_nil(document["signature"])
|
||||
return [false, "missing signed payload"] unless signed_payload_b64
|
||||
return [false, "missing signature"] unless signature_b64
|
||||
@@ -61,7 +61,7 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
payload_domain = string_or_nil(payload["domain"])
|
||||
payload_pubkey = sanitize_public_key_pem(payload["publicKey"])
|
||||
payload_pubkey = sanitize_public_key_pem(payload["public_key"] || payload["publicKey"])
|
||||
return [false, "signed payload domain mismatch"] unless payload_domain&.casecmp?(domain)
|
||||
return [false, "signed payload public key mismatch"] unless payload_pubkey == pubkey
|
||||
|
||||
|
||||
@@ -175,22 +175,26 @@ module PotatoMesh
|
||||
domain_value = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
|
||||
|
||||
payload = {
|
||||
publicKey: app_constant(:INSTANCE_PUBLIC_KEY_PEM),
|
||||
name: sanitized_site_name,
|
||||
version: app_constant(:APP_VERSION),
|
||||
domain: domain_value,
|
||||
lastUpdate: last_update,
|
||||
"public_key" => app_constant(:INSTANCE_PUBLIC_KEY_PEM),
|
||||
"name" => sanitized_site_name,
|
||||
"version" => app_constant(:APP_VERSION),
|
||||
"domain" => domain_value,
|
||||
"last_update" => last_update,
|
||||
}
|
||||
|
||||
signed_payload = JSON.generate(payload, sort_keys: true)
|
||||
# Shared snake_case canonicalizer (SPEC FS1/FS4, U0) stamps
|
||||
# signature_version inside the signed bytes so the format cannot be
|
||||
# silently downgraded.
|
||||
signed_payload = canonical_signed_payload(payload)
|
||||
signature = Base64.strict_encode64(
|
||||
app_constant(:INSTANCE_PRIVATE_KEY).sign(OpenSSL::Digest::SHA256.new, signed_payload),
|
||||
)
|
||||
|
||||
document = payload.merge(
|
||||
signature: signature,
|
||||
signatureAlgorithm: PotatoMesh::Config.instance_signature_algorithm,
|
||||
signedPayload: Base64.strict_encode64(signed_payload),
|
||||
"signature_version" => PotatoMesh::Config.federation_signature_version,
|
||||
"signature" => signature,
|
||||
"signature_algorithm" => PotatoMesh::Config.instance_signature_algorithm,
|
||||
"signed_payload" => Base64.strict_encode64(signed_payload),
|
||||
)
|
||||
|
||||
json_output = JSON.pretty_generate(document)
|
||||
|
||||
@@ -134,20 +134,24 @@ module PotatoMesh
|
||||
payload = {
|
||||
"id" => id,
|
||||
"domain" => domain,
|
||||
"pubkey" => pubkey,
|
||||
"public_key" => pubkey,
|
||||
"name" => string_or_nil(row["name"]),
|
||||
"version" => string_or_nil(row["version"]),
|
||||
"channel" => string_or_nil(row["channel"]),
|
||||
"frequency" => string_or_nil(row["frequency"]),
|
||||
"latitude" => coerce_float(row["latitude"]),
|
||||
"longitude" => coerce_float(row["longitude"]),
|
||||
"lastUpdateTime" => last_update_time,
|
||||
"isPrivate" => private_flag,
|
||||
"nodesCount" => coerce_integer(row["nodes_count"]),
|
||||
"meshcoreNodesCount" => coerce_integer(row["meshcore_nodes_count"]),
|
||||
"meshtasticNodesCount" => coerce_integer(row["meshtastic_nodes_count"]),
|
||||
"contactLink" => string_or_nil(row["contact_link"]),
|
||||
"last_update" => last_update_time,
|
||||
"is_private" => private_flag,
|
||||
"nodes_count" => coerce_integer(row["nodes_count"]),
|
||||
"meshcore_nodes_count" => coerce_integer(row["meshcore_nodes_count"]),
|
||||
"meshtastic_nodes_count" => coerce_integer(row["meshtastic_nodes_count"]),
|
||||
# reticulum count has no column (always 0 today); served signed=0 so a
|
||||
# crawler re-verifying a relayed v2 record rebuilds the same canonical.
|
||||
"reticulum_nodes_count" => 0,
|
||||
"contact_link" => string_or_nil(row["contact_link"]),
|
||||
"signature" => signature,
|
||||
"signature_version" => PotatoMesh::Config.federation_signature_version,
|
||||
}
|
||||
|
||||
payload.reject { |_, value| value.nil? }
|
||||
@@ -194,7 +198,7 @@ module PotatoMesh
|
||||
normalized = normalize_instance_row(row)
|
||||
next unless normalized
|
||||
|
||||
last_update_time = normalized["lastUpdateTime"]
|
||||
last_update_time = normalized["last_update"]
|
||||
next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time
|
||||
|
||||
memo << normalized
|
||||
|
||||
@@ -142,24 +142,29 @@ module PotatoMesh
|
||||
)
|
||||
halt 400, { error: "invalid domain" }.to_json
|
||||
end
|
||||
pubkey = sanitize_public_key_pem(payload["pubkey"])
|
||||
pubkey = sanitize_public_key_pem(payload["public_key"] || payload["pubkey"])
|
||||
name = string_or_nil(payload["name"])
|
||||
version = string_or_nil(payload["version"])
|
||||
channel = string_or_nil(payload["channel"])
|
||||
frequency = string_or_nil(payload["frequency"])
|
||||
latitude = coerce_float(payload["latitude"])
|
||||
longitude = coerce_float(payload["longitude"])
|
||||
last_update_time = coerce_integer(payload["last_update_time"] || payload["lastUpdateTime"])
|
||||
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
|
||||
last_update_time = coerce_integer(payload["last_update"] || payload["last_update_time"] || payload["lastUpdateTime"])
|
||||
raw_private = if payload.key?("is_private")
|
||||
payload["is_private"]
|
||||
elsif payload.key?("isPrivate")
|
||||
payload["isPrivate"]
|
||||
end
|
||||
is_private = coerce_boolean(raw_private)
|
||||
signature = string_or_nil(payload["signature"])
|
||||
# Accept either casing for third-party / cross-version compatibility
|
||||
# (the existing camelCase keys plus their snake_case aliases). +id+,
|
||||
# +lastUpdateTime+, and +isPrivate+ are already dual-keyed above.
|
||||
# Accept both v2 (snake_case) and legacy v1 (camelCase) wire keys
|
||||
# (SPEC FS4); the parsed counts (incl. reticulum) feed v2 signature
|
||||
# verification before they are recomputed from the live node list.
|
||||
contact_link = string_or_nil(payload["contactLink"] || payload["contact_link"])
|
||||
nodes_count = coerce_integer(payload["nodesCount"] || payload["nodes_count"])
|
||||
meshcore_nodes_count = coerce_integer(payload["meshcoreNodesCount"] || payload["meshcore_nodes_count"])
|
||||
meshtastic_nodes_count = coerce_integer(payload["meshtasticNodesCount"] || payload["meshtastic_nodes_count"])
|
||||
nodes_count = coerce_integer(payload["nodes_count"] || payload["nodesCount"])
|
||||
meshcore_nodes_count = coerce_integer(payload["meshcore_nodes_count"] || payload["meshcoreNodesCount"])
|
||||
meshtastic_nodes_count = coerce_integer(payload["meshtastic_nodes_count"] || payload["meshtasticNodesCount"])
|
||||
reticulum_nodes_count = coerce_integer(payload["reticulum_nodes_count"])
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
@@ -177,6 +182,9 @@ module PotatoMesh
|
||||
nodes_count: nodes_count,
|
||||
meshcore_nodes_count: meshcore_nodes_count,
|
||||
meshtastic_nodes_count: meshtastic_nodes_count,
|
||||
# Carried for v2 signature verification only (no DB column; always 0
|
||||
# until a Reticulum ingestor exists).
|
||||
reticulum_nodes_count: reticulum_nodes_count,
|
||||
}
|
||||
|
||||
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
|
||||
@@ -304,12 +312,12 @@ module PotatoMesh
|
||||
halt 400, { error: freshness_reason || "stale node data" }.to_json
|
||||
end
|
||||
|
||||
# Recompute node counts from the fetched node list so that
|
||||
# nodes_count, meshcore_nodes_count, and meshtastic_nodes_count
|
||||
# stay internally consistent. The announcement payload may carry
|
||||
# sender-asserted counts, but those are unsigned and could diverge
|
||||
# from the actual node data — overwriting them here is intentional.
|
||||
if remote_nodes.is_a?(Array)
|
||||
# Node-count fallback only (SPEC FS2/(a)): v2 announcements carry
|
||||
# SIGNED counts, which we keep verbatim so the stored — and later
|
||||
# relayed — record stays signature-consistent (a re-verifying peer
|
||||
# rebuilds the same canonical). We derive counts from the fetched
|
||||
# node list only when the announcement omits them.
|
||||
if remote_nodes.is_a?(Array) && attributes[:nodes_count].nil?
|
||||
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
|
||||
total = 0
|
||||
meshcore = 0
|
||||
|
||||
@@ -423,6 +423,16 @@ module PotatoMesh
|
||||
"rsa-sha256"
|
||||
end
|
||||
|
||||
# Current federation signature/canonical format version (SPEC FS3). Stamped
|
||||
# inside every signed payload (instance announcement + well-known) so the
|
||||
# format cannot be silently downgraded; verifiers accept this version and the
|
||||
# legacy unmarked v1 form.
|
||||
#
|
||||
# @return [Integer] the current signature format version.
|
||||
def federation_signature_version
|
||||
2
|
||||
end
|
||||
|
||||
# Connection timeout used when establishing federation HTTP sockets.
|
||||
#
|
||||
# The timeout can be customised with the REMOTE_INSTANCE_CONNECT_TIMEOUT
|
||||
|
||||
@@ -384,7 +384,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
defaultDirection: 'asc'
|
||||
},
|
||||
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
contact: { getValue: inst => (inst.contact_link ?? inst.contactLink) ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
channel: { getValue: inst => inst.channel ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
frequency: { getValue: inst => inst.frequency ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
|
||||
@@ -395,13 +395,13 @@ export async function initializeFederationPage(options = {}) {
|
||||
defaultDirection: 'desc'
|
||||
},
|
||||
meshcoreNodesCount: {
|
||||
getValue: inst => toFiniteNumber(inst.meshcoreNodesCount),
|
||||
getValue: inst => toFiniteNumber(inst.meshcore_nodes_count ?? inst.meshcoreNodesCount),
|
||||
compare: compareNumber,
|
||||
hasValue: hasNumberValue,
|
||||
defaultDirection: 'desc'
|
||||
},
|
||||
meshtasticNodesCount: {
|
||||
getValue: inst => toFiniteNumber(inst.meshtasticNodesCount),
|
||||
getValue: inst => toFiniteNumber(inst.meshtastic_nodes_count ?? inst.meshtasticNodesCount),
|
||||
compare: compareNumber,
|
||||
hasValue: hasNumberValue,
|
||||
defaultDirection: 'desc'
|
||||
@@ -409,7 +409,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
latitude: { getValue: inst => toFiniteNumber(inst.latitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
|
||||
longitude: { getValue: inst => toFiniteNumber(inst.longitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
|
||||
lastUpdateTime: {
|
||||
getValue: inst => toFiniteNumber(inst.lastUpdateTime),
|
||||
getValue: inst => toFiniteNumber(inst.last_update ?? inst.lastUpdateTime),
|
||||
compare: compareNumber,
|
||||
hasValue: hasNumberValue,
|
||||
defaultDirection: 'desc'
|
||||
@@ -488,12 +488,12 @@ export async function initializeFederationPage(options = {}) {
|
||||
const domainHtml = url
|
||||
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
|
||||
: escapeHtml(instance.domain || '');
|
||||
const contactHtml = renderContactHtml(instance.contactLink);
|
||||
const nodesCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
|
||||
const contactHtml = renderContactHtml(instance.contact_link ?? instance.contactLink);
|
||||
const nodesCountValue = toFiniteNumber(instance.nodes_count ?? instance.nodesCount);
|
||||
const nodesCountText = nodesCountValue == null ? '<em>—</em>' : escapeHtml(String(nodesCountValue));
|
||||
const mcNodesVal = toFiniteNumber(instance.meshcoreNodesCount);
|
||||
const mcNodesVal = toFiniteNumber(instance.meshcore_nodes_count ?? instance.meshcoreNodesCount);
|
||||
const mcNodesText = mcNodesVal == null ? '<em>—</em>' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`;
|
||||
const mtNodesVal = toFiniteNumber(instance.meshtasticNodesCount);
|
||||
const mtNodesVal = toFiniteNumber(instance.meshtastic_nodes_count ?? instance.meshtasticNodesCount);
|
||||
const mtNodesText = mtNodesVal == null ? '<em>—</em>' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`;
|
||||
|
||||
tr.innerHTML = `
|
||||
@@ -508,7 +508,7 @@ export async function initializeFederationPage(options = {}) {
|
||||
<td class="instances-col instances-col--meshtastic-nodes mono">${mtNodesText}</td>
|
||||
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
|
||||
<td class="instances-col instances-col--longitude mono">${fmtCoords(instance.longitude)}</td>
|
||||
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.lastUpdateTime, nowSec)}</td>
|
||||
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.last_update ?? instance.lastUpdateTime, nowSec)}</td>
|
||||
`;
|
||||
|
||||
frag.appendChild(tr);
|
||||
|
||||
+62
-61
@@ -1035,26 +1035,23 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
it "returns the sanitized domain when configuration is present" do
|
||||
ENV.delete("APP_ENV")
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ") do
|
||||
expect(application_class.self_instance_domain).to eq("example.org")
|
||||
end
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ")
|
||||
expect(application_class.self_instance_domain).to eq("example.org")
|
||||
end
|
||||
|
||||
it "returns nil when the domain is unavailable outside production" do
|
||||
ENV["APP_ENV"] = "development"
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
|
||||
expect(application_class.self_instance_domain).to be_nil
|
||||
end
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
|
||||
expect(application_class.self_instance_domain).to be_nil
|
||||
end
|
||||
|
||||
it "raises when the domain is unavailable in production" do
|
||||
ENV["APP_ENV"] = "production"
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
|
||||
expect { application_class.self_instance_domain }.to raise_error(
|
||||
RuntimeError,
|
||||
"INSTANCE_DOMAIN could not be determined",
|
||||
)
|
||||
end
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
|
||||
expect { application_class.self_instance_domain }.to raise_error(
|
||||
RuntimeError,
|
||||
"INSTANCE_DOMAIN could not be determined",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1062,67 +1059,60 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
let(:domain) { "spec.mesh.test" }
|
||||
|
||||
it "rejects registration when the domain source is not the environment" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do
|
||||
allowed, reason = application_class.self_instance_registration_decision(domain)
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns)
|
||||
allowed, reason = application_class.self_instance_registration_decision(domain)
|
||||
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN source is reverse_dns")
|
||||
end
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN source is reverse_dns")
|
||||
end
|
||||
|
||||
it "rejects registration when the domain is invalid" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
allowed, reason = application_class.self_instance_registration_decision(nil)
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
|
||||
allowed, reason = application_class.self_instance_registration_decision(nil)
|
||||
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN missing or invalid")
|
||||
end
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN missing or invalid")
|
||||
end
|
||||
|
||||
it "rejects registration when the domain resolves to a restricted IP" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
allowed, reason = application_class.self_instance_registration_decision("127.0.0.1")
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
|
||||
allowed, reason = application_class.self_instance_registration_decision("127.0.0.1")
|
||||
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP")
|
||||
end
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP")
|
||||
end
|
||||
|
||||
it "accepts registration when configuration is valid" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
allowed, reason = application_class.self_instance_registration_decision(domain)
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
|
||||
allowed, reason = application_class.self_instance_registration_decision(domain)
|
||||
|
||||
expect(allowed).to be(true)
|
||||
expect(reason).to be_nil
|
||||
end
|
||||
expect(allowed).to be(true)
|
||||
expect(reason).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe ".ensure_self_instance_record!" do
|
||||
it "persists the self instance when registration is allowed" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh") do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
|
||||
application_class.ensure_self_instance_record!
|
||||
|
||||
expect(instance_count).to eq(1)
|
||||
end
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh")
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
|
||||
application_class.ensure_self_instance_record!
|
||||
|
||||
expect(instance_count).to eq(1)
|
||||
end
|
||||
|
||||
it "skips persistence when registration is not allowed" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
|
||||
application_class.ensure_self_instance_record!
|
||||
|
||||
expect(instance_count).to eq(0)
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns)
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
|
||||
application_class.ensure_self_instance_record!
|
||||
|
||||
expect(instance_count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1238,16 +1228,22 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-z", 321])
|
||||
end
|
||||
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET") do
|
||||
json_output, signature = application_class.build_well_known_document
|
||||
document = JSON.parse(json_output)
|
||||
# NOTE: stub_const does not take a block (the previous block form silently
|
||||
# skipped every assertion below). Stub for the example, then assert inline.
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET")
|
||||
json_output, signature = application_class.build_well_known_document
|
||||
document = JSON.parse(json_output)
|
||||
|
||||
expect(document["domain"]).to eq("example.net")
|
||||
expect(document["lastUpdate"]).to eq(321)
|
||||
expect(document["signatureAlgorithm"]).to eq("rsa-sha256")
|
||||
expect(signature).to be_a(String)
|
||||
expect(signature).not_to be_empty
|
||||
end
|
||||
# v2 well-known is snake_case with a signature_version marker (SPEC FS1/FS3).
|
||||
expect(document["domain"]).to eq("example.net")
|
||||
expect(document["last_update"]).to eq(321)
|
||||
expect(document["signature_algorithm"]).to eq("rsa-sha256")
|
||||
expect(document["signature_version"]).to eq(2)
|
||||
expect(document["public_key"]).to eq(application_class::INSTANCE_PUBLIC_KEY_PEM)
|
||||
expect(document).not_to have_key("lastUpdate")
|
||||
expect(document).not_to have_key("signatureAlgorithm")
|
||||
expect(signature).to be_a(String)
|
||||
expect(signature).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2735,8 +2731,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
expect(self_entry).not_to be_nil
|
||||
expect(self_entry["domain"]).not_to be_nil
|
||||
expect(self_entry["isPrivate"]).to eq(false)
|
||||
expect(self_entry["is_private"]).to eq(false)
|
||||
expect(self_entry["signature"]).not_to be_nil
|
||||
expect(self_entry["signature_version"]).to eq(2)
|
||||
# FS-A4: wire is snake_case only — no camelCase keys leak through.
|
||||
expect(self_entry).not_to have_key("isPrivate")
|
||||
expect(self_entry).not_to have_key("lastUpdateTime")
|
||||
expect(self_entry).not_to have_key("nodesCount")
|
||||
end
|
||||
|
||||
it "exposes validated instance records from fixture data" do
|
||||
@@ -2789,7 +2790,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
expect(remote_entry).not_to be_nil
|
||||
expect(remote_entry["domain"]).to eq("remote.example")
|
||||
expect(remote_entry["isPrivate"]).to eq(false)
|
||||
expect(remote_entry["is_private"]).to eq(false)
|
||||
expect(remote_entry["signature"]).to eq(remote_signature)
|
||||
end
|
||||
|
||||
|
||||
@@ -1151,11 +1151,17 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
nodes_count: 42,
|
||||
meshcore_nodes_count: 30,
|
||||
meshtastic_nodes_count: 12,
|
||||
reticulum_nodes_count: 0,
|
||||
}
|
||||
payload = federation_helpers.instance_announcement_payload(attributes, "sig")
|
||||
expect(payload["nodesCount"]).to eq(42)
|
||||
expect(payload["meshcoreNodesCount"]).to eq(30)
|
||||
expect(payload["meshtasticNodesCount"]).to eq(12)
|
||||
expect(payload["nodes_count"]).to eq(42)
|
||||
expect(payload["meshcore_nodes_count"]).to eq(30)
|
||||
expect(payload["meshtastic_nodes_count"]).to eq(12)
|
||||
# v2 wire is snake_case with a forward-compat reticulum count + version marker.
|
||||
expect(payload["reticulum_nodes_count"]).to eq(0)
|
||||
expect(payload["public_key"]).to eq("key")
|
||||
expect(payload["signature_version"]).to eq(2)
|
||||
expect(payload).not_to have_key("nodesCount")
|
||||
end
|
||||
|
||||
it "omits node count fields when nil" do
|
||||
@@ -2528,6 +2534,54 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
end
|
||||
end
|
||||
|
||||
describe "instance signature v2 migration (FS1-FS4)" do
|
||||
let(:application_class) { PotatoMesh::Application }
|
||||
let(:keypair) { OpenSSL::PKey::RSA.new(2048) }
|
||||
let(:pubkey_pem) { keypair.public_key.to_pem }
|
||||
let(:attributes) do
|
||||
{
|
||||
id: "inst-1", domain: "a.mesh", pubkey: pubkey_pem, name: "A",
|
||||
version: "0.7.0", channel: "#x", frequency: "915MHz",
|
||||
latitude: 1.0, longitude: 2.0, last_update_time: 1_700_000_000,
|
||||
is_private: false, contact_link: "#a:x", nodes_count: 7,
|
||||
meshcore_nodes_count: 3, meshtastic_nodes_count: 4, reticulum_nodes_count: 0,
|
||||
}
|
||||
end
|
||||
|
||||
def sign_canonical(canonical)
|
||||
Base64.strict_encode64(keypair.sign(OpenSSL::Digest::SHA256.new, canonical))
|
||||
end
|
||||
|
||||
it "signs the v2 (snake_case) canonical with a signature_version marker and signed counts" do
|
||||
parsed = JSON.parse(application_class.canonical_instance_payload(attributes))
|
||||
expect(parsed).to include(
|
||||
"public_key", "last_update", "is_private", "contact_link",
|
||||
"nodes_count", "meshcore_nodes_count", "meshtastic_nodes_count",
|
||||
"reticulum_nodes_count", "signature_version"
|
||||
)
|
||||
expect(parsed["signature_version"]).to eq(2)
|
||||
expect(parsed).not_to have_key("publicKey")
|
||||
expect(parsed).not_to have_key("lastUpdateTime")
|
||||
expect(parsed).not_to have_key("isPrivate")
|
||||
end
|
||||
|
||||
it "verifies a v2 signature" do
|
||||
sig = sign_canonical(application_class.canonical_instance_payload_v2(attributes))
|
||||
expect(application_class.verify_instance_signature(attributes, sig, pubkey_pem)).to be(true)
|
||||
end
|
||||
|
||||
it "still verifies a legacy v1 (camelCase) signature — backward accept" do
|
||||
sig = sign_canonical(application_class.canonical_instance_payload_v1(attributes))
|
||||
expect(application_class.verify_instance_signature(attributes, sig, pubkey_pem)).to be(true)
|
||||
end
|
||||
|
||||
it "rejects a tampered node count (counts are signed in v2)" do
|
||||
sig = sign_canonical(application_class.canonical_instance_payload_v2(attributes))
|
||||
tampered = attributes.merge(nodes_count: 999)
|
||||
expect(application_class.verify_instance_signature(tampered, sig, pubkey_pem)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".validate_well_known_document" do
|
||||
let(:application_class) { PotatoMesh::Application }
|
||||
let(:domain) { "remote.mesh" }
|
||||
|
||||
@@ -94,7 +94,7 @@ RSpec.describe PotatoMesh::App::Instances do
|
||||
expect(domains).to include(application_class.app_constant(:INSTANCE_DOMAIN))
|
||||
expect(domains).not_to include("stale.mesh.test")
|
||||
expect(domains).not_to include("missing.mesh.test")
|
||||
expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true)
|
||||
expect(payload.all? { |row| row["last_update"] >= lower_bound }).to be(true)
|
||||
end
|
||||
|
||||
it "exposes contactLink when present and omits blank values" do
|
||||
@@ -130,8 +130,8 @@ RSpec.describe PotatoMesh::App::Instances do
|
||||
with_contact = payload.find { |row| row["domain"] == "alpha.mesh.test" }
|
||||
without_contact = payload.find { |row| row["domain"] == "beta.mesh.test" }
|
||||
|
||||
expect(with_contact["contactLink"]).to eq("https://example.org/contact")
|
||||
expect(without_contact.key?("contactLink")).to be(false)
|
||||
expect(with_contact["contact_link"]).to eq("https://example.org/contact")
|
||||
expect(without_contact.key?("contact_link")).to be(false)
|
||||
end
|
||||
|
||||
it "includes nodesCount values, preserving zeros" do
|
||||
@@ -173,8 +173,8 @@ RSpec.describe PotatoMesh::App::Instances do
|
||||
with_nodes = payload.find { |row| row["domain"] == "gamma.mesh.test" }
|
||||
zero_nodes = payload.find { |row| row["domain"] == "delta.mesh.test" }
|
||||
|
||||
expect(with_nodes["nodesCount"]).to eq(42)
|
||||
expect(zero_nodes["nodesCount"]).to eq(0)
|
||||
expect(with_nodes["nodes_count"]).to eq(42)
|
||||
expect(zero_nodes["nodes_count"]).to eq(0)
|
||||
end
|
||||
|
||||
it "includes per-protocol node counts when present and omits when nil" do
|
||||
@@ -218,10 +218,10 @@ RSpec.describe PotatoMesh::App::Instances do
|
||||
with_proto = payload.find { |row| row["domain"] == "proto.mesh.test" }
|
||||
without_proto = payload.find { |row| row["domain"] == "noproto.mesh.test" }
|
||||
|
||||
expect(with_proto["meshcoreNodesCount"]).to eq(20)
|
||||
expect(with_proto["meshtasticNodesCount"]).to eq(30)
|
||||
expect(without_proto.key?("meshcoreNodesCount")).to be(false)
|
||||
expect(without_proto.key?("meshtasticNodesCount")).to be(false)
|
||||
expect(with_proto["meshcore_nodes_count"]).to eq(20)
|
||||
expect(with_proto["meshtastic_nodes_count"]).to eq(30)
|
||||
expect(without_proto.key?("meshcore_nodes_count")).to be(false)
|
||||
expect(without_proto.key?("meshtastic_nodes_count")).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user