mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-09 16:25:35 +00:00
vcontact key fix
This commit is contained in:
+12
-3
@@ -611,9 +611,18 @@ unsolicited notices: a one-shot low-battery alert and a restart-reason message
|
||||
(all causes: PIN/SOFTWARE/BROWNOUT/POR/WATCHDOG/LOCKUP — offline-queue only,
|
||||
so routine power-on "noise" costs nothing over the air).
|
||||
|
||||
**Identity**: pubkey = `SHA256("zc-vcontact" || self_pubkey)` — stable per
|
||||
node, unique per device, and deliberately **not a real keypair**: no private
|
||||
key exists anywhere.
|
||||
**Identity**: seed = `SHA256("zc-vcontact" || self_prv_key || counter)`,
|
||||
pubkey = that seed's Ed25519 public point — stable per node, unique per device.
|
||||
It is a **real curve point**, which the bare hash it replaced was not: a random
|
||||
32-byte string decompresses to a valid Ed25519 point only ~50% of the time, so
|
||||
half of all nodes advertised a v-contact that strict clients reject on contact
|
||||
upsert and DM build ("peer pub_key is not a valid Ed25519 point"). ZephCore
|
||||
never noticed because it only `memcmp`s this key. The `counter` byte re-rolls
|
||||
the key on the protocol-reserved `0x00`/`0xFF` prefix (P = 2/256 per try).
|
||||
|
||||
Seeding from the *private* key keeps the v-contact unlinkable to its node by
|
||||
outsiders, and its private half is **derived and dropped** — never stored,
|
||||
never signs, never does ECDH. Only this node can recompute it.
|
||||
|
||||
**No-RF invariants** (all enforced in `CompanionMesh`):
|
||||
1. `vcontactHandleFrame()` intercepts `CMD_SEND_TXT_MSG` (and the handful of
|
||||
|
||||
@@ -4,7 +4,7 @@ Storage housekeeping, plus a listen-before-talk fix. The repeater's `erase` neve
|
||||
a node flashed from another firmware could start out with somebody else's leftovers underneath it,
|
||||
and switching a node between companion and repeater firmware quietly let the two share the same
|
||||
128 KB. Separately, the channel-activity detector was using the wrong reference table on LR1110
|
||||
boards.
|
||||
boards, and the companion's built-in `v` contact carried an unusable key on about half of all nodes.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Read the role-switching section before you flash a different role onto an existing node.** A
|
||||
@@ -115,6 +115,27 @@ unless you run a 250 or 500 kHz bandwidth, where the old value was up to ten cou
|
||||
|
||||
---
|
||||
|
||||
## The built-in `v` contact had an unusable key on half of all nodes
|
||||
|
||||
Every companion offers a contact named after itself with a `v` in front — the loopback chat that runs
|
||||
the console commands. Its key was built in a way that produced something key-shaped but, on roughly
|
||||
half of all nodes, not a valid key for the curve the protocol uses.
|
||||
|
||||
The official app never checked and so never minded. Other clients do check, and refused either to add
|
||||
the contact or to send it a message. That is why the v-contact has worked for some people and not for
|
||||
others with no apparent pattern: it was a coin flip settled when the node's identity was created, and
|
||||
nothing the owner did afterwards could change the outcome.
|
||||
|
||||
The key is now derived properly and is valid on every node. Nothing else about the v-contact moves —
|
||||
it is still local to the app, still never touches the radio, and still has no private key stored
|
||||
anywhere.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **The v-contact's key changes with this release, so your app will keep showing the old one.** Delete
|
||||
> the leftover `v<name>` entry by hand. The new one arrives on its own the next time the app connects.
|
||||
|
||||
---
|
||||
|
||||
## Also in this release
|
||||
|
||||
Nothing here changes how a node behaves.
|
||||
|
||||
@@ -232,8 +232,9 @@ void CompanionMesh::begin()
|
||||
BaseChatMesh::begin();
|
||||
|
||||
/* Derive the v-contact pubkey from our identity: stable per node, unique
|
||||
* per device. Deliberately NOT a real keypair — no private key exists
|
||||
* anywhere, so nothing addressed to this key is decryptable by anyone. */
|
||||
* per device. A real Ed25519 point (strict clients reject anything else),
|
||||
* but its private half is derived-and-dropped — never stored, never used —
|
||||
* and only this node can recompute it. */
|
||||
deriveVContactKey();
|
||||
/* Stamp lastmod only if a time source already ran (hardware RTC restore
|
||||
* happens before begin()). Otherwise stay deferred (lastmod = 0) until
|
||||
@@ -1052,13 +1053,71 @@ void CompanionMesh::vcontactFlushConfirm()
|
||||
|
||||
void CompanionMesh::deriveVContactKey()
|
||||
{
|
||||
/* v-contact pubkey = SHA256("zc-vcontact" || self pubkey). Re-run whenever
|
||||
* the identity changes (boot, CMD_IMPORT_PRIVATE_KEY) so the key always
|
||||
* tracks the current identity. */
|
||||
/* v-contact pubkey = the Ed25519 public point of a keypair derived from
|
||||
* SHA256("zc-vcontact" || self prv_key || counter). Re-run whenever the
|
||||
* identity changes (boot, CMD_IMPORT_PRIVATE_KEY) so the key always tracks
|
||||
* the current identity.
|
||||
*
|
||||
* This used to be the bare hash SHA256("zc-vcontact" || self pubkey) placed
|
||||
* straight into a pub_key field. That is wrong on the wire: a uniformly
|
||||
* random 32-byte string decompresses to a valid Ed25519 point only about
|
||||
* half the time (the recovered x^2 must be a quadratic residue), so ~50% of
|
||||
* nodes advertised a v-contact whose key strict clients reject outright —
|
||||
* they decompress peer keys on contact upsert and on DM build, and error
|
||||
* with "peer pub_key is not a valid Ed25519 point". ZephCore itself never
|
||||
* noticed because it only ever memcmp()s this key (see isVContactKey();
|
||||
* buildVContact() sets shared_secret_valid = false). Deriving the point via
|
||||
* scalarbase instead makes it valid by construction, for every node.
|
||||
*
|
||||
* Seeded from the PRIVATE key, not the public one: the seed then sits
|
||||
* behind a value no one else holds, so an outsider cannot link a v-contact
|
||||
* to the node it belongs to, and cannot derive the matching private key.
|
||||
* Nothing needs that link — the app receives the v-contact through an
|
||||
* explicit PUSH_CODE_NEW_ADVERT and never derives it. The private half is
|
||||
* computed here and dropped: it is never stored, never signs, and never
|
||||
* takes part in a key exchange. Publishing the point leaks nothing about
|
||||
* the identity — reaching prv from it means solving the Ed25519 discrete
|
||||
* log AND inverting SHA-512 AND inverting SHA-256.
|
||||
*
|
||||
* The counter byte serves the reserved-prefix guard: MeshCore treats
|
||||
* pub_key[0] of 0x00/0xFF as a protocol marker (same rule as
|
||||
* ZephyrRNG::generateFirstBootIdentity and validatePrivateKey), and a
|
||||
* deterministic derivation cannot simply "draw again" without one. Each
|
||||
* bump re-rolls the whole key; P(a single miss) = 2/256, so the loop
|
||||
* essentially always ends on the first pass and stays deterministic. */
|
||||
static const char vc_salt[] = "zc-vcontact";
|
||||
mesh::Utils::sha256(_vcontact_pubkey, PUB_KEY_SIZE,
|
||||
(const uint8_t *)vc_salt, sizeof(vc_salt) - 1,
|
||||
self_id.pub_key, PUB_KEY_SIZE);
|
||||
uint8_t material[PRV_KEY_SIZE + 1];
|
||||
uint8_t seed[SEED_SIZE];
|
||||
mesh::LocalIdentity vc;
|
||||
|
||||
/* writeTo()'s buffer format is prv || pub; capping max_len at PRV_KEY_SIZE
|
||||
* asks for the private half alone. */
|
||||
if (self_id.writeTo(material, PRV_KEY_SIZE) != PRV_KEY_SIZE) {
|
||||
/* Cannot happen — kept so a future signature change fails loudly
|
||||
* rather than seeding off an uninitialised stack buffer. */
|
||||
LOG_ERR("vcontact: private key unavailable, key not derived");
|
||||
mesh::Utils::secureZeroize(material, sizeof(material));
|
||||
return;
|
||||
}
|
||||
|
||||
for (int counter = 0; counter < 256; counter++) {
|
||||
material[PRV_KEY_SIZE] = (uint8_t)counter;
|
||||
mesh::Utils::sha256(seed, SEED_SIZE,
|
||||
(const uint8_t *)vc_salt, sizeof(vc_salt) - 1,
|
||||
material, sizeof(material));
|
||||
vc.fromSeed(seed);
|
||||
if (vc.pub_key[0] != 0x00 && vc.pub_key[0] != 0xFF) break;
|
||||
}
|
||||
memcpy(_vcontact_pubkey, vc.pub_key, PUB_KEY_SIZE);
|
||||
|
||||
/* material holds the real identity private key, and vc holds the v-key's
|
||||
* discarded private half. Neither has any business outliving this call.
|
||||
* LocalIdentity keeps prv_key private and has no wipe of its own, so the
|
||||
* object is cleared wholesale — it has no virtuals, so there is no vtable
|
||||
* pointer to destroy. */
|
||||
mesh::Utils::secureZeroize(material, sizeof(material));
|
||||
mesh::Utils::secureZeroize(seed, sizeof(seed));
|
||||
mesh::Utils::secureZeroize(&vc, sizeof(vc));
|
||||
}
|
||||
|
||||
void CompanionMesh::vcontactPushAdvert()
|
||||
|
||||
@@ -164,9 +164,12 @@ public:
|
||||
* A synthesized CHAT contact visible only to the connected BLE/USB app.
|
||||
* Messages to it are short-circuited into the CLI before any packet is
|
||||
* created — nothing ever reaches the dispatcher or the radio. Its pubkey
|
||||
* is SHA256("zc-vcontact" || self pubkey); no private key exists and it
|
||||
* is never registered in the RF RX matching path, so over-the-air
|
||||
* traffic addressed to it is inert. */
|
||||
* is the Ed25519 point of a keypair seeded from
|
||||
* SHA256("zc-vcontact" || self prv_key || counter) — a real point, because
|
||||
* strict clients decompress peer keys and reject anything else. The private
|
||||
* half is derived and dropped: never stored, never used. The key is never
|
||||
* registered in the RF RX matching path, so over-the-air traffic addressed
|
||||
* to it is inert. */
|
||||
void setVContactCLICallback(VContactCLICallback cb) { _vcontact_cli_cb = cb; }
|
||||
bool isVContactEnabled() const { return prefs.v_contact_enabled != 0; }
|
||||
/** Queue an unsolicited v-contact message (battery alert, restart reason).
|
||||
|
||||
Reference in New Issue
Block a user