From 5c96cda1b61bbdae4008946e93d9362dd8f6eef5 Mon Sep 17 00:00:00 2001 From: Jaron Rosenau Date: Tue, 21 Jul 2026 12:35:53 -0700 Subject: [PATCH 1/5] Add regtest HNSR proof of concept --- docs/experimental-hnsr.md | 127 ++ lib/net/common.js | 41 + lib/net/hnsr.js | 1790 +++++++++++++++++++++++++++++ lib/net/index.js | 1 + lib/net/packets.js | 99 +- lib/net/parser.js | 8 +- lib/net/pool.js | 39 + lib/node/fullnode.js | 47 + scripts/run-hnsr-regtest-trial.js | 471 ++++++++ test/hnsr-test.js | 251 ++++ 10 files changed, 2872 insertions(+), 2 deletions(-) create mode 100644 docs/experimental-hnsr.md create mode 100644 lib/net/hnsr.js create mode 100755 scripts/run-hnsr-regtest-trial.js create mode 100644 test/hnsr-test.js diff --git a/docs/experimental-hnsr.md b/docs/experimental-hnsr.md new file mode 100644 index 0000000000..9a78817bd4 --- /dev/null +++ b/docs/experimental-hnsr.md @@ -0,0 +1,127 @@ +# Experimental HNSR proof of concept + +This branch contains a deliberately bounded, regtest-only implementation of +the unnamed `HNS_NODE_V1` path from the draft **Handshake P2P Rendezvous and +Authenticated Service Relay** HIP. + +It is reference code for exercising the wire shape, authorization boundaries, +and lifecycle on actual `hsd` peers. It is not a production relay, a permanent +wire assignment, or a claim that every phase of the draft HIP is implemented. + +## Private assignments + +| Symbol | Private value | +| --- | ---: | +| HNSR rendezvous service | `0x04000000` | +| HNSR relay service | `0x08000000` | +| HNSR packet type | `0xf3` | + +The roles cannot be enabled outside regtest. Nodes on other networks do not +advertise either bit. These values are collision-prone experimental values and +must be replaced if the protocol receives assigned values. + +## Implemented trial profile + +The branch implements: + +- the version-1 HNSR envelope and all 21 reserved opcode numbers; +- strict envelope length, flag, version, opcode, and context checks; +- regtest-only role advertisement; +- endpoint-signed `RESERVE`, relay-signed `OFFER`, endpoint `CONFIRM`, and + jointly authenticated relay tickets; +- strict-DER, low-S secp256k1 signatures with network- and domain-separated + digests; +- self-authorized unnamed endpoint delegations and route records for + `HNS_NODE_V1`; +- bounded, expiring, sequence-aware in-memory route storage; +- `PUTROUTE` / `PUTRESULT` and exact-key `GETROUTE` / `ROUTES`; +- `OPEN` / `INCOMING` / `ACCEPT` / `OPENED` circuit establishment; +- opaque `DATA`, directional `WINDOW`, and `CLOSE` forwarding; +- per-ticket circuit and byte limits, bounded frames, and relay-side + directional credit enforcement; +- immediate local reservation invalidation when the endpoint peer disconnects; + and +- a virtual socket suitable for a complete end-to-end inner Brontide session. + +The proof-of-concept handler does not forward to a requester-selected host or +port. A circuit can terminate only at the exact live peer connection bound to +the signed reservation. + +## Deliberately unimplemented + +The branch does not yet implement: + +- iterative `FINDNODE` / `NODES` XOR routing or eight-node replication; +- `SAMPLEROUTES`, `RENEW`, or `WITHDRAW` behavior; +- named HNS authority, TXT root-key parsing, service authorizations, or + `HNS_WEB_V1`; +- persistent routing buckets or route storage; +- multi-relay selection, republishing, failover, or topology scoring; +- public-node admission, routability, per-prefix, or netgroup policy; +- RPCs, wallet integration, SPV discovery, Android lifecycle integration, or + browser-origin behavior; +- relay payment, reputation, or production abuse controls; or +- the production scheduler and telemetry required before any public-network + experiment. + +Those boundaries are intentional. In particular, direct exact-key storage at +one rendezvous FullNode validates authenticated record storage but is not a +Kademlia conformance claim. + +## Reproducible trial + +From this branch: + +```sh +npm ci +NODE_BACKEND=js npm run test-file -- test/hnsr-test.js test/net-test.js +NODE_BACKEND=js node scripts/run-hnsr-regtest-trial.js \ + ../artifacts/hnsr-regtest-trial.json +``` + +`NODE_BACKEND=js` selects bcrypto's portable JavaScript backend and is not a +protocol requirement. + +The trial starts four independently keyed, independently prefixed FullNodes: + +```text +Endpoint (no listener) == outer Brontide ==> Relay +Endpoint (no listener) == outer Brontide ==> Rendezvous +Requester == outer Brontide ==> Relay +Requester == outer Brontide ==> Rendezvous +``` + +It then: + +1. propagates a mined regtest block to height 1 across all four nodes; +2. obtains and mutually signs a relay reservation; +3. publishes and retrieves an authenticated unnamed route; +4. opens a relayed `HNS_NODE_V1` circuit; +5. completes a second, end-to-end Brontide handshake inside the opaque + circuit; +6. exchanges ordinary Handshake `version`, `verack`, `ping`, and `pong` + packets over that inner session; +7. verifies both inner static peer identities; +8. verifies that the ping nonce is absent from every relay-visible `DATA` + payload; and +9. disconnects the endpoint, retrieves the intentionally stale route, and + confirms that the relay rejects its now-invalid ticket. + +The evidence file contains fresh identities, ticket ID, route key, circuit ID, +opcode counts, byte counts, and a ciphertext transcript hash. Random values +change on every run. + +## Configuration surface + +The following illustrative flags are recognized by `FullNode`: + +```text +--experimental-hnsr +--experimental-hnsr-endpoint +--experimental-hnsr-relay +--experimental-hnsr-rendezvous +--experimental-hnsr-timeout= +``` + +The relay role also requires the ordinary peer listener. Endpoint and requester +roles advertise no HNSR service bit. diff --git a/lib/net/common.js b/lib/net/common.js index 43d5a69ebe..420fe8ff1a 100644 --- a/lib/net/common.js +++ b/lib/net/common.js @@ -49,6 +49,47 @@ exports.services = { BLOOM: 1 << 1 }; +/** + * Private regtest-only HNSR proof-of-concept assignments. + * + * These values are deliberately not protocol assignments and MUST NOT be + * advertised on public networks. They occupy the same experimental namespace + * as the companion DNS relay prototypes. + * @const {Number} + */ + +exports.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE = 0x04000000; +exports.EXPERIMENTAL_HNSR_RELAY_SERVICE = 0x08000000; +exports.EXPERIMENTAL_HNSR = 0xf3; + +exports.services.EXPERIMENTAL_HNSR_RENDEZVOUS = + exports.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE; +exports.services.EXPERIMENTAL_HNSR_RELAY = + exports.EXPERIMENTAL_HNSR_RELAY_SERVICE; + +/** + * Regtest HNSR proof-of-concept limits. + * @enum {Number} + */ + +exports.hnsr = { + VERSION: 1, + MAX_PACKET_SIZE: 65535, + MAX_RECORD_SIZE: 8192, + MAX_RECORDS_PER_KEY: 16, + MAX_STORED_RECORDS: 50000, + MAX_DATA_SIZE: 16384, + MAX_CIRCUIT_QUEUE: 65536, + MIN_WINDOW: 16384, + DEFAULT_WINDOW: 65536, + MAX_WINDOW: 1048576, + DEFAULT_TIMEOUT: 5000, + MAX_TICKET_LIFETIME: 7200, + MAX_ROUTE_LIFETIME: 7200, + MAX_CIRCUITS: 32, + MAX_SIGNATURE_SIZE: 80 +}; + /** * Our node's services (we support everything). * @const {Number} diff --git a/lib/net/hnsr.js b/lib/net/hnsr.js new file mode 100644 index 0000000000..f076495df3 --- /dev/null +++ b/lib/net/hnsr.js @@ -0,0 +1,1790 @@ +/*! + * hnsr.js - regtest proof of concept for Handshake rendezvous and relay. + * Copyright (c) 2026, Jaron Rosenau (MIT License). + */ + +'use strict'; + +const assert = require('bsert'); +const EventEmitter = require('events'); +const bio = require('bufio'); +const blake2b = require('bcrypto/lib/blake2b'); +const random = require('bcrypto/lib/random'); +const secp256k1 = require('bcrypto/lib/secp256k1'); +const common = require('./common'); +const packets = require('./packets'); + +const ZERO32 = Buffer.alloc(32); +const EMPTY = Buffer.alloc(0); + +const domains = { + RESERVE: Buffer.from('HNSR-RESERVE-V1\0', 'ascii'), + TICKET_RELAY: Buffer.from('HNSR-RELAY-TICKET-V1\0', 'ascii'), + TICKET_ENDPOINT: Buffer.from('HNSR-RELAY-CONFIRM-V1\0', 'ascii'), + DELEGATION: Buffer.from('HNSR-ENDPOINT-DELEGATION-V1\0', 'ascii'), + ROUTE: Buffer.from('HNSR-ROUTE-RECORD-V1\0', 'ascii'), + PEER_ROUTE: Buffer.from('HNSR-PEER-ROUTE-V1\0', 'ascii') +}; + +const opcodes = { + FINDNODE: 0, + NODES: 1, + PUTROUTE: 2, + PUTRESULT: 3, + GETROUTE: 4, + ROUTES: 5, + SAMPLEROUTES: 6, + RESERVE: 7, + OFFER: 8, + CONFIRM: 9, + CONFIRMED: 10, + RENEW: 11, + WITHDRAW: 12, + OPEN: 13, + INCOMING: 14, + ACCEPT: 15, + OPENED: 16, + DATA: 17, + WINDOW: 18, + CLOSE: 19, + ERROR: 20 +}; + +const errors = { + NORMAL: 0, + REFUSED: 1, + UNSUPPORTED: 2, + BUSY: 3, + INVALID: 4, + NOT_FOUND: 5, + EXPIRED: 6, + CAPACITY: 7, + TIMEOUT: 8, + PROTOCOL: 9, + INTERNAL: 10, + ENDPOINT_GONE: 11, + AUTH_FAILED: 12, + FLOW_CONTROL: 13, + RATE_LIMITED: 14, + SHUTDOWN: 15, + PROFILE_DISABLED: 16, + BYTE_LIMIT: 17 +}; + +const profiles = { + HNS_NODE_V1: 1, + HNS_WEB_V1: 2 +}; + +function now() { + return Math.floor(Date.now() / 1000); +} + +function hash(domain, ...items) { + return blake2b.digest(Buffer.concat([domain, ...items]), 32); +} + +function magicBytes(magic) { + const data = Buffer.allocUnsafe(4); + data.writeUInt32LE(magic, 0, true); + return data; +} + +function sign(domain, data, key) { + return secp256k1.signDER(hash(domain, data), key); +} + +function verify(domain, data, signature, key) { + if (!Buffer.isBuffer(signature) + || signature.length === 0 + || signature.length > common.hnsr.MAX_SIGNATURE_SIZE) { + return false; + } + + try { + return secp256k1.publicKeyVerify(key) + && secp256k1.isLowDER(signature) + && secp256k1.verifyDER(hash(domain, data), signature, key); + } catch (e) { + return false; + } +} + +function isZero(data) { + for (const ch of data) { + if (ch !== 0) + return false; + } + + return true; +} + +function randomID(size) { + let id; + + do { + id = random.randomBytes(size); + } while (isZero(id)); + + return id; +} + +function assertU64(value, name) { + assert(Number.isSafeInteger(value) && value >= 0, `${name} must be a u64.`); +} + +function readSignature(br, name) { + const size = br.readU8(); + + if (size > common.hnsr.MAX_SIGNATURE_SIZE) + throw new Error(`${name} signature exceeds the HNSR limit.`); + + return br.readBytes(size); +} + +function writeSignature(bw, signature) { + assert(Buffer.isBuffer(signature)); + assert(signature.length <= common.hnsr.MAX_SIGNATURE_SIZE); + bw.writeU8(signature.length); + bw.writeBytes(signature); +} + +function finish(br, name) { + if (br.left() !== 0) + throw new Error(`Trailing bytes in ${name}.`); +} + +function peerKey(peer, contextID) { + return `${peer.id}:${contextID.toString('hex')}`; +} + +function routeKey(magic, endpointKey) { + assert(secp256k1.publicKeyVerify(endpointKey)); + return hash(domains.PEER_ROUTE, magicBytes(magic), endpointKey); +} + +class ReserveRequest { + constructor(options = {}) { + this.endpointKey = options.endpointKey || Buffer.alloc(33); + this.profile = options.profile || profiles.HNS_NODE_V1; + this.lifetime = options.lifetime || 1800; + this.maxCircuits = options.maxCircuits || 8; + this.maxBytes = options.maxBytes || 1048576; + this.nonce = options.nonce || Buffer.alloc(16); + this.signature = options.signature || EMPTY; + } + + encodeUnsigned() { + assert(secp256k1.publicKeyVerify(this.endpointKey)); + assert((this.profile & 0xffff) === this.profile); + assert((this.lifetime >>> 0) === this.lifetime); + assert((this.maxCircuits & 0xffff) === this.maxCircuits); + assertU64(this.maxBytes, 'maxBytes'); + assert(this.nonce.length === 16); + + const bw = bio.write(65); + bw.writeBytes(this.endpointKey); + bw.writeU16(this.profile); + bw.writeU32(this.lifetime); + bw.writeU16(this.maxCircuits); + bw.writeU64(this.maxBytes); + bw.writeBytes(this.nonce); + return bw.render(); + } + + signatureData(magic, relayKey, contextID) { + assert(contextID.length === 8); + return Buffer.concat([ + magicBytes(magic), + relayKey, + contextID, + this.encodeUnsigned() + ]); + } + + sign(magic, relayKey, contextID, privateKey) { + this.signature = sign( + domains.RESERVE, + this.signatureData(magic, relayKey, contextID), + privateKey); + return this; + } + + verify(magic, relayKey, contextID) { + return verify( + domains.RESERVE, + this.signatureData(magic, relayKey, contextID), + this.signature, + this.endpointKey); + } + + encode() { + const unsigned = this.encodeUnsigned(); + const bw = bio.write(unsigned.length + 1 + this.signature.length); + bw.writeBytes(unsigned); + writeSignature(bw, this.signature); + return bw.render(); + } + + static decode(data) { + const br = bio.read(data); + const request = new ReserveRequest(); + request.endpointKey = br.readBytes(33); + request.profile = br.readU16(); + request.lifetime = br.readU32(); + request.maxCircuits = br.readU16(); + request.maxBytes = br.readU64(); + request.nonce = br.readBytes(16); + request.signature = readSignature(br, 'reserve'); + finish(br, 'reserve request'); + request.encodeUnsigned(); + return request; + } +} + +class RelayTicket { + constructor(options = {}) { + this.version = 1; + this.networkMagic = options.networkMagic || 0; + this.profile = options.profile || profiles.HNS_NODE_V1; + this.transport = options.transport || 0; + this.hostType = options.hostType || 1; + this.host = options.host || Buffer.alloc(16); + this.port = options.port || 0; + this.relayKey = options.relayKey || Buffer.alloc(33); + this.endpointKey = options.endpointKey || Buffer.alloc(33); + this.reservationID = options.reservationID || Buffer.alloc(16); + this.issuedAt = options.issuedAt || 0; + this.expiresAt = options.expiresAt || 0; + this.maxActiveCircuits = options.maxActiveCircuits || 0; + this.maxBytesPerCircuit = options.maxBytesPerCircuit || 0; + this.maxTotalBytes = options.maxTotalBytes || 0; + this.flags = options.flags || 0; + this.relaySignature = options.relaySignature || EMPTY; + this.endpointSignature = options.endpointSignature || EMPTY; + } + + encodeUnsigned() { + assert(this.version === 1); + assert((this.networkMagic >>> 0) === this.networkMagic); + assert((this.profile & 0xffff) === this.profile); + assert((this.transport & 0xff) === this.transport); + assert((this.hostType & 0xff) === this.hostType); + assert(Buffer.isBuffer(this.host) && this.host.length === 16); + assert((this.port & 0xffff) === this.port); + assert(secp256k1.publicKeyVerify(this.relayKey)); + assert(secp256k1.publicKeyVerify(this.endpointKey)); + assert(this.reservationID.length === 16 && !isZero(this.reservationID)); + assertU64(this.issuedAt, 'issuedAt'); + assertU64(this.expiresAt, 'expiresAt'); + assert((this.maxActiveCircuits & 0xffff) === this.maxActiveCircuits); + assertU64(this.maxBytesPerCircuit, 'maxBytesPerCircuit'); + assertU64(this.maxTotalBytes, 'maxTotalBytes'); + assert((this.flags & 0xffff) === this.flags); + + const bw = bio.write(145); + bw.writeU8(this.version); + bw.writeU32(this.networkMagic); + bw.writeU16(this.profile); + bw.writeU8(this.transport); + bw.writeU8(this.hostType); + bw.writeBytes(this.host); + bw.writeU16(this.port); + bw.writeBytes(this.relayKey); + bw.writeBytes(this.endpointKey); + bw.writeBytes(this.reservationID); + bw.writeU64(this.issuedAt); + bw.writeU64(this.expiresAt); + bw.writeU16(this.maxActiveCircuits); + bw.writeU64(this.maxBytesPerCircuit); + bw.writeU64(this.maxTotalBytes); + bw.writeU16(this.flags); + return bw.render(); + } + + signRelay(privateKey) { + this.relaySignature = sign( + domains.TICKET_RELAY, + this.encodeUnsigned(), + privateKey); + return this; + } + + verifyRelay() { + return verify( + domains.TICKET_RELAY, + this.encodeUnsigned(), + this.relaySignature, + this.relayKey); + } + + endpointData() { + return Buffer.concat([this.encodeUnsigned(), this.relaySignature]); + } + + signEndpoint(privateKey) { + assert(this.verifyRelay()); + this.endpointSignature = sign( + domains.TICKET_ENDPOINT, + this.endpointData(), + privateKey); + return this; + } + + verifyEndpoint() { + return verify( + domains.TICKET_ENDPOINT, + this.endpointData(), + this.endpointSignature, + this.endpointKey); + } + + verify(magic, timestamp = now()) { + if (this.networkMagic !== magic + || this.profile !== profiles.HNS_NODE_V1 + || this.transport !== 0 + || (this.hostType !== 1 && this.hostType !== 2) + || this.port === 0 + || this.flags !== 0 + || this.relayKey.equals(this.endpointKey) + || this.maxActiveCircuits < 1 + || this.maxActiveCircuits > common.hnsr.MAX_CIRCUITS + || this.maxBytesPerCircuit < 1 + || this.maxTotalBytes < this.maxBytesPerCircuit + || this.expiresAt <= this.issuedAt + || this.expiresAt - this.issuedAt > common.hnsr.MAX_TICKET_LIFETIME + || timestamp < this.issuedAt + || timestamp >= this.expiresAt) { + return false; + } + + return this.verifyRelay() && this.verifyEndpoint(); + } + + id() { + return blake2b.digest(this.encode(), 32); + } + + encode() { + const unsigned = this.encodeUnsigned(); + const size = unsigned.length + 2 + + this.relaySignature.length + + this.endpointSignature.length; + const bw = bio.write(size); + bw.writeBytes(unsigned); + writeSignature(bw, this.relaySignature); + writeSignature(bw, this.endpointSignature); + return bw.render(); + } + + static read(br) { + const ticket = new RelayTicket(); + ticket.version = br.readU8(); + ticket.networkMagic = br.readU32(); + ticket.profile = br.readU16(); + ticket.transport = br.readU8(); + ticket.hostType = br.readU8(); + ticket.host = br.readBytes(16); + ticket.port = br.readU16(); + ticket.relayKey = br.readBytes(33); + ticket.endpointKey = br.readBytes(33); + ticket.reservationID = br.readBytes(16); + ticket.issuedAt = br.readU64(); + ticket.expiresAt = br.readU64(); + ticket.maxActiveCircuits = br.readU16(); + ticket.maxBytesPerCircuit = br.readU64(); + ticket.maxTotalBytes = br.readU64(); + ticket.flags = br.readU16(); + ticket.relaySignature = readSignature(br, 'relay ticket'); + ticket.endpointSignature = readSignature(br, 'endpoint ticket'); + ticket.encodeUnsigned(); + return ticket; + } + + static decode(data) { + const br = bio.read(data); + const ticket = RelayTicket.read(br); + finish(br, 'relay ticket'); + return ticket; + } +} + +class EndpointDelegation { + constructor(options = {}) { + this.version = 1; + this.authorizationID = options.authorizationID || ZERO32; + this.endpointKey = options.endpointKey || Buffer.alloc(33); + this.sequence = options.sequence || 1; + this.issuedAt = options.issuedAt || 0; + this.expiresAt = options.expiresAt || 0; + this.maxActiveCircuits = options.maxActiveCircuits || 8; + this.maxBytesPerCircuit = options.maxBytesPerCircuit || 1048576; + this.flags = options.flags || 0; + this.signature = options.signature || EMPTY; + } + + encodeUnsigned() { + assert(this.version === 1); + assert(this.authorizationID.length === 32); + assert(secp256k1.publicKeyVerify(this.endpointKey)); + assertU64(this.sequence, 'endpoint sequence'); + assertU64(this.issuedAt, 'issuedAt'); + assertU64(this.expiresAt, 'expiresAt'); + assert((this.maxActiveCircuits & 0xffff) === this.maxActiveCircuits); + assertU64(this.maxBytesPerCircuit, 'maxBytesPerCircuit'); + assert((this.flags & 0xffff) === this.flags); + + const bw = bio.write(102); + bw.writeU8(this.version); + bw.writeBytes(this.authorizationID); + bw.writeBytes(this.endpointKey); + bw.writeU64(this.sequence); + bw.writeU64(this.issuedAt); + bw.writeU64(this.expiresAt); + bw.writeU16(this.maxActiveCircuits); + bw.writeU64(this.maxBytesPerCircuit); + bw.writeU16(this.flags); + return bw.render(); + } + + sign(privateKey) { + this.signature = sign( + domains.DELEGATION, + this.encodeUnsigned(), + privateKey); + return this; + } + + verify(timestamp = now()) { + if (!isZero(this.authorizationID) + || this.sequence < 1 + || this.expiresAt <= this.issuedAt + || this.expiresAt - this.issuedAt > 604800 + || timestamp < this.issuedAt + || timestamp >= this.expiresAt + || this.maxActiveCircuits < 1 + || this.maxActiveCircuits > common.hnsr.MAX_CIRCUITS + || this.maxBytesPerCircuit < 1 + || this.flags !== 0) { + return false; + } + + return verify( + domains.DELEGATION, + this.encodeUnsigned(), + this.signature, + this.endpointKey); + } + + encode() { + const unsigned = this.encodeUnsigned(); + const bw = bio.write(unsigned.length + 1 + this.signature.length); + bw.writeBytes(unsigned); + writeSignature(bw, this.signature); + return bw.render(); + } + + static decode(data) { + const br = bio.read(data); + const delegation = new EndpointDelegation(); + delegation.version = br.readU8(); + delegation.authorizationID = br.readBytes(32); + delegation.endpointKey = br.readBytes(33); + delegation.sequence = br.readU64(); + delegation.issuedAt = br.readU64(); + delegation.expiresAt = br.readU64(); + delegation.maxActiveCircuits = br.readU16(); + delegation.maxBytesPerCircuit = br.readU64(); + delegation.flags = br.readU16(); + delegation.signature = readSignature(br, 'endpoint delegation'); + finish(br, 'endpoint delegation'); + delegation.encodeUnsigned(); + return delegation; + } +} + +class RouteRecord { + constructor(options = {}) { + this.version = 1; + this.authorityType = 0; + this.routeKey = options.routeKey || Buffer.alloc(32); + this.profile = options.profile || profiles.HNS_NODE_V1; + this.sequence = options.sequence || 1; + this.issuedAt = options.issuedAt || 0; + this.expiresAt = options.expiresAt || 0; + this.authorization = options.authorization || EMPTY; + this.delegation = options.delegation || new EndpointDelegation(); + this.tickets = options.tickets || []; + this.endpointSignature = options.endpointSignature || EMPTY; + } + + encodeUnsigned() { + assert(this.version === 1); + assert(this.authorityType === 0); + assert(this.routeKey.length === 32); + assert(this.profile === profiles.HNS_NODE_V1); + assertU64(this.sequence, 'record sequence'); + assertU64(this.issuedAt, 'issuedAt'); + assertU64(this.expiresAt, 'expiresAt'); + assert(this.authorization.length === 0); + assert(this.tickets.length >= 1 && this.tickets.length <= 8); + + const delegation = this.delegation.encode(); + const encodedTickets = this.tickets.map(ticket => ticket.encode()); + let size = 65 + delegation.length; + + for (const ticket of encodedTickets) + size += ticket.length; + + const bw = bio.write(size); + bw.writeU8(this.version); + bw.writeU8(this.authorityType); + bw.writeBytes(this.routeKey); + bw.writeU16(this.profile); + bw.writeU64(this.sequence); + bw.writeU64(this.issuedAt); + bw.writeU64(this.expiresAt); + bw.writeU16(0); + bw.writeU16(delegation.length); + bw.writeBytes(delegation); + bw.writeU8(encodedTickets.length); + + for (const ticket of encodedTickets) + bw.writeBytes(ticket); + + return bw.render(); + } + + sign(privateKey) { + this.endpointSignature = sign( + domains.ROUTE, + this.encodeUnsigned(), + privateKey); + return this; + } + + verify(magic, timestamp = now()) { + if (this.authorityType !== 0 + || this.profile !== profiles.HNS_NODE_V1 + || this.sequence < 1 + || this.expiresAt <= this.issuedAt + || this.expiresAt - this.issuedAt > common.hnsr.MAX_ROUTE_LIFETIME + || timestamp < this.issuedAt + || timestamp >= this.expiresAt + || this.tickets.length < 1 + || this.tickets.length > 8 + || !this.routeKey.equals(routeKey(magic, this.delegation.endpointKey)) + || !this.delegation.verify(timestamp) + || this.delegation.expiresAt < this.expiresAt) { + return false; + } + + for (const ticket of this.tickets) { + if (!ticket.endpointKey.equals(this.delegation.endpointKey) + || ticket.profile !== this.profile + || ticket.expiresAt < this.expiresAt + || !ticket.verify(magic, timestamp)) { + return false; + } + } + + return verify( + domains.ROUTE, + this.encodeUnsigned(), + this.endpointSignature, + this.delegation.endpointKey); + } + + encode() { + const unsigned = this.encodeUnsigned(); + const bw = bio.write(unsigned.length + 1 + this.endpointSignature.length); + bw.writeBytes(unsigned); + writeSignature(bw, this.endpointSignature); + return bw.render(); + } + + static decode(data) { + if (data.length === 0 || data.length > common.hnsr.MAX_RECORD_SIZE) + throw new Error('Route record exceeds the HNSR limit.'); + + const br = bio.read(data); + const record = new RouteRecord(); + record.version = br.readU8(); + record.authorityType = br.readU8(); + record.routeKey = br.readBytes(32); + record.profile = br.readU16(); + record.sequence = br.readU64(); + record.issuedAt = br.readU64(); + record.expiresAt = br.readU64(); + const authSize = br.readU16(); + record.authorization = br.readBytes(authSize); + const delegationSize = br.readU16(); + record.delegation = EndpointDelegation.decode(br.readBytes(delegationSize)); + const count = br.readU8(); + + if (count < 1 || count > 8) + throw new Error('Invalid HNSR route ticket count.'); + + record.tickets = []; + + for (let i = 0; i < count; i++) + record.tickets.push(RelayTicket.read(br)); + + record.endpointSignature = readSignature(br, 'route record'); + finish(br, 'route record'); + record.encodeUnsigned(); + return record; + } +} + +class RouteStore { + constructor(magic, options = {}) { + this.magic = magic; + this.maxRecords = options.maxRecords || common.hnsr.MAX_STORED_RECORDS; + this.maxPerKey = options.maxPerKey || common.hnsr.MAX_RECORDS_PER_KEY; + this.records = new Map(); + this.size = 0; + } + + put(key, raw, timestamp = now()) { + if (!Buffer.isBuffer(key) || key.length !== 32) + throw new Error('Invalid HNSR route key.'); + + const record = RouteRecord.decode(raw); + + if (!record.routeKey.equals(key) || !record.verify(this.magic, timestamp)) + throw new Error('Invalid HNSR route record.'); + + const hex = key.toString('hex'); + const items = this._active(hex, timestamp); + const endpoint = record.delegation.endpointKey.toString('hex'); + const index = items.findIndex(item => item.endpoint === endpoint); + + if (index !== -1) { + if (items[index].sequence >= record.sequence) + throw new Error('Stale HNSR route sequence.'); + items.splice(index, 1); + this.size -= 1; + } + + if (items.length >= this.maxPerKey) + throw new Error('HNSR per-key route capacity reached.'); + + if (this.size >= this.maxRecords) + throw new Error('HNSR route store capacity reached.'); + + items.push({ + endpoint, + sequence: record.sequence, + expiresAt: record.expiresAt, + raw: Buffer.from(raw) + }); + + this.records.set(hex, items); + this.size += 1; + + return record.expiresAt; + } + + get(key, maximum = 16, timestamp = now()) { + assert(Buffer.isBuffer(key) && key.length === 32); + const items = this._active(key.toString('hex'), timestamp); + items.sort((a, b) => b.sequence - a.sequence); + return items.slice(0, maximum).map(item => Buffer.from(item.raw)); + } + + _active(hex, timestamp) { + const items = this.records.get(hex) || []; + const active = items.filter(item => item.expiresAt > timestamp); + this.size -= items.length - active.length; + + if (active.length === 0) + this.records.delete(hex); + else + this.records.set(hex, active); + + return active; + } +} + +class CircuitSocket extends EventEmitter { + constructor(service, peer, contextID, window) { + super(); + + this.service = service; + this.peer = peer; + this.contextID = Buffer.from(contextID); + this.sendCredit = window; + this.destroyed = false; + this.connected = false; + } + + connect() { + if (this.destroyed || this.connected) + return; + + this.connected = true; + this.emit('connect'); + } + + write(data) { + assert(Buffer.isBuffer(data)); + + if (this.destroyed) + return false; + + if (data.length > this.sendCredit) { + this.destroy(); + this.emit('error', new Error('HNSR circuit flow-control exhausted.')); + return false; + } + + this.sendCredit -= data.length; + + for (let off = 0; off < data.length; off += common.hnsr.MAX_DATA_SIZE) { + const end = Math.min(off + common.hnsr.MAX_DATA_SIZE, data.length); + this.service._send( + this.peer, + opcodes.DATA, + this.contextID, + data.slice(off, end)); + } + + return true; + } + + addCredit(credit) { + if (!Number.isSafeInteger(credit) || credit <= 0) + throw new Error('Invalid HNSR window credit.'); + + if (this.sendCredit + credit > common.hnsr.MAX_WINDOW) + throw new Error('HNSR window exceeds the maximum.'); + + this.sendCredit += credit; + } + + receive(data) { + if (this.destroyed) + return; + + this.emit('data', data); + + const bw = bio.write(4); + bw.writeU32(data.length); + this.service._send( + this.peer, + opcodes.WINDOW, + this.contextID, + bw.render()); + } + + remoteClose() { + if (this.destroyed) + return; + + this.destroyed = true; + this.emit('close'); + } + + destroy() { + if (this.destroyed) + return; + + this.destroyed = true; + const bw = bio.write(3); + bw.writeU16(errors.NORMAL); + bw.writeU8(0); + this.service._send( + this.peer, + opcodes.CLOSE, + this.contextID, + bw.render()); + this.service._dropSocket(this.peer, this.contextID); + this.emit('close'); + } + + end() { + this.destroy(); + } +} + +class HNSRService extends EventEmitter { + constructor(options) { + super(); + + assert(options); + assert(options.network); + assert(Buffer.isBuffer(options.identityKey)); + assert(secp256k1.privateKeyVerify(options.identityKey)); + + this.network = options.network; + this.identityKey = options.identityKey; + this.publicKey = secp256k1.publicKeyCreate(this.identityKey, true); + this.logger = options.logger && options.logger.context + ? options.logger.context('hnsr') + : options.logger; + this.enabled = options.enabled === true; + this.rendezvous = options.rendezvous === true; + this.relay = options.relay === true; + this.endpoint = options.endpoint === true; + this.relayHost = options.relayHost || Buffer.from([ + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0xff, 0xff, 127, 0, 0, 1 + ]); + this.relayPort = options.relayPort || this.network.port; + this.timeout = options.timeout || common.hnsr.DEFAULT_TIMEOUT; + this.opened = false; + this.store = new RouteStore(this.network.magic, options.storeOptions); + this.pending = new Map(); + this.provisional = new Map(); + this.reservations = new Map(); + this.endpointTickets = new Map(); + this.opening = new Map(); + this.relayCircuits = new Map(); + this.sockets = new Map(); + this.relayBytes = 0; + this.relayPayloads = []; + } + + open() { + this.opened = true; + } + + close() { + this.opened = false; + + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('HNSR service closed.')); + } + + this.pending.clear(); + + for (const socket of this.sockets.values()) + socket.remoteClose(); + + this.sockets.clear(); + + for (const state of this.opening.values()) + clearTimeout(state.timer); + + this.opening.clear(); + this.relayCircuits.clear(); + this.provisional.clear(); + this.reservations.clear(); + this.endpointTickets.clear(); + } + + isReady() { + return this.enabled && this.opened; + } + + services() { + let bits = 0; + + if (this.enabled && this.rendezvous) + bits |= common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE; + + if (this.enabled && this.relay) + bits |= common.EXPERIMENTAL_HNSR_RELAY_SERVICE; + + return bits >>> 0; + } + + _send(peer, opcode, contextID, body) { + if (!this.enabled || !peer || peer.destroyed) + return false; + + assert(Buffer.isBuffer(contextID) && contextID.length === 8); + assert(!isZero(contextID)); + assert(Buffer.isBuffer(body)); + assert(body.length <= common.hnsr.MAX_PACKET_SIZE - 12); + + peer.send(new packets.HNSRPacket(1, opcode, contextID, body)); + return true; + } + + _request(peer, opcode, body, expected, contextID = randomID(8)) { + assert(Array.isArray(expected)); + + const key = peerKey(peer, contextID); + + if (this.pending.has(key)) + return Promise.reject(new Error('Duplicate live HNSR context ID.')); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(key); + reject(new Error('HNSR request timed out.')); + }, this.timeout); + + this.pending.set(key, { + expected: new Set(expected), + resolve, + reject, + timer + }); + + if (!this._send(peer, opcode, contextID, body)) { + clearTimeout(timer); + this.pending.delete(key); + reject(new Error('HNSR peer is unavailable.')); + } + }); + } + + _resolvePending(peer, packet) { + const key = peerKey(peer, packet.contextID); + const pending = this.pending.get(key); + + if (!pending) + return false; + + if (packet.opcode === opcodes.ERROR) { + clearTimeout(pending.timer); + this.pending.delete(key); + + try { + const br = bio.read(packet.body); + const reason = br.readU16(); + const size = br.readU8(); + const detail = br.readString(size, 'utf8'); + finish(br, 'HNSR error'); + const err = new Error(detail || `HNSR error ${reason}.`); + err.code = reason; + pending.reject(err); + } catch (e) { + pending.reject(e); + } + + return true; + } + + if (!pending.expected.has(packet.opcode)) + return false; + + clearTimeout(pending.timer); + this.pending.delete(key); + pending.resolve(packet); + return true; + } + + _sendError(peer, contextID, reason, detail) { + const message = Buffer.from(detail || '', 'utf8').slice(0, 128); + const bw = bio.write(3 + message.length); + bw.writeU16(reason); + bw.writeU8(message.length); + bw.writeBytes(message); + this._send(peer, opcodes.ERROR, contextID, bw.render()); + } + + async handle(peer, packet) { + if (!this.enabled) + return; + + if (this._resolvePending(peer, packet)) + return; + + try { + switch (packet.opcode) { + case opcodes.PUTROUTE: + this._handlePutRoute(peer, packet); + break; + case opcodes.GETROUTE: + this._handleGetRoute(peer, packet); + break; + case opcodes.RESERVE: + this._handleReserve(peer, packet); + break; + case opcodes.CONFIRM: + this._handleConfirm(peer, packet); + break; + case opcodes.OPEN: + this._handleOpen(peer, packet); + break; + case opcodes.INCOMING: + this._handleIncoming(peer, packet); + break; + case opcodes.ACCEPT: + this._handleAccept(peer, packet); + break; + case opcodes.DATA: + this._handleData(peer, packet); + break; + case opcodes.WINDOW: + this._handleWindow(peer, packet); + break; + case opcodes.CLOSE: + this._handleClose(peer, packet); + break; + case opcodes.OFFER: + case opcodes.CONFIRMED: + case opcodes.PUTRESULT: + case opcodes.ROUTES: + case opcodes.OPENED: + case opcodes.ERROR: + this.emit('unsolicited response', peer, packet); + return; + default: + this._sendError( + peer, + packet.contextID, + errors.UNSUPPORTED, + 'HNSR opcode is not implemented by this PoC.'); + break; + } + } catch (e) { + this.emit('protocol error', e, peer, packet); + this._sendError(peer, packet.contextID, errors.INVALID, e.message); + } + } + + async reserve(peer, options = {}) { + if (!this.enabled || !this.endpoint) + throw new Error('HNSR endpoint role is disabled.'); + + if (!(peer.services & common.EXPERIMENTAL_HNSR_RELAY_SERVICE)) + throw new Error('Peer does not advertise the HNSR relay role.'); + + const relayKey = options.relayKey + || (peer.address && peer.address.key); + + if (!Buffer.isBuffer(relayKey) || !secp256k1.publicKeyVerify(relayKey)) + throw new Error('Authenticated HNSR relay key is required.'); + + const contextID = randomID(8); + const request = new ReserveRequest({ + endpointKey: this.publicKey, + profile: options.profile || profiles.HNS_NODE_V1, + lifetime: options.lifetime || 1800, + maxCircuits: options.maxCircuits || 8, + maxBytes: options.maxBytes || 1048576, + nonce: randomID(16) + }); + + request.sign(this.network.magic, relayKey, contextID, this.identityKey); + + const offered = await this._request( + peer, + opcodes.RESERVE, + request.encode(), + [opcodes.OFFER], + contextID); + const ticket = RelayTicket.decode(offered.body); + + if (!ticket.relayKey.equals(relayKey) + || !ticket.endpointKey.equals(this.publicKey) + || ticket.networkMagic !== this.network.magic + || ticket.endpointSignature.length !== 0 + || !ticket.verifyRelay() + || ticket.expiresAt <= now()) { + throw new Error('Invalid HNSR relay offer.'); + } + + ticket.signEndpoint(this.identityKey); + + const bw = bio.write(17 + ticket.endpointSignature.length); + bw.writeBytes(ticket.reservationID); + writeSignature(bw, ticket.endpointSignature); + + const confirmed = await this._request( + peer, + opcodes.CONFIRM, + bw.render(), + [opcodes.CONFIRMED], + contextID); + const br = bio.read(confirmed.body); + const reservationID = br.readBytes(16); + const ticketID = br.readBytes(32); + const expiresAt = br.readU64(); + finish(br, 'HNSR confirmation'); + + if (!reservationID.equals(ticket.reservationID) + || !ticketID.equals(ticket.id()) + || expiresAt !== ticket.expiresAt) { + throw new Error('Mismatched HNSR confirmation.'); + } + + this.endpointTickets.set(ticketID.toString('hex'), {peer, ticket}); + return ticket; + } + + async publish(peer, tickets, options = {}) { + if (!this.enabled || !this.endpoint) + throw new Error('HNSR endpoint role is disabled.'); + + if (!(peer.services & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE)) + throw new Error('Peer does not advertise the HNSR rendezvous role.'); + + assert(Array.isArray(tickets) && tickets.length > 0); + + const timestamp = now(); + const expiresAt = Math.min( + timestamp + (options.lifetime || 900), + ...tickets.map(ticket => ticket.expiresAt)); + const delegation = new EndpointDelegation({ + endpointKey: this.publicKey, + sequence: options.endpointSequence || 1, + issuedAt: timestamp, + expiresAt, + maxActiveCircuits: Math.min( + ...tickets.map(ticket => ticket.maxActiveCircuits)), + maxBytesPerCircuit: Math.min( + ...tickets.map(ticket => ticket.maxBytesPerCircuit)) + }).sign(this.identityKey); + const key = routeKey(this.network.magic, this.publicKey); + const record = new RouteRecord({ + routeKey: key, + profile: profiles.HNS_NODE_V1, + sequence: options.sequence || 1, + issuedAt: timestamp, + expiresAt, + delegation, + tickets + }).sign(this.identityKey); + const raw = record.encode(); + + if (raw.length > common.hnsr.MAX_RECORD_SIZE) + throw new Error('HNSR route record exceeds the storage limit.'); + + const bw = bio.write(34 + raw.length); + bw.writeBytes(key); + bw.writeU16(raw.length); + bw.writeBytes(raw); + + const result = await this._request( + peer, + opcodes.PUTROUTE, + bw.render(), + [opcodes.PUTRESULT]); + const br = bio.read(result.body); + const status = br.readU16(); + const storedUntil = br.readU64(); + finish(br, 'HNSR put result'); + + if (status !== 0 || storedUntil !== record.expiresAt) + throw new Error(`HNSR rendezvous store rejected route (${status}).`); + + return record; + } + + async lookup(peer, key, maximum = 16) { + if (!this.enabled) + throw new Error('HNSR is disabled.'); + + if (!(peer.services & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE)) + throw new Error('Peer does not advertise the HNSR rendezvous role.'); + + if (!Buffer.isBuffer(key) || key.length !== 32) + throw new Error('Invalid HNSR route key.'); + + if (maximum < 1 || maximum > 16) + throw new Error('Invalid HNSR route result limit.'); + + const bw = bio.write(33); + bw.writeBytes(key); + bw.writeU8(maximum); + const response = await this._request( + peer, + opcodes.GETROUTE, + bw.render(), + [opcodes.ROUTES]); + const br = bio.read(response.body); + const count = br.readU8(); + + if (count > maximum) + throw new Error('Too many HNSR route records returned.'); + + const records = []; + + for (let i = 0; i < count; i++) { + const size = br.readU16(); + + if (size === 0 || size > common.hnsr.MAX_RECORD_SIZE) + throw new Error('Invalid returned HNSR record length.'); + + const record = RouteRecord.decode(br.readBytes(size)); + + if (!record.routeKey.equals(key) + || !record.verify(this.network.magic)) { + throw new Error('Rendezvous peer returned an invalid HNSR record.'); + } + + records.push(record); + } + + finish(br, 'HNSR routes response'); + return records; + } + + async openCircuit(peer, ticket, options = {}) { + if (!this.enabled) + throw new Error('HNSR is disabled.'); + + if (!(peer.services & common.EXPERIMENTAL_HNSR_RELAY_SERVICE)) + throw new Error('Peer does not advertise the HNSR relay role.'); + + if (!ticket.verify(this.network.magic)) + throw new Error('Invalid or expired HNSR relay ticket.'); + + const window = options.window || common.hnsr.DEFAULT_WINDOW; + + if (window < common.hnsr.MIN_WINDOW || window > common.hnsr.MAX_WINDOW) + throw new Error('Invalid HNSR initial window.'); + + const bw = bio.write(103); + bw.writeBytes(ticket.id()); + bw.writeBytes(ticket.reservationID); + bw.writeBytes(ticket.endpointKey); + bw.writeU16(ticket.profile); + bw.writeBytes(randomID(16)); + bw.writeU32(window); + const response = await this._request( + peer, + opcodes.OPEN, + bw.render(), + [opcodes.OPENED]); + const br = bio.read(response.body); + const circuitID = br.readBytes(8); + const acceptedWindow = br.readU32(); + const endpointNonce = br.readBytes(16); + finish(br, 'HNSR opened response'); + + if (isZero(circuitID) + || isZero(endpointNonce) + || acceptedWindow < common.hnsr.MIN_WINDOW + || acceptedWindow > window) { + throw new Error('Invalid HNSR opened response.'); + } + + const socket = new CircuitSocket(this, peer, circuitID, acceptedWindow); + this.sockets.set(peerKey(peer, circuitID), socket); + setImmediate(() => socket.connect()); + + return {socket, circuitID, endpointNonce}; + } + + _handleReserve(peer, packet) { + if (!this.relay) + throw new Error('HNSR relay role is disabled.'); + + const request = ReserveRequest.decode(packet.body); + + if (!request.verify( + this.network.magic, + this.publicKey, + packet.contextID)) { + throw new Error('Invalid HNSR reservation signature.'); + } + + if (request.profile !== profiles.HNS_NODE_V1 + || request.lifetime < 300 + || request.lifetime > common.hnsr.MAX_TICKET_LIFETIME + || request.maxCircuits < 1 + || request.maxCircuits > common.hnsr.MAX_CIRCUITS + || request.maxBytes < 1 + || request.maxBytes > 67108864) { + throw new Error('HNSR reservation exceeds PoC policy.'); + } + + let count = 0; + + for (const item of this.provisional.values()) { + if (item.peer === peer) + count += 1; + } + + if (count >= 2) + throw new Error('HNSR provisional reservation capacity reached.'); + + const timestamp = now(); + const ticket = new RelayTicket({ + networkMagic: this.network.magic, + profile: request.profile, + transport: 0, + hostType: 1, + host: this.relayHost, + port: this.relayPort, + relayKey: this.publicKey, + endpointKey: request.endpointKey, + reservationID: randomID(16), + issuedAt: timestamp, + expiresAt: timestamp + request.lifetime, + maxActiveCircuits: request.maxCircuits, + maxBytesPerCircuit: request.maxBytes, + maxTotalBytes: request.maxBytes * request.maxCircuits, + flags: 0 + }).signRelay(this.identityKey); + const key = ticket.reservationID.toString('hex'); + + this.provisional.set(key, {peer, ticket}); + this._send(peer, opcodes.OFFER, packet.contextID, ticket.encode()); + } + + _handleConfirm(peer, packet) { + if (!this.relay) + throw new Error('HNSR relay role is disabled.'); + + const br = bio.read(packet.body); + const reservationID = br.readBytes(16); + const endpointSignature = readSignature(br, 'reservation confirmation'); + finish(br, 'HNSR reservation confirmation'); + const key = reservationID.toString('hex'); + const item = this.provisional.get(key); + + if (!item || item.peer !== peer) + throw new Error('Unknown HNSR provisional reservation.'); + + const ticket = item.ticket; + ticket.endpointSignature = endpointSignature; + + if (!ticket.verify(this.network.magic)) + throw new Error('Invalid HNSR endpoint confirmation.'); + + this.provisional.delete(key); + this.reservations.set(key, { + peer, + ticket, + activeCircuits: 0, + totalBytes: 0 + }); + + const bw = bio.write(56); + bw.writeBytes(ticket.reservationID); + bw.writeBytes(ticket.id()); + bw.writeU64(ticket.expiresAt); + this._send(peer, opcodes.CONFIRMED, packet.contextID, bw.render()); + } + + _handlePutRoute(peer, packet) { + if (!this.rendezvous) + throw new Error('HNSR rendezvous role is disabled.'); + + const br = bio.read(packet.body); + const key = br.readBytes(32); + const size = br.readU16(); + + if (size === 0 || size > common.hnsr.MAX_RECORD_SIZE || br.left() !== size) + throw new Error('Invalid HNSR put-route length.'); + + const raw = br.readBytes(size); + let status = 0; + let storedUntil = 0; + + try { + storedUntil = this.store.put(key, raw); + } catch (e) { + status = errors.INVALID; + this.emit('store reject', e, peer); + } + + const bw = bio.write(10); + bw.writeU16(status); + bw.writeU64(storedUntil); + this._send(peer, opcodes.PUTRESULT, packet.contextID, bw.render()); + } + + _handleGetRoute(peer, packet) { + if (!this.rendezvous) + throw new Error('HNSR rendezvous role is disabled.'); + + const br = bio.read(packet.body); + const key = br.readBytes(32); + const maximum = br.readU8(); + finish(br, 'HNSR get-route request'); + + if (maximum < 1 || maximum > 16) + throw new Error('Invalid HNSR route result limit.'); + + const records = this.store.get(key, maximum); + let size = 1; + + for (const raw of records) + size += 2 + raw.length; + + if (size > common.hnsr.MAX_PACKET_SIZE - 12) + throw new Error('HNSR routes response exceeds packet limit.'); + + const bw = bio.write(size); + bw.writeU8(records.length); + + for (const raw of records) { + bw.writeU16(raw.length); + bw.writeBytes(raw); + } + + this._send(peer, opcodes.ROUTES, packet.contextID, bw.render()); + } + + _handleOpen(peer, packet) { + if (!this.relay) + throw new Error('HNSR relay role is disabled.'); + + const br = bio.read(packet.body); + const ticketID = br.readBytes(32); + const reservationID = br.readBytes(16); + const endpointKey = br.readBytes(33); + const profile = br.readU16(); + const requesterNonce = br.readBytes(16); + const initialWindow = br.readU32(); + finish(br, 'HNSR open request'); + + if (isZero(requesterNonce) + || initialWindow < common.hnsr.MIN_WINDOW + || initialWindow > common.hnsr.MAX_WINDOW) { + throw new Error('Invalid HNSR open parameters.'); + } + + const reservation = this.reservations.get( + reservationID.toString('hex')); + + if (!reservation + || reservation.ticket.expiresAt <= now() + || !reservation.ticket.id().equals(ticketID) + || !reservation.ticket.endpointKey.equals(endpointKey) + || reservation.ticket.profile !== profile + || reservation.activeCircuits + >= reservation.ticket.maxActiveCircuits + || reservation.peer.destroyed) { + this._sendError( + peer, + packet.contextID, + errors.ENDPOINT_GONE, + 'HNSR reservation is unavailable.'); + return; + } + + const circuitID = randomID(8); + const key = peerKey(reservation.peer, circuitID); + const state = { + requesterPeer: peer, + requesterContext: Buffer.from(packet.contextID), + endpointPeer: reservation.peer, + circuitID, + ticket: reservation.ticket, + reservation, + requesterNonce, + initialWindow, + totalBytes: 0, + timer: null + }; + + state.timer = setTimeout(() => { + this.opening.delete(key); + this._sendError( + peer, + packet.contextID, + errors.TIMEOUT, + 'HNSR endpoint did not accept the circuit.'); + }, 10000); + + this.opening.set(key, state); + + const bw = bio.write(62); + bw.writeBytes(ticketID); + bw.writeBytes(packet.contextID); + bw.writeU16(profile); + bw.writeBytes(requesterNonce); + bw.writeU32(initialWindow); + this._send( + reservation.peer, + opcodes.INCOMING, + circuitID, + bw.render()); + } + + _handleIncoming(peer, packet) { + if (!this.endpoint) + throw new Error('HNSR endpoint role is disabled.'); + + const br = bio.read(packet.body); + const ticketID = br.readBytes(32); + const openRequestID = br.readBytes(8); + const profile = br.readU16(); + const requesterNonce = br.readBytes(16); + const initialWindow = br.readU32(); + finish(br, 'HNSR incoming request'); + const item = this.endpointTickets.get(ticketID.toString('hex')); + + if (!item + || item.peer !== peer + || item.ticket.profile !== profile + || item.ticket.expiresAt <= now() + || isZero(openRequestID) + || isZero(requesterNonce) + || initialWindow < common.hnsr.MIN_WINDOW + || initialWindow > common.hnsr.MAX_WINDOW) { + throw new Error('Invalid HNSR incoming circuit.'); + } + + const endpointNonce = randomID(16); + const socket = new CircuitSocket( + this, + peer, + packet.contextID, + initialWindow); + this.sockets.set(peerKey(peer, packet.contextID), socket); + this.emit('circuit', socket, { + circuitID: Buffer.from(packet.contextID), + ticket: item.ticket, + requesterNonce, + endpointNonce, + profile + }); + + const bw = bio.write(20); + bw.writeU32(initialWindow); + bw.writeBytes(endpointNonce); + this._send(peer, opcodes.ACCEPT, packet.contextID, bw.render()); + } + + _handleAccept(peer, packet) { + if (!this.relay) + throw new Error('HNSR relay role is disabled.'); + + const key = peerKey(peer, packet.contextID); + const state = this.opening.get(key); + + if (!state || state.endpointPeer !== peer) + throw new Error('Unknown HNSR pending circuit.'); + + const br = bio.read(packet.body); + const acceptedWindow = br.readU32(); + const endpointNonce = br.readBytes(16); + finish(br, 'HNSR accept response'); + + if (acceptedWindow < common.hnsr.MIN_WINDOW + || acceptedWindow > state.initialWindow + || isZero(endpointNonce)) { + throw new Error('Invalid HNSR accept response.'); + } + + clearTimeout(state.timer); + this.opening.delete(key); + state.reservation.activeCircuits += 1; + state.acceptedWindow = acceptedWindow; + state.endpointNonce = endpointNonce; + state.credit = { + requester: acceptedWindow, + endpoint: acceptedWindow + }; + this.relayCircuits.set( + peerKey(state.requesterPeer, state.circuitID), + {state, other: state.endpointPeer, side: 'requester'}); + this.relayCircuits.set( + peerKey(state.endpointPeer, state.circuitID), + {state, other: state.requesterPeer, side: 'endpoint'}); + + const bw = bio.write(28); + bw.writeBytes(state.circuitID); + bw.writeU32(acceptedWindow); + bw.writeBytes(endpointNonce); + this._send( + state.requesterPeer, + opcodes.OPENED, + state.requesterContext, + bw.render()); + } + + _handleData(peer, packet) { + if (packet.body.length === 0 + || packet.body.length > common.hnsr.MAX_DATA_SIZE) { + throw new Error('Invalid HNSR DATA size.'); + } + + const key = peerKey(peer, packet.contextID); + const circuit = this.relayCircuits.get(key); + + if (circuit) { + if (packet.body.length > circuit.state.credit[circuit.side]) { + this._closeRelayCircuit(circuit.state, errors.FLOW_CONTROL); + return; + } + + circuit.state.credit[circuit.side] -= packet.body.length; + circuit.state.totalBytes += packet.body.length; + circuit.state.reservation.totalBytes += packet.body.length; + + if (circuit.state.totalBytes + > circuit.state.ticket.maxBytesPerCircuit + || circuit.state.reservation.totalBytes + > circuit.state.ticket.maxTotalBytes) { + this._closeRelayCircuit(circuit.state, errors.BYTE_LIMIT); + return; + } + + this.relayBytes += packet.body.length; + + if (this.relayPayloads.length < 128) + this.relayPayloads.push(Buffer.from(packet.body)); + + this._send( + circuit.other, + opcodes.DATA, + packet.contextID, + packet.body); + return; + } + + const socket = this.sockets.get(key); + + if (!socket) + throw new Error('Unknown HNSR circuit DATA.'); + + socket.receive(Buffer.from(packet.body)); + } + + _handleWindow(peer, packet) { + const br = bio.read(packet.body); + const credit = br.readU32(); + finish(br, 'HNSR window update'); + + if (credit === 0 || credit > common.hnsr.MAX_WINDOW) + throw new Error('Invalid HNSR window credit.'); + + const key = peerKey(peer, packet.contextID); + const circuit = this.relayCircuits.get(key); + + if (circuit) { + const side = circuit.side === 'requester' ? 'endpoint' : 'requester'; + + if (circuit.state.credit[side] + credit > common.hnsr.MAX_WINDOW) { + this._closeRelayCircuit(circuit.state, errors.FLOW_CONTROL); + return; + } + + circuit.state.credit[side] += credit; + this._send( + circuit.other, + opcodes.WINDOW, + packet.contextID, + packet.body); + return; + } + + const socket = this.sockets.get(key); + + if (!socket) + throw new Error('Unknown HNSR circuit window.'); + + socket.addCredit(credit); + } + + _handleClose(peer, packet) { + const br = bio.read(packet.body); + br.readU16(); + const size = br.readU8(); + + if (size > 128) + throw new Error('HNSR close detail exceeds the limit.'); + + br.readBytes(size); + finish(br, 'HNSR close'); + const key = peerKey(peer, packet.contextID); + const circuit = this.relayCircuits.get(key); + + if (circuit) { + this._send( + circuit.other, + opcodes.CLOSE, + packet.contextID, + packet.body); + this._dropRelayCircuit(circuit.state); + return; + } + + const socket = this.sockets.get(key); + + if (socket) { + this.sockets.delete(key); + socket.remoteClose(); + } + } + + _closeRelayCircuit(state, reason) { + const bw = bio.write(3); + bw.writeU16(reason); + bw.writeU8(0); + const body = bw.render(); + this._send(state.requesterPeer, opcodes.CLOSE, state.circuitID, body); + this._send(state.endpointPeer, opcodes.CLOSE, state.circuitID, body); + this._dropRelayCircuit(state); + } + + _dropRelayCircuit(state) { + this.relayCircuits.delete(peerKey(state.requesterPeer, state.circuitID)); + this.relayCircuits.delete(peerKey(state.endpointPeer, state.circuitID)); + + if (state.reservation.activeCircuits > 0) + state.reservation.activeCircuits -= 1; + } + + _dropSocket(peer, contextID) { + this.sockets.delete(peerKey(peer, contextID)); + } + + cancelPeer(peer) { + const prefix = `${peer.id}:`; + + for (const [key, pending] of this.pending) { + if (!key.startsWith(prefix)) + continue; + clearTimeout(pending.timer); + pending.reject(new Error('HNSR peer disconnected.')); + this.pending.delete(key); + } + + for (const [key, item] of this.provisional) { + if (item.peer === peer) + this.provisional.delete(key); + } + + for (const [key, item] of this.reservations) { + if (item.peer === peer) + this.reservations.delete(key); + } + + for (const [key, item] of this.endpointTickets) { + if (item.peer === peer) + this.endpointTickets.delete(key); + } + + for (const [key, state] of this.opening) { + if (state.requesterPeer !== peer && state.endpointPeer !== peer) + continue; + clearTimeout(state.timer); + this.opening.delete(key); + } + + const states = new Set(); + + for (const circuit of this.relayCircuits.values()) { + if (circuit.state.requesterPeer === peer + || circuit.state.endpointPeer === peer) { + states.add(circuit.state); + } + } + + for (const state of states) + this._closeRelayCircuit(state, errors.ENDPOINT_GONE); + + for (const [key, socket] of this.sockets) { + if (!key.startsWith(prefix)) + continue; + this.sockets.delete(key); + socket.remoteClose(); + } + } +} + +exports.opcodes = opcodes; +exports.errors = errors; +exports.profiles = profiles; +exports.routeKey = routeKey; +exports.ReserveRequest = ReserveRequest; +exports.RelayTicket = RelayTicket; +exports.EndpointDelegation = EndpointDelegation; +exports.RouteRecord = RouteRecord; +exports.RouteStore = RouteStore; +exports.CircuitSocket = CircuitSocket; +exports.HNSRService = HNSRService; diff --git a/lib/net/index.js b/lib/net/index.js index 5e357201d2..1b783ba721 100644 --- a/lib/net/index.js +++ b/lib/net/index.js @@ -12,6 +12,7 @@ exports.bip152 = require('./bip152'); exports.common = require('./common'); +exports.hnsr = require('./hnsr'); exports.Framer = require('./framer'); exports.HostList = require('./hostlist'); exports.NetAddress = require('./netaddress'); diff --git a/lib/net/packets.js b/lib/net/packets.js index 7efc4bcb92..0bfa7e2c68 100644 --- a/lib/net/packets.js +++ b/lib/net/packets.js @@ -76,7 +76,9 @@ exports.types = { UNKNOWN: 30, // Internal INTERNAL: 31, - DATA: 32 + DATA: 32, + // Private regtest-only proof-of-concept assignment. + EXPERIMENTAL_HNSR: common.EXPERIMENTAL_HNSR }; const types = exports.types; @@ -124,6 +126,8 @@ exports.typesByVal = [ 'DATA' ]; +exports.typesByVal[exports.types.EXPERIMENTAL_HNSR] = 'EXPERIMENTAL_HNSR'; + /** * Base Packet */ @@ -1794,6 +1798,87 @@ class AirdropPacket extends Packet { } } +/** + * Experimental HNSR Packet + * @extends Packet + * @property {Number} version + * @property {Number} opcode + * @property {Number} flags + * @property {Buffer} contextID + * @property {Buffer} body + */ + +class HNSRPacket extends Packet { + /** + * Create a regtest-only HNSR packet. + * @constructor + * @param {Number?} version + * @param {Number?} opcode + * @param {Buffer?} contextID + * @param {Buffer?} body + */ + + constructor(version, opcode, contextID, body) { + super(); + + this.type = exports.types.EXPERIMENTAL_HNSR; + this.version = version != null ? version : common.hnsr.VERSION; + this.opcode = opcode != null ? opcode : 0; + this.flags = 0; + this.contextID = contextID || Buffer.alloc(8); + this.body = body || DUMMY; + } + + getSize() { + return 12 + this.body.length; + } + + write(bw) { + assert((this.version & 0xff) === this.version); + assert((this.opcode & 0xff) === this.opcode); + assert((this.flags & 0xffff) === this.flags); + assert(this.flags === 0); + assert(Buffer.isBuffer(this.contextID)); + assert(this.contextID.length === 8); + assert(Buffer.isBuffer(this.body)); + assert(this.body.length <= common.hnsr.MAX_PACKET_SIZE - 12); + + bw.writeU8(this.version); + bw.writeU8(this.opcode); + bw.writeU16(this.flags); + bw.writeBytes(this.contextID); + bw.writeBytes(this.body); + + return bw; + } + + read(br) { + if (br.left() < 12) + throw new Error('Truncated HNSR envelope.'); + + this.version = br.readU8(); + this.opcode = br.readU8(); + this.flags = br.readU16(); + this.contextID = br.readBytes(8); + + if (this.version !== common.hnsr.VERSION) + throw new Error('Unknown HNSR version.'); + + if (this.flags !== 0) + throw new Error('Reserved HNSR flags are set.'); + + if (this.opcode > 20) + throw new Error('Unknown HNSR opcode.'); + + if (isZeroID(this.contextID) && this.opcode !== 6) + throw new Error('HNSR context ID is zero.'); + + this.body = br.readBytes(br.left()); + + return this; + } +} + /** * Unknown Packet * @extends Packet @@ -1922,11 +2007,22 @@ exports.decode = function decode(type, data) { return ClaimPacket.decode(data); case types.AIRDROP: return AirdropPacket.decode(data); + case types.EXPERIMENTAL_HNSR: + return HNSRPacket.decode(data); default: return UnknownPacket.decode(data, type); } }; +function isZeroID(id) { + for (const ch of id) { + if (ch !== 0) + return false; + } + + return true; +} + /* * Expose */ @@ -1962,4 +2058,5 @@ exports.GetProofPacket = GetProofPacket; exports.ProofPacket = ProofPacket; exports.ClaimPacket = ClaimPacket; exports.AirdropPacket = AirdropPacket; +exports.HNSRPacket = HNSRPacket; exports.UnknownPacket = UnknownPacket; diff --git a/lib/net/parser.js b/lib/net/parser.js index dbbbd9d8ea..6e0f69e180 100644 --- a/lib/net/parser.js +++ b/lib/net/parser.js @@ -127,8 +127,14 @@ class Parser extends EventEmitter { const type = data[4]; const size = data.readUInt32LE(5, true); + let maxSize = common.MAX_MESSAGE; - if (size > common.MAX_MESSAGE) { + if (type === packets.types.EXPERIMENTAL_HNSR) + maxSize = common.hnsr.MAX_PACKET_SIZE; + + if (size > maxSize) { + this.pending.length = 0; + this.total = 0; this.waiting = 9; this.error('Packet length too large: %d.', size); return null; diff --git a/lib/net/pool.js b/lib/net/pool.js index b0bd6ee1af..35c8412128 100644 --- a/lib/net/pool.js +++ b/lib/net/pool.js @@ -63,6 +63,7 @@ class Pool extends EventEmitter { this.logger = this.options.logger.context('net'); this.chain = this.options.chain; this.mempool = this.options.mempool; + this.hnsr = null; this.server = this.options.createServer(); this.brontide = this.options.createServer(); this.nonces = this.options.nonces; @@ -106,6 +107,21 @@ class Pool extends EventEmitter { this.init(); } + /** + * Attach the optional regtest HNSR service before the pool opens. + * @param {HNSRService} service + * @returns {Pool} + */ + + setHNSR(service) { + assert(!this.opened, 'Cannot attach HNSR to an open pool.'); + assert(service && typeof service.handle === 'function'); + assert(typeof service.cancelPeer === 'function'); + + this.hnsr = service; + return this; + } + /** * Initialize the pool. * @private @@ -1335,6 +1351,9 @@ class Pool extends EventEmitter { case packetTypes.AIRDROP: await this.handleAirdrop(peer, packet); break; + case packetTypes.EXPERIMENTAL_HNSR: + await this.handleHNSR(peer, packet); + break; case packetTypes.UNKNOWN: await this.handleUnknown(peer, packet); break; @@ -1439,6 +1458,9 @@ class Pool extends EventEmitter { const loader = peer.loader; const size = peer.blockMap.size; + if (this.hnsr) + this.hnsr.cancelPeer(peer); + this.removePeer(peer); if (loader) { @@ -2772,6 +2794,23 @@ class Pool extends EventEmitter { } } + /** + * Handle one experimental HNSR envelope. + * @method + * @private + * @param {Peer} peer + * @param {HNSRPacket} packet + */ + + async handleHNSR(peer, packet) { + if (!this.hnsr || !this.hnsr.isReady()) { + this.logger.debug('Ignoring HNSR packet from %s.', peer.hostname()); + return; + } + + await this.hnsr.handle(peer, packet); + } + /** * Handle an airdrop proof. Attempt to add to mempool (without a lock). * @method diff --git a/lib/node/fullnode.js b/lib/node/fullnode.js index 801fe7537e..f491a3cd70 100644 --- a/lib/node/fullnode.js +++ b/lib/node/fullnode.js @@ -18,6 +18,8 @@ const RPC = require('./rpc'); const blockstore = require('../blockstore'); const pkg = require('../pkg'); const {RootServer, RecursiveServer} = require('../dns/server'); +const {HNSRService} = require('../net/hnsr'); +const netCommon = require('../net/common'); /** * Full Node @@ -188,6 +190,48 @@ class FullNode extends Node { } } + const hnsrRendezvous = this.config.bool( + 'experimental-hnsr-rendezvous'); + const hnsrRelay = this.config.bool('experimental-hnsr-relay'); + const hnsrEndpoint = this.config.bool('experimental-hnsr-endpoint'); + const hnsrEnabled = this.config.bool('experimental-hnsr') + || hnsrRendezvous + || hnsrRelay + || hnsrEndpoint; + + if (hnsrEnabled && this.network.type !== 'regtest') { + throw new Error( + 'The private HNSR proof of concept is regtest-only.'); + } + + if (hnsrRelay && !this.pool.options.listen) { + throw new Error( + 'The HNSR relay role requires an ordinary listening peer.'); + } + + this.hnsr = new HNSRService({ + enabled: hnsrEnabled, + rendezvous: hnsrRendezvous, + relay: hnsrRelay, + endpoint: hnsrEndpoint, + identityKey: this.identityKey, + network: this.network, + logger: this.logger, + relayPort: this.pool.options.publicBrontidePort, + timeout: this.config.uint( + 'experimental-hnsr-timeout', + netCommon.hnsr.DEFAULT_TIMEOUT) + }); + + this.pool.setHNSR(this.hnsr); + + const hnsrServices = this.hnsr.services(); + this.pool.options.services |= hnsrServices; + this.pool.options.services >>>= 0; + this.pool.hosts.options.services = this.pool.options.services; + this.pool.hosts.address.services = this.pool.options.services; + this.pool.hosts.brontide.services = this.pool.options.services; + this.init(); } @@ -203,6 +247,7 @@ class FullNode extends Node { this.mempool.on('error', err => this.error(err)); this.pool.on('error', err => this.error(err)); + this.hnsr.on('error', err => this.error(err)); this.miner.on('error', err => this.error(err)); if (this.http) @@ -296,6 +341,7 @@ class FullNode extends Node { await this.mempool.open(); await this.miner.open(); await this.pool.open(); + this.hnsr.open(); await this.openPlugins(); @@ -342,6 +388,7 @@ class FullNode extends Node { await this.closePlugins(); + this.hnsr.close(); await this.pool.close(); await this.miner.close(); await this.mempool.close(); diff --git a/scripts/run-hnsr-regtest-trial.js b/scripts/run-hnsr-regtest-trial.js new file mode 100755 index 0000000000..0e078328ac --- /dev/null +++ b/scripts/run-hnsr-regtest-trial.js @@ -0,0 +1,471 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('bsert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const random = require('bcrypto/lib/random'); +const secp256k1 = require('bcrypto/lib/secp256k1'); +const sha256 = require('bcrypto/lib/sha256'); +const FullNode = require('../lib/node/fullnode'); +const Address = require('../lib/primitives/address'); +const NetAddress = require('../lib/net/netaddress'); +const Parser = require('../lib/net/parser'); +const Framer = require('../lib/net/framer'); +const packets = require('../lib/net/packets'); +const common = require('../lib/net/common'); +const {BrontideStream} = require('../lib/net/brontide'); +const {opcodes, routeKey} = require('../lib/net/hnsr'); + +function waitFor(test, message, timeout = 10000) { + const start = Date.now(); + + return new Promise((resolve, reject) => { + const check = () => { + try { + const result = test(); + + if (result) { + resolve(result); + return; + } + } catch (e) { + reject(e); + return; + } + + if (Date.now() - start >= timeout) { + reject(new Error(typeof message === 'function' ? message() : message)); + return; + } + + setTimeout(check, 25); + }; + + check(); + }); +} + +function peerAddress(node, port) { + return NetAddress.fromHost( + '127.0.0.1', + port, + secp256k1.publicKeyCreate(node.identityKey, true), + 'regtest').hostname; +} + +function nodeOptions(prefix, identityKey, ports, extra = {}) { + return Object.assign({ + network: 'regtest', + memory: false, + prefix, + identityKey, + workers: false, + listen: true, + host: '127.0.0.1', + port: ports.p2p, + brontidePort: ports.brontide, + publicPort: ports.p2p, + publicBrontidePort: ports.brontide, + httpHost: '127.0.0.1', + httpPort: ports.http, + noAuth: true, + noDns: true, + seeds: [], + checkpoints: false, + logConsole: process.env.HNSR_TRIAL_DEBUG === '1', + logLevel: process.env.HNSR_TRIAL_DEBUG === '1' ? 'debug' : 'none', + logFile: false, + persistentMempool: false, + maxOutbound: 2, + experimentalHnsr: true + }, extra); +} + +function findPeer(node, services) { + for (let peer = node.pool.peers.head(); peer; peer = peer.next) { + if (peer.ack && (peer.services & services) === services) + return peer; + } + + return null; +} + +function peerCount(node) { + let count = 0; + + for (let peer = node.pool.peers.head(); peer; peer = peer.next) { + if (peer.ack) + count += 1; + } + + return count; +} + +function frame(framer, packet) { + return framer.packet(packet.type, packet.encode()); +} + +function version(nonce) { + return new packets.VersionPacket({ + services: common.services.NETWORK, + nonce, + agent: '/hnsr-poc:0.0.1/', + height: 1, + noRelay: true + }); +} + +async function openNode(node, opened) { + await node.ensure(); + await node.open(); + opened.push(node); + await node.connect(); + node.startSync(); +} + +async function main() { + const artifact = process.argv[2] ? path.resolve(process.argv[2]) : null; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hsd-hnsr-regtest-')); + const identities = { + endpoint: secp256k1.privateKeyGenerate(), + relay: secp256k1.privateKeyGenerate(), + rendezvous: secp256k1.privateKeyGenerate(), + requester: secp256k1.privateKeyGenerate() + }; + const ports = { + relay: {p2p: 14428, brontide: 14438, http: 14448}, + rendezvous: {p2p: 14429, brontide: 14439, http: 14449}, + endpoint: {p2p: 14427, brontide: 14437, http: 14447}, + requester: {p2p: 14426, brontide: 14436, http: 14446} + }; + const opened = []; + let endpoint = null; + let relay = null; + let rendezvous = null; + let requester = null; + + try { + relay = new FullNode(nodeOptions( + path.join(root, 'relay'), + identities.relay, + ports.relay, + {experimentalHnsrRelay: true})); + rendezvous = new FullNode(nodeOptions( + path.join(root, 'rendezvous'), + identities.rendezvous, + ports.rendezvous, + {experimentalHnsrRendezvous: true})); + + await openNode(relay, opened); + await openNode(rendezvous, opened); + + const relayAddress = peerAddress(relay, ports.relay.brontide); + const rendezvousAddress = peerAddress( + rendezvous, + ports.rendezvous.brontide); + + endpoint = new FullNode(nodeOptions( + path.join(root, 'endpoint'), + identities.endpoint, + ports.endpoint, + { + listen: false, + experimentalHnsrEndpoint: true, + nodes: [relayAddress, rendezvousAddress] + })); + requester = new FullNode(nodeOptions( + path.join(root, 'requester'), + identities.requester, + ports.requester, + { + listen: false, + nodes: [relayAddress, rendezvousAddress] + })); + + await openNode(endpoint, opened); + await openNode(requester, opened); + + await waitFor( + () => peerCount(endpoint) === 2 && peerCount(requester) === 2, + 'Timed out waiting for the four-node HNSR topology.'); + + const endpointRelay = findPeer( + endpoint, + common.EXPERIMENTAL_HNSR_RELAY_SERVICE); + const endpointRendezvous = findPeer( + endpoint, + common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE); + const requesterRelay = findPeer( + requester, + common.EXPERIMENTAL_HNSR_RELAY_SERVICE); + const requesterRendezvous = findPeer( + requester, + common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE); + + assert(endpointRelay && endpointRendezvous); + assert(requesterRelay && requesterRendezvous); + assert(endpointRelay.address.key.equals(relay.hnsr.publicKey)); + assert(requesterRelay.address.key.equals(relay.hnsr.publicKey)); + + for (const node of [endpoint, relay, rendezvous, requester]) + node.chain.synced = true; + + const coinbase = Address.fromProgram(0, Buffer.alloc(20, 0x01)); + const block = await relay.miner.mineBlock(relay.chain.tip, coinbase); + await relay.chain.add(block); + relay.pool.announceBlock(block); + await waitFor( + () => [endpoint, relay, rendezvous, requester] + .every(node => node.chain.height === 1), + () => `Core regtest block did not propagate: ${[ + endpoint, + relay, + rendezvous, + requester + ].map(node => node.chain.height).join(',')}.`); + + const wireCounts = {}; + + for (const node of [endpoint, relay, rendezvous, requester]) { + node.pool.on('packet', (packet) => { + if (packet.type !== packets.types.EXPERIMENTAL_HNSR) + return; + const name = Object.keys(opcodes) + .find(key => opcodes[key] === packet.opcode); + wireCounts[name] = (wireCounts[name] || 0) + 1; + }); + } + + const ticket = await endpoint.hnsr.reserve(endpointRelay, { + lifetime: 1800, + maxCircuits: 4, + maxBytes: 1048576 + }); + const record = await endpoint.hnsr.publish( + endpointRendezvous, + [ticket], + {lifetime: 900}); + const key = routeKey( + endpoint.network.magic, + endpoint.hnsr.publicKey); + const routes = await requester.hnsr.lookup( + requesterRendezvous, + key); + + assert.strictEqual(routes.length, 1); + assert(routes[0].verify(requester.network.magic)); + assert(routes[0].tickets[0].id().equals(ticket.id())); + + const endpointParser = new Parser('regtest'); + const requesterParser = new Parser('regtest'); + const framer = new Framer('regtest'); + const requesterNonce = random.randomBytes(8); + const endpointNonce = random.randomBytes(8); + const pingNonce = random.randomBytes(8); + let endpointInner = null; + let requesterInner = null; + let endpointSawVersion = false; + let endpointSawVerack = false; + let requesterSawVersion = false; + let requesterSawVerack = false; + let requesterSawPong = false; + + endpointParser.on('error', (err) => { + throw err; + }); + requesterParser.on('error', (err) => { + throw err; + }); + endpointParser.on('packet', (packet) => { + if (packet.type === packets.types.VERSION) { + endpointSawVersion = packet.agent === '/hnsr-poc:0.0.1/'; + endpointInner.write(frame(framer, version(endpointNonce))); + endpointInner.write(frame(framer, new packets.VerackPacket())); + } else if (packet.type === packets.types.VERACK) { + endpointSawVerack = true; + } else if (packet.type === packets.types.PING) { + endpointInner.write(frame( + framer, + new packets.PongPacket(packet.nonce))); + } + }); + requesterParser.on('packet', (packet) => { + if (packet.type === packets.types.VERSION) { + requesterSawVersion = packet.agent === '/hnsr-poc:0.0.1/'; + requesterInner.write(frame(framer, new packets.VerackPacket())); + } else if (packet.type === packets.types.VERACK) { + requesterSawVerack = true; + } else if (packet.type === packets.types.PONG) { + requesterSawPong = packet.nonce.equals(pingNonce); + } + }); + + endpoint.hnsr.once('circuit', (socket) => { + socket.on('error', (err) => { + throw err; + }); + endpointInner = BrontideStream.fromInbound( + socket, + endpoint.identityKey); + endpointInner.on('error', (err) => { + throw err; + }); + endpointInner.on('data', data => endpointParser.feed(data)); + }); + + const circuit = await requester.hnsr.openCircuit( + requesterRelay, + routes[0].tickets[0]); + circuit.socket.on('error', (err) => { + throw err; + }); + requesterInner = BrontideStream.fromOutbound( + circuit.socket, + requester.identityKey, + routes[0].delegation.endpointKey); + requesterInner.on('error', (err) => { + throw err; + }); + requesterInner.on('data', data => requesterParser.feed(data)); + + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Inner Brontide handshake timed out.')), + 10000); + requesterInner.once('connect', () => { + clearTimeout(timer); + requesterInner.write(frame(framer, version(requesterNonce))); + resolve(); + }); + }); + + await waitFor( + () => endpointSawVersion + && endpointSawVerack + && requesterSawVersion + && requesterSawVerack, + 'Inner Handshake version/verack exchange did not complete.'); + + requesterInner.write(frame(framer, new packets.PingPacket(pingNonce))); + await waitFor( + () => requesterSawPong, + 'Inner Handshake ping/pong did not complete.'); + + assert(endpointInner.remoteStatic.equals( + secp256k1.publicKeyCreate(requester.identityKey, true))); + assert(requesterInner.remoteStatic.equals(endpoint.hnsr.publicKey)); + assert.strictEqual( + relay.hnsr.relayPayloads.some(raw => raw.includes(pingNonce)), + false); + + await endpoint.close(); + opened.splice(opened.indexOf(endpoint), 1); + await waitFor( + () => relay.hnsr.reservations.size === 0, + 'Relay did not invalidate the disconnected endpoint reservation.'); + + const staleRoutes = await requester.hnsr.lookup( + requesterRendezvous, + key); + assert.strictEqual(staleRoutes.length, 1); + let staleRejected = false; + + try { + await requester.hnsr.openCircuit( + requesterRelay, + staleRoutes[0].tickets[0]); + } catch (e) { + staleRejected = e.code === 11; + } + + assert(staleRejected); + + const result = { + schema: 1, + network: 'regtest', + assignment: { + rendezvousServiceBit: + `0x${common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE.toString(16)}`, + relayServiceBit: + `0x${common.EXPERIMENTAL_HNSR_RELAY_SERVICE.toString(16)}`, + packetType: `0x${common.EXPERIMENTAL_HNSR.toString(16)}` + }, + topology: { + fullNodes: 4, + outerTransport: 'authenticated Handshake Brontide', + endpointListeners: 0, + convergedRegtestHeight: 1, + endpoint: endpoint.hnsr.publicKey.toString('hex'), + relay: relay.hnsr.publicKey.toString('hex'), + rendezvous: rendezvous.hnsr.publicKey.toString('hex'), + requester: requester.hnsr.publicKey.toString('hex') + }, + reservation: { + relaySignatureVerified: ticket.verifyRelay(), + endpointSignatureVerified: ticket.verifyEndpoint(), + ticketID: ticket.id().toString('hex'), + maxActiveCircuits: ticket.maxActiveCircuits, + maxBytesPerCircuit: ticket.maxBytesPerCircuit + }, + rendezvous: { + routeKey: key.toString('hex'), + routeBytes: record.encode().length, + routeSignatureVerified: record.verify(endpoint.network.magic), + returnedRecords: routes.length, + storedCopiesInTrial: rendezvous.hnsr.store.size + }, + circuit: { + profile: 'HNS_NODE_V1', + circuitID: circuit.circuitID.toString('hex'), + innerTransport: 'end-to-end Handshake Brontide', + endpointAuthenticated: requesterInner.remoteStatic.equals( + endpoint.hnsr.publicKey), + requesterAuthenticated: endpointInner.remoteStatic.equals( + requester.hnsr.publicKey), + versionVerack: true, + pingPong: true + }, + relayView: { + forwardedEncryptedBytes: relay.hnsr.relayBytes, + plaintextPingNonceObserved: relay.hnsr.relayPayloads + .some(raw => raw.includes(pingNonce)), + transcriptSHA256: sha256.digest(Buffer.concat( + relay.hnsr.relayPayloads)).toString('hex') + }, + lifecycle: { + staleRouteStillReturned: staleRoutes.length === 1, + disconnectedReservationInvalidated: + relay.hnsr.reservations.size === 0, + staleTicketRejected: staleRejected + }, + observedOpcodes: wireCounts, + result: 'pass' + }; + const output = JSON.stringify(result, null, 2) + '\n'; + + if (artifact) { + fs.mkdirSync(path.dirname(artifact), {recursive: true}); + fs.writeFileSync(artifact, output, {encoding: 'utf8', mode: 0o600}); + } + + process.stdout.write(output); + } finally { + for (const node of opened.reverse()) { + try { + await node.close(); + } catch (e) { + process.stderr.write(`cleanup warning: ${e.message}\n`); + } + } + + fs.rmSync(root, {recursive: true, force: true}); + } +} + +main().catch((err) => { + process.stderr.write(`${err.stack || err.message}\n`); + process.exitCode = 1; +}); diff --git a/test/hnsr-test.js b/test/hnsr-test.js new file mode 100644 index 0000000000..cb01d994e6 --- /dev/null +++ b/test/hnsr-test.js @@ -0,0 +1,251 @@ +'use strict'; + +const assert = require('bsert'); +const secp256k1 = require('bcrypto/lib/secp256k1'); +const FullNode = require('../lib/node/fullnode'); +const Network = require('../lib/protocol/network'); +const common = require('../lib/net/common'); +const packets = require('../lib/net/packets'); +const { + ReserveRequest, + RelayTicket, + EndpointDelegation, + RouteRecord, + RouteStore, + routeKey +} = require('../lib/net/hnsr'); + +const network = Network.get('regtest'); +const ORDER = BigInt( + '0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'); + +function writeBig(value, size) { + const hex = value.toString(16).padStart(size * 2, '0'); + return Buffer.from(hex, 'hex'); +} + +function highS(signature) { + const raw = secp256k1.signatureImport(signature); + const low = BigInt(`0x${raw.slice(32).toString('hex')}`); + writeBig(ORDER - low, 32).copy(raw, 32); + return secp256k1.signatureExport(raw); +} + +function fixture(timestamp = Math.floor(Date.now() / 1000), sequence = 1) { + const endpointPrivate = secp256k1.privateKeyGenerate(); + const endpointKey = secp256k1.publicKeyCreate(endpointPrivate, true); + const relayPrivate = secp256k1.privateKeyGenerate(); + const relayKey = secp256k1.publicKeyCreate(relayPrivate, true); + const ticket = new RelayTicket({ + networkMagic: network.magic, + profile: 1, + transport: 0, + hostType: 1, + host: Buffer.alloc(16), + port: network.brontidePort, + relayKey, + endpointKey, + reservationID: Buffer.alloc(16, 0x01), + issuedAt: timestamp, + expiresAt: timestamp + 1800, + maxActiveCircuits: 8, + maxBytesPerCircuit: 1048576, + maxTotalBytes: 8388608 + }).signRelay(relayPrivate).signEndpoint(endpointPrivate); + const delegation = new EndpointDelegation({ + endpointKey, + sequence, + issuedAt: timestamp, + expiresAt: timestamp + 900, + maxActiveCircuits: 8, + maxBytesPerCircuit: 1048576 + }).sign(endpointPrivate); + const key = routeKey(network.magic, endpointKey); + const record = new RouteRecord({ + routeKey: key, + sequence, + issuedAt: timestamp, + expiresAt: timestamp + 900, + delegation, + tickets: [ticket] + }).sign(endpointPrivate); + + return { + endpointPrivate, + endpointKey, + relayPrivate, + relayKey, + ticket, + delegation, + key, + record, + timestamp + }; +} + +describe('HNSR', function() { + it('should round trip the private HNSR envelope', () => { + const context = Buffer.from('0102030405060708', 'hex'); + const body = Buffer.from('deadbeef', 'hex'); + const packet = new packets.HNSRPacket(1, 17, context, body); + const decoded = packets.decode(packet.type, packet.encode()); + + assert.strictEqual(decoded.version, 1); + assert.strictEqual(decoded.opcode, 17); + assert(decoded.contextID.equals(context)); + assert(decoded.body.equals(body)); + }); + + it('should reject malformed HNSR envelopes', () => { + const context = Buffer.from('0102030405060708', 'hex'); + const packet = new packets.HNSRPacket(1, 17, context, Buffer.alloc(1)); + const raw = packet.encode(); + + assert.throws(() => packets.decode(packet.type, raw.slice(0, 11))); + + const badVersion = Buffer.from(raw); + badVersion[0] = 2; + assert.throws(() => packets.decode(packet.type, badVersion)); + + const flags = Buffer.from(raw); + flags[2] = 1; + assert.throws(() => packets.decode(packet.type, flags)); + + const zero = Buffer.from(raw); + zero.fill(0, 4, 12); + assert.throws(() => packets.decode(packet.type, zero)); + + const opcode = Buffer.from(raw); + opcode[1] = 21; + assert.throws(() => packets.decode(packet.type, opcode)); + }); + + it('should bind a reservation to relay, network, and context', () => { + const item = fixture(); + const context = Buffer.from('0102030405060708', 'hex'); + const request = new ReserveRequest({ + endpointKey: item.endpointKey, + profile: 1, + lifetime: 1800, + maxCircuits: 8, + maxBytes: 1048576, + nonce: Buffer.alloc(16, 0x02) + }).sign( + network.magic, + item.relayKey, + context, + item.endpointPrivate); + const decoded = ReserveRequest.decode(request.encode()); + + assert(decoded.verify(network.magic, item.relayKey, context)); + assert.strictEqual( + decoded.verify(network.magic + 1, item.relayKey, context), + false); + assert.strictEqual( + decoded.verify(network.magic, item.endpointKey, context), + false); + assert.strictEqual( + decoded.verify(network.magic, item.relayKey, Buffer.alloc(8, 0x03)), + false); + }); + + it('should round trip and authenticate a relay ticket', () => { + const item = fixture(); + const decoded = RelayTicket.decode(item.ticket.encode()); + + assert(decoded.verifyRelay()); + assert(decoded.verifyEndpoint()); + assert(decoded.verify(network.magic, item.timestamp)); + assert(decoded.id().equals(item.ticket.id())); + + decoded.endpointSignature[decoded.endpointSignature.length - 1] ^= 1; + assert.strictEqual(decoded.verifyEndpoint(), false); + }); + + it('should reject a high-S ticket signature', () => { + const item = fixture(); + item.ticket.relaySignature = highS(item.ticket.relaySignature); + assert.strictEqual(secp256k1.isLowDER(item.ticket.relaySignature), false); + assert.strictEqual(item.ticket.verifyRelay(), false); + }); + + it('should round trip an unnamed route authorization chain', () => { + const item = fixture(); + const raw = item.record.encode(); + const decoded = RouteRecord.decode(raw); + + assert(raw.length <= common.hnsr.MAX_RECORD_SIZE); + assert(decoded.verify(network.magic, item.timestamp)); + assert(decoded.routeKey.equals(item.key)); + assert(decoded.delegation.endpointKey.equals(item.endpointKey)); + assert(decoded.tickets[0].id().equals(item.ticket.id())); + }); + + it('should reject a route-key substitution', () => { + const item = fixture(); + item.record.routeKey[0] ^= 1; + item.record.sign(item.endpointPrivate); + assert.strictEqual(item.record.verify(network.magic, item.timestamp), false); + }); + + it('should replace only increasing endpoint route sequences', () => { + const item = fixture(1700000000, 1); + const store = new RouteStore(network.magic, { + maxRecords: 4, + maxPerKey: 2 + }); + + store.put(item.key, item.record.encode(), item.timestamp); + assert.strictEqual(store.get(item.key, 16, item.timestamp).length, 1); + assert.throws(() => { + store.put(item.key, item.record.encode(), item.timestamp); + }, /Stale HNSR route sequence/); + + item.record.sequence = 2; + item.record.sign(item.endpointPrivate); + store.put(item.key, item.record.encode(), item.timestamp); + + const records = store.get(item.key, 16, item.timestamp); + assert.strictEqual(records.length, 1); + assert.strictEqual(RouteRecord.decode(records[0]).sequence, 2); + }); + + it('should expire rendezvous records without a withdrawal broadcast', () => { + const item = fixture(1700000000); + const store = new RouteStore(network.magic); + store.put(item.key, item.record.encode(), item.timestamp); + + assert.strictEqual( + store.get(item.key, 16, item.record.expiresAt).length, + 0); + assert.strictEqual(store.size, 0); + }); + + it('should expose role bits only for configured regtest roles', () => { + const node = new FullNode({ + network: 'regtest', + memory: true, + listen: true, + noDns: true, + experimentalHnsr: true, + experimentalHnsrRelay: true, + experimentalHnsrRendezvous: true + }); + const expected = common.services.NETWORK + | common.EXPERIMENTAL_HNSR_RELAY_SERVICE + | common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE; + + assert.strictEqual(node.pool.options.services, expected); + }); + + it('should refuse the private assignment outside regtest', () => { + assert.throws(() => new FullNode({ + network: 'main', + memory: true, + listen: true, + noDns: true, + experimentalHnsr: true, + experimentalHnsrRelay: true + }), /regtest-only/); + }); +}); From 6ec99cb99d876d830cafa2814d0e6fc68efcde9f Mon Sep 17 00:00:00 2001 From: Jaron Rosenau Date: Tue, 21 Jul 2026 17:54:57 -0700 Subject: [PATCH 2/5] Complete HNSR unnamed-node phase 1 --- docs/experimental-hnsr.md | 153 ++-- docs/hnsr-regtest-phase1.json | 114 +++ lib/net/common.js | 11 +- lib/net/hnsr.js | 1185 +++++++++++++++++++++++++++-- lib/net/peer.js | 3 + lib/net/pool.js | 185 ++++- scripts/run-hnsr-regtest-trial.js | 651 +++++++++------- test/hnsr-test.js | 117 +++ 8 files changed, 2039 insertions(+), 380 deletions(-) create mode 100644 docs/hnsr-regtest-phase1.json diff --git a/docs/experimental-hnsr.md b/docs/experimental-hnsr.md index 9a78817bd4..191a8f924a 100644 --- a/docs/experimental-hnsr.md +++ b/docs/experimental-hnsr.md @@ -27,46 +27,104 @@ The branch implements: - the version-1 HNSR envelope and all 21 reserved opcode numbers; - strict envelope length, flag, version, opcode, and context checks; - regtest-only role advertisement; +- iterative `FINDNODE` / `NODES` discovery with XOR ordering, parallelism of + three, a 32-query bound, authenticated rendezvous contacts, and connections + to newly discovered Handshake peers; +- publisher-driven route replication with a configurable replica count and + minimum-store quorum; +- deterministic bounded `SAMPLEROUTES` discovery for unnamed node routes; - endpoint-signed `RESERVE`, relay-signed `OFFER`, endpoint `CONFIRM`, and jointly authenticated relay tickets; +- reservation `RENEW` and signed `WITHDRAW`, including retirement of replaced + tickets; - strict-DER, low-S secp256k1 signatures with network- and domain-separated digests; - self-authorized unnamed endpoint delegations and route records for `HNS_NODE_V1`; -- bounded, expiring, sequence-aware in-memory route storage; +- bounded, expiring, sequence-aware in-memory route storage with global, + per-key, and per-publishing-peer limits; - `PUTROUTE` / `PUTRESULT` and exact-key `GETROUTE` / `ROUTES`; +- multi-relay records, renewed-record republishing, sequential relay failover, + and failure reporting; - `OPEN` / `INCOMING` / `ACCEPT` / `OPENED` circuit establishment; - opaque `DATA`, directional `WINDOW`, and `CLOSE` forwarding; - per-ticket circuit and byte limits, bounded frames, and relay-side directional credit enforcement; +- bounded relay queues with burst-yield scheduling, control-request admission, + request-byte limits, and observable queue/drop counters; - immediate local reservation invalidation when the endpoint peer disconnects; and -- a virtual socket suitable for a complete end-to-end inner Brontide session. +- actual inbound and outbound `Peer` objects in the ordinary HSD pool, running + complete end-to-end Brontide and Handshake peer sessions over virtual circuit + sockets. The proof-of-concept handler does not forward to a requester-selected host or port. A circuit can terminate only at the exact live peer connection bound to the signed reservation. -## Deliberately unimplemented +## Milestone boundaries -The branch does not yet implement: +The following groups are deliberately separated so that completion of the +unnamed-node experiment is not confused with named services, client product +work, or public-network readiness. -- iterative `FINDNODE` / `NODES` XOR routing or eight-node replication; -- `SAMPLEROUTES`, `RENEW`, or `WITHDRAW` behavior; -- named HNS authority, TXT root-key parsing, service authorizations, or - `HNS_WEB_V1`; -- persistent routing buckets or route storage; -- multi-relay selection, republishing, failover, or topology scoring; -- public-node admission, routability, per-prefix, or netgroup policy; -- RPCs, wallet integration, SPV discovery, Android lifecycle integration, or - browser-origin behavior; -- relay payment, reputation, or production abuse controls; or -- the production scheduler and telemetry required before any public-network - experiment. +### Unnamed-node Phase 1: implemented here -Those boundaries are intentional. In particular, direct exact-key storage at -one rendezvous FullNode validates authenticated record storage but is not a -Kademlia conformance claim. +The branch completes the directly executable unnamed `HNS_NODE_V1` slice: + +- two independent relay candidates in each tested route; +- four iteratively discovered rendezvous nodes and four-copy publication; +- random unnamed-node sampling and exact-key lookup; +- reservation, renewal, replacement publication, withdrawal, disconnect, and + stale-ticket rejection; +- rendezvous loss and first-relay failure recovery; +- real inner full-node block traffic; and +- flow-control, scheduler saturation, control admission, and zero-drop checks. + +The rendezvous table is intentionally the bounded live/recently-connected +contact set for this regtest phase. It exercises iterative XOR routing but is +not yet the persistent bucket implementation required for a public network. + +### Phase 1B: named service authorization and profiles + +Still to implement before claiming the HIP's complete Phase 1 service surface: + +- authenticated HNS authority lookup and canonical TXT root-key parsing; +- service authorizations and named endpoint delegations; +- named route-key derivation and authorization-chain validation; and +- the `HNS_WEB_V1` handler and origin rules. + +These features are not prerequisites for review of the unnamed full-node +transport, but they are prerequisites for claiming named HNS service support. + +### Phase 2: bounded testnet hardening + +Still required before any testnet experiment: + +- persistent routing buckets and optional durable route storage; +- eight-replica, multi-path churn tests over larger and adversarial topologies; +- public-address admission, routability, per-prefix, and netgroup policy; +- peer-dial budgets, topology scoring, republish/failover timers, and restart + recovery; and +- scheduler integration that explicitly prioritizes blocks, headers, proofs, + and transaction traffic, plus operational telemetry. + +### Phase 3: node, mobile, and browser integration + +Still required for user-facing adoption: + +- RPCs and configuration/status APIs; +- address-manager, wallet, and SPV discovery integration; +- Android foreground/background lifecycle and network-change handling; +- HNS-aware browser navigation and named-origin behavior; and +- operator documentation, compatibility behavior, and upgrade UX. + +### Public-network and production readiness + +Permanent service/packet assignments, production abuse controls, reputation or +payment policy, privacy review, deployment gates, and sustained public-network +load measurements remain outside this PoC. The regtest-only feature guard stays +in place until those questions are resolved. ## Reproducible trial @@ -74,42 +132,51 @@ From this branch: ```sh npm ci -NODE_BACKEND=js npm run test-file -- test/hnsr-test.js test/net-test.js +NODE_BACKEND=js npm run test-file -- \ + test/hnsr-test.js test/brontide-test.js test/net-test.js NODE_BACKEND=js node scripts/run-hnsr-regtest-trial.js \ - ../artifacts/hnsr-regtest-trial.json + docs/hnsr-regtest-phase1.json ``` `NODE_BACKEND=js` selects bcrypto's portable JavaScript backend and is not a protocol requirement. -The trial starts four independently keyed, independently prefixed FullNodes: +The trial starts eight independently keyed, independently prefixed FullNodes: ```text -Endpoint (no listener) == outer Brontide ==> Relay -Endpoint (no listener) == outer Brontide ==> Rendezvous -Requester == outer Brontide ==> Relay -Requester == outer Brontide ==> Rendezvous +Endpoint (no listener) ==> Relay A, Relay B, Rendezvous 0 +Requester ==> Rendezvous 0 +Rendezvous 0 ==> Rendezvous 1 ==> Rendezvous 2 ==> Rendezvous 3 + +Requester == inner HNS peer ==> surviving relay ==> Endpoint ``` It then: -1. propagates a mined regtest block to height 1 across all four nodes; -2. obtains and mutually signs a relay reservation; -3. publishes and retrieves an authenticated unnamed route; -4. opens a relayed `HNS_NODE_V1` circuit; -5. completes a second, end-to-end Brontide handshake inside the opaque - circuit; -6. exchanges ordinary Handshake `version`, `verack`, `ping`, and `pong` - packets over that inner session; -7. verifies both inner static peer identities; -8. verifies that the ping nonce is absent from every relay-visible `DATA` - payload; and -9. disconnects the endpoint, retrieves the intentionally stale route, and - confirms that the relay rejects its now-invalid ticket. - -The evidence file contains fresh identities, ticket ID, route key, circuit ID, -opcode counts, byte counts, and a ciphertext transcript hash. Random values -change on every run. +1. iteratively discovers all four rendezvous nodes from one bootstrap; +2. reserves both relays and stores one signed route at all four rendezvous + nodes; +3. discovers the route with `SAMPLEROUTES`; +4. renews both tickets, republishes a higher sequence, and withdraws the old + reservations; +5. issues 72 concurrent lookup requests and verifies bounded admission; +6. stops one rendezvous node and retrieves the refreshed record from the three + survivors; +7. stops Relay A and verifies automatic fallback to Relay B; +8. constructs ordinary inbound/outbound HSD `Peer` objects over the circuit + and verifies both inner static identities; +9. sends 1,000 ordinary Handshake pings while mining and relaying a real block; +10. proves only endpoint and requester reach height 1 while both relays and all + four rendezvous chains remain at height 0; +11. verifies bounded queues, multiple scheduler yields, a control reservation + during load, and zero relay drops; and +12. disconnects the endpoint, retrieves the intentionally stale route, and + confirms the surviving relay rejects its invalid ticket. + +The checked-in `docs/hnsr-regtest-phase1.json` is one passing run. It records +topology, discovery, replica survival, lifecycle transitions, selected relay, +block-only inner convergence, saturation counters, admission results, opcode +counts, and a ciphertext transcript hash. Random values change on every run. ## Configuration surface diff --git a/docs/hnsr-regtest-phase1.json b/docs/hnsr-regtest-phase1.json new file mode 100644 index 0000000000..174877a48b --- /dev/null +++ b/docs/hnsr-regtest-phase1.json @@ -0,0 +1,114 @@ +{ + "schema": 2, + "network": "regtest", + "assignment": { + "rendezvousServiceBit": "0x4000000", + "relayServiceBit": "0x8000000", + "packetType": "0xf3" + }, + "topology": { + "fullNodes": 8, + "relays": 2, + "rendezvousNodes": 4, + "endpointListeners": 0, + "outerTransport": "authenticated Handshake Brontide", + "innerTransport": "end-to-end authenticated Handshake Brontide" + }, + "discovery": { + "bootstrapRendezvous": 1, + "endpointKnownRendezvous": 4, + "requesterKnownRendezvous": 4, + "sampledRecords": 1, + "sampledEndpointFound": true, + "iterativeLookupLiveNodes": 3 + }, + "replication": { + "requestedCopies": 4, + "initialStoredCopies": 4, + "refreshedStoredCopies": 4, + "survivingStores": [ + 1, + 1, + 1 + ], + "rendezvousFailureRecovered": true + }, + "lifecycle": { + "initialSequence": 1, + "refreshedSequence": 2, + "renewedTickets": 2, + "oldTicketsWithdrawn": 2, + "staleRouteStillReturned": true, + "disconnectedReservationInvalidated": true, + "staleTicketRejected": true + }, + "failover": { + "firstRelayStopped": true, + "failedCandidates": 1, + "selectedRelay": "039b9e8e86146290505e67871bf376323b60faf2afc492622a44633c4f96fca481", + "selectedSecondRelay": true + }, + "innerPeer": { + "profile": "HNS_NODE_V1", + "actualHsdPeerObjects": true, + "versionVerack": true, + "endpointAuthenticated": true, + "requesterAuthenticated": true + }, + "blockTraffic": { + "hash": "029aeb36f6ed4d0d74b825ddcd76fcb3ed46dcd0b7606bb48acd30e71a922013", + "endpointHeight": 1, + "requesterHeight": 1, + "controlNodeHeights": [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + "deliveredOnlyByInnerPeer": true, + "latencyMs": 8439 + }, + "saturation": { + "pingPackets": 1000, + "relayFrames": 8043, + "relayBytes": 107461, + "schedulerFlushes": 14, + "maximumQueuedBytes": 53054, + "queueLimitBytes": 65536, + "relayDrops": 0, + "controlReservationLatencyMs": 9038, + "admissionRequests": 72, + "admissionAccepted": 64, + "admissionRateLimited": 8 + }, + "relayView": { + "plaintextBlockHashObserved": false, + "transcriptSHA256": "133e2352fb9ea7d0825a614fc83464236ecd6310d31f0c8437124ea9dd82f27f" + }, + "observedOpcodes": { + "RESERVE": 3, + "OFFER": 5, + "CONFIRM": 5, + "CONFIRMED": 8, + "FINDNODE": 18, + "NODES": 18, + "PUTROUTE": 8, + "PUTRESULT": 8, + "SAMPLEROUTES": 3, + "ROUTES": 73, + "RENEW": 2, + "WITHDRAW": 3, + "GETROUTE": 78, + "ERROR": 9, + "OPEN": 2, + "INCOMING": 1, + "ACCEPT": 1, + "OPENED": 1, + "DATA": 16086, + "WINDOW": 16086, + "CLOSE": 1 + }, + "result": "pass" +} diff --git a/lib/net/common.js b/lib/net/common.js index 420fe8ff1a..7b4c38b86a 100644 --- a/lib/net/common.js +++ b/lib/net/common.js @@ -78,8 +78,14 @@ exports.hnsr = { MAX_RECORD_SIZE: 8192, MAX_RECORDS_PER_KEY: 16, MAX_STORED_RECORDS: 50000, + MAX_CONTACTS: 16, + MAX_FIND_QUERIES: 32, + ROUTE_REPLICATION: 8, + MIN_ROUTE_STORES: 3, MAX_DATA_SIZE: 16384, MAX_CIRCUIT_QUEUE: 65536, + MAX_SOCKET_QUEUE: 8 * 1000 * 1000 + 65536, + RELAY_BURST: 32768, MIN_WINDOW: 16384, DEFAULT_WINDOW: 65536, MAX_WINDOW: 1048576, @@ -87,7 +93,10 @@ exports.hnsr = { MAX_TICKET_LIFETIME: 7200, MAX_ROUTE_LIFETIME: 7200, MAX_CIRCUITS: 32, - MAX_SIGNATURE_SIZE: 80 + MAX_SIGNATURE_SIZE: 80, + MAX_REQUESTS_PER_SECOND: 64, + MAX_REQUEST_BYTES_PER_SECOND: 1048576, + MAX_STORES_PER_PEER: 256 }; /** diff --git a/lib/net/hnsr.js b/lib/net/hnsr.js index f076495df3..11fb8eedd6 100644 --- a/lib/net/hnsr.js +++ b/lib/net/hnsr.js @@ -8,10 +8,12 @@ const assert = require('bsert'); const EventEmitter = require('events'); const bio = require('bufio'); +const IP = require('binet'); const blake2b = require('bcrypto/lib/blake2b'); const random = require('bcrypto/lib/random'); const secp256k1 = require('bcrypto/lib/secp256k1'); const common = require('./common'); +const NetAddress = require('./netaddress'); const packets = require('./packets'); const ZERO32 = Buffer.alloc(32); @@ -23,7 +25,10 @@ const domains = { TICKET_ENDPOINT: Buffer.from('HNSR-RELAY-CONFIRM-V1\0', 'ascii'), DELEGATION: Buffer.from('HNSR-ENDPOINT-DELEGATION-V1\0', 'ascii'), ROUTE: Buffer.from('HNSR-ROUTE-RECORD-V1\0', 'ascii'), - PEER_ROUTE: Buffer.from('HNSR-PEER-ROUTE-V1\0', 'ascii') + PEER_ROUTE: Buffer.from('HNSR-PEER-ROUTE-V1\0', 'ascii'), + RENDEZVOUS_NODE: Buffer.from('HNSR-RENDEZVOUS-NODE-V1\0', 'ascii'), + WITHDRAW: Buffer.from('HNSR-WITHDRAW-V1\0', 'ascii'), + SAMPLE: Buffer.from('HNSR-SAMPLE-ROUTES-V1\0', 'ascii') }; const opcodes = { @@ -163,6 +168,134 @@ function routeKey(magic, endpointKey) { return hash(domains.PEER_ROUTE, magicBytes(magic), endpointKey); } +function withdrawData(magic, relayKey, contextID, reservationID, ticketID) { + assert(secp256k1.publicKeyVerify(relayKey)); + assert(contextID.length === 8); + assert(reservationID.length === 16); + assert(ticketID.length === 32); + return Buffer.concat([ + magicBytes(magic), + relayKey, + contextID, + reservationID, + ticketID + ]); +} + +function rendezvousNodeID(magic, peerKey) { + assert(secp256k1.publicKeyVerify(peerKey)); + return hash(domains.RENDEZVOUS_NODE, magicBytes(magic), peerKey); +} + +function compareDistance(a, b, target) { + assert(a.length === 32 && b.length === 32 && target.length === 32); + + for (let i = 0; i < 32; i++) { + const left = a[i] ^ target[i]; + const right = b[i] ^ target[i]; + + if (left !== right) + return left - right; + } + + return 0; +} + +function peerPublicKey(peer) { + if (peer && peer.address && secp256k1.publicKeyVerify(peer.address.key)) + return peer.address.key; + + if (peer && peer.brontide + && secp256k1.publicKeyVerify(peer.brontide.remoteStatic)) { + return peer.brontide.remoteStatic; + } + + return null; +} + +class RendezvousContact { + constructor(options = {}) { + this.nodeID = options.nodeID || Buffer.alloc(32); + this.hostType = options.hostType || 1; + this.host = options.host || Buffer.alloc(16); + this.port = options.port || 0; + this.services = options.services || 0; + this.peerKey = options.peerKey || Buffer.alloc(33); + this.observedAt = options.observedAt || 0; + } + + verify(magic, timestamp = now()) { + if (!Buffer.isBuffer(this.nodeID) + || this.nodeID.length !== 32 + || (this.hostType !== 1 && this.hostType !== 2) + || !Buffer.isBuffer(this.host) + || this.host.length !== 16 + || this.port === 0 + || !Number.isSafeInteger(this.services) + || this.services < 0 + || (this.services + & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE) === 0 + || !secp256k1.publicKeyVerify(this.peerKey) + || !Number.isSafeInteger(this.observedAt) + || this.observedAt > timestamp + 600 + || timestamp - this.observedAt > 86400) { + return false; + } + + return this.nodeID.equals(rendezvousNodeID(magic, this.peerKey)); + } + + encode() { + assert(this.nodeID.length === 32); + assert((this.hostType & 0xff) === this.hostType); + assert(this.host.length === 16); + assert((this.port & 0xffff) === this.port); + assertU64(this.services, 'rendezvous services'); + assert(secp256k1.publicKeyVerify(this.peerKey)); + assertU64(this.observedAt, 'observedAt'); + + const bw = bio.write(100); + bw.writeBytes(this.nodeID); + bw.writeU8(this.hostType); + bw.writeBytes(this.host); + bw.writeU16(this.port); + bw.writeU64(this.services); + bw.writeBytes(this.peerKey); + bw.writeU64(this.observedAt); + return bw.render(); + } + + toAddress(network) { + const address = NetAddress.fromHost( + IP.toString(this.host), + this.port, + this.peerKey, + network); + address.services = this.services; + address.time = this.observedAt; + return address; + } + + static read(br) { + const contact = new RendezvousContact(); + contact.nodeID = br.readBytes(32); + contact.hostType = br.readU8(); + contact.host = br.readBytes(16); + contact.port = br.readU16(); + contact.services = br.readU64(); + contact.peerKey = br.readBytes(33); + contact.observedAt = br.readU64(); + return contact; + } + + static decode(data) { + const br = bio.read(data); + const contact = RendezvousContact.read(br); + finish(br, 'HNSR rendezvous contact'); + return contact; + } +} + class ReserveRequest { constructor(options = {}) { this.endpointKey = options.endpointKey || Buffer.alloc(33); @@ -642,14 +775,20 @@ class RouteStore { this.magic = magic; this.maxRecords = options.maxRecords || common.hnsr.MAX_STORED_RECORDS; this.maxPerKey = options.maxPerKey || common.hnsr.MAX_RECORDS_PER_KEY; + this.maxPerPeer = options.maxPerPeer + || common.hnsr.MAX_STORES_PER_PEER; this.records = new Map(); + this.sourceCounts = new Map(); this.size = 0; } - put(key, raw, timestamp = now()) { + put(key, raw, timestamp = now(), source = 'local') { if (!Buffer.isBuffer(key) || key.length !== 32) throw new Error('Invalid HNSR route key.'); + if (typeof source !== 'string' || source.length === 0) + throw new Error('Invalid HNSR route source.'); + const record = RouteRecord.decode(raw); if (!record.routeKey.equals(key) || !record.verify(this.magic, timestamp)) @@ -659,28 +798,41 @@ class RouteStore { const items = this._active(hex, timestamp); const endpoint = record.delegation.endpointKey.toString('hex'); const index = items.findIndex(item => item.endpoint === endpoint); + const previous = index !== -1 ? items[index] : null; - if (index !== -1) { - if (items[index].sequence >= record.sequence) + if (previous) { + if (previous.sequence >= record.sequence) throw new Error('Stale HNSR route sequence.'); - items.splice(index, 1); - this.size -= 1; } - if (items.length >= this.maxPerKey) + if (!previous && items.length >= this.maxPerKey) throw new Error('HNSR per-key route capacity reached.'); - if (this.size >= this.maxRecords) + if (!previous && this.size >= this.maxRecords) throw new Error('HNSR route store capacity reached.'); + const sourceCount = this.sourceCounts.get(source) || 0; + const replacesSameSource = previous && previous.source === source; + + if (sourceCount >= this.maxPerPeer && !replacesSameSource) + throw new Error('HNSR per-peer route capacity reached.'); + + if (previous) { + this._decrementSource(previous.source); + items.splice(index, 1); + this.size -= 1; + } + items.push({ endpoint, sequence: record.sequence, expiresAt: record.expiresAt, + source, raw: Buffer.from(raw) }); this.records.set(hex, items); + this.sourceCounts.set(source, (this.sourceCounts.get(source) || 0) + 1); this.size += 1; return record.expiresAt; @@ -693,9 +845,43 @@ class RouteStore { return items.slice(0, maximum).map(item => Buffer.from(item.raw)); } + sample(maximum, seed, timestamp = now()) { + assert(Number.isSafeInteger(maximum) && maximum >= 1 && maximum <= 16); + assert(Buffer.isBuffer(seed) && seed.length === 32); + + const items = []; + + for (const hex of Array.from(this.records.keys())) { + for (const item of this._active(hex, timestamp)) { + items.push({ + score: hash(domains.SAMPLE, seed, item.raw), + raw: item.raw + }); + } + } + + items.sort((a, b) => a.score.compare(b.score)); + return items.slice(0, maximum).map(item => Buffer.from(item.raw)); + } + + _decrementSource(source) { + const count = this.sourceCounts.get(source) || 0; + + if (count <= 1) + this.sourceCounts.delete(source); + else + this.sourceCounts.set(source, count - 1); + } + _active(hex, timestamp) { const items = this.records.get(hex) || []; const active = items.filter(item => item.expiresAt > timestamp); + + for (const item of items) { + if (item.expiresAt <= timestamp) + this._decrementSource(item.source); + } + this.size -= items.length - active.length; if (active.length === 0) @@ -715,8 +901,19 @@ class CircuitSocket extends EventEmitter { this.peer = peer; this.contextID = Buffer.from(contextID); this.sendCredit = window; + this.sendQueue = []; + this.sendQueueBytes = 0; + this.receiveQueue = []; + this.receiveQueueBytes = 0; + this.paused = false; this.destroyed = false; this.connected = false; + this.readable = true; + this.writable = true; + this.remoteAddress = '127.0.0.1'; + this.localAddress = '127.0.0.1'; + this.remotePort = 49152 + contextID.readUInt16LE(0, true) % 16384; + this.localPort = 0; } connect() { @@ -733,24 +930,50 @@ class CircuitSocket extends EventEmitter { if (this.destroyed) return false; - if (data.length > this.sendCredit) { + if (data.length === 0) + return true; + + if (this.sendQueueBytes + data.length > common.hnsr.MAX_SOCKET_QUEUE) { + const err = new Error('HNSR circuit send queue exhausted.'); this.destroy(); - this.emit('error', new Error('HNSR circuit flow-control exhausted.')); + this.emit('error', err); return false; } - this.sendCredit -= data.length; + this.sendQueue.push(Buffer.from(data)); + this.sendQueueBytes += data.length; + this._flush(); + return this.sendQueueBytes === 0; + } - for (let off = 0; off < data.length; off += common.hnsr.MAX_DATA_SIZE) { - const end = Math.min(off + common.hnsr.MAX_DATA_SIZE, data.length); - this.service._send( - this.peer, - opcodes.DATA, - this.contextID, - data.slice(off, end)); + _sendData(data) { + assert(data.length > 0 && data.length <= common.hnsr.MAX_DATA_SIZE); + assert(data.length <= this.sendCredit); + this.sendCredit -= data.length; + this.service._send(this.peer, opcodes.DATA, this.contextID, data); + } + + _flush() { + while (!this.destroyed && this.sendCredit > 0 + && this.sendQueue.length > 0) { + const data = this.sendQueue[0]; + const size = Math.min( + data.length, + this.sendCredit, + common.hnsr.MAX_DATA_SIZE); + const chunk = data.slice(0, size); + + this._sendData(chunk); + this.sendQueueBytes -= size; + + if (size === data.length) + this.sendQueue.shift(); + else + this.sendQueue[0] = data.slice(size); } - return true; + if (!this.destroyed && this.sendQueueBytes === 0) + setImmediate(() => this.emit('drain')); } addCredit(credit) { @@ -761,12 +984,31 @@ class CircuitSocket extends EventEmitter { throw new Error('HNSR window exceeds the maximum.'); this.sendCredit += credit; + this._flush(); } receive(data) { if (this.destroyed) return; + if (this.paused) { + if (this.receiveQueueBytes + data.length + > common.hnsr.MAX_CIRCUIT_QUEUE) { + const err = new Error('HNSR circuit receive queue exhausted.'); + this.destroy(); + this.emit('error', err); + return; + } + + this.receiveQueue.push(Buffer.from(data)); + this.receiveQueueBytes += data.length; + return; + } + + this._deliver(data); + } + + _deliver(data) { this.emit('data', data); const bw = bio.write(4); @@ -778,11 +1020,51 @@ class CircuitSocket extends EventEmitter { bw.render()); } + pause() { + this.paused = true; + return this; + } + + resume() { + this.paused = false; + + while (!this.destroyed && !this.paused + && this.receiveQueue.length > 0) { + const data = this.receiveQueue.shift(); + this.receiveQueueBytes -= data.length; + this._deliver(data); + } + + return this; + } + + setNoDelay() { + return this; + } + + setKeepAlive() { + return this; + } + + setTimeout() { + return this; + } + + _clear() { + this.readable = false; + this.writable = false; + this.sendQueue.length = 0; + this.sendQueueBytes = 0; + this.receiveQueue.length = 0; + this.receiveQueueBytes = 0; + } + remoteClose() { if (this.destroyed) return; this.destroyed = true; + this._clear(); this.emit('close'); } @@ -791,6 +1073,7 @@ class CircuitSocket extends EventEmitter { return; this.destroyed = true; + this._clear(); const bw = bio.write(3); bw.writeU16(errors.NORMAL); bw.writeU8(0); @@ -820,6 +1103,7 @@ class HNSRService extends EventEmitter { this.network = options.network; this.identityKey = options.identityKey; this.publicKey = secp256k1.publicKeyCreate(this.identityKey, true); + this.pool = options.pool || null; this.logger = options.logger && options.logger.context ? options.logger.context('hnsr') : options.logger; @@ -835,15 +1119,27 @@ class HNSRService extends EventEmitter { this.timeout = options.timeout || common.hnsr.DEFAULT_TIMEOUT; this.opened = false; this.store = new RouteStore(this.network.magic, options.storeOptions); + this.contacts = new Map(); + this.admission = new Map(); this.pending = new Map(); this.provisional = new Map(); this.reservations = new Map(); this.endpointTickets = new Map(); this.opening = new Map(); this.relayCircuits = new Map(); + this.relayQueue = []; + this.relayQueueBytes = 0; + this.relayFlushScheduled = false; + this.relayFlushHandle = null; this.sockets = new Map(); this.relayBytes = 0; + this.relayFrames = 0; + this.relayFlushes = 0; + this.maxRelayQueuedBytes = 0; + this.relayDrops = 0; this.relayPayloads = []; + this.routeSequence = 0; + this.endpointSequence = 0; } open() { @@ -853,6 +1149,15 @@ class HNSRService extends EventEmitter { close() { this.opened = false; + if (this.relayFlushHandle != null) { + clearImmediate(this.relayFlushHandle); + this.relayFlushHandle = null; + } + + this.relayFlushScheduled = false; + this.relayQueue.length = 0; + this.relayQueueBytes = 0; + for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('HNSR service closed.')); @@ -873,6 +1178,8 @@ class HNSRService extends EventEmitter { this.provisional.clear(); this.reservations.clear(); this.endpointTickets.clear(); + this.contacts.clear(); + this.admission.clear(); } isReady() { @@ -891,6 +1198,93 @@ class HNSRService extends EventEmitter { return bits >>> 0; } + selfContact(timestamp = now()) { + if (!this.rendezvous) + return null; + + return new RendezvousContact({ + nodeID: rendezvousNodeID(this.network.magic, this.publicKey), + hostType: 1, + host: Buffer.from(this.relayHost), + port: this.relayPort, + services: this.services(), + peerKey: this.publicKey, + observedAt: timestamp + }); + } + + addPeer(peer) { + if (!this.enabled || !peer || !peer.outbound || peer.hnsrVirtual) + return null; + + if ((peer.services + & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE) === 0) { + return null; + } + + const key = peerPublicKey(peer); + + if (!key || !peer.address || peer.address.port === 0) + return null; + + const contact = new RendezvousContact({ + nodeID: rendezvousNodeID(this.network.magic, key), + hostType: 1, + host: Buffer.from(peer.address.raw), + port: peer.address.port, + services: peer.services, + peerKey: Buffer.from(key), + observedAt: now() + }); + + if (!contact.verify(this.network.magic)) + return null; + + this.contacts.set(key.toString('hex'), contact); + return contact; + } + + _admit(peer, bytes) { + const timestamp = Date.now(); + const key = peer.id; + let state = this.admission.get(key); + + if (!state || timestamp - state.started >= 1000) { + state = {started: timestamp, requests: 0, bytes: 0}; + this.admission.set(key, state); + } + + state.requests += 1; + state.bytes += bytes; + + return state.requests <= common.hnsr.MAX_REQUESTS_PER_SECOND + && state.bytes <= common.hnsr.MAX_REQUEST_BYTES_PER_SECOND; + } + + _closestContacts(target, maximum) { + const contacts = []; + const seen = new Set(); + const self = this.selfContact(); + + if (self) { + contacts.push(self); + seen.add(self.peerKey.toString('hex')); + } + + for (const contact of this.contacts.values()) { + const key = contact.peerKey.toString('hex'); + + if (seen.has(key) || !contact.verify(this.network.magic)) + continue; + + seen.add(key); + contacts.push(contact); + } + + contacts.sort((a, b) => compareDistance(a.nodeID, b.nodeID, target)); + return contacts.slice(0, maximum); + } + _send(peer, opcode, contextID, body) { if (!this.enabled || !peer || peer.destroyed) return false; @@ -985,16 +1379,46 @@ class HNSRService extends EventEmitter { if (this._resolvePending(peer, packet)) return; + if (packet.opcode === opcodes.FINDNODE + || packet.opcode === opcodes.PUTROUTE + || packet.opcode === opcodes.GETROUTE + || packet.opcode === opcodes.SAMPLEROUTES + || packet.opcode === opcodes.RESERVE + || packet.opcode === opcodes.RENEW + || packet.opcode === opcodes.WITHDRAW + || packet.opcode === opcodes.OPEN) { + if (!this._admit(peer, packet.body.length + 12)) { + this._sendError( + peer, + packet.contextID, + errors.RATE_LIMITED, + 'HNSR peer admission limit reached.'); + return; + } + } + try { switch (packet.opcode) { + case opcodes.FINDNODE: + this._handleFindNode(peer, packet); + break; case opcodes.PUTROUTE: this._handlePutRoute(peer, packet); break; case opcodes.GETROUTE: this._handleGetRoute(peer, packet); break; + case opcodes.SAMPLEROUTES: + this._handleSampleRoutes(peer, packet); + break; case opcodes.RESERVE: - this._handleReserve(peer, packet); + this._handleReserve(peer, packet, false); + break; + case opcodes.RENEW: + this._handleReserve(peer, packet, true); + break; + case opcodes.WITHDRAW: + this._handleWithdraw(peer, packet); break; case opcodes.CONFIRM: this._handleConfirm(peer, packet); @@ -1018,6 +1442,7 @@ class HNSRService extends EventEmitter { this._handleClose(peer, packet); break; case opcodes.OFFER: + case opcodes.NODES: case opcodes.CONFIRMED: case opcodes.PUTRESULT: case opcodes.ROUTES: @@ -1040,6 +1465,19 @@ class HNSRService extends EventEmitter { } async reserve(peer, options = {}) { + return this._reserve(peer, options, null); + } + + async renew(peer, ticket, options = {}) { + if (!(ticket instanceof RelayTicket) + || !ticket.verify(this.network.magic)) { + throw new Error('Invalid HNSR ticket to renew.'); + } + + return this._reserve(peer, options, ticket); + } + + async _reserve(peer, options = {}, previous) { if (!this.enabled || !this.endpoint) throw new Error('HNSR endpoint role is disabled.'); @@ -1064,10 +1502,15 @@ class HNSRService extends EventEmitter { request.sign(this.network.magic, relayKey, contextID, this.identityKey); + let body = request.encode(); + + if (previous) + body = Buffer.concat([previous.reservationID, body]); + const offered = await this._request( peer, - opcodes.RESERVE, - request.encode(), + previous ? opcodes.RENEW : opcodes.RESERVE, + body, [opcodes.OFFER], contextID); const ticket = RelayTicket.decode(offered.body); @@ -1109,22 +1552,228 @@ class HNSRService extends EventEmitter { return ticket; } - async publish(peer, tickets, options = {}) { + async withdraw(peer, ticket) { if (!this.enabled || !this.endpoint) throw new Error('HNSR endpoint role is disabled.'); - if (!(peer.services & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE)) - throw new Error('Peer does not advertise the HNSR rendezvous role.'); + if (!(ticket instanceof RelayTicket) + || !ticket.verify(this.network.magic)) { + throw new Error('Invalid HNSR ticket to withdraw.'); + } + + const relayKey = peerPublicKey(peer); + + if (!relayKey || !relayKey.equals(ticket.relayKey)) + throw new Error('HNSR withdrawal relay key mismatch.'); + + const contextID = randomID(8); + const ticketID = ticket.id(); + const signature = sign( + domains.WITHDRAW, + withdrawData( + this.network.magic, + relayKey, + contextID, + ticket.reservationID, + ticketID), + this.identityKey); + const bw = bio.write(49 + signature.length); + bw.writeBytes(ticket.reservationID); + bw.writeBytes(ticketID); + writeSignature(bw, signature); + const response = await this._request( + peer, + opcodes.WITHDRAW, + bw.render(), + [opcodes.CONFIRMED], + contextID); + const br = bio.read(response.body); + const reservationID = br.readBytes(16); + const confirmedTicket = br.readBytes(32); + const expiresAt = br.readU64(); + finish(br, 'HNSR withdrawal confirmation'); + + if (!reservationID.equals(ticket.reservationID) + || !confirmedTicket.equals(ticketID) + || expiresAt !== 0) { + throw new Error('Mismatched HNSR withdrawal confirmation.'); + } + + this.endpointTickets.delete(ticketID.toString('hex')); + return true; + } + + async _queryFindNode(peer, target, maximum) { + const bw = bio.write(33); + bw.writeBytes(target); + bw.writeU8(maximum); + const response = await this._request( + peer, + opcodes.FINDNODE, + bw.render(), + [opcodes.NODES]); + const br = bio.read(response.body); + const count = br.readU8(); + + if (count > maximum || count > common.hnsr.MAX_CONTACTS) + throw new Error('Too many HNSR rendezvous contacts returned.'); + + const contacts = []; + + for (let i = 0; i < count; i++) { + const contact = RendezvousContact.read(br); + + if (!contact.verify(this.network.magic)) + throw new Error('Invalid HNSR rendezvous contact.'); + + contacts.push(contact); + } + + finish(br, 'HNSR nodes response'); + return contacts; + } + + async _peerForContact(contact) { + if (!this.pool) + throw new Error('HNSR rendezvous discovery requires a peer pool.'); + + let peer = this.pool.findHNSRPeer(contact.peerKey); + + if (peer && peer.handshake && !peer.destroyed) + return peer; + + peer = await this.pool.connectHNSRContact(contact); + + if (!peer || !peer.handshake || peer.destroyed) + throw new Error('Could not connect to discovered HNSR rendezvous peer.'); + + return peer; + } + + async findNodes(bootstrap, target, maximum = common.hnsr.ROUTE_REPLICATION) { + if (!Buffer.isBuffer(target) || target.length !== 32) + throw new Error('Invalid HNSR rendezvous target.'); + + if (!Array.isArray(bootstrap)) + bootstrap = [bootstrap]; + + if (bootstrap.length === 0) + throw new Error( + 'At least one HNSR rendezvous bootstrap peer is required.'); + + if (maximum < 1 || maximum > common.hnsr.MAX_CONTACTS) + throw new Error('Invalid HNSR rendezvous result limit.'); + + const candidates = new Map(); + const peers = new Map(); + const queried = new Set(); + let queries = 0; + + for (const peer of bootstrap) { + if (!peer || peer.destroyed + || (peer.services + & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE) === 0) { + continue; + } + + const contact = this.addPeer(peer); + + if (!contact) + continue; + + const key = contact.peerKey.toString('hex'); + candidates.set(key, contact); + peers.set(key, peer); + } + + if (candidates.size === 0) + throw new Error('No usable HNSR rendezvous bootstrap peer.'); + + while (queries < common.hnsr.MAX_FIND_QUERIES) { + const ordered = Array.from(candidates.values()).sort( + (a, b) => compareDistance(a.nodeID, b.nodeID, target)); + const batch = ordered.filter((contact) => { + return !queried.has(contact.peerKey.toString('hex')); + }).slice(0, Math.min( + 3, + common.hnsr.MAX_FIND_QUERIES - queries)); + + if (batch.length === 0) + break; + + const results = await Promise.all(batch.map(async (contact) => { + const key = contact.peerKey.toString('hex'); + queried.add(key); + queries += 1; + + try { + let peer = peers.get(key); + + if (!peer || peer.destroyed || !peer.handshake) + peer = await this._peerForContact(contact); + + peers.set(key, peer); + return await this._queryFindNode( + peer, + target, + common.hnsr.MAX_CONTACTS); + } catch (e) { + this.emit('discovery failure', e, contact); + return []; + } + })); + + for (const contacts of results) { + for (const contact of contacts) { + if (contact.peerKey.equals(this.publicKey)) + continue; + + const key = contact.peerKey.toString('hex'); + + if (!candidates.has(key)) + candidates.set(key, contact); + } + } + } + + const selected = Array.from(candidates.values()).sort( + (a, b) => compareDistance(a.nodeID, b.nodeID, target)).slice(0, maximum); + const result = []; + + for (const contact of selected) { + const key = contact.peerKey.toString('hex'); + + try { + let peer = peers.get(key); + + if (!peer || peer.destroyed || !peer.handshake) + peer = await this._peerForContact(contact); + result.push({contact, peer}); + } catch (e) { + this.emit('discovery failure', e, contact); + } + } + + if (result.length === 0) + throw new Error('HNSR iterative lookup found no live rendezvous peers.'); + + return result; + } + + _createRoute(tickets, options = {}) { assert(Array.isArray(tickets) && tickets.length > 0); const timestamp = now(); const expiresAt = Math.min( timestamp + (options.lifetime || 900), ...tickets.map(ticket => ticket.expiresAt)); + const endpointSequence = options.endpointSequence + || ++this.endpointSequence; + const sequence = options.sequence || ++this.routeSequence; const delegation = new EndpointDelegation({ endpointKey: this.publicKey, - sequence: options.endpointSequence || 1, + sequence: endpointSequence, issuedAt: timestamp, expiresAt, maxActiveCircuits: Math.min( @@ -1136,22 +1785,25 @@ class HNSRService extends EventEmitter { const record = new RouteRecord({ routeKey: key, profile: profiles.HNS_NODE_V1, - sequence: options.sequence || 1, + sequence, issuedAt: timestamp, expiresAt, delegation, tickets }).sign(this.identityKey); - const raw = record.encode(); - if (raw.length > common.hnsr.MAX_RECORD_SIZE) + if (record.encode().length > common.hnsr.MAX_RECORD_SIZE) throw new Error('HNSR route record exceeds the storage limit.'); + return record; + } + + async _putRoute(peer, record) { + const raw = record.encode(); const bw = bio.write(34 + raw.length); - bw.writeBytes(key); + bw.writeBytes(record.routeKey); bw.writeU16(raw.length); bw.writeBytes(raw); - const result = await this._request( peer, opcodes.PUTROUTE, @@ -1165,9 +1817,68 @@ class HNSRService extends EventEmitter { if (status !== 0 || storedUntil !== record.expiresAt) throw new Error(`HNSR rendezvous store rejected route (${status}).`); + return storedUntil; + } + + async publish(peer, tickets, options = {}) { + if (!this.enabled || !this.endpoint) + throw new Error('HNSR endpoint role is disabled.'); + + if (!(peer.services & common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE)) + throw new Error('Peer does not advertise the HNSR rendezvous role.'); + + const record = this._createRoute(tickets, options); + await this._putRoute(peer, record); return record; } + async publishReplicated(bootstrap, tickets, options = {}) { + if (!this.enabled || !this.endpoint) + throw new Error('HNSR endpoint role is disabled.'); + + const record = this._createRoute(tickets, options); + const nodes = await this.findNodes( + bootstrap, + record.routeKey, + options.replicas || common.hnsr.ROUTE_REPLICATION); + const stored = []; + const failures = []; + + await Promise.all(nodes.map(async ({contact, peer}) => { + try { + await this._putRoute(peer, record); + stored.push(contact); + } catch (e) { + failures.push({contact, error: e}); + } + })); + + const required = options.minimumStores + || common.hnsr.MIN_ROUTE_STORES; + + if (stored.length < required) { + throw new Error( + `HNSR route replication quorum failed (${stored.length}/${required}).`); + } + + return {record, stored, failures}; + } + + async republish(publication, tickets, bootstrap, options = {}) { + const previous = publication.record || publication; + + if (!(previous instanceof RouteRecord)) + throw new Error('Invalid HNSR publication to refresh.'); + + return this.publishReplicated( + bootstrap, + tickets, + Object.assign({}, options, { + sequence: previous.sequence + 1, + endpointSequence: previous.delegation.sequence + 1 + })); + } + async lookup(peer, key, maximum = 16) { if (!this.enabled) throw new Error('HNSR is disabled.'); @@ -1217,6 +1928,132 @@ class HNSRService extends EventEmitter { return records; } + async lookupReplicated(bootstrap, key, maximum = 16, options = {}) { + const nodes = await this.findNodes( + bootstrap, + key, + options.replicas || common.hnsr.ROUTE_REPLICATION); + const failures = []; + const records = new Map(); + + await Promise.all(nodes.map(async ({contact, peer}) => { + try { + for (const record of await this.lookup(peer, key, maximum)) { + const endpoint = record.delegation.endpointKey.toString('hex'); + const current = records.get(endpoint); + + if (!current || current.sequence < record.sequence) + records.set(endpoint, record); + } + } catch (e) { + failures.push({contact, error: e}); + } + })); + + const result = Array.from(records.values()) + .sort((a, b) => b.sequence - a.sequence) + .slice(0, maximum); + + return { + records: result, + queried: nodes.map(item => item.contact), + failures + }; + } + + async sampleRoutes(bootstrap, maximum = 16) { + if (maximum < 1 || maximum > 16) + throw new Error('Invalid HNSR sample result limit.'); + + const seed = random.randomBytes(32); + const target = hash(domains.SAMPLE, seed); + const nodes = await this.findNodes(bootstrap, target, 3); + const records = new Map(); + const failures = []; + + await Promise.all(nodes.map(async ({contact, peer}) => { + const bw = bio.write(33); + bw.writeU8(maximum); + bw.writeBytes(seed); + + try { + const response = await this._request( + peer, + opcodes.SAMPLEROUTES, + bw.render(), + [opcodes.ROUTES]); + const br = bio.read(response.body); + const count = br.readU8(); + + if (count > maximum) + throw new Error('Too many sampled HNSR routes returned.'); + + for (let i = 0; i < count; i++) { + const size = br.readU16(); + + if (size === 0 || size > common.hnsr.MAX_RECORD_SIZE) + throw new Error('Invalid sampled HNSR route length.'); + + const record = RouteRecord.decode(br.readBytes(size)); + + if (!record.verify(this.network.magic)) + throw new Error('Invalid sampled HNSR route.'); + + const key = record.routeKey.toString('hex'); + const current = records.get(key); + + if (!current || current.sequence < record.sequence) + records.set(key, record); + } + + finish(br, 'HNSR sampled routes response'); + } catch (e) { + failures.push({contact, error: e}); + } + })); + + return { + records: Array.from(records.values()).slice(0, maximum), + queried: nodes.map(item => item.contact), + failures + }; + } + + async openRoute(record, options = {}) { + if (!(record instanceof RouteRecord) + || !record.verify(this.network.magic)) { + throw new Error('Invalid HNSR route record.'); + } + + if (!this.pool) + throw new Error('HNSR route opening requires a peer pool.'); + + const failures = []; + + for (const ticket of record.tickets) { + try { + const peer = await this.pool.connectHNSRTicket(ticket); + const circuit = await this.openCircuit(peer, ticket, options); + return {ticket, relayPeer: peer, failures, ...circuit}; + } catch (e) { + failures.push({ticket, error: e}); + } + } + + const err = new Error('All HNSR relay candidates failed.'); + err.failures = failures; + throw err; + } + + async openPeer(record, options = {}) { + const circuit = await this.openRoute(record, options); + const peer = await this.pool.addHNSROutbound( + circuit.socket, + circuit.ticket.endpointKey, + circuit); + return {peer, ...circuit}; + } + async openCircuit(peer, ticket, options = {}) { if (!this.enabled) throw new Error('HNSR is disabled.'); @@ -1264,11 +2101,27 @@ class HNSRService extends EventEmitter { return {socket, circuitID, endpointNonce}; } - _handleReserve(peer, packet) { + _handleReserve(peer, packet, renewal) { if (!this.relay) throw new Error('HNSR relay role is disabled.'); - const request = ReserveRequest.decode(packet.body); + let previous = null; + let body = packet.body; + + if (renewal) { + const br = bio.read(packet.body); + const reservationID = br.readBytes(16); + body = br.readBytes(br.left()); + previous = this.reservations.get(reservationID.toString('hex')); + + if (!previous + || previous.peer !== peer + || previous.ticket.expiresAt <= now()) { + throw new Error('Unknown HNSR reservation to renew.'); + } + } + + const request = ReserveRequest.decode(body); if (!request.verify( this.network.magic, @@ -1287,6 +2140,11 @@ class HNSRService extends EventEmitter { throw new Error('HNSR reservation exceeds PoC policy.'); } + if (previous + && !previous.ticket.endpointKey.equals(request.endpointKey)) { + throw new Error('HNSR renewal endpoint key mismatch.'); + } + let count = 0; for (const item of this.provisional.values()) { @@ -1317,7 +2175,11 @@ class HNSRService extends EventEmitter { }).signRelay(this.identityKey); const key = ticket.reservationID.toString('hex'); - this.provisional.set(key, {peer, ticket}); + this.provisional.set(key, { + peer, + ticket, + replaces: previous ? previous.ticket.reservationID.toString('hex') : null + }); this._send(peer, opcodes.OFFER, packet.contextID, ticket.encode()); } @@ -1346,9 +2208,17 @@ class HNSRService extends EventEmitter { peer, ticket, activeCircuits: 0, - totalBytes: 0 + totalBytes: 0, + retired: false }); + if (item.replaces) { + const previous = this.reservations.get(item.replaces); + + if (previous) + previous.retired = true; + } + const bw = bio.write(56); bw.writeBytes(ticket.reservationID); bw.writeBytes(ticket.id()); @@ -1356,6 +2226,112 @@ class HNSRService extends EventEmitter { this._send(peer, opcodes.CONFIRMED, packet.contextID, bw.render()); } + _handleFindNode(peer, packet) { + if (!this.rendezvous) + throw new Error('HNSR rendezvous role is disabled.'); + + const br = bio.read(packet.body); + const target = br.readBytes(32); + const maximum = br.readU8(); + finish(br, 'HNSR find-node request'); + + if (maximum < 1 || maximum > common.hnsr.MAX_CONTACTS) + throw new Error('Invalid HNSR rendezvous result limit.'); + + const contacts = this._closestContacts(target, maximum); + const bw = bio.write(1 + contacts.length * 100); + bw.writeU8(contacts.length); + + for (const contact of contacts) + bw.writeBytes(contact.encode()); + + this._send(peer, opcodes.NODES, packet.contextID, bw.render()); + } + + _sendRoutes(peer, contextID, records) { + let size = 1; + + for (const raw of records) + size += 2 + raw.length; + + if (size > common.hnsr.MAX_PACKET_SIZE - 12) + throw new Error('HNSR routes response exceeds packet limit.'); + + const bw = bio.write(size); + bw.writeU8(records.length); + + for (const raw of records) { + bw.writeU16(raw.length); + bw.writeBytes(raw); + } + + this._send(peer, opcodes.ROUTES, contextID, bw.render()); + } + + _handleSampleRoutes(peer, packet) { + if (!this.rendezvous) + throw new Error('HNSR rendezvous role is disabled.'); + + const br = bio.read(packet.body); + const maximum = br.readU8(); + const seed = br.readBytes(32); + finish(br, 'HNSR sample-routes request'); + + if (maximum < 1 || maximum > 16 || isZero(seed)) + throw new Error('Invalid HNSR sample-routes parameters.'); + + this._sendRoutes( + peer, + packet.contextID, + this.store.sample(maximum, seed)); + } + + _handleWithdraw(peer, packet) { + if (!this.relay) + throw new Error('HNSR relay role is disabled.'); + + const br = bio.read(packet.body); + const reservationID = br.readBytes(16); + const ticketID = br.readBytes(32); + const signature = readSignature(br, 'reservation withdrawal'); + finish(br, 'HNSR reservation withdrawal'); + const key = reservationID.toString('hex'); + const reservation = this.reservations.get(key); + + if (!reservation + || reservation.peer !== peer + || !reservation.ticket.id().equals(ticketID) + || !verify( + domains.WITHDRAW, + withdrawData( + this.network.magic, + this.publicKey, + packet.contextID, + reservationID, + ticketID), + signature, + reservation.ticket.endpointKey)) { + throw new Error('Invalid HNSR reservation withdrawal.'); + } + + const states = new Set(); + + for (const circuit of this.relayCircuits.values()) { + if (circuit.state.reservation === reservation) + states.add(circuit.state); + } + + for (const state of states) + this._closeRelayCircuit(state, errors.SHUTDOWN); + + this.reservations.delete(key); + const bw = bio.write(56); + bw.writeBytes(reservationID); + bw.writeBytes(ticketID); + bw.writeU64(0); + this._send(peer, opcodes.CONFIRMED, packet.contextID, bw.render()); + } + _handlePutRoute(peer, packet) { if (!this.rendezvous) throw new Error('HNSR rendezvous role is disabled.'); @@ -1372,7 +2348,11 @@ class HNSRService extends EventEmitter { let storedUntil = 0; try { - storedUntil = this.store.put(key, raw); + const sourceKey = peerPublicKey(peer); + const source = sourceKey + ? sourceKey.toString('hex') + : `peer:${peer.id}`; + storedUntil = this.store.put(key, raw, now(), source); } catch (e) { status = errors.INVALID; this.emit('store reject', e, peer); @@ -1396,24 +2376,10 @@ class HNSRService extends EventEmitter { if (maximum < 1 || maximum > 16) throw new Error('Invalid HNSR route result limit.'); - const records = this.store.get(key, maximum); - let size = 1; - - for (const raw of records) - size += 2 + raw.length; - - if (size > common.hnsr.MAX_PACKET_SIZE - 12) - throw new Error('HNSR routes response exceeds packet limit.'); - - const bw = bio.write(size); - bw.writeU8(records.length); - - for (const raw of records) { - bw.writeU16(raw.length); - bw.writeBytes(raw); - } - - this._send(peer, opcodes.ROUTES, packet.contextID, bw.render()); + this._sendRoutes( + peer, + packet.contextID, + this.store.get(key, maximum)); } _handleOpen(peer, packet) { @@ -1439,6 +2405,7 @@ class HNSRService extends EventEmitter { reservationID.toString('hex')); if (!reservation + || reservation.retired || reservation.ticket.expiresAt <= now() || !reservation.ticket.id().equals(ticketID) || !reservation.ticket.endpointKey.equals(endpointKey) @@ -1568,6 +2535,8 @@ class HNSRService extends EventEmitter { requester: acceptedWindow, endpoint: acceptedWindow }; + state.queuedBytes = 0; + state.closed = false; this.relayCircuits.set( peerKey(state.requesterPeer, state.circuitID), {state, other: state.endpointPeer, side: 'requester'}); @@ -1613,16 +2582,7 @@ class HNSRService extends EventEmitter { return; } - this.relayBytes += packet.body.length; - - if (this.relayPayloads.length < 128) - this.relayPayloads.push(Buffer.from(packet.body)); - - this._send( - circuit.other, - opcodes.DATA, - packet.contextID, - packet.body); + this._queueRelayData(circuit, packet.body); return; } @@ -1634,6 +2594,83 @@ class HNSRService extends EventEmitter { socket.receive(Buffer.from(packet.body)); } + _queueRelayData(circuit, data) { + const state = circuit.state; + + if (state.closed) + return; + + if (state.queuedBytes + data.length > common.hnsr.MAX_CIRCUIT_QUEUE) { + this.relayDrops += 1; + this._closeRelayCircuit(state, errors.CAPACITY); + return; + } + + const raw = Buffer.from(data); + state.queuedBytes += raw.length; + this.relayQueueBytes += raw.length; + this.maxRelayQueuedBytes = Math.max( + this.maxRelayQueuedBytes, + this.relayQueueBytes); + this.relayQueue.push({ + state, + other: circuit.other, + contextID: Buffer.from(state.circuitID), + data: raw + }); + this._scheduleRelayFlush(); + } + + _scheduleRelayFlush() { + if (this.relayFlushScheduled || !this.opened) + return; + + this.relayFlushScheduled = true; + this.relayFlushHandle = setImmediate(() => { + this.relayFlushHandle = null; + this.relayFlushScheduled = false; + this._flushRelayQueue(); + }); + } + + _flushRelayQueue() { + let bytes = 0; + + this.relayFlushes += 1; + + while (this.relayQueue.length > 0) { + const item = this.relayQueue[0]; + + if (item.state.closed) { + this.relayQueue.shift(); + this.relayQueueBytes -= item.data.length; + continue; + } + + if (bytes > 0 && bytes + item.data.length > common.hnsr.RELAY_BURST) + break; + + this.relayQueue.shift(); + item.state.queuedBytes -= item.data.length; + this.relayQueueBytes -= item.data.length; + bytes += item.data.length; + this.relayBytes += item.data.length; + this.relayFrames += 1; + + if (this.relayPayloads.length < 128) + this.relayPayloads.push(Buffer.from(item.data)); + + this._send( + item.other, + opcodes.DATA, + item.contextID, + item.data); + } + + if (this.relayQueue.length > 0) + this._scheduleRelayFlush(); + } + _handleWindow(peer, packet) { const br = bio.read(packet.body); const credit = br.readU32(); @@ -1712,9 +2749,24 @@ class HNSRService extends EventEmitter { } _dropRelayCircuit(state) { + state.closed = true; this.relayCircuits.delete(peerKey(state.requesterPeer, state.circuitID)); this.relayCircuits.delete(peerKey(state.endpointPeer, state.circuitID)); + if (state.queuedBytes > 0) { + const retained = []; + + for (const item of this.relayQueue) { + if (item.state === state) + this.relayQueueBytes -= item.data.length; + else + retained.push(item); + } + + this.relayQueue = retained; + state.queuedBytes = 0; + } + if (state.reservation.activeCircuits > 0) state.reservation.activeCircuits -= 1; } @@ -1726,6 +2778,8 @@ class HNSRService extends EventEmitter { cancelPeer(peer) { const prefix = `${peer.id}:`; + this.admission.delete(peer.id); + for (const [key, pending] of this.pending) { if (!key.startsWith(prefix)) continue; @@ -1781,6 +2835,9 @@ exports.opcodes = opcodes; exports.errors = errors; exports.profiles = profiles; exports.routeKey = routeKey; +exports.rendezvousNodeID = rendezvousNodeID; +exports.compareDistance = compareDistance; +exports.RendezvousContact = RendezvousContact; exports.ReserveRequest = ReserveRequest; exports.RelayTicket = RelayTicket; exports.EndpointDelegation = EndpointDelegation; diff --git a/lib/net/peer.js b/lib/net/peer.js index 7a154baf96..f815324842 100644 --- a/lib/net/peer.js +++ b/lib/net/peer.js @@ -97,6 +97,9 @@ class Peer extends EventEmitter { this.height = -1; this.agent = null; this.noRelay = false; + this.hnsrControl = false; + this.hnsrVirtual = false; + this.hnsrInfo = null; this.preferHeaders = false; this.hashContinue = consensus.ZERO_HASH; this.spvFilter = null; diff --git a/lib/net/pool.js b/lib/net/pool.js index 35c8412128..254ea5cddf 100644 --- a/lib/net/pool.js +++ b/lib/net/pool.js @@ -30,6 +30,7 @@ const BIP152 = require('./bip152'); const Network = require('../protocol/network'); const Peer = require('./peer'); const HostList = require('./hostlist'); +const NetAddress = require('./netaddress'); const InvItem = require('../primitives/invitem'); const packets = require('./packets'); const consensus = require('../protocol/consensus'); @@ -119,6 +120,19 @@ class Pool extends EventEmitter { assert(typeof service.cancelPeer === 'function'); this.hnsr = service; + service.pool = this; + service.on('circuit', (socket, info) => { + if (info.profile !== 1) + return; + + try { + const peer = this.addHNSRInbound(socket, info); + service.emit('virtual peer', peer, info); + } catch (e) { + socket.destroy(); + this.emit('error', e); + } + }); return this; } @@ -1177,6 +1191,165 @@ class Pool extends EventEmitter { return peer; } + /** + * Find an authenticated live peer by static Brontide key. + * @param {Buffer} key + * @returns {Peer?} + */ + + findHNSRPeer(key) { + assert(Buffer.isBuffer(key) && key.length === 33); + + for (let peer = this.peers.head(); peer; peer = peer.next) { + if (peer.destroyed || peer.hnsrVirtual) + continue; + + if (peer.address.key.equals(key) + || (peer.brontide && peer.brontide.remoteStatic.equals(key))) { + return peer; + } + } + + return null; + } + + async _connectHNSRAddress(addr, requiredServices) { + let peer = this.findHNSRPeer(addr.key); + + if (peer && peer.handshake) { + if ((peer.services & requiredServices) !== requiredServices) + throw new Error('Connected peer lacks the required HNSR role.'); + return peer; + } + + peer = this.createOutbound(addr); + peer.hnsrControl = true; + this.peers.add(peer); + this.emit('peer', peer); + + await new Promise((resolve, reject) => { + let timer = null; + let onOpen = null; + let onClose = null; + const cleanup = () => { + clearTimeout(timer); + peer.removeListener('open', onOpen); + peer.removeListener('close', onClose); + }; + onOpen = () => { + cleanup(); + resolve(); + }; + onClose = () => { + cleanup(); + reject(new Error('HNSR discovered peer disconnected.')); + }; + timer = setTimeout(() => { + cleanup(); + peer.destroy(); + reject(new Error('HNSR discovered peer handshake timed out.')); + }, 10000); + + peer.once('open', onOpen); + peer.once('close', onClose); + }); + + if ((peer.services & requiredServices) !== requiredServices) { + peer.destroy(); + throw new Error('Discovered peer lacks the required HNSR role.'); + } + + return peer; + } + + async connectHNSRContact(contact) { + const addr = contact.toAddress(this.network); + return this._connectHNSRAddress( + addr, + common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE); + } + + async connectHNSRTicket(ticket) { + const peer = this.findHNSRPeer(ticket.relayKey); + + if (peer && peer.handshake + && (peer.services & common.EXPERIMENTAL_HNSR_RELAY_SERVICE) !== 0) { + return peer; + } + + const addr = NetAddress.fromHost( + IP.toString(ticket.host), + ticket.port, + ticket.relayKey, + this.network); + addr.services = common.services.NETWORK + | common.EXPERIMENTAL_HNSR_RELAY_SERVICE; + return this._connectHNSRAddress( + addr, + common.EXPERIMENTAL_HNSR_RELAY_SERVICE); + } + + addHNSRInbound(socket, info) { + if (!this.opened) + throw new Error('Cannot add an HNSR peer to a closed pool.'); + + const peer = this.createInbound(socket, true); + peer.hnsrVirtual = true; + peer.hnsrInfo = info; + this.peers.add(peer); + return peer; + } + + async addHNSROutbound(socket, endpointKey, info) { + if (!this.opened) + throw new Error('Cannot add an HNSR peer to a closed pool.'); + + const addr = NetAddress.fromHost( + socket.remoteAddress, + socket.remotePort, + endpointKey, + this.network); + addr.services = common.services.NETWORK; + const options = Object.create(this.options); + options.createSocket = () => socket; + const peer = Peer.fromOutbound(options, addr); + peer.hnsrVirtual = true; + peer.hnsrInfo = info; + this.bindPeer(peer); + peer.tryOpen(); + this.peers.add(peer); + this.emit('peer', peer); + + await new Promise((resolve, reject) => { + let timer = null; + let onOpen = null; + let onClose = null; + const cleanup = () => { + clearTimeout(timer); + peer.removeListener('open', onOpen); + peer.removeListener('close', onClose); + }; + onOpen = () => { + cleanup(); + resolve(); + }; + onClose = () => { + cleanup(); + reject(new Error('Inner HNSR peer disconnected.')); + }; + timer = setTimeout(() => { + cleanup(); + peer.destroy(); + reject(new Error('Inner HNSR peer handshake timed out.')); + }, 10000); + + peer.once('open', onOpen); + peer.once('close', onClose); + }); + + return peer; + } + /** * Allocate new peer id. * @returns {Number} @@ -1375,7 +1548,7 @@ class Pool extends EventEmitter { async handleConnect(peer) { this.logger.info('Connected to %s.', peer.hostname()); - if (peer.outbound) + if (peer.outbound && !peer.hnsrVirtual) this.hosts.markSuccess(peer.hostname()); this.emit('peer connect', peer); @@ -1389,8 +1562,11 @@ class Pool extends EventEmitter { */ async handleOpen(peer) { + if (this.hnsr) + this.hnsr.addPeer(peer); + // Advertise our address. - if (peer.outbound) { + if (peer.outbound && !peer.hnsrVirtual) { if (this.options.listen) { const addr = this.hosts.getLocal(peer.address); @@ -1425,7 +1601,8 @@ class Pool extends EventEmitter { this.sendSync(peer); // Mark success. - this.hosts.markAck(peer.hostname(), peer.services); + if (!peer.hnsrVirtual) + this.hosts.markAck(peer.hostname(), peer.services); // If we don't have an ack'd // loader yet consider it dead. @@ -3534,7 +3711,7 @@ class Pool extends EventEmitter { removePeer(peer) { this.peers.remove(peer); - if (peer.outbound) + if (peer.outbound && !peer.hnsrVirtual) this.connectedGroups.delete(peer.address.getGroup()); for (const hash of peer.blockMap.keys()) diff --git a/scripts/run-hnsr-regtest-trial.js b/scripts/run-hnsr-regtest-trial.js index 0e078328ac..1eb026e15b 100755 --- a/scripts/run-hnsr-regtest-trial.js +++ b/scripts/run-hnsr-regtest-trial.js @@ -6,20 +6,16 @@ const assert = require('bsert'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const random = require('bcrypto/lib/random'); const secp256k1 = require('bcrypto/lib/secp256k1'); const sha256 = require('bcrypto/lib/sha256'); const FullNode = require('../lib/node/fullnode'); const Address = require('../lib/primitives/address'); const NetAddress = require('../lib/net/netaddress'); -const Parser = require('../lib/net/parser'); -const Framer = require('../lib/net/framer'); const packets = require('../lib/net/packets'); const common = require('../lib/net/common'); -const {BrontideStream} = require('../lib/net/brontide'); const {opcodes, routeKey} = require('../lib/net/hnsr'); -function waitFor(test, message, timeout = 10000) { +function waitFor(test, message, timeout = 15000) { const start = Date.now(); return new Promise((resolve, reject) => { @@ -48,11 +44,40 @@ function waitFor(test, message, timeout = 10000) { }); } -function peerAddress(node, port) { +function waitEvent(emitter, event, timeout = 15000) { + return new Promise((resolve, reject) => { + let timer = null; + let onEvent = null; + const cleanup = () => { + clearTimeout(timer); + emitter.removeListener(event, onEvent); + }; + onEvent = (...args) => { + cleanup(); + resolve(args); + }; + timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ${event}.`)); + }, timeout); + + emitter.once(event, onEvent); + }); +} + +function identity() { + return secp256k1.privateKeyGenerate(); +} + +function publicKey(key) { + return secp256k1.publicKeyCreate(key, true); +} + +function nodeAddress(key, port) { return NetAddress.fromHost( '127.0.0.1', port, - secp256k1.publicKeyCreate(node.identityKey, true), + publicKey(key), 'regtest').hostname; } @@ -79,43 +104,31 @@ function nodeOptions(prefix, identityKey, ports, extra = {}) { logLevel: process.env.HNSR_TRIAL_DEBUG === '1' ? 'debug' : 'none', logFile: false, persistentMempool: false, - maxOutbound: 2, + maxOutbound: 12, experimentalHnsr: true }, extra); } -function findPeer(node, services) { - for (let peer = node.pool.peers.head(); peer; peer = peer.next) { - if (peer.ack && (peer.services & services) === services) - return peer; - } - - return null; +function ports(base, index) { + return { + p2p: base + index, + brontide: base + 16 + index, + http: base + 32 + index + }; } -function peerCount(node) { - let count = 0; - - for (let peer = node.pool.peers.head(); peer; peer = peer.next) { - if (peer.ack) - count += 1; - } - - return count; +function findPeer(node, key) { + const peer = node.pool.findHNSRPeer(publicKey(key)); + return peer && peer.handshake && !peer.destroyed ? peer : null; } -function frame(framer, packet) { - return framer.packet(packet.type, packet.encode()); -} +function opcodeName(value) { + for (const [name, opcode] of Object.entries(opcodes)) { + if (opcode === value) + return name; + } -function version(nonce) { - return new packets.VersionPacket({ - services: common.services.NETWORK, - nonce, - agent: '/hnsr-poc:0.0.1/', - height: 1, - noRelay: true - }); + return `UNKNOWN_${value}`; } async function openNode(node, opened) { @@ -126,265 +139,339 @@ async function openNode(node, opened) { node.startSync(); } +async function closeNode(node, opened) { + const index = opened.indexOf(node); + + await node.close(); + + if (index !== -1) + opened.splice(index, 1); +} + async function main() { const artifact = process.argv[2] ? path.resolve(process.argv[2]) : null; const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hsd-hnsr-regtest-')); + const base = 20000 + (process.pid % 1000) * 24; const identities = { - endpoint: secp256k1.privateKeyGenerate(), - relay: secp256k1.privateKeyGenerate(), - rendezvous: secp256k1.privateKeyGenerate(), - requester: secp256k1.privateKeyGenerate() + relays: [identity(), identity()], + rendezvous: [identity(), identity(), identity(), identity()], + endpoint: identity(), + requester: identity() }; - const ports = { - relay: {p2p: 14428, brontide: 14438, http: 14448}, - rendezvous: {p2p: 14429, brontide: 14439, http: 14449}, - endpoint: {p2p: 14427, brontide: 14437, http: 14447}, - requester: {p2p: 14426, brontide: 14436, http: 14446} + const nodePorts = { + relays: [ports(base, 0), ports(base, 1)], + rendezvous: [ + ports(base, 2), + ports(base, 3), + ports(base, 4), + ports(base, 5) + ], + endpoint: ports(base, 6), + requester: ports(base, 7) }; const opened = []; + const wireCounts = {}; + const relayWirePayloads = new Map(); + const nodes = []; let endpoint = null; - let relay = null; - let rendezvous = null; let requester = null; try { - relay = new FullNode(nodeOptions( - path.join(root, 'relay'), - identities.relay, - ports.relay, - {experimentalHnsrRelay: true})); - rendezvous = new FullNode(nodeOptions( - path.join(root, 'rendezvous'), - identities.rendezvous, - ports.rendezvous, - {experimentalHnsrRendezvous: true})); - - await openNode(relay, opened); - await openNode(rendezvous, opened); - - const relayAddress = peerAddress(relay, ports.relay.brontide); - const rendezvousAddress = peerAddress( - rendezvous, - ports.rendezvous.brontide); + const relays = identities.relays.map((key, index) => { + return new FullNode(nodeOptions( + path.join(root, `relay-${index}`), + key, + nodePorts.relays[index], + {experimentalHnsrRelay: true})); + }); + const rendezvous = new Array(4); + + for (let index = 3; index >= 0; index--) { + const extra = {experimentalHnsrRendezvous: true}; + + if (index < 3) { + extra.nodes = [nodeAddress( + identities.rendezvous[index + 1], + nodePorts.rendezvous[index + 1].brontide)]; + } + + rendezvous[index] = new FullNode(nodeOptions( + path.join(root, `rendezvous-${index}`), + identities.rendezvous[index], + nodePorts.rendezvous[index], + extra)); + } + + nodes.push(...relays, ...rendezvous); + + for (const relay of relays) + await openNode(relay, opened); + + for (let index = 3; index >= 0; index--) + await openNode(rendezvous[index], opened); + + for (let index = 0; index < 3; index++) { + await waitFor( + () => findPeer(rendezvous[index], identities.rendezvous[index + 1]), + `Rendezvous link ${index}->${index + 1} did not authenticate.`); + } + + const relayAddresses = identities.relays.map((key, index) => { + return nodeAddress(key, nodePorts.relays[index].brontide); + }); + const rendezvousBootstrap = nodeAddress( + identities.rendezvous[0], + nodePorts.rendezvous[0].brontide); endpoint = new FullNode(nodeOptions( path.join(root, 'endpoint'), identities.endpoint, - ports.endpoint, + nodePorts.endpoint, { listen: false, experimentalHnsrEndpoint: true, - nodes: [relayAddress, rendezvousAddress] + nodes: [...relayAddresses, rendezvousBootstrap] })); requester = new FullNode(nodeOptions( path.join(root, 'requester'), identities.requester, - ports.requester, - { - listen: false, - nodes: [relayAddress, rendezvousAddress] - })); + nodePorts.requester, + {listen: false, nodes: [rendezvousBootstrap]})); + nodes.push(endpoint, requester); await openNode(endpoint, opened); await openNode(requester, opened); - await waitFor( - () => peerCount(endpoint) === 2 && peerCount(requester) === 2, - 'Timed out waiting for the four-node HNSR topology.'); + await waitFor(() => { + return identities.relays.every(key => findPeer(endpoint, key)) + && findPeer(endpoint, identities.rendezvous[0]) + && findPeer(requester, identities.rendezvous[0]); + }, 'Endpoint and requester bootstrap peers did not authenticate.'); - const endpointRelay = findPeer( - endpoint, - common.EXPERIMENTAL_HNSR_RELAY_SERVICE); - const endpointRendezvous = findPeer( - endpoint, - common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE); - const requesterRelay = findPeer( - requester, - common.EXPERIMENTAL_HNSR_RELAY_SERVICE); - const requesterRendezvous = findPeer( - requester, - common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE); - - assert(endpointRelay && endpointRendezvous); - assert(requesterRelay && requesterRendezvous); - assert(endpointRelay.address.key.equals(relay.hnsr.publicKey)); - assert(requesterRelay.address.key.equals(relay.hnsr.publicKey)); - - for (const node of [endpoint, relay, rendezvous, requester]) + for (const node of nodes) { node.chain.synced = true; - - const coinbase = Address.fromProgram(0, Buffer.alloc(20, 0x01)); - const block = await relay.miner.mineBlock(relay.chain.tip, coinbase); - await relay.chain.add(block); - relay.pool.announceBlock(block); - await waitFor( - () => [endpoint, relay, rendezvous, requester] - .every(node => node.chain.height === 1), - () => `Core regtest block did not propagate: ${[ - endpoint, - relay, - rendezvous, - requester - ].map(node => node.chain.height).join(',')}.`); - - const wireCounts = {}; - - for (const node of [endpoint, relay, rendezvous, requester]) { + relayWirePayloads.set(node, []); node.pool.on('packet', (packet) => { if (packet.type !== packets.types.EXPERIMENTAL_HNSR) return; - const name = Object.keys(opcodes) - .find(key => opcodes[key] === packet.opcode); + + const name = opcodeName(packet.opcode); wireCounts[name] = (wireCounts[name] || 0) + 1; + + if (node.hnsr.relay && packet.opcode === opcodes.DATA) + relayWirePayloads.get(node).push(Buffer.from(packet.body)); }); } - const ticket = await endpoint.hnsr.reserve(endpointRelay, { + const endpointRelays = identities.relays.map( + key => findPeer(endpoint, key)); + const endpointRendezvous = findPeer( + endpoint, + identities.rendezvous[0]); + const requesterRendezvous = findPeer( + requester, + identities.rendezvous[0]); + const reservationOptions = { lifetime: 1800, maxCircuits: 4, - maxBytes: 1048576 - }); - const record = await endpoint.hnsr.publish( + maxBytes: 8 * 1024 * 1024 + }; + const initialTickets = await Promise.all(endpointRelays.map((peer) => { + return endpoint.hnsr.reserve(peer, reservationOptions); + })); + const publication = await endpoint.hnsr.publishReplicated( endpointRendezvous, - [ticket], - {lifetime: 900}); - const key = routeKey( - endpoint.network.magic, - endpoint.hnsr.publicKey); - const routes = await requester.hnsr.lookup( + initialTickets, + {lifetime: 900, replicas: 4, minimumStores: 4}); + const key = routeKey(endpoint.network.magic, endpoint.hnsr.publicKey); + + assert.strictEqual(publication.stored.length, 4); + assert(rendezvous.every(node => node.hnsr.store.size === 1)); + assert(endpoint.hnsr.contacts.size >= 4); + + const sampled = await requester.hnsr.sampleRoutes( requesterRendezvous, - key); - - assert.strictEqual(routes.length, 1); - assert(routes[0].verify(requester.network.magic)); - assert(routes[0].tickets[0].id().equals(ticket.id())); - - const endpointParser = new Parser('regtest'); - const requesterParser = new Parser('regtest'); - const framer = new Framer('regtest'); - const requesterNonce = random.randomBytes(8); - const endpointNonce = random.randomBytes(8); - const pingNonce = random.randomBytes(8); - let endpointInner = null; - let requesterInner = null; - let endpointSawVersion = false; - let endpointSawVerack = false; - let requesterSawVersion = false; - let requesterSawVerack = false; - let requesterSawPong = false; - - endpointParser.on('error', (err) => { - throw err; - }); - requesterParser.on('error', (err) => { - throw err; - }); - endpointParser.on('packet', (packet) => { - if (packet.type === packets.types.VERSION) { - endpointSawVersion = packet.agent === '/hnsr-poc:0.0.1/'; - endpointInner.write(frame(framer, version(endpointNonce))); - endpointInner.write(frame(framer, new packets.VerackPacket())); - } else if (packet.type === packets.types.VERACK) { - endpointSawVerack = true; - } else if (packet.type === packets.types.PING) { - endpointInner.write(frame( - framer, - new packets.PongPacket(packet.nonce))); - } - }); - requesterParser.on('packet', (packet) => { - if (packet.type === packets.types.VERSION) { - requesterSawVersion = packet.agent === '/hnsr-poc:0.0.1/'; - requesterInner.write(frame(framer, new packets.VerackPacket())); - } else if (packet.type === packets.types.VERACK) { - requesterSawVerack = true; - } else if (packet.type === packets.types.PONG) { - requesterSawPong = packet.nonce.equals(pingNonce); - } + 8); + const sampledRoute = sampled.records.find((record) => { + return record.routeKey.equals(key); }); - endpoint.hnsr.once('circuit', (socket) => { - socket.on('error', (err) => { - throw err; - }); - endpointInner = BrontideStream.fromInbound( - socket, - endpoint.identityKey); - endpointInner.on('error', (err) => { - throw err; - }); - endpointInner.on('data', data => endpointParser.feed(data)); - }); + assert(sampledRoute); + assert(sampledRoute.verify(requester.network.magic)); + assert(requester.hnsr.contacts.size >= 4); + const endpointKnownRendezvous = endpoint.hnsr.contacts.size; + + const renewedTickets = await Promise.all(endpointRelays.map( + (peer, index) => endpoint.hnsr.renew( + peer, + initialTickets[index], + reservationOptions))); + const refreshed = await endpoint.hnsr.republish( + publication, + renewedTickets, + endpointRendezvous, + {lifetime: 900, replicas: 4, minimumStores: 4}); - const circuit = await requester.hnsr.openCircuit( - requesterRelay, - routes[0].tickets[0]); - circuit.socket.on('error', (err) => { - throw err; - }); - requesterInner = BrontideStream.fromOutbound( - circuit.socket, - requester.identityKey, - routes[0].delegation.endpointKey); - requesterInner.on('error', (err) => { - throw err; - }); - requesterInner.on('data', data => requesterParser.feed(data)); - - await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('Inner Brontide handshake timed out.')), - 10000); - requesterInner.once('connect', () => { - clearTimeout(timer); - requesterInner.write(frame(framer, version(requesterNonce))); - resolve(); - }); + assert.strictEqual( + refreshed.record.sequence, + publication.record.sequence + 1); + assert.strictEqual( + refreshed.record.delegation.sequence, + publication.record.delegation.sequence + 1); + assert.strictEqual(refreshed.stored.length, 4); + + await Promise.all(endpointRelays.map((peer, index) => { + return endpoint.hnsr.withdraw(peer, initialTickets[index]); + })); + assert(relays.every(node => node.hnsr.reservations.size === 1)); + + await new Promise(resolve => setTimeout(resolve, 1100)); + const admission = await Promise.allSettled(new Array(72).fill(null).map( + () => requester.hnsr.lookup(requesterRendezvous, key, 1))); + const admitted = admission.filter(item => item.status === 'fulfilled'); + const rateLimited = admission.filter((item) => { + return item.status === 'rejected' && item.reason.code === 14; }); + assert(admitted.length > 0); + assert(rateLimited.length > 0); + + await closeNode(rendezvous[3], opened); + await new Promise(resolve => setTimeout(resolve, 1100)); + + const replicatedLookup = await requester.hnsr.lookupReplicated( + requesterRendezvous, + key, + 8, + {replicas: 4}); + + assert.strictEqual(replicatedLookup.records.length, 1); + assert.strictEqual( + replicatedLookup.records[0].sequence, + refreshed.record.sequence); + assert(replicatedLookup.queried.length >= 3); + assert(replicatedLookup.queried.length < 5); + + await closeNode(relays[0], opened); + + const endpointVirtualPromise = waitEvent(endpoint.hnsr, 'virtual peer'); + const openedRoute = await requester.hnsr.openPeer( + replicatedLookup.records[0]); + const [endpointVirtual] = await endpointVirtualPromise; + const requesterVirtual = openedRoute.peer; + await waitFor( - () => endpointSawVersion - && endpointSawVerack - && requesterSawVersion - && requesterSawVerack, - 'Inner Handshake version/verack exchange did not complete.'); + () => endpointVirtual.handshake && requesterVirtual.handshake, + 'Inner HNSR full-node peers did not complete version/verack.'); + assert(openedRoute.ticket.relayKey.equals(relays[1].hnsr.publicKey)); + assert.strictEqual(openedRoute.failures.length, 1); + assert(requesterVirtual.brontide.remoteStatic.equals( + endpoint.hnsr.publicKey)); + assert(endpointVirtual.brontide.remoteStatic.equals( + requester.hnsr.publicKey)); + + const relay = relays[1]; + const payloadStart = relayWirePayloads.get(relay).length; + const loadPackets = process.env.HNSR_LOAD_PACKETS + ? Number(process.env.HNSR_LOAD_PACKETS) + : 1000; + + assert(Number.isSafeInteger(loadPackets) && loadPackets >= 0); + + for (let i = 0; i < loadPackets; i++) { + const nonce = Buffer.allocUnsafe(8); + nonce.writeUInt32LE(i, 0); + nonce.writeUInt32LE(i ^ 0x5a5a5a5a, 4); + requesterVirtual.send(new packets.PingPacket(nonce)); + } + + const controlUnderLoadStarted = Date.now(); + const loadTicketPromise = endpoint.hnsr.reserve( + endpointRelays[1], + reservationOptions); + + const coinbase = Address.fromProgram(0, Buffer.alloc(20, 0x01)); + const block = await endpoint.miner.mineBlock(endpoint.chain.tip, coinbase); + const blockHash = block.hash(); + + for (let peer = endpoint.pool.peers.head(); peer; peer = peer.next) { + if (!peer.hnsrVirtual) + peer.invFilter.add(blockHash); + } + + for (let peer = requester.pool.peers.head(); peer; peer = peer.next) { + if (!peer.hnsrVirtual) + peer.invFilter.add(blockHash); + } - requesterInner.write(frame(framer, new packets.PingPacket(pingNonce))); + const propagationStarted = Date.now(); + await endpoint.chain.add(block); await waitFor( - () => requesterSawPong, - 'Inner Handshake ping/pong did not complete.'); + () => requester.chain.height === 1, + () => { + return 'Inner block did not converge (' + + `requester=${requester.chain.height}).`; + }, + 30000); + const blockLatency = Date.now() - propagationStarted; + const loadTicket = await loadTicketPromise; + const controlUnderLoadLatency = Date.now() - controlUnderLoadStarted; + await endpoint.hnsr.withdraw(endpointRelays[1], loadTicket); - assert(endpointInner.remoteStatic.equals( - secp256k1.publicKeyCreate(requester.identityKey, true))); - assert(requesterInner.remoteStatic.equals(endpoint.hnsr.publicKey)); + await waitFor( + () => relay.hnsr.relayQueueBytes === 0, + 'Relay scheduler did not drain after saturation.', + 30000); + await requesterVirtual.drain(); + + assert.strictEqual(endpoint.chain.height, 1); + assert.strictEqual(requester.chain.height, 1); + assert(relays.every(node => node.chain.height === 0)); + assert(rendezvous.every(node => node.chain.height === 0)); + assert(relay.hnsr.relayFrames > loadPackets); + assert(relay.hnsr.relayFlushes > 1); + assert(relay.hnsr.maxRelayQueuedBytes > common.hnsr.RELAY_BURST); + assert(relay.hnsr.maxRelayQueuedBytes + <= common.hnsr.MAX_CIRCUIT_QUEUE); + assert.strictEqual(relay.hnsr.relayDrops, 0); + const controlNodeHeights = [...relays, ...rendezvous] + .map(node => node.chain.height); + + assert(controlNodeHeights.every(height => height === 0)); + + assert.strictEqual(relay.hnsr.reservations.size, 1); assert.strictEqual( - relay.hnsr.relayPayloads.some(raw => raw.includes(pingNonce)), + relayWirePayloads.get(relay).slice(payloadStart) + .some(raw => raw.includes(blockHash)), false); - await endpoint.close(); - opened.splice(opened.indexOf(endpoint), 1); + await closeNode(endpoint, opened); await waitFor( () => relay.hnsr.reservations.size === 0, 'Relay did not invalidate the disconnected endpoint reservation.'); - const staleRoutes = await requester.hnsr.lookup( + const staleLookup = await requester.hnsr.lookupReplicated( requesterRendezvous, - key); - assert.strictEqual(staleRoutes.length, 1); + key, + 8, + {replicas: 4}); let staleRejected = false; try { - await requester.hnsr.openCircuit( - requesterRelay, - staleRoutes[0].tickets[0]); + await requester.hnsr.openRoute(staleLookup.records[0]); } catch (e) { - staleRejected = e.code === 11; + staleRejected = Array.isArray(e.failures) + && e.failures.some(item => item.error.code === 11); } assert(staleRejected); + const activeRendezvous = rendezvous.slice(0, 3); + const transcript = relayWirePayloads.get(relay).length > 0 + ? Buffer.concat(relayWirePayloads.get(relay)) + : Buffer.alloc(0); const result = { - schema: 1, + schema: 2, network: 'regtest', assignment: { rendezvousServiceBit: @@ -394,57 +481,85 @@ async function main() { packetType: `0x${common.EXPERIMENTAL_HNSR.toString(16)}` }, topology: { - fullNodes: 4, - outerTransport: 'authenticated Handshake Brontide', + fullNodes: 8, + relays: 2, + rendezvousNodes: 4, endpointListeners: 0, - convergedRegtestHeight: 1, - endpoint: endpoint.hnsr.publicKey.toString('hex'), - relay: relay.hnsr.publicKey.toString('hex'), - rendezvous: rendezvous.hnsr.publicKey.toString('hex'), - requester: requester.hnsr.publicKey.toString('hex') - }, - reservation: { - relaySignatureVerified: ticket.verifyRelay(), - endpointSignatureVerified: ticket.verifyEndpoint(), - ticketID: ticket.id().toString('hex'), - maxActiveCircuits: ticket.maxActiveCircuits, - maxBytesPerCircuit: ticket.maxBytesPerCircuit + outerTransport: 'authenticated Handshake Brontide', + innerTransport: 'end-to-end authenticated Handshake Brontide' }, - rendezvous: { - routeKey: key.toString('hex'), - routeBytes: record.encode().length, - routeSignatureVerified: record.verify(endpoint.network.magic), - returnedRecords: routes.length, - storedCopiesInTrial: rendezvous.hnsr.store.size + discovery: { + bootstrapRendezvous: 1, + endpointKnownRendezvous, + requesterKnownRendezvous: requester.hnsr.contacts.size, + sampledRecords: sampled.records.length, + sampledEndpointFound: Boolean(sampledRoute), + iterativeLookupLiveNodes: replicatedLookup.queried.length }, - circuit: { - profile: 'HNS_NODE_V1', - circuitID: circuit.circuitID.toString('hex'), - innerTransport: 'end-to-end Handshake Brontide', - endpointAuthenticated: requesterInner.remoteStatic.equals( - endpoint.hnsr.publicKey), - requesterAuthenticated: endpointInner.remoteStatic.equals( - requester.hnsr.publicKey), - versionVerack: true, - pingPong: true - }, - relayView: { - forwardedEncryptedBytes: relay.hnsr.relayBytes, - plaintextPingNonceObserved: relay.hnsr.relayPayloads - .some(raw => raw.includes(pingNonce)), - transcriptSHA256: sha256.digest(Buffer.concat( - relay.hnsr.relayPayloads)).toString('hex') + replication: { + requestedCopies: 4, + initialStoredCopies: publication.stored.length, + refreshedStoredCopies: refreshed.stored.length, + survivingStores: activeRendezvous.map(node => node.hnsr.store.size), + rendezvousFailureRecovered: replicatedLookup.records.length === 1 }, lifecycle: { - staleRouteStillReturned: staleRoutes.length === 1, + initialSequence: publication.record.sequence, + refreshedSequence: refreshed.record.sequence, + renewedTickets: renewedTickets.length, + oldTicketsWithdrawn: initialTickets.length, + staleRouteStillReturned: staleLookup.records.length === 1, disconnectedReservationInvalidated: relay.hnsr.reservations.size === 0, staleTicketRejected: staleRejected }, + failover: { + firstRelayStopped: true, + failedCandidates: openedRoute.failures.length, + selectedRelay: openedRoute.ticket.relayKey.toString('hex'), + selectedSecondRelay: openedRoute.ticket.relayKey.equals( + relays[1].hnsr.publicKey) + }, + innerPeer: { + profile: 'HNS_NODE_V1', + actualHsdPeerObjects: true, + versionVerack: requesterVirtual.handshake && endpointVirtual.handshake, + endpointAuthenticated: requesterVirtual.brontide.remoteStatic.equals( + endpoint.hnsr.publicKey), + requesterAuthenticated: endpointVirtual.brontide.remoteStatic.equals( + requester.hnsr.publicKey) + }, + blockTraffic: { + hash: blockHash.toString('hex'), + endpointHeight: 1, + requesterHeight: requester.chain.height, + controlNodeHeights, + deliveredOnlyByInnerPeer: controlNodeHeights + .every(height => height === 0), + latencyMs: blockLatency + }, + saturation: { + pingPackets: loadPackets, + relayFrames: relay.hnsr.relayFrames, + relayBytes: relay.hnsr.relayBytes, + schedulerFlushes: relay.hnsr.relayFlushes, + maximumQueuedBytes: relay.hnsr.maxRelayQueuedBytes, + queueLimitBytes: common.hnsr.MAX_CIRCUIT_QUEUE, + relayDrops: relay.hnsr.relayDrops, + controlReservationLatencyMs: controlUnderLoadLatency, + admissionRequests: admission.length, + admissionAccepted: admitted.length, + admissionRateLimited: rateLimited.length + }, + relayView: { + plaintextBlockHashObserved: relayWirePayloads.get(relay) + .some(raw => raw.includes(blockHash)), + transcriptSHA256: sha256.digest(transcript).toString('hex') + }, observedOpcodes: wireCounts, result: 'pass' }; - const output = JSON.stringify(result, null, 2) + '\n'; + const output = `${JSON.stringify(result, null, 2)}\n`; if (artifact) { fs.mkdirSync(path.dirname(artifact), {recursive: true}); diff --git a/test/hnsr-test.js b/test/hnsr-test.js index cb01d994e6..707f92a617 100644 --- a/test/hnsr-test.js +++ b/test/hnsr-test.js @@ -12,6 +12,11 @@ const { EndpointDelegation, RouteRecord, RouteStore, + RendezvousContact, + CircuitSocket, + rendezvousNodeID, + compareDistance, + opcodes, routeKey } = require('../lib/net/hnsr'); @@ -221,6 +226,118 @@ describe('HNSR', function() { assert.strictEqual(store.size, 0); }); + it('should encode authenticated rendezvous contacts and XOR order', () => { + const privateKey = secp256k1.privateKeyGenerate(); + const peerKey = secp256k1.publicKeyCreate(privateKey, true); + const timestamp = 1700000000; + const contact = new RendezvousContact({ + nodeID: rendezvousNodeID(network.magic, peerKey), + hostType: 1, + host: Buffer.from('00000000000000000000ffff7f000001', 'hex'), + port: network.brontidePort, + services: common.services.NETWORK + | common.EXPERIMENTAL_HNSR_RENDEZVOUS_SERVICE, + peerKey, + observedAt: timestamp + }); + const decoded = RendezvousContact.decode(contact.encode()); + + assert.strictEqual(contact.encode().length, 100); + assert(decoded.verify(network.magic, timestamp)); + assert(decoded.peerKey.equals(peerKey)); + assert.strictEqual(decoded.toAddress(network).host, '127.0.0.1'); + assert.strictEqual(decoded.toAddress(network).port, network.brontidePort); + + const target = Buffer.alloc(32); + const near = Buffer.alloc(32); + const far = Buffer.alloc(32); + near[0] = 1; + far[0] = 2; + assert(compareDistance(near, far, target) < 0); + assert(compareDistance(far, near, target) > 0); + }); + + it('should sample deterministically and enforce source quotas', () => { + const timestamp = 1700000000; + const first = fixture(timestamp, 1); + const second = fixture(timestamp, 1); + const store = new RouteStore(network.magic, { + maxRecords: 4, + maxPerKey: 2, + maxPerPeer: 1 + }); + + store.put(first.key, first.record.encode(), timestamp, 'peer-a'); + assert.throws(() => { + store.put(second.key, second.record.encode(), timestamp, 'peer-a'); + }, /per-peer route capacity/); + store.put(second.key, second.record.encode(), timestamp, 'peer-b'); + + const seed = Buffer.alloc(32, 0x11); + const sample = store.sample(2, seed, timestamp); + const repeated = store.sample(2, seed, timestamp); + + assert.strictEqual(sample.length, 2); + assert.bufferEqual(sample[0], repeated[0]); + assert.bufferEqual(sample[1], repeated[1]); + + first.record.sequence = 2; + first.record.sign(first.endpointPrivate); + assert.throws(() => { + store.put(first.key, first.record.encode(), timestamp, 'peer-b'); + }, /per-peer route capacity/); + + const retained = RouteRecord.decode(store.get( + first.key, + 1, + timestamp)[0]); + assert.strictEqual(retained.sequence, 1); + }); + + it('should apply circuit backpressure and delayed window credit', async () => { + const sent = []; + const service = { + _send(peer, opcode, contextID, body) { + sent.push({peer, opcode, contextID, body}); + return true; + }, + _dropSocket() {} + }; + const peer = {id: 1}; + const contextID = Buffer.from('0102030405060708', 'hex'); + const socket = new CircuitSocket( + service, + peer, + contextID, + common.hnsr.MIN_WINDOW); + const payload = Buffer.alloc(common.hnsr.MIN_WINDOW + 10, 0x22); + + assert.strictEqual(socket.write(payload), false); + assert.strictEqual(sent.length, 1); + assert.strictEqual(sent[0].opcode, opcodes.DATA); + assert.strictEqual(sent[0].body.length, common.hnsr.MIN_WINDOW); + assert.strictEqual(socket.sendQueueBytes, 10); + + const drained = new Promise(resolve => socket.once('drain', resolve)); + socket.addCredit(10); + await drained; + assert.strictEqual(socket.sendQueueBytes, 0); + assert.strictEqual(sent[1].body.length, 10); + + let received = null; + socket.on('data', (data) => { + received = data; + }); + socket.pause(); + socket.receive(Buffer.from('aabb', 'hex')); + assert.strictEqual(received, null); + assert.strictEqual(sent.length, 2); + socket.resume(); + assert.bufferEqual(received, Buffer.from('aabb', 'hex')); + assert.strictEqual(sent[2].opcode, opcodes.WINDOW); + assert.strictEqual(sent[2].body.readUInt32LE(0), 2); + }); + it('should expose role bits only for configured regtest roles', () => { const node = new FullNode({ network: 'regtest', From d9356231f0114d0981d4ea70f49896ff47ce60cc Mon Sep 17 00:00:00 2001 From: Jaron Rosenau Date: Tue, 21 Jul 2026 17:58:10 -0700 Subject: [PATCH 3/5] Bind HNSR renewals to prior reservations --- docs/hnsr-regtest-phase1.json | 14 ++++----- lib/net/hnsr.js | 59 ++++++++++++++++++++++++++++++++--- test/hnsr-test.js | 34 ++++++++++++++++++++ 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/docs/hnsr-regtest-phase1.json b/docs/hnsr-regtest-phase1.json index 174877a48b..d8ae35531e 100644 --- a/docs/hnsr-regtest-phase1.json +++ b/docs/hnsr-regtest-phase1.json @@ -45,7 +45,7 @@ "failover": { "firstRelayStopped": true, "failedCandidates": 1, - "selectedRelay": "039b9e8e86146290505e67871bf376323b60faf2afc492622a44633c4f96fca481", + "selectedRelay": "0264c8d538c8d6a2ea4ed20cd01ebfb6ec1a178574c134f2eeb74703449c4acc0b", "selectedSecondRelay": true }, "innerPeer": { @@ -56,7 +56,7 @@ "requesterAuthenticated": true }, "blockTraffic": { - "hash": "029aeb36f6ed4d0d74b825ddcd76fcb3ed46dcd0b7606bb48acd30e71a922013", + "hash": "3842f8a065d348109fde283b65d182238cc5376685176bf90b00960329b738c3", "endpointHeight": 1, "requesterHeight": 1, "controlNodeHeights": [ @@ -68,24 +68,24 @@ 0 ], "deliveredOnlyByInnerPeer": true, - "latencyMs": 8439 + "latencyMs": 7890 }, "saturation": { "pingPackets": 1000, "relayFrames": 8043, "relayBytes": 107461, - "schedulerFlushes": 14, - "maximumQueuedBytes": 53054, + "schedulerFlushes": 16, + "maximumQueuedBytes": 53000, "queueLimitBytes": 65536, "relayDrops": 0, - "controlReservationLatencyMs": 9038, + "controlReservationLatencyMs": 8256, "admissionRequests": 72, "admissionAccepted": 64, "admissionRateLimited": 8 }, "relayView": { "plaintextBlockHashObserved": false, - "transcriptSHA256": "133e2352fb9ea7d0825a614fc83464236ecd6310d31f0c8437124ea9dd82f27f" + "transcriptSHA256": "980097edaf000851270abcf6141aa13ff127eb41d08a03db500dc0eac7c0b7b6" }, "observedOpcodes": { "RESERVE": 3, diff --git a/lib/net/hnsr.js b/lib/net/hnsr.js index 11fb8eedd6..a5dc1fc55e 100644 --- a/lib/net/hnsr.js +++ b/lib/net/hnsr.js @@ -21,6 +21,7 @@ const EMPTY = Buffer.alloc(0); const domains = { RESERVE: Buffer.from('HNSR-RESERVE-V1\0', 'ascii'), + RENEW: Buffer.from('HNSR-RENEW-V1\0', 'ascii'), TICKET_RELAY: Buffer.from('HNSR-RELAY-TICKET-V1\0', 'ascii'), TICKET_ENDPOINT: Buffer.from('HNSR-RELAY-CONFIRM-V1\0', 'ascii'), DELEGATION: Buffer.from('HNSR-ENDPOINT-DELEGATION-V1\0', 'ascii'), @@ -351,6 +352,33 @@ class ReserveRequest { this.endpointKey); } + renewalData(magic, relayKey, contextID, reservationID) { + assert(Buffer.isBuffer(reservationID) && reservationID.length === 16); + return Buffer.concat([ + magicBytes(magic), + relayKey, + contextID, + reservationID, + this.encodeUnsigned() + ]); + } + + signRenewal(magic, relayKey, contextID, reservationID, privateKey) { + this.signature = sign( + domains.RENEW, + this.renewalData(magic, relayKey, contextID, reservationID), + privateKey); + return this; + } + + verifyRenewal(magic, relayKey, contextID, reservationID) { + return verify( + domains.RENEW, + this.renewalData(magic, relayKey, contextID, reservationID), + this.signature, + this.endpointKey); + } + encode() { const unsigned = this.encodeUnsigned(); const bw = bio.write(unsigned.length + 1 + this.signature.length); @@ -1500,7 +1528,20 @@ class HNSRService extends EventEmitter { nonce: randomID(16) }); - request.sign(this.network.magic, relayKey, contextID, this.identityKey); + if (previous) { + request.signRenewal( + this.network.magic, + relayKey, + contextID, + previous.reservationID, + this.identityKey); + } else { + request.sign( + this.network.magic, + relayKey, + contextID, + this.identityKey); + } let body = request.encode(); @@ -2123,10 +2164,18 @@ class HNSRService extends EventEmitter { const request = ReserveRequest.decode(body); - if (!request.verify( - this.network.magic, - this.publicKey, - packet.contextID)) { + const validSignature = renewal + ? request.verifyRenewal( + this.network.magic, + this.publicKey, + packet.contextID, + previous.ticket.reservationID) + : request.verify( + this.network.magic, + this.publicKey, + packet.contextID); + + if (!validSignature) { throw new Error('Invalid HNSR reservation signature.'); } diff --git a/test/hnsr-test.js b/test/hnsr-test.js index 707f92a617..4e062fdf01 100644 --- a/test/hnsr-test.js +++ b/test/hnsr-test.js @@ -154,6 +154,40 @@ describe('HNSR', function() { false); }); + it('should bind a renewal to its previous reservation', () => { + const item = fixture(); + const context = Buffer.from('0102030405060708', 'hex'); + const previous = Buffer.alloc(16, 0x04); + const request = new ReserveRequest({ + endpointKey: item.endpointKey, + profile: 1, + lifetime: 1800, + maxCircuits: 8, + maxBytes: 1048576, + nonce: Buffer.alloc(16, 0x05) + }).signRenewal( + network.magic, + item.relayKey, + context, + previous, + item.endpointPrivate); + const decoded = ReserveRequest.decode(request.encode()); + + assert(decoded.verifyRenewal( + network.magic, + item.relayKey, + context, + previous)); + assert.strictEqual(decoded.verifyRenewal( + network.magic, + item.relayKey, + context, + Buffer.alloc(16, 0x06)), false); + assert.strictEqual( + decoded.verify(network.magic, item.relayKey, context), + false); + }); + it('should round trip and authenticate a relay ticket', () => { const item = fixture(); const decoded = RelayTicket.decode(item.ticket.encode()); From 38e2a4946ea13a8f09e866a49bc0d802ffb37dbe Mon Sep 17 00:00:00 2001 From: Jaron Rosenau Date: Tue, 21 Jul 2026 18:01:18 -0700 Subject: [PATCH 4/5] Bind endpoint delegations to the network --- docs/hnsr-regtest-phase1.json | 10 +++++----- lib/net/hnsr.js | 12 ++++++------ test/hnsr-test.js | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/hnsr-regtest-phase1.json b/docs/hnsr-regtest-phase1.json index d8ae35531e..957ec75a13 100644 --- a/docs/hnsr-regtest-phase1.json +++ b/docs/hnsr-regtest-phase1.json @@ -45,7 +45,7 @@ "failover": { "firstRelayStopped": true, "failedCandidates": 1, - "selectedRelay": "0264c8d538c8d6a2ea4ed20cd01ebfb6ec1a178574c134f2eeb74703449c4acc0b", + "selectedRelay": "03ed129c6047c975cf65fc0eb651011dc58d4f5ae4e5e693b3d769fd6c61ee073d", "selectedSecondRelay": true }, "innerPeer": { @@ -56,7 +56,7 @@ "requesterAuthenticated": true }, "blockTraffic": { - "hash": "3842f8a065d348109fde283b65d182238cc5376685176bf90b00960329b738c3", + "hash": "201a270ec8583222dc56533994e6dc591ae4d462167f5273ca494deb6c2b1311", "endpointHeight": 1, "requesterHeight": 1, "controlNodeHeights": [ @@ -68,7 +68,7 @@ 0 ], "deliveredOnlyByInnerPeer": true, - "latencyMs": 7890 + "latencyMs": 8353 }, "saturation": { "pingPackets": 1000, @@ -78,14 +78,14 @@ "maximumQueuedBytes": 53000, "queueLimitBytes": 65536, "relayDrops": 0, - "controlReservationLatencyMs": 8256, + "controlReservationLatencyMs": 9212, "admissionRequests": 72, "admissionAccepted": 64, "admissionRateLimited": 8 }, "relayView": { "plaintextBlockHashObserved": false, - "transcriptSHA256": "980097edaf000851270abcf6141aa13ff127eb41d08a03db500dc0eac7c0b7b6" + "transcriptSHA256": "3eb75db41d0c41281b93f670657db621b3a4f24b090f7188a12d9f2556a3a5a9" }, "observedOpcodes": { "RESERVE": 3, diff --git a/lib/net/hnsr.js b/lib/net/hnsr.js index a5dc1fc55e..f08cb7bdac 100644 --- a/lib/net/hnsr.js +++ b/lib/net/hnsr.js @@ -608,15 +608,15 @@ class EndpointDelegation { return bw.render(); } - sign(privateKey) { + sign(magic, privateKey) { this.signature = sign( domains.DELEGATION, - this.encodeUnsigned(), + Buffer.concat([magicBytes(magic), this.encodeUnsigned()]), privateKey); return this; } - verify(timestamp = now()) { + verify(magic, timestamp = now()) { if (!isZero(this.authorizationID) || this.sequence < 1 || this.expiresAt <= this.issuedAt @@ -632,7 +632,7 @@ class EndpointDelegation { return verify( domains.DELEGATION, - this.encodeUnsigned(), + Buffer.concat([magicBytes(magic), this.encodeUnsigned()]), this.signature, this.endpointKey); } @@ -735,7 +735,7 @@ class RouteRecord { || this.tickets.length < 1 || this.tickets.length > 8 || !this.routeKey.equals(routeKey(magic, this.delegation.endpointKey)) - || !this.delegation.verify(timestamp) + || !this.delegation.verify(magic, timestamp) || this.delegation.expiresAt < this.expiresAt) { return false; } @@ -1821,7 +1821,7 @@ class HNSRService extends EventEmitter { ...tickets.map(ticket => ticket.maxActiveCircuits)), maxBytesPerCircuit: Math.min( ...tickets.map(ticket => ticket.maxBytesPerCircuit)) - }).sign(this.identityKey); + }).sign(this.network.magic, this.identityKey); const key = routeKey(this.network.magic, this.publicKey); const record = new RouteRecord({ routeKey: key, diff --git a/test/hnsr-test.js b/test/hnsr-test.js index 4e062fdf01..d29d1e7777 100644 --- a/test/hnsr-test.js +++ b/test/hnsr-test.js @@ -64,7 +64,7 @@ function fixture(timestamp = Math.floor(Date.now() / 1000), sequence = 1) { expiresAt: timestamp + 900, maxActiveCircuits: 8, maxBytesPerCircuit: 1048576 - }).sign(endpointPrivate); + }).sign(network.magic, endpointPrivate); const key = routeKey(network.magic, endpointKey); const record = new RouteRecord({ routeKey: key, From 2fc40f1c61ff16a2f39d9514cd950d1560430ced Mon Sep 17 00:00:00 2001 From: Jaron Rosenau Date: Tue, 21 Jul 2026 18:06:37 -0700 Subject: [PATCH 5/5] Bound HNSR relay and routing state --- docs/hnsr-regtest-phase1.json | 14 ++++----- lib/net/common.js | 4 +++ lib/net/hnsr.js | 58 ++++++++++++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/hnsr-regtest-phase1.json b/docs/hnsr-regtest-phase1.json index 957ec75a13..8f63e5bb35 100644 --- a/docs/hnsr-regtest-phase1.json +++ b/docs/hnsr-regtest-phase1.json @@ -45,7 +45,7 @@ "failover": { "firstRelayStopped": true, "failedCandidates": 1, - "selectedRelay": "03ed129c6047c975cf65fc0eb651011dc58d4f5ae4e5e693b3d769fd6c61ee073d", + "selectedRelay": "03664a4d9746167b043a17cd9b9f2211f92ef37992b2025cc3d4570b302150d94a", "selectedSecondRelay": true }, "innerPeer": { @@ -56,7 +56,7 @@ "requesterAuthenticated": true }, "blockTraffic": { - "hash": "201a270ec8583222dc56533994e6dc591ae4d462167f5273ca494deb6c2b1311", + "hash": "6d2615113467bbee7a6ed2215e28b81f2e0210a2a383b5854fd2105d086cdb9e", "endpointHeight": 1, "requesterHeight": 1, "controlNodeHeights": [ @@ -68,24 +68,24 @@ 0 ], "deliveredOnlyByInnerPeer": true, - "latencyMs": 8353 + "latencyMs": 3020 }, "saturation": { "pingPackets": 1000, "relayFrames": 8043, "relayBytes": 107461, - "schedulerFlushes": 16, - "maximumQueuedBytes": 53000, + "schedulerFlushes": 17, + "maximumQueuedBytes": 44129, "queueLimitBytes": 65536, "relayDrops": 0, - "controlReservationLatencyMs": 9212, + "controlReservationLatencyMs": 3082, "admissionRequests": 72, "admissionAccepted": 64, "admissionRateLimited": 8 }, "relayView": { "plaintextBlockHashObserved": false, - "transcriptSHA256": "3eb75db41d0c41281b93f670657db621b3a4f24b090f7188a12d9f2556a3a5a9" + "transcriptSHA256": "36257106017f1ef8693af71b67fa59bc568bfefb6a32dc09fcee745b42641712" }, "observedOpcodes": { "RESERVE": 3, diff --git a/lib/net/common.js b/lib/net/common.js index 7b4c38b86a..11a0915ded 100644 --- a/lib/net/common.js +++ b/lib/net/common.js @@ -79,12 +79,14 @@ exports.hnsr = { MAX_RECORDS_PER_KEY: 16, MAX_STORED_RECORDS: 50000, MAX_CONTACTS: 16, + MAX_ROUTING_CONTACTS: 2048, MAX_FIND_QUERIES: 32, ROUTE_REPLICATION: 8, MIN_ROUTE_STORES: 3, MAX_DATA_SIZE: 16384, MAX_CIRCUIT_QUEUE: 65536, MAX_SOCKET_QUEUE: 8 * 1000 * 1000 + 65536, + MAX_RELAY_QUEUE: 8 * 1024 * 1024, RELAY_BURST: 32768, MIN_WINDOW: 16384, DEFAULT_WINDOW: 65536, @@ -93,6 +95,8 @@ exports.hnsr = { MAX_TICKET_LIFETIME: 7200, MAX_ROUTE_LIFETIME: 7200, MAX_CIRCUITS: 32, + MAX_RESERVATIONS: 1024, + MAX_RESERVATIONS_PER_PEER: 16, MAX_SIGNATURE_SIZE: 80, MAX_REQUESTS_PER_SECOND: 64, MAX_REQUEST_BYTES_PER_SECOND: 1048576, diff --git a/lib/net/hnsr.js b/lib/net/hnsr.js index f08cb7bdac..b37ffe59fc 100644 --- a/lib/net/hnsr.js +++ b/lib/net/hnsr.js @@ -1268,7 +1268,30 @@ class HNSRService extends EventEmitter { if (!contact.verify(this.network.magic)) return null; - this.contacts.set(key.toString('hex'), contact); + const hex = key.toString('hex'); + + for (const [known, item] of this.contacts) { + if (!item.verify(this.network.magic)) + this.contacts.delete(known); + } + + if (!this.contacts.has(hex) + && this.contacts.size >= common.hnsr.MAX_ROUTING_CONTACTS) { + let oldestKey = null; + let oldestTime = Infinity; + + for (const [known, item] of this.contacts) { + if (item.observedAt < oldestTime) { + oldestKey = known; + oldestTime = item.observedAt; + } + } + + if (oldestKey != null) + this.contacts.delete(oldestKey); + } + + this.contacts.set(hex, contact); return contact; } @@ -2194,16 +2217,37 @@ class HNSRService extends EventEmitter { throw new Error('HNSR renewal endpoint key mismatch.'); } - let count = 0; + const renewalAllowance = previous ? 1 : 0; + const reservationCount = this.reservations.size + this.provisional.size; + + if (reservationCount + >= common.hnsr.MAX_RESERVATIONS + renewalAllowance) { + throw new Error('HNSR relay reservation capacity reached.'); + } + + let provisionalCount = 0; + let peerCount = 0; for (const item of this.provisional.values()) { + if (item.peer !== peer) + continue; + provisionalCount += 1; + peerCount += 1; + } + + for (const item of this.reservations.values()) { if (item.peer === peer) - count += 1; + peerCount += 1; } - if (count >= 2) + if (provisionalCount >= 2) throw new Error('HNSR provisional reservation capacity reached.'); + if (peerCount + >= common.hnsr.MAX_RESERVATIONS_PER_PEER + renewalAllowance) { + throw new Error('HNSR per-peer reservation capacity reached.'); + } + const timestamp = now(); const ticket = new RelayTicket({ networkMagic: this.network.magic, @@ -2655,6 +2699,12 @@ class HNSRService extends EventEmitter { return; } + if (this.relayQueueBytes + data.length > common.hnsr.MAX_RELAY_QUEUE) { + this.relayDrops += 1; + this._closeRelayCircuit(state, errors.CAPACITY); + return; + } + const raw = Buffer.from(data); state.queuedBytes += raw.length; this.relayQueueBytes += raw.length;