From 1d9206494affb06e5d1ab16d85dd2a6784c0a41c Mon Sep 17 00:00:00 2001 From: vsecoder Date: Wed, 22 Apr 2026 16:20:07 +0300 Subject: [PATCH] feat(chain): multi-device registry (v2.2.0-alpha1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- blockchain/chain.go | 217 +++++++++++++++++++ blockchain/devices_test.go | 413 +++++++++++++++++++++++++++++++++++++ blockchain/types.go | 74 ++++++- client-app/app.json | 4 +- cmd/node/main.go | 1 + deploy/single/join.sh | 202 ++++++++++++++++++ node/api_explorer.go | 53 +++++ node/api_routes.go | 5 + 8 files changed, 967 insertions(+), 2 deletions(-) create mode 100644 blockchain/devices_test.go create mode 100644 deploy/single/join.sh diff --git a/blockchain/chain.go b/blockchain/chain.go index 71b8852..fc4e1a0 100644 --- a/blockchain/chain.go +++ b/blockchain/chain.go @@ -54,6 +54,9 @@ const ( prefixRelay = "relay:" // relay: → RegisterRelayPayload JSON prefixRelayHB = "relayhb:" // relayhb: → unix seconds (int64) of last HB prefixRelayProof = "relayproof:" // relayproof: → claimant node_pubkey (1 claim per envelope) + // Multi-device registry (v2.2.0) + prefixDevice = "device:" // device: → DeviceRecord JSON + prefixDevicesByOwner = "devicesbyowner:" // devicesbyowner:: → "" (reverse index for O(k) listing) prefixContactIn = "contact_in:" // contact_in:: → contactRecord JSON prefixValidator = "validator:" // validator: → "" (presence = active) prefixContract = "contract:" // contract: → 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:: — 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 { diff --git a/blockchain/devices_test.go b/blockchain/devices_test.go new file mode 100644 index 0000000..10c05b6 --- /dev/null +++ b/blockchain/devices_test.go @@ -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)) + } +} diff --git a/blockchain/types.go b/blockchain/types.go index 25f8647..bd8d6ef 100644 --- a/blockchain/types.go +++ b/blockchain/types.go @@ -46,6 +46,14 @@ const ( EventUnfollow EventType = "UNFOLLOW" // unfollow an author EventLikePost EventType = "LIKE_POST" // like a post EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like + + // Multi-device support (v2.2.0): each physical device of an identity gets + // its own X25519 keypair for the messenger mailbox. LINK/UNLINK_DEVICE + // publish/revoke those pubs on-chain so senders can fan-out envelopes + // across the recipient's active devices. Signed by the identity's + // master Ed25519 key. + EventLinkDevice EventType = "LINK_DEVICE" + EventUnlinkDevice EventType = "UNLINK_DEVICE" ) // Token amounts are stored in micro-tokens (µT). @@ -554,7 +562,71 @@ type IdentityInfo struct { Address string `json:"address"` X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published Nickname string `json:"nickname"` - Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed + Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed + // DeviceCount is the number of currently-linked (non-revoked) devices in + // this identity's multi-device registry. `0` for legacy identities that + // only published a single X25519 via REGISTER_KEY — senders fall back + // to IdentityInfo.X25519Pub in that case. + DeviceCount int `json:"device_count"` +} + +// ── Multi-device registry (v2.2.0) ─────────────────────────────────────── + +// MaxDevicesPerOwner caps how many devices a single identity can have +// linked concurrently. A sender must encrypt + relay one envelope per +// device, so the multiplier bounds the per-message cost. Ten covers +// typical users (phone + tablet + laptop + desktop + work phone + …) +// without letting an abuse case blow up mailbox traffic. Revoked devices +// don't count. +const MaxDevicesPerOwner = 10 + +// MaxDeviceNameLen caps the length of a human-friendly device label +// ("Alice's iPhone 14", "Work MacBook Pro"). Longer names get rejected +// at validation. Kept short to discourage using this field as a free-form +// comment/profile channel. +const MaxDeviceNameLen = 64 + +// LinkDevicePayload is embedded in EventLinkDevice transactions. Master +// Ed25519 (= tx.From) asserts that the given X25519 pub belongs to one +// of its physical devices, publishing it so senders can fan out envelopes. +type LinkDevicePayload struct { + // X25519PubKey is the hex-encoded Curve25519 pub the device generated + // locally. Must be unique in the device registry — senders index + // envelopes by this key in the relay mailbox. + X25519PubKey string `json:"x25519_pub_key"` + // DeviceName is a short human label shown in Settings → Devices. + // Purely informational; not used for routing. + DeviceName string `json:"device_name"` +} + +// UnlinkDevicePayload is embedded in EventUnlinkDevice transactions. Signed +// by master Ed25519; marks the referenced device as revoked so senders +// stop shipping envelopes to its X25519 pub. The revoked device itself, +// once it sees its pub in the revoked list, is expected to wipe its +// local state (master Ed25519 priv + chat cache). +type UnlinkDevicePayload struct { + X25519PubKey string `json:"x25519_pub_key"` +} + +// DeviceRecord is the on-chain persisted state for one device link. +// Stored at key `prefixDevice + x25519_pub`; the reverse index +// `prefixDevicesByOwner + master_pub` keeps a slice of x25519 pubs per +// owner for efficient listing. +type DeviceRecord struct { + Owner string `json:"owner"` // master Ed25519 pub hex + X25519PubKey string `json:"x25519_pub_key"` // device X25519 pub hex + DeviceName string `json:"device_name"` + AddedAt int64 `json:"added_at"` // unix seconds, from tx timestamp + RevokedAt int64 `json:"revoked_at,omitempty"` // 0 = active; >0 = revoked +} + +// DeviceInfo is the public view of a DeviceRecord served by +// GET /api/devices/{master_pub}. Revoked records are not included +// in the response (intentionally — sender only needs the active set). +type DeviceInfo struct { + X25519PubKey string `json:"x25519_pub_key"` + DeviceName string `json:"device_name"` + AddedAt int64 `json:"added_at"` } // ConsensusMessage types used by the PBFT engine over the P2P layer. diff --git a/client-app/app.json b/client-app/app.json index 7419e5f..314d41a 100644 --- a/client-app/app.json +++ b/client-app/app.json @@ -12,12 +12,14 @@ "infoPlist": { "NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.", "NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.", - "NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library." + "NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library.", + "ITSAppUsesNonExemptEncryption": false } }, "android": { "package": "com.dchain.messenger", "softwareKeyboardLayoutMode": "pan", + "usesCleartextTraffic": true, "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.CAMERA", diff --git a/cmd/node/main.go b/cmd/node/main.go index 0fbf8d2..bd325ca 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -878,6 +878,7 @@ func main() { IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) { return chain.IdentityInfo(pubKeyOrAddr) }, + DevicesOf: chain.DevicesOf, ValidatorSet: chain.ValidatorSet, SubmitTx: func(tx *blockchain.Transaction) error { if err := engine.AddTransaction(tx); err != nil { diff --git a/deploy/single/join.sh b/deploy/single/join.sh new file mode 100644 index 0000000..3e2d6e7 --- /dev/null +++ b/deploy/single/join.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# rejoin.sh — полная переустановка dchain joiner-ноды с нуля. +# Публичный доступ без Caddy/TLS, без API-токена (на свой страх и риск). +# Запускать БЕЗ sudo. + +set -euo pipefail + +# ── КОНФИГ ───────────────────────────────────────────────────────────── +SEED_HTTP="${SEED_HTTP:-http://62.171.151.182:8082}" +SEED_P2P_IP="${SEED_P2P_IP:-62.171.151.182}" +SEED_P2P_PORT="${SEED_P2P_PORT:-4005}" +REPO_URL="${REPO_URL:-https://git.vsecoder.vodka/vsecoder/dchain.git}" +WORKDIR="${WORKDIR:-$HOME/dchain}" +LOCAL_P2P_PORT="${LOCAL_P2P_PORT:-4001}" +LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-8082}" +# ─────────────────────────────────────────────────────────────────────── + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mXX\033[0m %s\n' "$*" >&2; exit 1; } +need() { command -v "$1" >/dev/null || die "need $1 installed"; } + +# ── 0. Требования ────────────────────────────────────────────────────── +[[ $EUID -eq 0 ]] && die "don't run as root — use your user account, sudo is used per-command" +need docker; need git; need curl; need jq +sudo -v + +# ── 1. Снести предыдущее состояние ───────────────────────────────────── +log "stopping any existing dchain stack & purging volumes" +if [[ -d "$WORKDIR/deploy/single" ]]; then + (cd "$WORKDIR/deploy/single" && sudo docker compose down -v 2>/dev/null) || true +fi +sudo docker rm -f dchain_node dchain_caddy dchain 2>/dev/null || true +mapfile -t stale_vols < <(sudo docker volume ls -q | grep -E '^(dchain|dchain-single)' || true) +(( ${#stale_vols[@]} > 0 )) && sudo docker volume rm "${stale_vols[@]}" 2>/dev/null || true + +# ── 2. Свежий репозиторий ────────────────────────────────────────────── +log "fetching repo → $WORKDIR" +if [[ -d "$WORKDIR/.git" ]]; then + git -C "$WORKDIR" fetch --all --tags --prune + git -C "$WORKDIR" reset --hard origin/main +else + rm -rf "$WORKDIR" + git clone "$REPO_URL" "$WORKDIR" +fi +cd "$WORKDIR/deploy/single" + +# ── 3. keys/ под UID 100 ─────────────────────────────────────────────── +log "preparing keys/ for container UID 100:101" +mkdir -p keys +sudo chown 100:101 keys +sudo chmod 755 keys +[[ -f keys/node.json ]] && { sudo chown 100:101 keys/node.json; sudo chmod 600 keys/node.json; } + +# ── 4. Slim-образ ────────────────────────────────────────────────────── +log "building dchain image (slim)" +IMAGE=$(sudo docker build -q -f ../prod/Dockerfile.slim ../..) +[[ -z "$IMAGE" ]] && die "docker build failed" + +# ── 5. Ключ ноды ─────────────────────────────────────────────────────── +if [[ ! -f keys/node.json ]]; then + log "generating node identity" + sudo docker run --rm --entrypoint /usr/local/bin/client \ + --user 100:101 \ + -v "$PWD/keys:/out" \ + "$IMAGE" \ + keygen --out /out/node.json +else + log "reusing existing keys/node.json" +fi +sudo chown 100:101 keys/node.json +sudo chmod 600 keys/node.json + +# ── 6. peer_id seed'a ────────────────────────────────────────────────── +log "fetching seed peer_id from $SEED_HTTP/stats" +SEED_PEER_ID=$(curl -sfL "$SEED_HTTP/stats" | jq -r '.node.peer_id // empty') +[[ -z "$SEED_PEER_ID" ]] && die "can't reach seed at $SEED_HTTP or peer_id missing" +log "seed peer_id = $SEED_PEER_ID" + +# ── 7. Публичный IP ──────────────────────────────────────────────────── +PUBLIC_IP="${PUBLIC_IP:-$(curl -sfL https://api.ipify.org || true)}" +[[ -z "$PUBLIC_IP" ]] && die "can't detect public IP; export PUBLIC_IP=x.x.x.x и перезапусти" +log "public IP = $PUBLIC_IP" + +# ── 8. node.env (без токена, без genesis) ────────────────────────────── +log "writing node.env" +cp -f node.env.example node.env + +# удалить из example'а значения, которые мы хотим держать под своим контролем, +# чтобы они не перекрыли наши append'ы снизу +sudo sed -i -E '/^(#\s*)?DCHAIN_(GENESIS|API_TOKEN|API_PRIVATE|JOIN|PEERS|ANNOUNCE|DB|MAILBOX_DB|FEED_DB|REGISTER_RELAY|RELAY_FEE|FEED_DISK_LIMIT_MB|CHAIN_DISK_LIMIT_MB|UPDATE_SOURCE_URL)=/d' node.env + +sudo tee -a node.env > /dev/null <&1 +echo "──── /api/netstats ────" +curl -s "http://localhost:$LOCAL_HTTP_PORT/api/netstats" | jq '.' +echo "──── seed height (для сверки) ────" +curl -s "$SEED_HTTP/api/netstats" | jq '.total_blocks' + +cat <