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:
413
blockchain/devices_test.go
Normal file
413
blockchain/devices_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package blockchain_test
|
||||
|
||||
// Multi-device registry tests (v2.2.0). Cover happy-path link + list,
|
||||
// unlink idempotency, ownership validation, max-devices guard, and
|
||||
// backward-compat sender fall-back semantics (empty list for unknown
|
||||
// master). HTTP-layer tests live in the node package; these stay at
|
||||
// chain level — applyTx + state-shape only.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-blockchain/blockchain"
|
||||
"go-blockchain/identity"
|
||||
)
|
||||
|
||||
// uniqueTxID sidesteps the `chain_test.go:txID` race where two helper
|
||||
// calls in the same nanosecond produce identical tx IDs → chain.AddBlock
|
||||
// dedupes the second as "already committed". We add a strictly-monotonic
|
||||
// counter so every tx built in the same test gets a fresh ID even on
|
||||
// fast machines.
|
||||
var devicesTxIDCounter atomic.Uint64
|
||||
|
||||
func uniqueTxID() string {
|
||||
n := devicesTxIDCounter.Add(1)
|
||||
h := make([]byte, 16)
|
||||
_, _ = rand.Read(h)
|
||||
return hex.EncodeToString(h) + ":" + time.Now().Format("150405.000000000") + ":" + itoa(n)
|
||||
}
|
||||
|
||||
func itoa(n uint64) string {
|
||||
// tiny local conversion to avoid importing strconv just for tests.
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// linkDeviceRawTx / unlinkDeviceRawTx build txs with guaranteed-unique
|
||||
// IDs so we can stuff many of them into a single block during tests.
|
||||
func linkDeviceRawTx(master *identity.Identity, x25519Pub, name string) *blockchain.Transaction {
|
||||
payload := mustJSON(blockchain.LinkDevicePayload{
|
||||
X25519PubKey: x25519Pub,
|
||||
DeviceName: name,
|
||||
})
|
||||
return &blockchain.Transaction{
|
||||
ID: uniqueTxID(),
|
||||
Type: blockchain.EventLinkDevice,
|
||||
From: master.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func unlinkDeviceRawTx(master *identity.Identity, x25519Pub string) *blockchain.Transaction {
|
||||
payload := mustJSON(blockchain.UnlinkDevicePayload{X25519PubKey: x25519Pub})
|
||||
return &blockchain.Transaction{
|
||||
ID: uniqueTxID(),
|
||||
Type: blockchain.EventUnlinkDevice,
|
||||
From: master.PubKeyHex(),
|
||||
Fee: blockchain.MinFee,
|
||||
Payload: payload,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// randX25519Pub returns a throw-away 32-byte hex string suitable for the
|
||||
// x25519_pub_key field. We don't need the matching private key — the
|
||||
// chain only stores the pub and doesn't verify it's a valid curve point.
|
||||
func randX25519Pub(t *testing.T) string {
|
||||
t.Helper()
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// fundIdentity credits enough balance to cover `nFees` worth of MinFee
|
||||
// payments. Every test needs this because LINK_DEVICE and UNLINK_DEVICE
|
||||
// debit tx.Fee from the master's balance — without funding the first tx
|
||||
// errors with "insufficient funds" and no state changes.
|
||||
func fundIdentity(t *testing.T, c *blockchain.Chain, val, recipient *identity.Identity, nFees int) {
|
||||
t.Helper()
|
||||
amount := uint64(nFees+2) * blockchain.MinFee
|
||||
tx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), recipient.PubKeyHex(),
|
||||
amount, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||
}
|
||||
|
||||
// TestLinkDeviceHappyPath: after a single LINK_DEVICE, DevicesOf returns
|
||||
// exactly one record with the right owner/name/pub.
|
||||
func TestLinkDeviceHappyPath(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 1)
|
||||
dev1 := randX25519Pub(t)
|
||||
tx := linkDeviceRawTx(alice, dev1, "Alice's iPhone")
|
||||
b := buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})
|
||||
mustAddBlock(t, c, b)
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("expected 1 device, got %d", len(devs))
|
||||
}
|
||||
got := devs[0]
|
||||
if got.Owner != alice.PubKeyHex() {
|
||||
t.Errorf("owner: got %s, want %s", got.Owner, alice.PubKeyHex())
|
||||
}
|
||||
if got.X25519PubKey != dev1 {
|
||||
t.Errorf("x25519: got %s, want %s", got.X25519PubKey, dev1)
|
||||
}
|
||||
if got.DeviceName != "Alice's iPhone" {
|
||||
t.Errorf("name: got %q, want %q", got.DeviceName, "Alice's iPhone")
|
||||
}
|
||||
if got.RevokedAt != 0 {
|
||||
t.Errorf("freshly linked device should have RevokedAt=0, got %d", got.RevokedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceMultiple: multiple devices for the same owner all show up
|
||||
// in DevicesOf, and the count matches.
|
||||
func TestLinkDeviceMultiple(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
pubs := []string{randX25519Pub(t), randX25519Pub(t), randX25519Pub(t)}
|
||||
var txs []*blockchain.Transaction
|
||||
for i, p := range pubs {
|
||||
txs = append(txs, linkDeviceRawTx(alice, p, "device-"+string(rune('A'+i))))
|
||||
}
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, txs))
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 3 {
|
||||
t.Fatalf("expected 3 devices, got %d", len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceRejectsForeignOwner: if Alice already linked a X25519 pub
|
||||
// and Bob tries to link the SAME pub to himself, the tx is rejected.
|
||||
// This protects against a malicious actor hijacking someone else's
|
||||
// device-pub and trying to claim it.
|
||||
func TestLinkDeviceRejectsForeignOwner(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
bob := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 1)
|
||||
fundIdentity(t, c, val, bob, 1)
|
||||
dev := randX25519Pub(t)
|
||||
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
|
||||
|
||||
// Bob tries to claim the same pub. The chain silently drops invalid
|
||||
// txs (they're non-fatal for block commit), so we verify the *state*:
|
||||
// Alice still owns the pub, Bob has zero devices.
|
||||
bobTx := linkDeviceRawTx(bob, dev, "bob phone")
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{bobTx}))
|
||||
|
||||
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(aliceDevs) != 1 || aliceDevs[0].DeviceName != "alice phone" {
|
||||
t.Fatalf("Alice should still own the device, got %+v", aliceDevs)
|
||||
}
|
||||
bobDevs, _ := c.DevicesOf(bob.PubKeyHex())
|
||||
if len(bobDevs) != 0 {
|
||||
t.Fatalf("Bob's hijack should have been rejected, got %+v", bobDevs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceSameOwnerRefresh: the same owner re-linking the same
|
||||
// x25519_pub is a rename/refresh (not an error), and the revoked flag
|
||||
// gets cleared if it was set.
|
||||
func TestLinkDeviceSameOwnerRefresh(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
dev := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "old name")}))
|
||||
|
||||
// Unlink then re-link — should restore the record.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "new name")}))
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("expected 1 active device after re-link, got %d", len(devs))
|
||||
}
|
||||
if devs[0].DeviceName != "new name" {
|
||||
t.Errorf("expected refreshed name 'new name', got %q", devs[0].DeviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeviceRemovesFromActive: after UNLINK_DEVICE, DevicesOf no
|
||||
// longer returns that record — senders stop fanning out to it.
|
||||
func TestUnlinkDeviceRemovesFromActive(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
keep := randX25519Pub(t)
|
||||
revoke := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{
|
||||
linkDeviceRawTx(alice, keep, "kept"),
|
||||
linkDeviceRawTx(alice, revoke, "revoked"),
|
||||
}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, revoke)}))
|
||||
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 1 || devs[0].X25519PubKey != keep {
|
||||
t.Fatalf("expected only the kept device, got %+v", devs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeviceRejectsForeignSigner: Bob can't unlink Alice's device
|
||||
// even if he knows the X25519 pub — only the owner's signature (= tx.From)
|
||||
// authorises revoke.
|
||||
func TestUnlinkDeviceRejectsForeignSigner(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
bob := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 1)
|
||||
fundIdentity(t, c, val, bob, 1)
|
||||
dev := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
|
||||
|
||||
// Bob tries to unlink Alice's device — tx is silently dropped, Alice
|
||||
// still has it in her active list.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(bob, dev)}))
|
||||
|
||||
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(aliceDevs) != 1 || aliceDevs[0].X25519PubKey != dev {
|
||||
t.Fatalf("Alice's device should still be active, got %+v", aliceDevs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnlinkDeviceIdempotent: unlinking a device that's already revoked
|
||||
// does NOT error — this is needed for recovery scenarios where a client
|
||||
// retries the tx after a spotty connection.
|
||||
func TestUnlinkDeviceIdempotent(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 3)
|
||||
dev := randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "phone")}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||
// Second unlink of the same device must apply cleanly.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
|
||||
}
|
||||
|
||||
// TestLinkDeviceRejectsMalformedPub: payload.X25519PubKey must be 64-char
|
||||
// lowercase hex. Garbage / short / uppercase values are rejected at
|
||||
// applyTx time before any state change.
|
||||
func TestLinkDeviceRejectsMalformedPub(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 5)
|
||||
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"abc", // too short
|
||||
strings.Repeat("x", 64), // non-hex
|
||||
strings.Repeat("A", 64), // uppercase
|
||||
}
|
||||
for _, p := range bad {
|
||||
tx := linkDeviceRawTx(alice, p, "dev")
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||
}
|
||||
|
||||
devs, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(devs) != 0 {
|
||||
t.Fatalf("expected 0 devices after %d malformed link attempts, got %d",
|
||||
len(bad), len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceRejectsBadDeviceName: empty, too long, or control-char
|
||||
// names are refused — this field is rendered verbatim in clients.
|
||||
func TestLinkDeviceRejectsBadDeviceName(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
fundIdentity(t, c, val, alice, 5)
|
||||
cases := map[string]string{
|
||||
"empty": "",
|
||||
"too long": strings.Repeat("a", blockchain.MaxDeviceNameLen+1),
|
||||
"control char": "device\x01name",
|
||||
}
|
||||
for name, devName := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
before, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
tx := linkDeviceRawTx(alice, randX25519Pub(t), devName)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
|
||||
after, _ := c.DevicesOf(alice.PubKeyHex())
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("bad %s name should not have been linked (before=%d, after=%d)",
|
||||
name, len(before), len(after))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLinkDeviceMaxDevices: after MaxDevicesPerOwner active devices, any
|
||||
// further LINK_DEVICE is rejected. Revoking one frees a slot.
|
||||
func TestLinkDeviceMaxDevices(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
// Enough for cap + one overflow attempt + one unlink + one replacement.
|
||||
fundIdentity(t, c, val, alice, blockchain.MaxDevicesPerOwner+4)
|
||||
|
||||
// Fill to the cap — each in its own block so timestamps differ.
|
||||
pubs := make([]string, blockchain.MaxDevicesPerOwner)
|
||||
for i := 0; i < blockchain.MaxDevicesPerOwner; i++ {
|
||||
pubs[i] = randX25519Pub(t)
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, pubs[i], "d")}))
|
||||
}
|
||||
// One more — the tx is dropped, cap stays exact.
|
||||
over := linkDeviceRawTx(alice, randX25519Pub(t), "overflow")
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{over}))
|
||||
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
|
||||
t.Fatalf("overflow link should have been rejected, count=%d", len(devs))
|
||||
}
|
||||
|
||||
// Revoke one, then a fresh link succeeds.
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, pubs[0])}))
|
||||
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
|
||||
[]*blockchain.Transaction{linkDeviceRawTx(alice, randX25519Pub(t), "replacement")}))
|
||||
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
|
||||
t.Fatalf("after unlink+relink, should be %d active, got %d",
|
||||
blockchain.MaxDevicesPerOwner, len(devs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDevicesOfEmptyForUnknown: unknown master pubs return an empty list
|
||||
// (not an error) — this preserves the sender's ability to fall back to
|
||||
// IdentityInfo.X25519Pub for legacy identities.
|
||||
func TestDevicesOfEmptyForUnknown(t *testing.T) {
|
||||
c := newChain(t)
|
||||
val := newIdentity(t)
|
||||
addGenesis(t, c, val)
|
||||
|
||||
alice := newIdentity(t)
|
||||
devs, err := c.DevicesOf(alice.PubKeyHex())
|
||||
if err != nil {
|
||||
t.Fatalf("DevicesOf: %v", err)
|
||||
}
|
||||
if len(devs) != 0 {
|
||||
t.Fatalf("expected 0 devices for unknown master, got %d", len(devs))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user