Compare commits
5 Commits
v2.1.0
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7223b93c | ||
|
|
8940b97cc6 | ||
|
|
423d307125 | ||
|
|
1d9206494a | ||
|
|
217b374789 |
@@ -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 {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -555,6 +563,70 @@ type IdentityInfo struct {
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* один раз; переходы между tab'ами их не перезапускают.
|
||||
*/
|
||||
import React, { useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { View, Platform } from 'react-native';
|
||||
import { router, usePathname } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useStore } from '@/lib/store';
|
||||
@@ -25,7 +25,13 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { NavBar } from '@/components/NavBar';
|
||||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||
import { saveContact } from '@/lib/storage';
|
||||
import {
|
||||
saveContact,
|
||||
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||
} from '@/lib/storage';
|
||||
import {
|
||||
fetchDevices, buildLinkDeviceTx, submitTx,
|
||||
} from '@/lib/api';
|
||||
|
||||
export default function AppLayout() {
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
@@ -73,6 +79,79 @@ export default function AppLayout() {
|
||||
else ws.setAuthCreds(null);
|
||||
}, [keyFile]);
|
||||
|
||||
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
|
||||
//
|
||||
// Three branches, by (chain list × local "was registered" flag):
|
||||
//
|
||||
// 1. Our pub is in the chain's active list →
|
||||
// mark us registered locally (idempotent), done.
|
||||
//
|
||||
// 2. Our pub is NOT in the active list, AND we've registered before →
|
||||
// another device issued UNLINK_DEVICE against us. Wipe ALL local
|
||||
// state (master priv, contacts, chats, marker) and redirect to
|
||||
// the auth screen. This is the security-critical path: without
|
||||
// the wipe, a stolen phone after revoke would still decrypt
|
||||
// historical messages.
|
||||
//
|
||||
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
|
||||
// first boot on this chain; submit LINK_DEVICE so senders can
|
||||
// target us. Failures (fee, offline) are swallowed; next launch
|
||||
// retries.
|
||||
useEffect(() => {
|
||||
if (!keyFile) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let chainList;
|
||||
try {
|
||||
chainList = await fetchDevices(keyFile.pub_key);
|
||||
} catch {
|
||||
// Network unavailable — leave state unchanged; we'll resync on
|
||||
// the next launch. Do NOT wipe on network error.
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||
const previouslyRegistered = await isDeviceRegistered();
|
||||
if (cancelled) return;
|
||||
|
||||
if (inActive) {
|
||||
// Branch #1 — ensure the local marker is set.
|
||||
if (!previouslyRegistered) await markDeviceRegistered();
|
||||
return;
|
||||
}
|
||||
|
||||
if (previouslyRegistered) {
|
||||
// Branch #2 — REVOKED. Self-wipe.
|
||||
await wipeAllLocalState();
|
||||
useStore.getState().setKeyFile(null);
|
||||
// The redirect-on-null-keyFile effect below will push the user
|
||||
// back to the welcome screen automatically.
|
||||
return;
|
||||
}
|
||||
|
||||
// Branch #3 — first-boot link. Best-effort.
|
||||
try {
|
||||
const deviceName = Platform.select({
|
||||
ios: 'iPhone',
|
||||
android: 'Android phone',
|
||||
default: 'Device',
|
||||
}) ?? 'Device';
|
||||
const tx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: keyFile.x25519_pub,
|
||||
deviceName,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
await markDeviceRegistered();
|
||||
} catch {
|
||||
/* next launch retries */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyFile === null) {
|
||||
const t = setTimeout(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { useMessages } from '@/hooks/useMessages';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { sendEnvelope } from '@/lib/api';
|
||||
import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { appendMessage, loadMessages } from '@/lib/storage';
|
||||
import { randomId, safeBack } from '@/lib/utils';
|
||||
@@ -212,15 +212,34 @@ export default function ChatScreen() {
|
||||
// leaves the device, so no encryption/fee/network round-trip is needed.
|
||||
// Regular chats still go through the NaCl + relay pipeline below.
|
||||
if (hasText && !isSavedMessages) {
|
||||
// Multi-device fan-out (v2.2.0): resolve the recipient's active
|
||||
// device X25519 pubs via /api/devices. Legacy identities (no
|
||||
// devices registered) fall back to their published identity
|
||||
// x25519 pub, preserving the pre-v2.2.0 single-device path.
|
||||
// `contact.x25519Pub` stays the floor — if both network calls
|
||||
// fail we still attempt delivery to the cached pub so a flaky
|
||||
// connection doesn't block outgoing messages.
|
||||
let recipientPubs = await resolveRecipientKeys(contact.address);
|
||||
if (recipientPubs.length === 0 && contact.x25519Pub) {
|
||||
recipientPubs = [contact.x25519Pub];
|
||||
}
|
||||
if (recipientPubs.length === 0) {
|
||||
throw new Error('recipient has no encryption key published');
|
||||
}
|
||||
// One sealed envelope per recipient device. Parallel — slow
|
||||
// relays don't block each other; any individual failure
|
||||
// rejects the whole send (user retries).
|
||||
await Promise.all(recipientPubs.map(async (rpub) => {
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||||
actualText.trim(), keyFile.x25519_priv, rpub,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: contact.x25519Pub,
|
||||
recipientPub: rpub,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const msg: Message = {
|
||||
|
||||
490
client-app/app/(app)/devices.tsx
Normal file
490
client-app/app/(app)/devices.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Devices screen — Settings → Linked devices.
|
||||
*
|
||||
* Multi-device registry (v2.2.0). Lists every X25519 device published
|
||||
* on-chain under this identity's master Ed25519 key. Operators can:
|
||||
* - see added-at timestamps
|
||||
* - rename this device (local alias for now; rename via LINK_DEVICE
|
||||
* with same pub + new name is a v2.3 polish)
|
||||
* - revoke a remote device via UNLINK_DEVICE (requires fee)
|
||||
* - pair a new device (Phase 3 — separate modal, stub for now)
|
||||
*
|
||||
* This device is NEVER listed with an Unlink button — revoking yourself
|
||||
* is a footgun (you'd wipe your own state on next launch). Export/import
|
||||
* your key first, then revoke from the new device.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
|
||||
TextInput, KeyboardAvoidingView, Platform, Modal,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import {
|
||||
fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx,
|
||||
sendEnvelope, humanizeTxError,
|
||||
type DeviceInfo,
|
||||
} from '@/lib/api';
|
||||
import { encryptMessage } from '@/lib/crypto';
|
||||
import { Header } from '@/components/Header';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { safeBack } from '@/lib/utils';
|
||||
|
||||
function shortPub(p: string, n = 8): string {
|
||||
if (!p) return '—';
|
||||
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}…${p.slice(-n)}`;
|
||||
}
|
||||
|
||||
function formatDate(unixSec: number): string {
|
||||
return new Date(unixSec * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export default function DevicesScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const keyFile = useStore(s => s.keyFile);
|
||||
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
|
||||
|
||||
const load = useCallback(async (isRefresh = false) => {
|
||||
if (!keyFile) return;
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
try {
|
||||
const list = await fetchDevices(keyFile.pub_key);
|
||||
setDevices(list);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [keyFile]);
|
||||
|
||||
useEffect(() => { load(false); }, [load]);
|
||||
|
||||
const onUnlink = useCallback((dev: DeviceInfo) => {
|
||||
if (!keyFile) return;
|
||||
Alert.alert(
|
||||
'Unlink device?',
|
||||
`"${dev.device_name}" will stop receiving messages sent to you. ` +
|
||||
`This costs a small network fee. The revoked device wipes its ` +
|
||||
`local state the next time it checks in.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Unlink',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setUnlinking(dev.x25519_pub_key);
|
||||
try {
|
||||
const tx = buildUnlinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: dev.x25519_pub_key,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
// Optimistic — drop from local list immediately; next load
|
||||
// reconciles. Chain tx takes ~1 block to commit.
|
||||
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
|
||||
} catch (e: any) {
|
||||
Alert.alert('Unlink failed', humanizeTxError(e));
|
||||
} finally {
|
||||
setUnlinking(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [keyFile]);
|
||||
|
||||
const meX25519 = keyFile?.x25519_pub ?? '';
|
||||
|
||||
// Pairing modal state — filled by the operator reading values off the
|
||||
// new device's /auth/pair screen.
|
||||
const [pairOpen, setPairOpen] = useState(false);
|
||||
const [pairCode, setPairCode] = useState('');
|
||||
const [pairKey, setPairKey] = useState('');
|
||||
const [pairName, setPairName] = useState('');
|
||||
const [pairBusy, setPairBusy] = useState(false);
|
||||
|
||||
const submitPair = useCallback(async () => {
|
||||
if (!keyFile) return;
|
||||
const code = pairCode.replace(/\s+/g, '').trim();
|
||||
const key = pairKey.replace(/\s+/g, '').trim().toLowerCase();
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
Alert.alert('Invalid code', 'The pairing code is 6 digits.');
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(key)) {
|
||||
Alert.alert('Invalid key', 'The device key must be 64 hex characters.');
|
||||
return;
|
||||
}
|
||||
const name = pairName.trim() || 'New device';
|
||||
setPairBusy(true);
|
||||
try {
|
||||
// 1. LINK_DEVICE on-chain so senders learn the new pub.
|
||||
const tx = buildLinkDeviceTx({
|
||||
from: keyFile.pub_key,
|
||||
x25519Pub: key,
|
||||
deviceName: name,
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
// 2. Ship the handshake payload to the new device. Encrypted for
|
||||
// its x25519 pub so only it can read — master priv in plaintext
|
||||
// would be catastrophic, E2E is the whole point.
|
||||
const payload = JSON.stringify({
|
||||
v: 1,
|
||||
type: 'pair-handshake',
|
||||
code,
|
||||
master_pub: keyFile.pub_key,
|
||||
master_priv: keyFile.priv_key,
|
||||
master_x25519_pub: keyFile.x25519_pub,
|
||||
});
|
||||
const { nonce, ciphertext } = encryptMessage(
|
||||
payload, keyFile.x25519_priv, key,
|
||||
);
|
||||
await sendEnvelope({
|
||||
senderPub: keyFile.x25519_pub,
|
||||
recipientPub: key,
|
||||
senderEd25519Pub: keyFile.pub_key,
|
||||
nonce, ciphertext,
|
||||
});
|
||||
// 3. Optimistic local insert so the row shows up without waiting
|
||||
// for the next pull/refresh round-trip.
|
||||
setDevices(prev => {
|
||||
if (prev.some(d => d.x25519_pub_key === key)) return prev;
|
||||
return [...prev, {
|
||||
x25519_pub_key: key,
|
||||
device_name: name,
|
||||
added_at: Math.floor(Date.now() / 1000),
|
||||
}];
|
||||
});
|
||||
setPairOpen(false);
|
||||
setPairCode(''); setPairKey(''); setPairName('');
|
||||
Alert.alert(
|
||||
'Pairing sent',
|
||||
'The new device should finish pairing in a few seconds.',
|
||||
);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Pairing failed', humanizeTxError(e));
|
||||
} finally {
|
||||
setPairBusy(false);
|
||||
}
|
||||
}, [keyFile, pairCode, pairKey, pairName]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
title="Devices"
|
||||
divider
|
||||
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => load(true)}
|
||||
tintColor="#1d9bf0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
|
||||
Every linked device has its own encryption key. Messages sent to you
|
||||
are delivered to all active devices.
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<View style={{ paddingTop: 60, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="#1d9bf0" />
|
||||
</View>
|
||||
) : devices.length === 0 ? (
|
||||
<View style={{
|
||||
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
|
||||
}}>
|
||||
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
|
||||
<Text style={{
|
||||
color: '#ffffff', fontSize: 15, fontWeight: '700',
|
||||
marginTop: 10,
|
||||
}}>
|
||||
No devices registered yet
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
|
||||
marginTop: 6, lineHeight: 19,
|
||||
}}>
|
||||
This device auto-registers when the next network-fee is available.
|
||||
Top up your balance and pull to refresh.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{devices.map((d, i) => {
|
||||
const isMe = d.x25519_pub_key === meX25519;
|
||||
const busy = unlinking === d.x25519_pub_key;
|
||||
return (
|
||||
<View key={d.x25519_pub_key}>
|
||||
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
|
||||
}}>
|
||||
<Ionicons
|
||||
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
|
||||
size={22}
|
||||
color={isMe ? '#1d9bf0' : '#d0d0d0'}
|
||||
/>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text
|
||||
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{d.device_name || 'Unnamed device'}
|
||||
</Text>
|
||||
{isMe && (
|
||||
<View style={{
|
||||
paddingHorizontal: 6, paddingVertical: 1,
|
||||
borderRadius: 6, backgroundColor: '#0d2540',
|
||||
}}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
|
||||
THIS DEVICE
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||
marginTop: 3,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{shortPub(d.x25519_pub_key)}
|
||||
</Text>
|
||||
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||
Linked {formatDate(d.added_at)}
|
||||
</Text>
|
||||
</View>
|
||||
{!isMe && (
|
||||
<Pressable
|
||||
onPress={() => onUnlink(d)}
|
||||
disabled={busy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 12, paddingVertical: 7,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1, borderColor: '#3a2020',
|
||||
backgroundColor: pressed ? '#2a1414' : 'transparent',
|
||||
opacity: busy ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color="#ff6b6b" />
|
||||
) : (
|
||||
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
|
||||
Unlink
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Link new device — opens a modal with Code + DeviceKey inputs
|
||||
that the operator transcribes from the new device's
|
||||
/auth/pair screen. */}
|
||||
<View style={{ marginTop: 18 }}>
|
||||
<Pressable
|
||||
onPress={() => setPairOpen(true)}
|
||||
style={({ pressed }) => ({
|
||||
paddingVertical: 13, paddingHorizontal: 16,
|
||||
borderRadius: 14,
|
||||
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||
Link new device
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
Enter the 6-digit code from the new device
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* ── Pair new device modal ───────────────────────────────────── */}
|
||||
<Modal
|
||||
visible={pairOpen}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => !pairBusy && setPairOpen(false)}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
width: '100%', maxWidth: 420,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 18,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
padding: 20, gap: 12,
|
||||
}}>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
justifyContent: 'space-between', marginBottom: 4,
|
||||
}}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
|
||||
Link new device
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => !pairBusy && setPairOpen(false)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
|
||||
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
|
||||
the welcome screen, then transcribe the code + device key shown there.
|
||||
</Text>
|
||||
|
||||
<PairInput
|
||||
label="6-digit code"
|
||||
value={pairCode}
|
||||
onChangeText={setPairCode}
|
||||
placeholder="000000"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
monospace
|
||||
/>
|
||||
<PairInput
|
||||
label="Device key"
|
||||
value={pairKey}
|
||||
onChangeText={setPairKey}
|
||||
placeholder="64 hex chars"
|
||||
autoCapitalize="none"
|
||||
maxLength={64}
|
||||
monospace
|
||||
/>
|
||||
<PairInput
|
||||
label="Name for this device (optional)"
|
||||
value={pairName}
|
||||
onChangeText={setPairName}
|
||||
placeholder="e.g. Alice's laptop"
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
gap: 10, marginTop: 8,
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={() => setPairOpen(false)}
|
||||
disabled={pairBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 16, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
|
||||
opacity: pairBusy ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={submitPair}
|
||||
disabled={pairBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
|
||||
opacity: pairBusy ? 0.7 : 1,
|
||||
minWidth: 90, alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{pairBusy ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||
Link
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function PairInput({
|
||||
label, value, onChangeText, placeholder, keyboardType, autoCapitalize,
|
||||
maxLength, monospace,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
placeholder?: string;
|
||||
keyboardType?: 'default' | 'number-pad';
|
||||
autoCapitalize?: 'none' | 'sentences';
|
||||
maxLength?: number;
|
||||
monospace?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
|
||||
}}>
|
||||
{label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#3a3a3a"
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize ?? 'sentences'}
|
||||
autoCorrect={false}
|
||||
maxLength={maxLength}
|
||||
style={{
|
||||
color: '#ffffff', fontSize: monospace ? 13 : 14,
|
||||
fontFamily: monospace ? 'monospace' : undefined,
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 10,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -492,6 +492,18 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Devices — multi-device registry (v2.2.0) ── */}
|
||||
<SectionLabel>Devices</SectionLabel>
|
||||
<Card>
|
||||
<Row
|
||||
icon="phone-portrait-outline"
|
||||
label="Linked devices"
|
||||
value="Manage the devices that receive your messages"
|
||||
onPress={() => router.push('/(app)/devices' as never)}
|
||||
first
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ── Account ── */}
|
||||
<SectionLabel>Account</SectionLabel>
|
||||
<Card>
|
||||
|
||||
300
client-app/app/(auth)/pair.tsx
Normal file
300
client-app/app/(auth)/pair.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Pair — secondary-device onboarding.
|
||||
*
|
||||
* Flow (new device, this screen):
|
||||
* 1. Generate a fresh X25519 keypair locally + random 6-digit code.
|
||||
* 2. Display { code, device x25519 pub }. User enters both on their
|
||||
* primary device (Settings → Devices → Link new device).
|
||||
* 3. Primary device:
|
||||
* - submits LINK_DEVICE tx to publish our pub under its master,
|
||||
* - sends a relay envelope to our x25519 pub, encrypted with its
|
||||
* own x25519 priv, containing { code, master_pub, master_priv,
|
||||
* master_x25519_pub }.
|
||||
* 4. We poll /relay/inbox every few seconds; when a decryptable
|
||||
* envelope arrives whose payload.code matches our displayed code,
|
||||
* we treat it as the handshake, assemble a KeyFile, save it, and
|
||||
* redirect into (app).
|
||||
*
|
||||
* Security notes:
|
||||
* - Master Ed25519 priv travels only via this envelope, encrypted for
|
||||
* this device's X25519 pub (which only this device holds the priv
|
||||
* for). Exposure is limited to one successful decrypt; we DELETE
|
||||
* the envelope from the mailbox immediately.
|
||||
* - The 6-digit code defends against a confused primary device paired
|
||||
* with a different victim, or a race with an attacker who guesses
|
||||
* our X25519 pub. Envelope without matching code → ignored.
|
||||
* - Envelope is short-lived in the mailbox: we DELETE on first decrypt
|
||||
* and the relay node has its own TTL.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View, Text, Pressable, ActivityIndicator, ScrollView,
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import nacl from 'tweetnacl';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { bytesToHex, decryptMessage } from '@/lib/crypto';
|
||||
import { fetchInbox } from '@/lib/api';
|
||||
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
|
||||
import { safeBack } from '@/lib/utils';
|
||||
import type { KeyFile } from '@/lib/types';
|
||||
|
||||
// Protocol constant — bump if payload shape changes.
|
||||
const PAIR_ENVELOPE_VERSION = 1;
|
||||
|
||||
interface PairEnvelopePayload {
|
||||
v: number;
|
||||
type: 'pair-handshake';
|
||||
code: string;
|
||||
master_pub: string; // Ed25519 pub, hex
|
||||
master_priv: string; // Ed25519 priv, hex
|
||||
master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI)
|
||||
}
|
||||
|
||||
function randomCode(): string {
|
||||
// Six decimal digits. Entropy ~20 bits. Good enough for a one-shot
|
||||
// rendezvous code gated by an out-of-band delivery channel (envelope
|
||||
// targeted at a freshly-generated X25519 pub that only this device
|
||||
// holds the priv for).
|
||||
const n = Math.floor(Math.random() * 1_000_000);
|
||||
return n.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
export default function PairScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const setKeyFile = useStore(s => s.setKeyFile);
|
||||
|
||||
// One-shot keypair + code for this pairing session. Regenerate only on
|
||||
// manual Retry (unmount+remount).
|
||||
const session = useRef(genSession()).current;
|
||||
|
||||
const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const copyCode = useCallback(async () => {
|
||||
await Clipboard.setStringAsync(session.code);
|
||||
}, [session.code]);
|
||||
|
||||
const copyPub = useCallback(async () => {
|
||||
await Clipboard.setStringAsync(session.x25519Pub);
|
||||
}, [session.x25519Pub]);
|
||||
|
||||
// Poll mailbox until a matching handshake envelope arrives, or user
|
||||
// backs out. Interval 2.5s — conservative on battery, fine for a
|
||||
// flow the user is staring at for a minute.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const envs = await fetchInbox(session.x25519Pub);
|
||||
for (const env of envs) {
|
||||
// Decrypt each envelope with our session priv. We don't know
|
||||
// the primary's x25519 pub up front — it's inside the envelope
|
||||
// metadata. decryptMessage needs both pubs, so we pass the
|
||||
// envelope's sender_pub directly.
|
||||
const plain = decryptMessage(
|
||||
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
|
||||
);
|
||||
if (!plain) continue;
|
||||
let payload: PairEnvelopePayload;
|
||||
try {
|
||||
payload = JSON.parse(plain);
|
||||
} catch { continue; }
|
||||
if (
|
||||
payload.v !== PAIR_ENVELOPE_VERSION ||
|
||||
payload.type !== 'pair-handshake' ||
|
||||
payload.code !== session.code ||
|
||||
!payload.master_pub || !payload.master_priv
|
||||
) continue;
|
||||
|
||||
// Success — materialise a KeyFile and redirect.
|
||||
const kf: KeyFile = {
|
||||
pub_key: payload.master_pub,
|
||||
priv_key: payload.master_priv,
|
||||
x25519_pub: session.x25519Pub,
|
||||
x25519_priv: session.x25519Priv,
|
||||
};
|
||||
await saveKeyFile(kf);
|
||||
// The root layout auto-links us on first boot if needed, but
|
||||
// the primary device already submitted LINK_DEVICE for our
|
||||
// pub as part of the pairing, so the registry is already
|
||||
// correct. Mark ourselves registered so the revoke-detection
|
||||
// branch doesn't spuriously wipe on next launch.
|
||||
await markDeviceRegistered();
|
||||
setKeyFile(kf);
|
||||
|
||||
// Envelope stays in mailbox until relay TTL eviction; the
|
||||
// single-shot handshake is idempotent (saveKeyFile overwrites)
|
||||
// and our session pub won't be polled again after redirect.
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
if (!cancelled) router.replace('/(app)/chats' as never);
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* transient — retry */
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(tick, 2_500);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [session, setKeyFile]);
|
||||
|
||||
return (
|
||||
<View style={{
|
||||
flex: 1, backgroundColor: '#000000',
|
||||
paddingTop: insets.top + 12,
|
||||
paddingBottom: Math.max(insets.bottom, 20),
|
||||
}}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 24 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<Pressable
|
||||
onPress={() => safeBack('/')}
|
||||
hitSlop={8}
|
||||
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={28} color="#ffffff" />
|
||||
</Pressable>
|
||||
|
||||
<View style={{ alignItems: 'center', marginBottom: 28 }}>
|
||||
<View style={{
|
||||
width: 80, height: 80, borderRadius: 22,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<Ionicons name="link" size={40} color="#1d9bf0" />
|
||||
</View>
|
||||
<Text style={{
|
||||
color: '#ffffff', fontSize: 22, fontWeight: '800',
|
||||
marginTop: 14, textAlign: 'center',
|
||||
}}>
|
||||
Pair with your other device
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
|
||||
marginTop: 8, textAlign: 'center', maxWidth: 300,
|
||||
}}>
|
||||
On a device where you're already signed in,
|
||||
open Settings → Devices → Link new device,
|
||||
and enter these values.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Code */}
|
||||
<View style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
paddingVertical: 18, paddingHorizontal: 16,
|
||||
marginBottom: 14, alignItems: 'center',
|
||||
}}>
|
||||
<Text style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1.2,
|
||||
}}>
|
||||
1. Code
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#ffffff', fontSize: 38, fontWeight: '800',
|
||||
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
|
||||
}}>
|
||||
{session.code.slice(0, 3)} {session.code.slice(3)}
|
||||
</Text>
|
||||
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||
Copy code
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Device key */}
|
||||
<View style={{
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
paddingVertical: 16, paddingHorizontal: 16,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<Text style={{
|
||||
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1.2,
|
||||
}}>
|
||||
2. Device key
|
||||
</Text>
|
||||
<Text
|
||||
selectable
|
||||
style={{
|
||||
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
|
||||
marginTop: 6, lineHeight: 17,
|
||||
}}
|
||||
>
|
||||
{session.x25519Pub}
|
||||
</Text>
|
||||
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
|
||||
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
|
||||
Copy key
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Status */}
|
||||
<View style={{ alignItems: 'center', minHeight: 56 }}>
|
||||
{status === 'waiting' && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<ActivityIndicator size="small" color="#1d9bf0" />
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
|
||||
Waiting for your other device…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
|
||||
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
|
||||
Paired. Opening your chats…
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
|
||||
{err ?? 'Something went wrong. Please retry.'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── session helper ──────────────────────────────────────────────────────
|
||||
|
||||
interface PairSession {
|
||||
x25519Pub: string; // hex
|
||||
x25519Priv: string; // hex
|
||||
code: string;
|
||||
}
|
||||
|
||||
function genSession(): PairSession {
|
||||
const kp = nacl.box.keyPair();
|
||||
return {
|
||||
x25519Pub: bytesToHex(kp.publicKey),
|
||||
x25519Priv: bytesToHex(kp.secretKey),
|
||||
code: randomCode(),
|
||||
};
|
||||
}
|
||||
@@ -346,8 +346,13 @@ export default function WelcomeScreen() {
|
||||
{/* CTA — прижата к правому нижнему краю. */}
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Pair"
|
||||
icon="link"
|
||||
onPress={() => router.push('/(auth)/pair' as never)}
|
||||
/>
|
||||
<CTASecondary
|
||||
label="Import"
|
||||
onPress={() => router.push('/(auth)/import' as never)}
|
||||
|
||||
@@ -365,6 +365,25 @@ export interface IdentityInfo {
|
||||
x25519_pub: string; // hex Curve25519 key; empty string if not published
|
||||
nickname: string;
|
||||
registered: boolean;
|
||||
/**
|
||||
* Number of active (non-revoked) devices linked to this master identity
|
||||
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
|
||||
* a single X25519 via REGISTER_KEY — senders should fall back to
|
||||
* `x25519_pub` above and skip the device fan-out path.
|
||||
*/
|
||||
device_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One active device in an identity's multi-device registry. Returned by
|
||||
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
|
||||
* list to fan out one sealed envelope per X25519 pub so all of the
|
||||
* recipient's devices receive the message.
|
||||
*/
|
||||
export interface DeviceInfo {
|
||||
x25519_pub_key: string;
|
||||
device_name: string;
|
||||
added_at: number; // unix seconds
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,6 +428,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
|
||||
|
||||
interface DevicesResponse {
|
||||
master_pub: string;
|
||||
count: number;
|
||||
devices: DeviceInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/devices/{master_pub} — all active (non-revoked) device records
|
||||
* for the given master identity. Returns an empty array for a legacy
|
||||
* identity (device_count == 0) or a network error — callers should treat
|
||||
* both the same way and fall back to IdentityInfo.x25519_pub so the
|
||||
* pre-v2.2.0 single-device path keeps working.
|
||||
*/
|
||||
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
|
||||
try {
|
||||
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
|
||||
return resp.devices ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
|
||||
* Two paths, in priority order:
|
||||
*
|
||||
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
|
||||
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
|
||||
* (pre-v2.2.0 clients). Send to just that one.
|
||||
*
|
||||
* Returns an empty array only when the recipient has published nothing
|
||||
* at all — caller must surface "no encryption key" to the user rather
|
||||
* than drop the message on the floor.
|
||||
*/
|
||||
export async function resolveRecipientKeys(
|
||||
recipientMasterPub: string,
|
||||
): Promise<string[]> {
|
||||
const devs = await fetchDevices(recipientMasterPub);
|
||||
if (devs.length > 0) {
|
||||
return devs.map(d => d.x25519_pub_key);
|
||||
}
|
||||
const identity = await getIdentity(recipientMasterPub);
|
||||
if (identity?.x25519_pub) {
|
||||
return [identity.x25519_pub];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ─── Contract API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -712,6 +781,68 @@ export function buildTransferTx(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
|
||||
* identity's device registry so senders can fan out envelopes across
|
||||
* every active device. Signed by the master Ed25519 (= `from`).
|
||||
*
|
||||
* `deviceName` is a short human label shown in Settings → Devices
|
||||
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
|
||||
*/
|
||||
export function buildLinkDeviceTx(params: {
|
||||
from: string; // master Ed25519 pubkey
|
||||
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
|
||||
deviceName: string;
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payloadObj = {
|
||||
x25519_pub_key: params.x25519Pub,
|
||||
device_name: params.deviceName,
|
||||
};
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'LINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'LINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
|
||||
* stop shipping envelopes to its X25519 pub. The revoked device itself,
|
||||
* when it next comes online and sees its own pub in the revoked list,
|
||||
* is expected to wipe local state (master priv + cached chats).
|
||||
*/
|
||||
export function buildUnlinkDeviceTx(params: {
|
||||
from: string; // master Ed25519 pubkey
|
||||
x25519Pub: string; // pub to revoke
|
||||
privKey: string;
|
||||
}): RawTx {
|
||||
const id = newTxID();
|
||||
const timestamp = rfc3339Now();
|
||||
const payloadObj = { x25519_pub_key: params.x25519Pub };
|
||||
const payload = strToBase64(JSON.stringify(payloadObj));
|
||||
|
||||
const canonical = txCanonicalBytes({
|
||||
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
|
||||
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||
signature: signBase64(canonical, params.privKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CONTACT_REQUEST transaction.
|
||||
*
|
||||
|
||||
@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
|
||||
const CONTACTS_KEY = 'dchain_contacts';
|
||||
const SETTINGS_KEY = 'dchain_settings';
|
||||
const CHATS_KEY = 'dchain_chats';
|
||||
// Remembers (locally, per install) that this device's X25519 pub has been
|
||||
// successfully linked on-chain at least once. Distinguishes "first boot,
|
||||
// not registered yet" from "we were registered and then revoked by another
|
||||
// device". The second case triggers self-wipe. Stored in AsyncStorage —
|
||||
// if it's missing, we simply re-link.
|
||||
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||
|
||||
/** Save the key file in secure storage (encrypted on device). */
|
||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
|
||||
const trimmed = msgs.slice(-500);
|
||||
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||
}
|
||||
|
||||
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* isDeviceRegistered returns true if this device has ever successfully
|
||||
* linked its X25519 pub on-chain under the current master identity.
|
||||
* A true-then-absent transition (registered → not in chain's active list)
|
||||
* is interpreted as a remote revoke and triggers self-wipe.
|
||||
*/
|
||||
export async function isDeviceRegistered(): Promise<boolean> {
|
||||
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
|
||||
}
|
||||
|
||||
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
|
||||
observed in the registry on startup. */
|
||||
export async function markDeviceRegistered(): Promise<void> {
|
||||
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
|
||||
}
|
||||
|
||||
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
|
||||
explicit logout. */
|
||||
export async function clearDeviceRegistered(): Promise<void> {
|
||||
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* wipeAllLocalState zeroes out every on-device artifact tied to the
|
||||
* current identity: secure-store key, settings, contacts, chats cache,
|
||||
* registered-device marker. Safe to call multiple times.
|
||||
*
|
||||
* Called in two scenarios:
|
||||
* 1. Explicit "Delete account" in Settings.
|
||||
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
|
||||
* in the active registry but we previously marked it registered,
|
||||
* so another device issued UNLINK_DEVICE against us. We must not
|
||||
* keep using the master priv any more — it still works at the
|
||||
* crypto level, but the social contract is that we're revoked.
|
||||
*/
|
||||
export async function wipeAllLocalState(): Promise<void> {
|
||||
// Secure store (key).
|
||||
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
|
||||
// AsyncStorage — enumerate our known keys. We don't clear() the whole
|
||||
// store because a share-provider or other app shard could live there.
|
||||
const ks = await AsyncStorage.getAllKeys();
|
||||
const ours = ks.filter(k =>
|
||||
k === CONTACTS_KEY ||
|
||||
k === SETTINGS_KEY ||
|
||||
k === DEVICE_REGISTERED_KEY ||
|
||||
k.startsWith(`${CHATS_KEY}_`),
|
||||
);
|
||||
if (ours.length > 0) {
|
||||
await AsyncStorage.multiRemove(ours);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
202
deploy/single/join.sh
Normal file
202
deploy/single/join.sh
Normal file
@@ -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 <<EOF
|
||||
|
||||
# ── AUTO-GENERATED BY rejoin.sh ─────────────────────────────────────
|
||||
# joiner → seed $SEED_HTTP (public node, no token)
|
||||
DCHAIN_JOIN=$SEED_HTTP
|
||||
DCHAIN_PEERS=/ip4/$SEED_P2P_IP/tcp/$SEED_P2P_PORT/p2p/$SEED_PEER_ID
|
||||
DCHAIN_ANNOUNCE=/ip4/$PUBLIC_IP/tcp/$LOCAL_P2P_PORT
|
||||
|
||||
DCHAIN_DB=/data/chain
|
||||
DCHAIN_MAILBOX_DB=/data/mailbox
|
||||
DCHAIN_FEED_DB=/data/feed
|
||||
|
||||
DCHAIN_REGISTER_RELAY=true
|
||||
DCHAIN_RELAY_FEE=1000
|
||||
|
||||
DCHAIN_FEED_DISK_LIMIT_MB=4096
|
||||
DCHAIN_CHAIN_DISK_LIMIT_MB=20480
|
||||
|
||||
DCHAIN_UPDATE_SOURCE_URL=https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest
|
||||
EOF
|
||||
|
||||
# ── 9. Compose: прямой проброс 4001+8080 наружу, Caddy снести навсегда ─
|
||||
log "patching docker-compose.yml: direct ports, caddy removed"
|
||||
python3 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path('docker-compose.yml')
|
||||
src = p.read_text()
|
||||
|
||||
# 9a. Вырезать весь сервис caddy (от " caddy:" до следующего сервиса
|
||||
# того же уровня или конца блока services).
|
||||
src = re.sub(
|
||||
r'(?ms)^ caddy:\n(?:(?: .*\n)|\n)+?(?=^ [A-Za-z_-]+:|\Z)',
|
||||
'', src,
|
||||
)
|
||||
|
||||
# 9b. Убрать зависимости от caddy, если где-то остались.
|
||||
src = re.sub(r'(?m)^\s*-\s*caddy\s*$\n', '', src)
|
||||
src = re.sub(r'(?m)^\s*depends_on:\s*\n(\s*-\s*caddy\s*\n)+', '', src)
|
||||
|
||||
# 9c. Заставить node светить наружу 4001 (libp2p) и 8080 (HTTP).
|
||||
# Ищем первый ports: в сервисе node; если его нет — инжектим.
|
||||
m = re.search(r'(?ms)^ node:\n(.*?)(?=^ [A-Za-z_-]+:|\Z)', src)
|
||||
if not m:
|
||||
raise SystemExit('no `node:` service in compose')
|
||||
node_block = m.group(1)
|
||||
|
||||
wanted = f' ports:\n - "$LOCAL_P2P_PORT:4001"\n - "$LOCAL_HTTP_PORT:8080"\n'
|
||||
|
||||
if re.search(r'(?m)^ ports:', node_block):
|
||||
# Заменить существующий блок ports целиком
|
||||
node_block_new = re.sub(
|
||||
r'(?ms)^ ports:\n(?: -.*\n)+',
|
||||
wanted, node_block,
|
||||
)
|
||||
else:
|
||||
# Добавить сразу после строки "container_name:" (или restart:)
|
||||
node_block_new = re.sub(
|
||||
r'(?m)^( (?:container_name|restart):.*\n)',
|
||||
r'\\1' + wanted, node_block, count=1,
|
||||
)
|
||||
if node_block_new == node_block: # fallback — в начало блока
|
||||
node_block_new = wanted + node_block
|
||||
|
||||
src = src[:m.start(1)] + node_block_new + src[m.end(1):]
|
||||
|
||||
# 9d. Убрать блок expose у node — не нужен, раз ports наружу.
|
||||
src = re.sub(
|
||||
r'(?ms)^ expose:\n(?: -.*\n)+', '', src,
|
||||
)
|
||||
|
||||
p.write_text(src)
|
||||
PY
|
||||
|
||||
# ── 10. Подъём ─────────────────────────────────────────────────────────
|
||||
log "docker compose up -d"
|
||||
sudo docker compose up -d --build
|
||||
|
||||
# ── 11. Sanity checks ──────────────────────────────────────────────────
|
||||
sleep 3
|
||||
log "sanity checks (дай ~30 сек чтобы healthcheck подхватился):"
|
||||
set +e
|
||||
echo "──── docker compose ps ────"
|
||||
sudo docker compose ps
|
||||
echo "──── docker logs dchain_node | tail ────"
|
||||
sudo docker logs --tail 30 dchain_node 2>&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 <<EOM
|
||||
|
||||
======================================================================
|
||||
✓ Готово. Нода публичная, без токена и без TLS.
|
||||
|
||||
API:
|
||||
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/api/netstats
|
||||
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/swagger
|
||||
|
||||
Следи за sync'ом:
|
||||
sudo docker logs -f dchain_node | grep -E 'applied|height|peer'
|
||||
|
||||
Повторный полный сброс:
|
||||
./rejoin.sh
|
||||
|
||||
Открой в firewall/security-group на этом VPS:
|
||||
- $LOCAL_P2P_PORT/tcp (libp2p)
|
||||
- $LOCAL_HTTP_PORT/tcp (HTTP API)
|
||||
======================================================================
|
||||
EOM
|
||||
219
docs/ROADMAP.md
Normal file
219
docs/ROADMAP.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# Roadmap
|
||||
|
||||
Living doc — «куда идём дальше». Два активных вектора: **v2.2.0 multi-device**
|
||||
и **desktop Electron client**. Всё что tentative — помечено `?`; всё что
|
||||
решено и принято к работе — без знаков.
|
||||
|
||||
---
|
||||
|
||||
## v2.2.0 — Multi-device (per-device X25519, on-chain device registry)
|
||||
|
||||
### Проблема
|
||||
|
||||
Сейчас у пользователя одна пара X25519 (хранится в `keyFile`), и relay-mailbox —
|
||||
queue с DELETE-после-чтения. При двух устройствах:
|
||||
|
||||
- Конверт забирает то устройство, что первое дёрнуло `/relay/inbox` — второе
|
||||
теряет сообщение навсегда.
|
||||
- История чатов per-device, не синхронизируется (`AsyncStorage` на каждом
|
||||
устройстве свой).
|
||||
|
||||
On-chain часть (posts, follows, tx, wallet) работает нормально — оба устройства
|
||||
читают один чейн и видят одно и то же. Чинить надо только мессенджер.
|
||||
|
||||
### Решение — путь А (Signal-style): X25519 на устройство + device registry
|
||||
|
||||
1. **Master identity = Ed25519** (как сейчас). Подписывает tx, владеет
|
||||
балансом, — один на всю identity.
|
||||
2. **X25519 — per-device**. Каждое устройство при первом старте генерит
|
||||
свою пару. Relay-mailbox остаётся queue'ом, но адресуется по
|
||||
**устройству**, не по identity.
|
||||
3. **Device registry on-chain**: новые tx-типы `LINK_DEVICE` / `UNLINK_DEVICE`,
|
||||
подписываются master Ed25519, публикуют/отзывают связку
|
||||
`(master_pub → device_x25519_pub, device_name)`.
|
||||
4. **Sender-side fan-out**: при отправке — один envelope на каждое
|
||||
активное устройство получателя. Мессадж → N конвертов.
|
||||
5. **Revoke**: master подписывает `UNLINK_DEVICE`, клиент, обнаруживший
|
||||
свой `x25519_pub` в revoked — wipe'ит локальную БД (zeroize master
|
||||
Ed25519 + delete chats).
|
||||
|
||||
### План работ (PR-by-PR)
|
||||
|
||||
#### PR #1 — Chain-side (backend) — _v2.2.0-alpha1_
|
||||
|
||||
- [ ] `blockchain/types.go`: `EventLinkDevice`, `EventUnlinkDevice`
|
||||
+ payload structs `LinkDevicePayload`, `UnlinkDevicePayload`.
|
||||
- [ ] `blockchain/chain.go`: applyTx-ветки, state persistence:
|
||||
- `prefixDevice + x25519_pub → DeviceRecord{owner, name, added_at, revoked_at?}`.
|
||||
- Обратный индекс `prefixDevicesByOwner + master_pub → [x25519_pub, …]`.
|
||||
- [ ] Константа `MaxDevicesPerOwner = 10`.
|
||||
- [ ] Validation:
|
||||
- Tx подписана master'ом; подпись matches `payload.Owner`.
|
||||
- `x25519_pub` уникален в registry (не занят другим master'ом).
|
||||
- `device_name` ≤ 64 символов; printable.
|
||||
- При `UNLINK_DEVICE`: запись существует, owner совпадает с signer'ом.
|
||||
- [ ] HTTP: `GET /api/devices/{master_pub}` → `[{x25519_pub, device_name, added_at}, …]`,
|
||||
фильтрует revoked.
|
||||
- [ ] В `/api/identity/{pub}` добавить поле `device_count`.
|
||||
- [ ] Unit-тесты:
|
||||
- Happy path link + list.
|
||||
- Unlink → list сократился.
|
||||
- Лимит `MaxDevicesPerOwner`.
|
||||
- Чужая подпись → reject.
|
||||
- Duplicate x25519_pub → reject.
|
||||
- [ ] Swagger + `docs/api/devices.md`.
|
||||
|
||||
Совместимость: старые клиенты, которые не обновили identity, продолжают
|
||||
работать по old-schema (envelope на published X25519 из identity). Sender
|
||||
fall-back'ит на identity.x25519, если `device_count == 0`.
|
||||
|
||||
#### PR #2 — Client fan-out (mobile) — _v2.2.0-alpha2_
|
||||
|
||||
- [ ] `lib/api.ts`: `fetchDevices(masterPub): Promise<DeviceRecord[]>`,
|
||||
с кэшем в zustand store + инвалидацией по WS-ивенту `tx:LINK_DEVICE|UNLINK_DEVICE`.
|
||||
- [ ] `chats/[id].tsx` → `sendCore`: `Promise.all` по `devices[]`;
|
||||
если `devices.length == 0` — fall-back на identity.x25519 (legacy path).
|
||||
- [ ] При первом старте (онбординг): если identity ещё не зарегистрирована —
|
||||
`PUBLISH_KEY` tx + `LINK_DEVICE` tx (**этот** девайс как первый в списке).
|
||||
- [ ] UI: в chat-list tile бейдж `2/2` на своих сообщениях (доставлено
|
||||
на N устройств получателя).
|
||||
- [ ] Тест: два клиента на одну identity, отправляем на contact — оба
|
||||
получают.
|
||||
|
||||
#### PR #3 — Devices screen + pairing flow (mobile + desktop) — _v2.2.0-alpha3_
|
||||
|
||||
- [ ] Settings → **Devices**: список активных, unlink-кнопка у каждого
|
||||
(кроме `this device`).
|
||||
- [ ] «Link new device» flow:
|
||||
- Новое устройство генерит свой X25519, показывает QR
|
||||
`{x25519_pub, device_name, nonce, rendezvous_id}`.
|
||||
- Старое сканирует → Confirm → подписывает `LINK_DEVICE` tx +
|
||||
шлёт через relay envelope `{master_ed25519_priv, recent_history}` —
|
||||
encrypted for new device's X25519, TTL ≤ 60 сек.
|
||||
- Новое читает envelope, сохраняет master + history, готово.
|
||||
- [ ] Self-wipe при обнаружении своего `x25519_pub` в revoked:
|
||||
zeroize master, clear AsyncStorage/keychain, redirect на onboarding.
|
||||
|
||||
#### PR #4 — Desktop Electron shell — _v2.2.0-rc1_
|
||||
|
||||
См. отдельный раздел ниже.
|
||||
|
||||
### Нерешённые вопросы
|
||||
|
||||
- **QR-pairing UX на desktop'е**: у ноутбука камеры часто нет. Вариант:
|
||||
master на phone шлёт invite-envelope по pre-shared secret (6-digit code,
|
||||
вводится в обе стороны), без QR. Подумать после PR #2.
|
||||
- **History-sync backup формат**: JSON export всей ChatStorage shreds'ов?
|
||||
Ограничение по размеру? TTL у backup-конверта? — в PR #3.
|
||||
- **Revoked-device-wipe race**: если устройство оффлайн 6 месяцев, потом
|
||||
поднимается — первое что видит это свой revoked. Должен успеть wipe'нуться
|
||||
**до** попытки послать tx с master-ключом. Продумать порядок bootstrap'а.
|
||||
|
||||
---
|
||||
|
||||
## Desktop client (Electron)
|
||||
|
||||
Цель: 1:1 функциональный паритет с мобильным, десктопная эргономика, keyboard-first.
|
||||
|
||||
### Архитектура
|
||||
|
||||
- **Electron** + **React (Vite)** + **TypeScript**.
|
||||
- 80% `lib/` переиспользуется из `client-app/lib/` (api, feed, crypto, store, types).
|
||||
- Нативные модули: `electron-store` (+ `safeStorage`) для keys, `clipboard`,
|
||||
`Notification`, `dialog.showOpenDialog`.
|
||||
- Custom title bar (frame-less), 3-panel layout.
|
||||
|
||||
### Экраны
|
||||
|
||||
Full design — `docs/desktop-design.md` (TODO). Sections:
|
||||
|
||||
- **Messages** — список чатов + conversation panel. Saved Messages pinned top.
|
||||
- **Feed** — tabs (For You / Following / Trending 24h / 7d / Hashtag) +
|
||||
post list + thread panel.
|
||||
- **Wallet** — account overview + tx history + tx detail.
|
||||
- **Contacts** — grouped list (All / Online / Blocked / Requests) + contact profile.
|
||||
- **Settings** — grouped: Node, Identity, **Devices**, Privacy, Feed, Notifications,
|
||||
Advanced, About.
|
||||
- **Profile** (внизу nav) — self profile; чужой — в detail pane.
|
||||
- **Auth/Onboarding** — Welcome / Create / Import / Pair-with-phone (QR/code).
|
||||
|
||||
### Стилистика (переносим)
|
||||
|
||||
Чёрный `#000`, карточки `#0a0a0a`, бордеры `#1f1f1f`, текст `#fff`/`#8b8b8b`.
|
||||
Синий accent `#1d9bf0`, оранж warning `#f0b35a`. Синий bookmark-avatar для Saved Messages.
|
||||
|
||||
### Не переносим
|
||||
|
||||
Все React-Native-анимации (Pressable-animations, `scaleY: -1`, gesture-handler
|
||||
long-press). На десктопе — правый клик, hover-states, keyboard shortcuts,
|
||||
обычный scroll вместо inverted FlatList.
|
||||
|
||||
### Global keybinds
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl/Cmd+1..5` | Переключение секций |
|
||||
| `Ctrl/Cmd+N` | Новый пост / новый контакт (контекстно) |
|
||||
| `Ctrl/Cmd+K` | Global search |
|
||||
| `Ctrl/Cmd+F` | Search в текущей list pane |
|
||||
| `Ctrl/Cmd+,` | Settings |
|
||||
| `Ctrl/Cmd+Enter` | Send (chat / publish post) |
|
||||
| `Esc` | Close modal / cancel selection |
|
||||
|
||||
### Структура папок
|
||||
|
||||
```
|
||||
desktop/
|
||||
├── electron/
|
||||
│ ├── main.ts # BrowserWindow, IPC, auto-update
|
||||
│ ├── preload.ts # contextBridge — safe API
|
||||
│ ├── menu.ts # native menu bar
|
||||
│ └── deep-link.ts # dchain:// protocol handler
|
||||
├── src/ # renderer
|
||||
│ ├── App.tsx
|
||||
│ ├── shell/ # nav, status bar, title bar
|
||||
│ ├── sections/
|
||||
│ │ ├── messages/
|
||||
│ │ ├── feed/
|
||||
│ │ ├── wallet/
|
||||
│ │ ├── contacts/
|
||||
│ │ ├── settings/
|
||||
│ │ └── profile/
|
||||
│ ├── modals/
|
||||
│ ├── auth/
|
||||
│ ├── lib/ # reuse из client-app/lib
|
||||
│ └── styles/
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### План работ (отдельно, после v2.2.0-alpha3)
|
||||
|
||||
- [ ] Boilerplate: Electron + Vite + React + TS, frame-less window, 3-panel shell.
|
||||
- [ ] Сопрягание `lib/` из client-app (заменить expo-* на Electron-эквиваленты).
|
||||
- [ ] Sections по порядку: Messages → Feed → Wallet → Contacts → Settings → Profile.
|
||||
- [ ] Multi-device pairing flow (использует v2.2.0 registry).
|
||||
- [ ] Auto-update через electron-updater или через тот же `/api/update-check`.
|
||||
- [ ] Packaging: `electron-builder` → `.dmg`, `.exe`, `.AppImage`, `.deb`.
|
||||
|
||||
### Открытые вопросы (desktop)
|
||||
|
||||
- **Auto-update channel**: гнать через Gitea releases (как node) или отдельный S3/Gitea-attachments?
|
||||
- **Sandbox**: desktop хранит master Ed25519 — обязательно `safeStorage` (macOS Keychain, Windows DPAPI, libsecret на Linux). На Linux без libsecret — fallback на plaintext + warning.
|
||||
- **Deep-link**: `dchain://chat/<pub>` для шаринга профилей/постов из браузера.
|
||||
|
||||
---
|
||||
|
||||
## Меньшие хвосты (неприоритетно)
|
||||
|
||||
- `prev_hash mismatch` при двойной gossip-доставке — понизить до Debug уровня,
|
||||
не пугать операторов (см. history с joiner-node bring-up).
|
||||
- `/api/network-info.peers` у seed'а возвращает `[]` если
|
||||
`DCHAIN_ANNOUNCE` не задан — добавить fallback:
|
||||
если announce пустой, публиковать *первый listen-addr + свой peer_id*
|
||||
(не `127.0.0.1`), чтобы joiner'ы не упирались в `peers:[]`.
|
||||
- **Groups (3+ участников)** в чатах — тип `Contact.kind == 'group'`
|
||||
в клиенте есть, а на backend'е не реализован. После v2.2.0, потому что
|
||||
pairwise messaging ↔ group messaging на multi-device — это другой
|
||||
математический слой (MLS или Sender Keys).
|
||||
- Repa как weight в selection-lider PBFT — сейчас только scoring; для
|
||||
настоящего proof-of-reputation нужно переписать leader-rotation.
|
||||
@@ -510,10 +510,63 @@ func apiIdentity(q ExplorerQuery) http.HandlerFunc {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
// Multi-device (v2.2.0): stuff the active device count into the
|
||||
// identity payload so a sender can decide upfront whether to
|
||||
// fan out envelopes or fall back to the legacy single-X25519
|
||||
// path (device_count == 0).
|
||||
if q.DevicesOf != nil {
|
||||
if devs, derr := q.DevicesOf(pubKey); derr == nil {
|
||||
info.DeviceCount = len(devs)
|
||||
}
|
||||
}
|
||||
jsonOK(w, info)
|
||||
}
|
||||
}
|
||||
|
||||
// apiDevices — GET /api/devices/{master_pub_or_addr}
|
||||
//
|
||||
// Returns the active (non-revoked) devices linked to a master Ed25519
|
||||
// identity. Used by senders to fan out one envelope per device. Legacy
|
||||
// identities (count=0) should be handled by the caller by falling back
|
||||
// to IdentityInfo.X25519Pub — this endpoint is strictly the new registry.
|
||||
func apiDevices(q ExplorerQuery) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
input := strings.TrimPrefix(r.URL.Path, "/api/devices/")
|
||||
input = strings.Trim(input, "/")
|
||||
if input == "" {
|
||||
jsonErr(w, fmt.Errorf("master pubkey or address required"), 400)
|
||||
return
|
||||
}
|
||||
if q.DevicesOf == nil {
|
||||
jsonErr(w, fmt.Errorf("device registry not available"), 503)
|
||||
return
|
||||
}
|
||||
pubKey, err := resolveAccountID(q, input)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 404)
|
||||
return
|
||||
}
|
||||
recs, err := q.DevicesOf(pubKey)
|
||||
if err != nil {
|
||||
jsonErr(w, err, 500)
|
||||
return
|
||||
}
|
||||
out := make([]blockchain.DeviceInfo, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, blockchain.DeviceInfo{
|
||||
X25519PubKey: rec.X25519PubKey,
|
||||
DeviceName: rec.DeviceName,
|
||||
AddedAt: rec.AddedAt,
|
||||
})
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"master_pub": pubKey,
|
||||
"count": len(out),
|
||||
"devices": out,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
|
||||
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
|
||||
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is
|
||||
|
||||
@@ -53,6 +53,10 @@ type ExplorerQuery struct {
|
||||
NetStats func() (blockchain.NetStats, error)
|
||||
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
|
||||
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
|
||||
// DevicesOf (multi-device v2.2.0) returns the identity's non-revoked
|
||||
// device records. Empty slice if the identity hasn't linked any yet
|
||||
// — senders fall back to IdentityInfo.X25519Pub for legacy clients.
|
||||
DevicesOf func(masterPub string) ([]blockchain.DeviceRecord, error)
|
||||
ValidatorSet func() ([]string, error)
|
||||
SubmitTx func(tx *blockchain.Transaction) error
|
||||
// ConnectedPeers (optional) returns the local libp2p view of currently
|
||||
@@ -222,6 +226,7 @@ func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
|
||||
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
|
||||
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
|
||||
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
|
||||
mux.HandleFunc("/api/devices/", apiDevices(q)) // GET /api/devices/{master_pub} — multi-device registry (v2.2.0)
|
||||
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
|
||||
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
|
||||
// Live event stream (SSE) — GET /api/events
|
||||
|
||||
Reference in New Issue
Block a user