From 027b6160dfb8f79e7abe7a031b95527c981d6777 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:01:45 +0200 Subject: [PATCH] web: federation signature v2 migration (#805) * web: federation signature v2 migration * web: address review comments --- .../api-casing-and-federation-signature.md | 80 ++++++++---- ACCEPTANCE.md | 53 ++++++++ SPEC.md | 26 +++- .../application/federation/crawl.rb | 31 +++-- .../application/federation/self_instance.rb | 19 ++- .../application/federation/signature.rb | 75 +++++++++-- .../application/federation/validation.rb | 8 +- web/lib/potato_mesh/application/identity.rb | 22 ++-- web/lib/potato_mesh/application/instances.rb | 20 +-- .../potato_mesh/application/routes/ingest.rb | 38 +++--- web/lib/potato_mesh/config.rb | 10 ++ web/public/assets/js/app/federation-page.js | 18 +-- web/spec/app_spec.rb | 123 +++++++++--------- web/spec/federation_spec.rb | 60 ++++++++- web/spec/instances_spec.rb | 18 +-- 15 files changed, 428 insertions(+), 173 deletions(-) diff --git a/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md b/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md index f3ed2c6a..c4cdb10e 100644 --- a/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md +++ b/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md @@ -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. diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index a805cb08..421f6a1d 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 1c56f7bb..f09140d6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 | diff --git a/web/lib/potato_mesh/application/federation/crawl.rb b/web/lib/potato_mesh/application/federation/crawl.rb index 73df37b8..c11d3930 100644 --- a/web/lib/potato_mesh/application/federation/crawl.rb +++ b/web/lib/potato_mesh/application/federation/crawl.rb @@ -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" diff --git a/web/lib/potato_mesh/application/federation/self_instance.rb b/web/lib/potato_mesh/application/federation/self_instance.rb index d1770f96..33a7e684 100644 --- a/web/lib/potato_mesh/application/federation/self_instance.rb +++ b/web/lib/potato_mesh/application/federation/self_instance.rb @@ -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 diff --git a/web/lib/potato_mesh/application/federation/signature.rb b/web/lib/potato_mesh/application/federation/signature.rb index 5c851fba..7a1e1651 100644 --- a/web/lib/potato_mesh/application/federation/signature.rb +++ b/web/lib/potato_mesh/application/federation/signature.rb @@ -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 diff --git a/web/lib/potato_mesh/application/federation/validation.rb b/web/lib/potato_mesh/application/federation/validation.rb index 9ff92d4e..c6369c87 100644 --- a/web/lib/potato_mesh/application/federation/validation.rb +++ b/web/lib/potato_mesh/application/federation/validation.rb @@ -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 diff --git a/web/lib/potato_mesh/application/identity.rb b/web/lib/potato_mesh/application/identity.rb index f3e1b875..ed8337d9 100644 --- a/web/lib/potato_mesh/application/identity.rb +++ b/web/lib/potato_mesh/application/identity.rb @@ -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) diff --git a/web/lib/potato_mesh/application/instances.rb b/web/lib/potato_mesh/application/instances.rb index fb153b2e..15461899 100644 --- a/web/lib/potato_mesh/application/instances.rb +++ b/web/lib/potato_mesh/application/instances.rb @@ -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 diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index f3ccdb82..7095c7a3 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -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 diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 9479b5dd..39f767f5 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -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 diff --git a/web/public/assets/js/app/federation-page.js b/web/public/assets/js/app/federation-page.js index ad41323e..9ab171a4 100644 --- a/web/public/assets/js/app/federation-page.js +++ b/web/public/assets/js/app/federation-page.js @@ -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 ? `${escapeHtml(instance.domain || '')}` : 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 ? '—' : escapeHtml(String(nodesCountValue)); - const mcNodesVal = toFiniteNumber(instance.meshcoreNodesCount); + const mcNodesVal = toFiniteNumber(instance.meshcore_nodes_count ?? instance.meshcoreNodesCount); const mcNodesText = mcNodesVal == null ? '—' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`; - const mtNodesVal = toFiniteNumber(instance.meshtasticNodesCount); + const mtNodesVal = toFiniteNumber(instance.meshtastic_nodes_count ?? instance.meshtasticNodesCount); const mtNodesText = mtNodesVal == null ? '—' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`; tr.innerHTML = ` @@ -508,7 +508,7 @@ export async function initializeFederationPage(options = {}) {