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).
414 lines
14 KiB
Go
414 lines
14 KiB
Go
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))
|
|
}
|
|
}
|