feat(chain): multi-device registry (v2.2.0-alpha1)

PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered
on-chain so senders can fan out envelopes across all of a recipient's
physical devices — fixes the single-device limitation where a second
phone / desktop loses messages as soon as the first one reads them.

Chain (blockchain/):
  - New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's
    master Ed25519.
  - LinkDevicePayload {x25519_pub_key, device_name} +
    UnlinkDevicePayload {x25519_pub_key} on the wire.
  - State: prefixDevice + x25519_pub → DeviceRecord{owner, name,
    added_at, revoked_at?}; reverse index prefixDevicesByOwner for
    O(k) listing. Revoke is a soft-delete — the row stays as a visible
    tombstone so offline clients can detect their own revocation and
    wipe local state.
  - MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64.
  - Strict lowercase-hex validation on x25519_pub so clients can't
    desync on letter case.
  - Same-owner re-link is a rename/refresh (recreates reverse index too
    — needed after a revoke).
  - Chain.DevicesOf(master_pub) returns the active records; empty slice
    for legacy identities so senders can fall back to IdentityInfo.X25519Pub.

HTTP (node/):
  - GET /api/devices/{master_pub_or_addr} — returns {master_pub, count,
    devices[]}. Revoked records filtered out.
  - /api/identity/{pub} gains `device_count` so senders can decide
    upfront whether to fan out or take the legacy path.

Tests (blockchain/devices_test.go):
  - Happy paths (1, 3 devices), foreign-owner rejection, same-owner
    refresh after revoke, unlink removes from active set,
    foreign-signer unlink rejection, idempotent double-unlink,
    malformed pub/name rejection, MaxDevices cap + recovery after
    unlink frees a slot, empty list for unknown master.

Also in this commit:
  - deploy/single/join.sh — convenience script operators have been
    iterating on in this session (joiner-node bring-up + firewall
    port patching + Caddy opt-out).
  - client-app/app.json — `usesCleartextTraffic: true` on Android so
    installed APKs can talk to http:// dev nodes without TLS.

See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow,
desktop Electron shell).
This commit is contained in:
vsecoder
2026-04-22 16:20:07 +03:00
parent 217b374789
commit 1d9206494a
8 changed files with 967 additions and 2 deletions

View File

@@ -54,6 +54,9 @@ const (
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
// Multi-device registry (v2.2.0)
prefixDevice = "device:" // device:<x25519_pub> → DeviceRecord JSON
prefixDevicesByOwner = "devicesbyowner:" // devicesbyowner:<master_pub>:<x25519_pub> → "" (reverse index for O(k) listing)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
@@ -771,6 +774,99 @@ func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64)
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
}
// DevicesOf returns the active (non-revoked) device records for a master
// identity. Sender fan-out reads this to decide how many envelopes to
// produce per outgoing message. Empty slice (not error) for identities
// with no registry entries yet — caller should fall back to the legacy
// single-X25519 path via IdentityInfo.
func (c *Chain) DevicesOf(masterPub string) ([]DeviceRecord, error) {
var out []DeviceRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := it.Item().KeyCopy(nil)
// key layout: devicesbyowner:<master>:<x25519> — take the suffix
x25519Pub := string(key[len(prefix):])
recItem, err := txn.Get([]byte(prefixDevice + x25519Pub))
if err != nil {
// Reverse index points to a missing record — stale entry,
// skip silently (shouldn't happen unless the DB was tampered
// with, but we don't want to fail the whole call).
continue
}
var rec DeviceRecord
if verr := recItem.Value(func(v []byte) error {
return json.Unmarshal(v, &rec)
}); verr != nil {
continue
}
if rec.RevokedAt != 0 {
continue
}
out = append(out, rec)
}
return nil
})
return out, err
}
// countActiveDevicesForOwner walks the reverse index (entries there are,
// by construction, non-revoked — UNLINK_DEVICE deletes the index row).
// O(k) where k = active device count, bounded by MaxDevicesPerOwner.
func (c *Chain) countActiveDevicesForOwner(txn *badger.Txn, masterPub string) (int, error) {
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
defer it.Close()
n := 0
for it.Rewind(); it.Valid(); it.Next() {
n++
}
return n, nil
}
// validateDevicePubKey checks the wire-level format of a device X25519
// public key: 64 hex characters (32 bytes). Does NOT check that the key
// is a valid curve point — senders learn that at encrypt time, and an
// invalid point there just yields garbage ciphertext which the device
// can't decrypt (self-punishing).
func validateDevicePubKey(hexPub string) error {
if len(hexPub) != 64 {
return fmt.Errorf("x25519_pub_key must be 64 hex chars (got %d)", len(hexPub))
}
// Enforce strict lowercase-hex to keep keys canonical across clients —
// two clients hashing the same bytes with different letter cases would
// produce different registry lookups, which breaks sender fan-out.
for _, r := range hexPub {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
default:
return fmt.Errorf("x25519_pub_key must be lowercase hex")
}
}
return nil
}
// validateDeviceName enforces the UX constraint that device labels stay
// short and printable — this field gets displayed in Settings → Devices
// and has no business carrying newlines, control chars, or 2KB blobs.
func validateDeviceName(name string) error {
if name == "" {
return fmt.Errorf("device_name is required")
}
if len(name) > MaxDeviceNameLen {
return fmt.Errorf("device_name %d bytes > max %d", len(name), MaxDeviceNameLen)
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("device_name contains a control character")
}
}
return nil
}
// Reputation returns the reputation stats for a public key.
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
var r RepStats
@@ -1261,6 +1357,127 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, err
}
case EventLinkDevice:
// Master Ed25519 (= tx.From) publishes a per-device X25519 pub.
// Validation:
// 1. Payload well-formed, X25519 pub is hex(32 bytes).
// 2. Device name is short + printable.
// 3. X25519 pub isn't already registered to a DIFFERENT owner.
// 4. Same owner re-linking the same pub is a no-op refresh
// (updates device_name / added_at — useful for rename).
// 5. Owner has < MaxDevicesPerOwner active (non-revoked) devices.
//
// Fee: standard tx.Fee is debited from master's balance — cheap
// anti-spam. Block validation already enforced `tx.Fee >= MinFee`.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("LINK_DEVICE debit: %w", err)
}
var p LinkDevicePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE bad payload: %v", ErrTxFailed, err)
}
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
}
if err := validateDeviceName(p.DeviceName); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
}
devKey := []byte(prefixDevice + p.X25519PubKey)
if item, err := txn.Get(devKey); err == nil {
var existing DeviceRecord
if verr := item.Value(func(v []byte) error {
return json.Unmarshal(v, &existing)
}); verr == nil {
if existing.Owner != tx.From {
return 0, fmt.Errorf("%w: LINK_DEVICE: x25519 pub already linked to a different owner",
ErrTxFailed)
}
// Same owner — rename/refresh path. Keep AddedAt.
existing.DeviceName = p.DeviceName
existing.RevokedAt = 0 // re-link cancels a previous revoke
refreshed, _ := json.Marshal(existing)
if err := txn.Set(devKey, refreshed); err != nil {
return 0, err
}
// Restore reverse index too — UNLINK_DEVICE deletes it, so
// a re-link after revoke must recreate it or DevicesOf
// will skip the record.
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Set(idxKey, []byte{}); err != nil {
return 0, err
}
break
}
}
// Count owner's active devices.
active, err := c.countActiveDevicesForOwner(txn, tx.From)
if err != nil {
return 0, fmt.Errorf("LINK_DEVICE count devices: %w", err)
}
if active >= MaxDevicesPerOwner {
return 0, fmt.Errorf("%w: LINK_DEVICE: owner already has %d devices (max %d)",
ErrTxFailed, active, MaxDevicesPerOwner)
}
rec := DeviceRecord{
Owner: tx.From,
X25519PubKey: p.X25519PubKey,
DeviceName: p.DeviceName,
AddedAt: tx.Timestamp.Unix(),
}
val, _ := json.Marshal(rec)
if err := txn.Set(devKey, val); err != nil {
return 0, err
}
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Set(idxKey, []byte{}); err != nil {
return 0, err
}
case EventUnlinkDevice:
// Revoke (soft-delete) a device record. Mark RevokedAt and keep
// the row — clients wake up, notice their own pub is revoked, and
// wipe local state. A permanent delete would let a sender that
// missed the revoke keep encrypting for the old pub silently.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("UNLINK_DEVICE debit: %w", err)
}
var p UnlinkDevicePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE bad payload: %v", ErrTxFailed, err)
}
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: %v", ErrTxFailed, err)
}
devKey := []byte(prefixDevice + p.X25519PubKey)
item, err := txn.Get(devKey)
if err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: device not found", ErrTxFailed)
}
var existing DeviceRecord
if verr := item.Value(func(v []byte) error {
return json.Unmarshal(v, &existing)
}); verr != nil {
return 0, fmt.Errorf("UNLINK_DEVICE decode: %w", verr)
}
if existing.Owner != tx.From {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: signer is not the owner of this device",
ErrTxFailed)
}
if existing.RevokedAt != 0 {
// Already revoked — idempotent success, don't error.
break
}
existing.RevokedAt = tx.Timestamp.Unix()
updated, _ := json.Marshal(existing)
if err := txn.Set(devKey, updated); err != nil {
return 0, err
}
// Drop reverse index so countActiveDevicesForOwner is O(k_active).
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Delete(idxKey); err != nil {
return 0, err
}
case EventContactRequest:
var p ContactRequestPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {