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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user