From 4c300aa7327e2246c0e17fb0df156340354d3e66 Mon Sep 17 00:00:00 2001 From: Jaron Rosenau Date: Tue, 21 Jul 2026 19:30:53 -0700 Subject: [PATCH] Implement HNSR named-service Phase 1B --- docs/experimental-hnsr.md | 90 +- docs/hnsr-regtest-phase1.json | 105 ++- lib/net/common.js | 7 +- lib/net/hnsr.js | 1263 ++++++++++++++++++++++++++++- lib/node/fullnode.js | 6 +- scripts/run-hnsr-regtest-trial.js | 385 ++++++++- test/hnsr-test.js | 323 +++++++- 7 files changed, 2070 insertions(+), 109 deletions(-) diff --git a/docs/experimental-hnsr.md b/docs/experimental-hnsr.md index 191a8f924..84f17270c 100644 --- a/docs/experimental-hnsr.md +++ b/docs/experimental-hnsr.md @@ -1,8 +1,8 @@ # 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. +the Phase 1 `HNS_NODE_V1` and `HNS_WEB_V1` paths 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 @@ -20,7 +20,7 @@ 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 +## Implemented trial profiles The branch implements: @@ -56,7 +56,20 @@ The branch implements: and - 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. + sockets; +- current, validated, closed HNS name-state lookup and canonical + `hnsr1 k=` TXT parsing with ambiguity rejection; +- root-signed service authorization, service-signed endpoint delegation, named + route-key derivation, and complete requester-side authority-chain checks; +- replicated named `HNS_WEB_V1` records for multiple endpoints; +- inner endpoint-authenticated Brontide carrying bounded HTTP/1.1 requests and + responses without exposing their plaintext to the relay; +- strict Host, HNS authority, and service agreement, with duplicate-header, + transfer-encoding, upgrade, body-size, request-count, and timeout limits; +- reusable inner web sessions for up to 16 request/response exchanges; +- stable origin derivation from `(hnsr, name_hash, service_name, profile_id)`, + independent of relay, ticket, and endpoint rotation; and +- sequential named-endpoint and per-record relay failover. 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 @@ -85,17 +98,22 @@ 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 +### Phase 1B: named-service regtest implemented here -Still to implement before claiming the HIP's complete Phase 1 service surface: +The branch completes the directly executable, bounded `HNS_WEB_V1` slice: -- 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. +- an on-chain regtest name auction and authenticated root-key `UPDATE`; +- service authorization, two endpoint delegations, and four-copy named routes; +- inner Brontide HTTP request/response and same-circuit connection reuse; +- authority mismatch rejection and stable browser-origin derivation; +- endpoint failure followed by authenticated fallback; and +- web-specific cryptographic, framing, circuit, byte, and admission limits. -These features are not prerequisites for review of the unnamed full-node -transport, but they are prerequisites for claiming named HNS service support. +This PoC intentionally buffers each bounded HTTP message (maximum 16 KiB of +headers and 1 MiB of body) instead of providing an unbounded streaming API. +It derives and returns the mandatory origin tuple, but HSD cannot itself +isolate browser cookies, storage, permissions, or service workers. Native +browser enforcement remains a Phase 3 client-integration requirement. ### Phase 2: bounded testnet hardening @@ -141,36 +159,47 @@ NODE_BACKEND=js node scripts/run-hnsr-regtest-trial.js \ `NODE_BACKEND=js` selects bcrypto's portable JavaScript backend and is not a protocol requirement. -The trial starts eight independently keyed, independently prefixed FullNodes: +The trial starts nine independently keyed, independently prefixed FullNodes: ```text -Endpoint (no listener) ==> Relay A, Relay B, Rendezvous 0 -Requester ==> Rendezvous 0 -Rendezvous 0 ==> Rendezvous 1 ==> Rendezvous 2 ==> Rendezvous 3 +Endpoint/fallback web (no listener) ==> Relay A, Relay B, Rendezvous 0 +Primary web 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 +Requester == inner HNS_WEB_V1 ==> relay ==> authenticated named endpoint ``` It then: -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 +1. mines a shared regtest chain, auctions `phase1b`, and publishes a canonical + HNSR root key in its authenticated resource; +2. authorizes `p2p-site`, registers two named web endpoints, and publishes both + named records to four rendezvous nodes; +3. performs an HTTP request over inner Brontide, reuses another inner circuit + for two requests, rejects a mismatched authority with status 421, and proves + relay DATA frames do not contain the response plaintext; +4. disconnects the primary named endpoint and reaches the fallback endpoint + after rejecting the stale higher-sequence candidate; +5. iteratively discovers all four rendezvous nodes from one bootstrap; +6. reserves both relays and stores one signed unnamed route at all four + rendezvous nodes; +7. discovers the unnamed route with `SAMPLEROUTES`; +8. 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 +9. issues 72 concurrent lookup requests and verifies bounded admission; +10. 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 +11. stops Relay A and verifies automatic fallback to Relay B; +12. 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 +13. sends 1,000 ordinary Handshake pings while mining and relaying a real block; +14. proves only endpoint and requester advance from height 47 to 48 while both + relays and all rendezvous controls remain at height 47; +15. 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 +16. 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 @@ -187,6 +216,7 @@ The following illustrative flags are recognized by `FullNode`: --experimental-hnsr-endpoint --experimental-hnsr-relay --experimental-hnsr-rendezvous +--experimental-hnsr-web --experimental-hnsr-timeout= ``` diff --git a/docs/hnsr-regtest-phase1.json b/docs/hnsr-regtest-phase1.json index 8f63e5bb3..a39bf6e1e 100644 --- a/docs/hnsr-regtest-phase1.json +++ b/docs/hnsr-regtest-phase1.json @@ -1,5 +1,5 @@ { - "schema": 2, + "schema": 3, "network": "regtest", "assignment": { "rendezvousServiceBit": "0x4000000", @@ -7,7 +7,7 @@ "packetType": "0xf3" }, "topology": { - "fullNodes": 8, + "fullNodes": 9, "relays": 2, "rendezvousNodes": 4, "endpointListeners": 0, @@ -27,12 +27,38 @@ "initialStoredCopies": 4, "refreshedStoredCopies": 4, "survivingStores": [ - 1, - 1, - 1 + 3, + 3, + 3 ], "rendezvousFailureRecovered": true }, + "namedWeb": { + "rootName": "phase1b", + "serviceName": "p2p-site", + "hnsStateHeight": 47, + "canonicalRootKeyAuthenticated": true, + "serviceAuthorizationVerified": true, + "namedRouteCopies": 4, + "namedEndpoints": 2, + "profile": "HNS_WEB_V1", + "innerBrontide": true, + "initialStatusCode": 200, + "initialBody": "primary:/hello?phase=1b", + "connectionReuse": true, + "reusedRequests": 16, + "firstReusedBody": "primary:/reuse/1", + "lastReusedBody": "primary:/reuse/16", + "origin": "hnsr:b9ee830c68070a6bda13dedf78ddf714d99542d7dad69c96c0ad51c4a13b6e4e:p2p-site:2", + "authorityMismatchStatus": 421, + "primaryEndpointStopped": true, + "failedEndpointCandidates": 1, + "failoverStatusCode": 200, + "failoverBody": "fallback:/hello?phase=1b", + "relayObservedPlaintext": false, + "maximumBodyBytes": 1048576, + "maximumRequestsPerCircuit": 16 + }, "lifecycle": { "initialSequence": 1, "refreshedSequence": 2, @@ -45,7 +71,7 @@ "failover": { "firstRelayStopped": true, "failedCandidates": 1, - "selectedRelay": "03664a4d9746167b043a17cd9b9f2211f92ef37992b2025cc3d4570b302150d94a", + "selectedRelay": "038d69eb9805e7de1d387d9b91805977968d494ade8d465aa5bd7030699926981e", "selectedSecondRelay": true }, "innerPeer": { @@ -56,59 +82,60 @@ "requesterAuthenticated": true }, "blockTraffic": { - "hash": "6d2615113467bbee7a6ed2215e28b81f2e0210a2a383b5854fd2105d086cdb9e", - "endpointHeight": 1, - "requesterHeight": 1, + "hash": "517194c2cadf3e25a10a1f1e4e540457db662126bb136520962ef00d1607bd7d", + "baselineHeight": 47, + "endpointHeight": 48, + "requesterHeight": 48, "controlNodeHeights": [ - 0, - 0, - 0, - 0, - 0, - 0 + 47, + 47, + 47, + 47, + 47, + 47 ], "deliveredOnlyByInnerPeer": true, - "latencyMs": 3020 + "latencyMs": 6710 }, "saturation": { "pingPackets": 1000, "relayFrames": 8043, - "relayBytes": 107461, - "schedulerFlushes": 17, - "maximumQueuedBytes": 44129, + "relayBytes": 107973, + "schedulerFlushes": 14, + "maximumQueuedBytes": 53000, "queueLimitBytes": 65536, "relayDrops": 0, - "controlReservationLatencyMs": 3082, + "controlReservationLatencyMs": 7061, "admissionRequests": 72, "admissionAccepted": 64, "admissionRateLimited": 8 }, "relayView": { "plaintextBlockHashObserved": false, - "transcriptSHA256": "36257106017f1ef8693af71b67fa59bc568bfefb6a32dc09fcee745b42641712" + "transcriptSHA256": "7d437355d85ce764c1dac45a8348abae55878f0fbd255fd87d82434ef36d3af2" }, "observedOpcodes": { - "RESERVE": 3, - "OFFER": 5, - "CONFIRM": 5, - "CONFIRMED": 8, - "FINDNODE": 18, - "NODES": 18, - "PUTROUTE": 8, - "PUTRESULT": 8, + "RESERVE": 8, + "OFFER": 9, + "CONFIRM": 9, + "CONFIRMED": 12, + "FINDNODE": 38, + "NODES": 38, + "PUTROUTE": 16, + "PUTRESULT": 16, "SAMPLEROUTES": 3, - "ROUTES": 73, + "ROUTES": 85, "RENEW": 2, "WITHDRAW": 3, - "GETROUTE": 78, - "ERROR": 9, - "OPEN": 2, - "INCOMING": 1, - "ACCEPT": 1, - "OPENED": 1, - "DATA": 16086, - "WINDOW": 16086, - "CLOSE": 1 + "GETROUTE": 90, + "OPEN": 8, + "INCOMING": 5, + "ACCEPT": 5, + "OPENED": 5, + "DATA": 16414, + "WINDOW": 16413, + "CLOSE": 9, + "ERROR": 13 }, "result": "pass" } diff --git a/lib/net/common.js b/lib/net/common.js index 11a0915de..1b6633757 100644 --- a/lib/net/common.js +++ b/lib/net/common.js @@ -100,7 +100,12 @@ exports.hnsr = { MAX_SIGNATURE_SIZE: 80, MAX_REQUESTS_PER_SECOND: 64, MAX_REQUEST_BYTES_PER_SECOND: 1048576, - MAX_STORES_PER_PEER: 256 + MAX_STORES_PER_PEER: 256, + MAX_WEB_HEADER_SIZE: 16384, + MAX_WEB_BODY_SIZE: 1048576, + MAX_WEB_REQUESTS_PER_CIRCUIT: 16, + WEB_IDLE_TIMEOUT: 120000, + INNER_HANDSHAKE_TIMEOUT: 15000 }; /** diff --git a/lib/net/hnsr.js b/lib/net/hnsr.js index b37ffe59f..0ef78b83a 100644 --- a/lib/net/hnsr.js +++ b/lib/net/hnsr.js @@ -9,12 +9,17 @@ const assert = require('bsert'); const EventEmitter = require('events'); const bio = require('bufio'); const IP = require('binet'); +const base32 = require('bcrypto/lib/encoding/base32'); 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 {BrontideStream} = require('./brontide'); +const rules = require('../covenants/rules'); +const {Resource} = require('../dns/resource'); +const {hsTypes} = require('../dns/common'); const ZERO32 = Buffer.alloc(32); const EMPTY = Buffer.alloc(0); @@ -26,6 +31,8 @@ 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'), + SERVICE_AUTH: Buffer.from('HNSR-SERVICE-AUTH-V1\0', 'ascii'), + NAMED_ROUTE: Buffer.from('HNSR-NAMED-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'), @@ -169,6 +176,526 @@ function routeKey(magic, endpointKey) { return hash(domains.PEER_ROUTE, magicBytes(magic), endpointKey); } +function validProfile(profile) { + return profile === profiles.HNS_NODE_V1 + || profile === profiles.HNS_WEB_V1; +} + +function canonicalServiceName(name) { + if (typeof name !== 'string' + || name.length < 1 + || name.length > 63 + || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) { + throw new Error('Invalid canonical HNSR service name.'); + } + + return name; +} + +function canonicalRootName(name) { + if (typeof name !== 'string' + || name !== name.toLowerCase() + || !rules.verifyString(name)) { + throw new Error('Invalid canonical HNS root name.'); + } + + return name; +} + +function namedRouteKey(magic, nameHash, serviceName, profile) { + assert((magic >>> 0) === magic); + assert(Buffer.isBuffer(nameHash) && nameHash.length === 32); + assert((profile & 0xffff) === profile && validProfile(profile)); + const service = Buffer.from(canonicalServiceName(serviceName), 'ascii'); + return hash( + domains.NAMED_ROUTE, + magicBytes(magic), + nameHash, + Buffer.from([service.length]), + service, + Buffer.from([profile & 0xff, profile >>> 8])); +} + +function parseHNSRRootKey(resource) { + if (!(resource instanceof Resource)) + throw new Error('Authenticated HNS resource is required.'); + + const candidates = []; + + for (const record of resource.records) { + if (record.type !== hsTypes.TXT + || !Array.isArray(record.txt) + || record.txt.length !== 1) { + continue; + } + + const match = /^hnsr1 k=([a-z2-7]+)$/.exec(record.txt[0]); + + if (!match) + continue; + + let key; + + try { + key = base32.decode(match[1]); + } catch (e) { + continue; + } + + if (key.length !== 33 + || base32.encode(key) !== match[1] + || !secp256k1.publicKeyVerify(key)) { + continue; + } + + candidates.push(key); + } + + if (candidates.length === 0) + throw new Error('HNS resource has no canonical hnsr1 root key.'); + + if (candidates.length !== 1) + throw new Error('HNS resource has ambiguous hnsr1 root keys.'); + + return candidates[0]; +} + +function parseHNSRURI(uri) { + if (typeof uri !== 'string' || !uri.startsWith('hnsr://')) + throw new Error('Invalid HNSR web URI.'); + + let parsed; + + try { + parsed = new URL(uri); + } catch (e) { + throw new Error('Invalid HNSR web URI.'); + } + + if (parsed.protocol !== 'hnsr:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.port !== '') { + throw new Error('Invalid HNSR web URI authority.'); + } + + const rootName = canonicalRootName(parsed.hostname); + const path = parsed.pathname; + const slash = path.indexOf('/', 1); + const rawService = slash === -1 + ? path.slice(1) + : path.slice(1, slash); + const serviceName = canonicalServiceName(rawService); + const requestPath = (slash === -1 ? '/' : path.slice(slash)) + + parsed.search; + + return { + rootName, + serviceName, + path: requestPath, + fragment: parsed.hash + }; +} + +function webOrigin(nameHash, serviceName, profile = profiles.HNS_WEB_V1) { + assert(Buffer.isBuffer(nameHash) && nameHash.length === 32); + assert(profile === profiles.HNS_WEB_V1); + const service = canonicalServiceName(serviceName); + const name = nameHash.toString('hex'); + return Object.freeze({ + scheme: 'hnsr', + nameHash: name, + serviceName: service, + profile, + key: `hnsr:${name}:${service}:${profile}` + }); +} + +function parseHTTPHead(raw, kind) { + if (!Buffer.isBuffer(raw) + || raw.length === 0 + || raw.length > common.hnsr.MAX_WEB_HEADER_SIZE) { + throw new Error('HNSR web header exceeds the limit.'); + } + + const text = raw.toString('latin1'); + + if (text.includes('\0') || /(^|\r\n)[ \t]/.test(text)) + throw new Error('Invalid HNSR web header syntax.'); + + const lines = text.split('\r\n'); + const start = lines.shift(); + const headers = new Map(); + + for (const line of lines) { + const colon = line.indexOf(':'); + + if (colon < 1) + throw new Error('Invalid HNSR web header line.'); + + const name = line.slice(0, colon).toLowerCase(); + const value = line.slice(colon + 1).trim(); + + if (!/^[a-z0-9!#$%&'*+.^_`|~-]+$/.test(name) + || Array.from(value).some((ch) => { + const code = ch.charCodeAt(0); + return code < 0x20 || code === 0x7f; + }) + || headers.has(name)) { + throw new Error('Invalid or duplicate HNSR web header.'); + } + + headers.set(name, value); + } + + if (headers.has('transfer-encoding') + || headers.has('upgrade') + || /(?:^|,)\s*upgrade\s*(?:,|$)/i.test(headers.get('connection') || '')) { + throw new Error('HNSR web transfer coding or upgrade is not permitted.'); + } + + let contentLength = 0; + + if (headers.has('content-length')) { + const value = headers.get('content-length'); + + if (!/^(0|[1-9][0-9]*)$/.test(value)) + throw new Error('Invalid HNSR web Content-Length.'); + + contentLength = Number(value); + + if (!Number.isSafeInteger(contentLength) + || contentLength > common.hnsr.MAX_WEB_BODY_SIZE) { + throw new Error('HNSR web body exceeds the limit.'); + } + } + + if (kind === 'request') { + const match = /^([A-Z]+) ([^ ]+) HTTP\/1\.1$/.exec(start); + + if (!match + || match[1] === 'CONNECT' + || match[1] === 'TRACE' + || !match[2].startsWith('/') + || /[\0-\x20\x7f]/.test(match[2])) { + throw new Error('Invalid HNSR web request line.'); + } + + return {method: match[1], path: match[2], headers, contentLength}; + } + + const match = /^HTTP\/1\.1 ([1-5][0-9][0-9]) ([\x20-\x7e]+)$/.exec(start); + + if (!match) + throw new Error('Invalid HNSR web response line.'); + + return { + statusCode: Number(match[1]), + reason: match[2], + headers, + contentLength + }; +} + +class HTTPMessageParser { + constructor(kind) { + assert(kind === 'request' || kind === 'response'); + this.kind = kind; + this.buffer = Buffer.alloc(0); + this.head = null; + this.total = 0; + } + + feed(data) { + if (!Buffer.isBuffer(data) || data.length === 0) + throw new Error('Invalid empty HNSR web data.'); + + this.total += data.length; + + if (this.total > common.hnsr.MAX_WEB_HEADER_SIZE + + common.hnsr.MAX_WEB_BODY_SIZE) { + throw new Error('HNSR web message exceeds the limit.'); + } + + this.buffer = Buffer.concat([this.buffer, data]); + const messages = []; + + for (;;) { + if (!this.head) { + const end = this.buffer.indexOf('\r\n\r\n'); + + if (end === -1) { + if (this.buffer.length > common.hnsr.MAX_WEB_HEADER_SIZE) + throw new Error('HNSR web header exceeds the limit.'); + break; + } + + this.head = parseHTTPHead(this.buffer.slice(0, end), this.kind); + this.buffer = this.buffer.slice(end + 4); + } + + if (this.buffer.length < this.head.contentLength) + break; + + const body = this.buffer.slice(0, this.head.contentLength); + this.buffer = this.buffer.slice(this.head.contentLength); + messages.push(Object.assign(this.head, {body})); + this.head = null; + this.total = this.buffer.length; + } + + return messages; + } +} + +class HNSRWebSession { + constructor(circuit, identityKey, endpointKey, origin, record, options = {}) { + assert(circuit && circuit.socket); + assert(secp256k1.privateKeyVerify(identityKey)); + assert(secp256k1.publicKeyVerify(endpointKey)); + + this.circuit = circuit; + this.origin = origin; + this.record = record; + this.timeout = options.timeout || common.hnsr.INNER_HANDSHAKE_TIMEOUT; + this.stream = BrontideStream.fromOutbound( + circuit.socket, + identityKey, + endpointKey); + this.parser = new HTTPMessageParser('response'); + this.pending = []; + this.requests = 0; + this.connected = false; + this.closed = false; + this.openResolve = null; + this.openReject = null; + this.openTimer = null; + this.opened = new Promise((resolve, reject) => { + this.openResolve = resolve; + this.openReject = reject; + }); + + this.onConnect = () => this._handleConnect(); + this.onData = data => this._handleData(data); + this.onError = error => this._fail(error); + this.onClose = () => this._fail( + new Error('HNSR web circuit closed.')); + + this.openTimer = setTimeout(() => { + this._fail(new Error('HNSR web inner handshake timed out.')); + }, this.timeout); + this.stream.once('connect', this.onConnect); + this.stream.on('data', this.onData); + this.stream.once('error', this.onError); + this.circuit.socket.once('error', this.onError); + this.circuit.socket.once('close', this.onClose); + } + + async open() { + await this.opened; + return this; + } + + async request(rootName, serviceName, options = {}) { + await this.open(); + + if (this.closed) + throw new Error('HNSR web session is closed.'); + + if (this.requests >= common.hnsr.MAX_WEB_REQUESTS_PER_CIRCUIT) + throw new Error('HNSR web request limit reached.'); + + const request = encodeWebRequest(rootName, serviceName, options); + const timeout = options.timeout || this.timeout; + this.requests += 1; + + return new Promise((resolve, reject) => { + const item = {resolve, reject, timer: null}; + item.timer = setTimeout(() => { + this._fail(new Error('HNSR web response timed out.')); + }, timeout); + this.pending.push(item); + + try { + this.stream.write(request); + } catch (e) { + this._fail(e); + } + }); + } + + close() { + this._fail(new Error('HNSR web session closed.')); + } + + _handleConnect() { + if (this.closed) + return; + + clearTimeout(this.openTimer); + this.openTimer = null; + this.connected = true; + this.openResolve(this); + } + + _handleData(data) { + let messages; + + try { + messages = this.parser.feed(data); + } catch (e) { + this._fail(e); + return; + } + + for (const message of messages) { + const item = this.pending.shift(); + + if (!item) { + this._fail(new Error('Unsolicited HNSR web response.')); + return; + } + + clearTimeout(item.timer); + item.resolve({ + statusCode: message.statusCode, + reason: message.reason, + headers: Object.fromEntries(message.headers), + body: message.body, + origin: this.origin, + record: this.record, + circuit: this.circuit + }); + } + } + + _fail(error) { + if (this.closed) + return; + + this.closed = true; + clearTimeout(this.openTimer); + this.openTimer = null; + + this.circuit.socket.removeListener('close', this.onClose); + this.stream.removeListener('connect', this.onConnect); + this.stream.removeListener('data', this.onData); + this.stream.removeListener('error', this.onError); + this.stream.destroy(); + this.circuit.socket.destroy(); + + if (!this.connected) + this.openReject(error); + + for (const item of this.pending) { + clearTimeout(item.timer); + item.reject(error); + } + + this.pending.length = 0; + } +} + +function headerEntries(headers) { + if (headers == null) + return []; + + if (headers instanceof Map) + return Array.from(headers.entries()); + + if (typeof headers === 'object') + return Object.entries(headers); + + throw new Error('Invalid HNSR web headers.'); +} + +function encodeHTTPMessage(start, required, headers, body) { + const lines = [start]; + const seen = new Set(); + + for (const [name, value] of required) { + lines.push(`${name}: ${value}`); + seen.add(name.toLowerCase()); + } + + for (const [rawName, rawValue] of headerEntries(headers)) { + const name = String(rawName); + const lower = name.toLowerCase(); + const value = String(rawValue); + + if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name) + || /[\r\n\0]/.test(value) + || seen.has(lower) + || lower === 'transfer-encoding' + || lower === 'upgrade') { + throw new Error('Invalid or reserved HNSR web header.'); + } + + lines.push(`${name}: ${value}`); + seen.add(lower); + } + + const head = Buffer.from(`${lines.join('\r\n')}\r\n\r\n`, 'latin1'); + + if (head.length > common.hnsr.MAX_WEB_HEADER_SIZE) + throw new Error('HNSR web header exceeds the limit.'); + + return Buffer.concat([head, body]); +} + +function encodeWebRequest(rootName, serviceName, options = {}) { + const root = canonicalRootName(rootName); + const service = canonicalServiceName(serviceName); + const method = options.method || 'GET'; + const path = options.path || '/'; + const body = Buffer.isBuffer(options.body) + ? options.body + : Buffer.from(options.body || ''); + + if (!/^[A-Z]+$/.test(method) + || method === 'CONNECT' + || method === 'TRACE' + || typeof path !== 'string' + || !path.startsWith('/') + || /[\0-\x20\x7f]/.test(path) + || body.length > common.hnsr.MAX_WEB_BODY_SIZE) { + throw new Error('Invalid HNSR web request.'); + } + + return encodeHTTPMessage( + `${method} ${path} HTTP/1.1`, + [ + ['Host', `${service}.${root}`], + ['HNSR-Authority', root], + ['HNSR-Service', service], + ['Content-Length', body.length] + ], + options.headers, + body); +} + +function encodeWebResponse(response = {}) { + const statusCode = response.statusCode || 200; + const reason = response.reason || (statusCode === 200 ? 'OK' : 'Response'); + const body = Buffer.isBuffer(response.body) + ? response.body + : Buffer.from(response.body || ''); + + if (!Number.isSafeInteger(statusCode) + || statusCode < 100 + || statusCode > 599 + || !/^[\x20-\x7e]+$/.test(reason) + || body.length > common.hnsr.MAX_WEB_BODY_SIZE) { + throw new Error('Invalid HNSR web response.'); + } + + return encodeHTTPMessage( + `HTTP/1.1 ${statusCode} ${reason}`, + [['Content-Length', body.length]], + response.headers, + body); +} + function withdrawData(magic, relayKey, contextID, reservationID, ticketID) { assert(secp256k1.publicKeyVerify(relayKey)); assert(contextID.length === 8); @@ -502,7 +1029,7 @@ class RelayTicket { verify(magic, timestamp = now()) { if (this.networkMagic !== magic - || this.profile !== profiles.HNS_NODE_V1 + || !validProfile(this.profile) || this.transport !== 0 || (this.hostType !== 1 && this.hostType !== 2) || this.port === 0 @@ -570,6 +1097,155 @@ class RelayTicket { } } +class ServiceAuthorization { + constructor(options = {}) { + this.version = 1; + this.networkMagic = options.networkMagic || 0; + this.nameHash = options.nameHash || Buffer.alloc(32); + this.serviceName = options.serviceName || ''; + this.profile = options.profile || profiles.HNS_WEB_V1; + this.serviceKey = options.serviceKey || Buffer.alloc(33); + this.flags = options.flags || 0; + this.serial = options.serial || 1; + this.validFromHeight = options.validFromHeight || 0; + this.validUntilHeight = options.validUntilHeight || 0; + this.maxEndpointLifetime = options.maxEndpointLifetime || 3600; + this.maxRouteLifetime = options.maxRouteLifetime || 900; + this.rootSignature = options.rootSignature || EMPTY; + } + + encodeUnsigned() { + assert(this.version === 1); + assert((this.networkMagic >>> 0) === this.networkMagic); + assert(Buffer.isBuffer(this.nameHash) && this.nameHash.length === 32); + const service = Buffer.from( + canonicalServiceName(this.serviceName), + 'ascii'); + assert((this.profile & 0xffff) === this.profile + && validProfile(this.profile)); + assert(secp256k1.publicKeyVerify(this.serviceKey)); + assert((this.flags & 0xffff) === this.flags); + assertU64(this.serial, 'service authorization serial'); + assert((this.validFromHeight >>> 0) === this.validFromHeight); + assert((this.validUntilHeight >>> 0) === this.validUntilHeight); + assert((this.maxEndpointLifetime >>> 0) === this.maxEndpointLifetime); + assert((this.maxRouteLifetime >>> 0) === this.maxRouteLifetime); + + const bw = bio.write(99 + service.length); + bw.writeU8(this.version); + bw.writeU32(this.networkMagic); + bw.writeBytes(this.nameHash); + bw.writeU8(service.length); + bw.writeBytes(service); + bw.writeU16(this.profile); + bw.writeBytes(this.serviceKey); + bw.writeU16(this.flags); + bw.writeU64(this.serial); + bw.writeU32(this.validFromHeight); + bw.writeU32(this.validUntilHeight); + bw.writeU32(this.maxEndpointLifetime); + bw.writeU32(this.maxRouteLifetime); + return bw.render(); + } + + signatureData(magic) { + return Buffer.concat([magicBytes(magic), this.encodeUnsigned()]); + } + + sign(magic, rootPrivateKey) { + this.rootSignature = sign( + domains.SERVICE_AUTH, + this.signatureData(magic), + rootPrivateKey); + return this; + } + + validate(magic, height = null) { + if (this.version !== 1 + || this.networkMagic !== magic + || !Buffer.isBuffer(this.nameHash) + || this.nameHash.length !== 32 + || !validProfile(this.profile) + || !secp256k1.publicKeyVerify(this.serviceKey) + || this.flags !== 0 + || this.serial < 1 + || (this.validUntilHeight !== 0 + && this.validUntilHeight < this.validFromHeight) + || this.maxEndpointLifetime < 300 + || this.maxEndpointLifetime > 604800 + || this.maxRouteLifetime < 60 + || this.maxRouteLifetime > common.hnsr.MAX_ROUTE_LIFETIME + || !Buffer.isBuffer(this.rootSignature) + || this.rootSignature.length === 0 + || this.rootSignature.length > common.hnsr.MAX_SIGNATURE_SIZE) { + return false; + } + + try { + canonicalServiceName(this.serviceName); + + if (!secp256k1.isLowDER(this.rootSignature)) + return false; + } catch (e) { + return false; + } + + if (height != null) { + if (!Number.isSafeInteger(height) + || height < this.validFromHeight + || (this.validUntilHeight !== 0 + && height > this.validUntilHeight)) { + return false; + } + } + + return true; + } + + verify(rootKey, magic, height) { + return this.validate(magic, height) + && verify( + domains.SERVICE_AUTH, + this.signatureData(magic), + this.rootSignature, + rootKey); + } + + id() { + return blake2b.digest(this.encode(), 32); + } + + encode() { + const unsigned = this.encodeUnsigned(); + const bw = bio.write(unsigned.length + 1 + this.rootSignature.length); + bw.writeBytes(unsigned); + writeSignature(bw, this.rootSignature); + return bw.render(); + } + + static decode(data) { + const br = bio.read(data); + const authorization = new ServiceAuthorization(); + authorization.version = br.readU8(); + authorization.networkMagic = br.readU32(); + authorization.nameHash = br.readBytes(32); + const serviceSize = br.readU8(); + authorization.serviceName = br.readString(serviceSize, 'ascii'); + authorization.profile = br.readU16(); + authorization.serviceKey = br.readBytes(33); + authorization.flags = br.readU16(); + authorization.serial = br.readU64(); + authorization.validFromHeight = br.readU32(); + authorization.validUntilHeight = br.readU32(); + authorization.maxEndpointLifetime = br.readU32(); + authorization.maxRouteLifetime = br.readU32(); + authorization.rootSignature = readSignature(br, 'service authorization'); + finish(br, 'service authorization'); + authorization.encodeUnsigned(); + return authorization; + } +} + class EndpointDelegation { constructor(options = {}) { this.version = 1; @@ -616,11 +1292,9 @@ class EndpointDelegation { return this; } - verify(magic, timestamp = now()) { - if (!isZero(this.authorizationID) - || this.sequence < 1 + verify(magic, timestamp = now(), authorization = null) { + if (this.sequence < 1 || this.expiresAt <= this.issuedAt - || this.expiresAt - this.issuedAt > 604800 || timestamp < this.issuedAt || timestamp >= this.expiresAt || this.maxActiveCircuits < 1 @@ -630,11 +1304,30 @@ class EndpointDelegation { return false; } + let signer = this.endpointKey; + let maximumLifetime = 604800; + + if (isZero(this.authorizationID)) { + if (authorization) + return false; + } else { + if (!(authorization instanceof ServiceAuthorization) + || !this.authorizationID.equals(authorization.id())) { + return false; + } + + signer = authorization.serviceKey; + maximumLifetime = authorization.maxEndpointLifetime; + } + + if (this.expiresAt - this.issuedAt > maximumLifetime) + return false; + return verify( domains.DELEGATION, Buffer.concat([magicBytes(magic), this.encodeUnsigned()]), this.signature, - this.endpointKey); + signer); } encode() { @@ -667,7 +1360,7 @@ class EndpointDelegation { class RouteRecord { constructor(options = {}) { this.version = 1; - this.authorityType = 0; + this.authorityType = options.authorityType || 0; this.routeKey = options.routeKey || Buffer.alloc(32); this.profile = options.profile || profiles.HNS_NODE_V1; this.sequence = options.sequence || 1; @@ -681,18 +1374,24 @@ class RouteRecord { encodeUnsigned() { assert(this.version === 1); - assert(this.authorityType === 0); + assert(this.authorityType === 0 || this.authorityType === 1); assert(this.routeKey.length === 32); - assert(this.profile === profiles.HNS_NODE_V1); + assert(validProfile(this.profile)); assertU64(this.sequence, 'record sequence'); assertU64(this.issuedAt, 'issuedAt'); assertU64(this.expiresAt, 'expiresAt'); - assert(this.authorization.length === 0); + assert(Buffer.isBuffer(this.authorization)); + + if (this.authorityType === 0) + assert(this.authorization.length === 0); + else + 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; + let size = 65 + this.authorization.length + delegation.length; for (const ticket of encodedTickets) size += ticket.length; @@ -705,7 +1404,8 @@ class RouteRecord { bw.writeU64(this.sequence); bw.writeU64(this.issuedAt); bw.writeU64(this.expiresAt); - bw.writeU16(0); + bw.writeU16(this.authorization.length); + bw.writeBytes(this.authorization); bw.writeU16(delegation.length); bw.writeBytes(delegation); bw.writeU8(encodedTickets.length); @@ -724,9 +1424,10 @@ class RouteRecord { return this; } - verify(magic, timestamp = now()) { - if (this.authorityType !== 0 - || this.profile !== profiles.HNS_NODE_V1 + verify(magic, timestamp = now(), options = {}) { + if (this.version !== 1 + || (this.authorityType !== 0 && this.authorityType !== 1) + || !validProfile(this.profile) || this.sequence < 1 || this.expiresAt <= this.issuedAt || this.expiresAt - this.issuedAt > common.hnsr.MAX_ROUTE_LIFETIME @@ -734,12 +1435,49 @@ class RouteRecord { || timestamp >= this.expiresAt || this.tickets.length < 1 || this.tickets.length > 8 - || !this.routeKey.equals(routeKey(magic, this.delegation.endpointKey)) - || !this.delegation.verify(magic, timestamp) || this.delegation.expiresAt < this.expiresAt) { return false; } + let authorization = null; + let maximumLifetime = common.hnsr.MAX_ROUTE_LIFETIME; + + if (this.authorityType === 0) { + if (this.profile !== profiles.HNS_NODE_V1 + || this.authorization.length !== 0 + || !this.routeKey.equals( + routeKey(magic, this.delegation.endpointKey)) + || !this.delegation.verify(magic, timestamp)) { + return false; + } + } else { + try { + authorization = ServiceAuthorization.decode(this.authorization); + } catch (e) { + return false; + } + + const height = options.height != null ? options.height : null; + + if (!authorization.validate(magic, height) + || (options.rootKey + && !authorization.verify(options.rootKey, magic, height)) + || authorization.profile !== this.profile + || !this.routeKey.equals(namedRouteKey( + magic, + authorization.nameHash, + authorization.serviceName, + authorization.profile)) + || !this.delegation.verify(magic, timestamp, authorization)) { + return false; + } + + maximumLifetime = authorization.maxRouteLifetime; + } + + if (this.expiresAt - this.issuedAt > maximumLifetime) + return false; + for (const ticket of this.tickets) { if (!ticket.endpointKey.equals(this.delegation.endpointKey) || ticket.profile !== this.profile @@ -855,6 +1593,8 @@ class RouteStore { endpoint, sequence: record.sequence, expiresAt: record.expiresAt, + authorityType: record.authorityType, + profile: record.profile, source, raw: Buffer.from(raw) }); @@ -881,6 +1621,11 @@ class RouteStore { for (const hex of Array.from(this.records.keys())) { for (const item of this._active(hex, timestamp)) { + if (item.authorityType !== 0 + || item.profile !== profiles.HNS_NODE_V1) { + continue; + } + items.push({ score: hash(domains.SAMPLE, seed, item.raw), raw: item.raw @@ -1129,6 +1874,7 @@ class HNSRService extends EventEmitter { assert(secp256k1.privateKeyVerify(options.identityKey)); this.network = options.network; + this.chain = options.chain || null; this.identityKey = options.identityKey; this.publicKey = secp256k1.publicKeyCreate(this.identityKey, true); this.pool = options.pool || null; @@ -1139,6 +1885,7 @@ class HNSRService extends EventEmitter { this.rendezvous = options.rendezvous === true; this.relay = options.relay === true; this.endpoint = options.endpoint === true; + this.web = options.web === true; this.relayHost = options.relayHost || Buffer.from([ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1 @@ -1153,6 +1900,7 @@ class HNSRService extends EventEmitter { this.provisional = new Map(); this.reservations = new Map(); this.endpointTickets = new Map(); + this.webServices = new Map(); this.opening = new Map(); this.relayCircuits = new Map(); this.relayQueue = []; @@ -1206,6 +1954,7 @@ class HNSRService extends EventEmitter { this.provisional.clear(); this.reservations.clear(); this.endpointTickets.clear(); + this.webServices.clear(); this.contacts.clear(); this.admission.clear(); } @@ -1943,6 +2692,441 @@ class HNSRService extends EventEmitter { })); } + _createNamedRoute(tickets, authorization, servicePrivateKey, options = {}) { + assert(Array.isArray(tickets) && tickets.length > 0); + + if (!(authorization instanceof ServiceAuthorization) + || !authorization.validate(this.network.magic)) { + throw new Error('Invalid HNSR named service authorization.'); + } + + if (!Buffer.isBuffer(servicePrivateKey) + || !secp256k1.privateKeyVerify(servicePrivateKey) + || !secp256k1.publicKeyCreate(servicePrivateKey, true) + .equals(authorization.serviceKey)) { + throw new Error('HNSR service private key does not match authorization.'); + } + + for (const ticket of tickets) { + if (!(ticket instanceof RelayTicket) + || ticket.profile !== authorization.profile + || !ticket.endpointKey.equals(this.publicKey) + || !ticket.verify(this.network.magic)) { + throw new Error('Invalid HNSR named-service relay ticket.'); + } + } + + const timestamp = now(); + const lifetime = options.lifetime || authorization.maxRouteLifetime; + const expiresAt = Math.min( + timestamp + lifetime, + timestamp + authorization.maxRouteLifetime, + timestamp + authorization.maxEndpointLifetime, + ...tickets.map(ticket => ticket.expiresAt)); + const endpointSequence = options.endpointSequence + || ++this.endpointSequence; + const sequence = options.sequence || ++this.routeSequence; + const delegation = new EndpointDelegation({ + authorizationID: authorization.id(), + endpointKey: this.publicKey, + sequence: endpointSequence, + issuedAt: timestamp, + expiresAt, + maxActiveCircuits: Math.min( + ...tickets.map(ticket => ticket.maxActiveCircuits)), + maxBytesPerCircuit: Math.min( + ...tickets.map(ticket => ticket.maxBytesPerCircuit)) + }).sign(this.network.magic, servicePrivateKey); + const key = namedRouteKey( + this.network.magic, + authorization.nameHash, + authorization.serviceName, + authorization.profile); + const record = new RouteRecord({ + authorityType: 1, + routeKey: key, + profile: authorization.profile, + sequence, + issuedAt: timestamp, + expiresAt, + authorization: authorization.encode(), + delegation, + tickets + }).sign(this.identityKey); + + if (!record.verify(this.network.magic) + || record.encode().length > common.hnsr.MAX_RECORD_SIZE) { + throw new Error('Invalid or oversized HNSR named route record.'); + } + + return record; + } + + async publishNamedReplicated( + bootstrap, + tickets, + authorization, + servicePrivateKey, + options = {}) { + if (!this.enabled || !this.endpoint) + throw new Error('HNSR endpoint role is disabled.'); + + const record = this._createNamedRoute( + tickets, + authorization, + servicePrivateKey, + 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 named route replication quorum failed ' + + `(${stored.length}/${required}).`); + } + + return {record, stored, failures}; + } + + async resolveNamedAuthority(rootName) { + const name = canonicalRootName(rootName); + + if (!this.chain) + throw new Error('HNSR named authority requires a validated HNS chain.'); + + const height = this.chain.height; + const nameHash = rules.hashName(name); + const state = await this.chain.db.getNameStatus(nameHash, height); + + if (!state + || !state.isClosed(height, this.network) + || !Buffer.isBuffer(state.data) + || state.data.length === 0) { + throw new Error('HNSR root name is not active with authenticated data.'); + } + + const resource = Resource.decode(state.data); + const rootKey = parseHNSRRootKey(resource); + return {name, nameHash, rootKey, height, resource}; + } + + async lookupNamed( + bootstrap, + rootName, + serviceName, + maximum = 16, + options = {}) { + const authority = await this.resolveNamedAuthority(rootName); + const service = canonicalServiceName(serviceName); + const profile = options.profile || profiles.HNS_WEB_V1; + const key = namedRouteKey( + this.network.magic, + authority.nameHash, + service, + profile); + const lookup = await this.lookupReplicated( + bootstrap, + key, + maximum, + options); + const records = lookup.records.filter((record) => { + if (!record.verify(this.network.magic, now(), { + rootKey: authority.rootKey, + height: authority.height + })) { + return false; + } + + const authorization = ServiceAuthorization.decode(record.authorization); + return authorization.nameHash.equals(authority.nameHash) + && authorization.serviceName === service + && authorization.profile === profile; + }); + + return Object.assign({}, lookup, { + authority, + routeKey: key, + records + }); + } + + async registerWebService(rootName, authorization, handler) { + if (!this.enabled || !this.endpoint || !this.web) + throw new Error('HNSR web endpoint role is disabled.'); + + if (!(authorization instanceof ServiceAuthorization) + || authorization.profile !== profiles.HNS_WEB_V1 + || typeof handler !== 'function') { + throw new Error('Invalid HNSR web service registration.'); + } + + const authority = await this.resolveNamedAuthority(rootName); + + if (!authorization.nameHash.equals(authority.nameHash) + || !authorization.verify( + authority.rootKey, + this.network.magic, + authority.height)) { + throw new Error( + 'HNSR web service is not authorized by current HNS state.'); + } + + const key = `${authority.name}/${authorization.serviceName}`; + const registration = { + authority, + authorization, + handler, + origin: webOrigin( + authority.nameHash, + authorization.serviceName, + authorization.profile) + }; + this.webServices.set(key, registration); + return registration; + } + + unregisterWebService(rootName, serviceName) { + const root = canonicalRootName(rootName); + const service = canonicalServiceName(serviceName); + const key = `${root}/${service}`; + return this.webServices.delete(key); + } + + async requestNamedWeb(bootstrap, uri, options = {}) { + const target = parseHNSRURI(uri); + const lookup = await this.lookupNamed( + bootstrap, + target.rootName, + target.serviceName, + options.maximumRecords || 16, + Object.assign({}, options, {profile: profiles.HNS_WEB_V1})); + + if (lookup.records.length === 0) + throw new Error('No authenticated HNSR web routes were found.'); + + const failures = []; + + for (const record of lookup.records) { + try { + const response = await this._requestWebRecord( + record, + lookup.authority, + target, + options); + return Object.assign(response, {lookup, failures}); + } catch (e) { + failures.push({record, error: e}); + } + } + + const err = new Error('All authenticated HNSR web endpoints failed.'); + err.failures = failures; + throw err; + } + + async openNamedWeb(bootstrap, uri, options = {}) { + const target = parseHNSRURI(uri); + const lookup = await this.lookupNamed( + bootstrap, + target.rootName, + target.serviceName, + options.maximumRecords || 16, + Object.assign({}, options, {profile: profiles.HNS_WEB_V1})); + + if (lookup.records.length === 0) + throw new Error('No authenticated HNSR web routes were found.'); + + const failures = []; + + for (const record of lookup.records) { + try { + const session = await this._openWebRecord( + record, + lookup.authority, + target, + options); + return {session, lookup, failures, target}; + } catch (e) { + failures.push({record, error: e}); + } + } + + const err = new Error('All authenticated HNSR web endpoints failed.'); + err.failures = failures; + throw err; + } + + async _requestWebRecord(record, authority, target, options) { + const session = await this._openWebRecord( + record, + authority, + target, + options); + + try { + return await session.request( + target.rootName, + target.serviceName, + Object.assign({}, options, {path: options.path || target.path})); + } finally { + session.close(); + } + } + + async _openWebRecord(record, authority, target, options) { + if (!record.verify(this.network.magic, now(), { + rootKey: authority.rootKey, + height: authority.height + })) { + throw new Error('Invalid authenticated HNSR web route.'); + } + + const authorization = ServiceAuthorization.decode(record.authorization); + + if (!authorization.nameHash.equals(authority.nameHash) + || authorization.serviceName !== target.serviceName + || authorization.profile !== profiles.HNS_WEB_V1) { + throw new Error('HNSR web route does not match the requested authority.'); + } + + const circuit = await this.openRoute(record, options); + const session = new HNSRWebSession( + circuit, + this.identityKey, + record.delegation.endpointKey, + webOrigin( + authority.nameHash, + target.serviceName, + profiles.HNS_WEB_V1), + record, + options); + + return session.open(); + } + + _handleWebCircuit(socket, info) { + const stream = BrontideStream.fromInbound(socket, this.identityKey); + const parser = new HTTPMessageParser('request'); + let requests = 0; + let queue = Promise.resolve(); + let timer = null; + + const armTimer = (duration) => { + clearTimeout(timer); + timer = setTimeout(() => socket.destroy(), duration); + }; + const sendError = (statusCode, reason, detail) => { + try { + stream.write(encodeWebResponse({ + statusCode, + reason, + headers: {'Content-Type': 'text/plain; charset=utf-8'}, + body: `${detail}\n` + })); + } catch (e) { + socket.destroy(); + } + }; + const handle = async (request) => { + requests += 1; + + if (requests > common.hnsr.MAX_WEB_REQUESTS_PER_CIRCUIT) { + sendError(429, 'Too Many Requests', 'HNSR web request limit reached.'); + socket.destroy(); + return; + } + + const rootName = request.headers.get('hnsr-authority'); + const serviceName = request.headers.get('hnsr-service'); + const host = request.headers.get('host'); + let root; + let service; + + try { + root = canonicalRootName(rootName); + service = canonicalServiceName(serviceName); + } catch (e) { + sendError(421, 'Misdirected Request', 'Invalid HNSR authority.'); + return; + } + + if (host !== `${service}.${root}`) { + sendError( + 421, + 'Misdirected Request', + 'HNSR authority headers disagree.'); + return; + } + + const registration = this.webServices.get(`${root}/${service}`); + + if (!registration) { + sendError(421, 'Misdirected Request', 'Unknown HNSR web service.'); + return; + } + + const response = await registration.handler({ + method: request.method, + path: request.path, + headers: Object.fromEntries(request.headers), + body: request.body, + origin: registration.origin, + ticket: info.ticket + }); + stream.write(encodeWebResponse(response)); + + if ((request.headers.get('connection') || '').toLowerCase() === 'close') + socket.destroy(); + }; + + armTimer(common.hnsr.INNER_HANDSHAKE_TIMEOUT); + stream.once('connect', () => armTimer(common.hnsr.WEB_IDLE_TIMEOUT)); + stream.on('data', (data) => { + armTimer(common.hnsr.WEB_IDLE_TIMEOUT); + + try { + for (const request of parser.feed(data)) { + queue = queue.then(() => handle(request)).catch((error) => { + this.emit('web error', error, info); + sendError(500, 'Internal Server Error', 'HNSR web handler failed.'); + }); + } + } catch (e) { + this.emit('web error', e, info); + sendError(400, 'Bad Request', e.message); + socket.destroy(); + } + }); + stream.on('error', (error) => { + this.emit('web error', error, info); + socket.destroy(); + }); + socket.once('error', (error) => { + this.emit('web error', error, info); + stream.destroy(); + }); + socket.once('close', () => { + clearTimeout(timer); + stream.destroy(); + }); + this.emit('web circuit', stream, info); + } + async lookup(peer, key, maximum = 16) { if (!this.enabled) throw new Error('HNSR is disabled.'); @@ -2187,6 +3371,15 @@ class HNSRService extends EventEmitter { const request = ReserveRequest.decode(body); + if (request.profile === profiles.HNS_WEB_V1 && !this.web) { + this._sendError( + peer, + packet.contextID, + errors.PROFILE_DISABLED, + 'HNSR web relay profile is disabled.'); + return; + } + const validSignature = renewal ? request.verifyRenewal( this.network.magic, @@ -2202,11 +3395,15 @@ class HNSRService extends EventEmitter { throw new Error('Invalid HNSR reservation signature.'); } - if (request.profile !== profiles.HNS_NODE_V1 + const maximumCircuits = request.profile === profiles.HNS_WEB_V1 + ? 4 + : common.hnsr.MAX_CIRCUITS; + + if (!validProfile(request.profile) || request.lifetime < 300 || request.lifetime > common.hnsr.MAX_TICKET_LIFETIME || request.maxCircuits < 1 - || request.maxCircuits > common.hnsr.MAX_CIRCUITS + || request.maxCircuits > maximumCircuits || request.maxBytes < 1 || request.maxBytes > 67108864) { throw new Error('HNSR reservation exceeds PoC policy.'); @@ -2577,6 +3774,14 @@ class HNSRService extends EventEmitter { throw new Error('Invalid HNSR incoming circuit.'); } + if (profile === profiles.HNS_WEB_V1 + && (!this.web || this.webServices.size === 0)) { + throw new Error('HNSR web endpoint profile is disabled.'); + } + + if (!validProfile(profile)) + throw new Error('Unsupported HNSR incoming profile.'); + const endpointNonce = randomID(16); const socket = new CircuitSocket( this, @@ -2584,13 +3789,18 @@ class HNSRService extends EventEmitter { packet.contextID, initialWindow); this.sockets.set(peerKey(peer, packet.contextID), socket); - this.emit('circuit', socket, { + const info = { circuitID: Buffer.from(packet.contextID), ticket: item.ticket, requesterNonce, endpointNonce, profile - }); + }; + + if (profile === profiles.HNS_NODE_V1) + this.emit('circuit', socket, info); + else + this._handleWebCircuit(socket, info); const bw = bio.write(20); bw.writeU32(initialWindow); @@ -2934,11 +4144,22 @@ exports.opcodes = opcodes; exports.errors = errors; exports.profiles = profiles; exports.routeKey = routeKey; +exports.namedRouteKey = namedRouteKey; +exports.canonicalServiceName = canonicalServiceName; +exports.canonicalRootName = canonicalRootName; +exports.parseHNSRRootKey = parseHNSRRootKey; +exports.parseHNSRURI = parseHNSRURI; +exports.webOrigin = webOrigin; +exports.HTTPMessageParser = HTTPMessageParser; +exports.HNSRWebSession = HNSRWebSession; +exports.encodeWebRequest = encodeWebRequest; +exports.encodeWebResponse = encodeWebResponse; exports.rendezvousNodeID = rendezvousNodeID; exports.compareDistance = compareDistance; exports.RendezvousContact = RendezvousContact; exports.ReserveRequest = ReserveRequest; exports.RelayTicket = RelayTicket; +exports.ServiceAuthorization = ServiceAuthorization; exports.EndpointDelegation = EndpointDelegation; exports.RouteRecord = RouteRecord; exports.RouteStore = RouteStore; diff --git a/lib/node/fullnode.js b/lib/node/fullnode.js index f491a3cd7..6f8b525e3 100644 --- a/lib/node/fullnode.js +++ b/lib/node/fullnode.js @@ -194,10 +194,12 @@ class FullNode extends Node { 'experimental-hnsr-rendezvous'); const hnsrRelay = this.config.bool('experimental-hnsr-relay'); const hnsrEndpoint = this.config.bool('experimental-hnsr-endpoint'); + const hnsrWeb = this.config.bool('experimental-hnsr-web'); const hnsrEnabled = this.config.bool('experimental-hnsr') || hnsrRendezvous || hnsrRelay - || hnsrEndpoint; + || hnsrEndpoint + || hnsrWeb; if (hnsrEnabled && this.network.type !== 'regtest') { throw new Error( @@ -214,8 +216,10 @@ class FullNode extends Node { rendezvous: hnsrRendezvous, relay: hnsrRelay, endpoint: hnsrEndpoint, + web: hnsrWeb, identityKey: this.identityKey, network: this.network, + chain: this.chain, logger: this.logger, relayPort: this.pool.options.publicBrontidePort, timeout: this.config.uint( diff --git a/scripts/run-hnsr-regtest-trial.js b/scripts/run-hnsr-regtest-trial.js index 1eb026e15..afaba6504 100755 --- a/scripts/run-hnsr-regtest-trial.js +++ b/scripts/run-hnsr-regtest-trial.js @@ -6,6 +6,7 @@ const assert = require('bsert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const base32 = require('bcrypto/lib/encoding/base32'); const secp256k1 = require('bcrypto/lib/secp256k1'); const sha256 = require('bcrypto/lib/sha256'); const FullNode = require('../lib/node/fullnode'); @@ -13,7 +14,17 @@ const Address = require('../lib/primitives/address'); const NetAddress = require('../lib/net/netaddress'); const packets = require('../lib/net/packets'); const common = require('../lib/net/common'); -const {opcodes, routeKey} = require('../lib/net/hnsr'); +const rules = require('../lib/covenants/rules'); +const {Resource} = require('../lib/dns/resource'); +const walletPlugin = require('../lib/wallet/plugin'); +const {BrontideStream} = require('../lib/net/brontide'); +const { + opcodes, + profiles, + routeKey, + ServiceAuthorization, + HTTPMessageParser +} = require('../lib/net/hnsr'); function waitFor(test, message, timeout = 15000) { const start = Date.now(); @@ -113,7 +124,8 @@ function ports(base, index) { return { p2p: base + index, brontide: base + 16 + index, - http: base + 32 + index + http: base + 32 + index, + wallet: base + 48 + index }; } @@ -148,6 +160,66 @@ async function closeNode(node, opened) { opened.splice(index, 1); } +async function mineShared(producer, nodes, count, address) { + for (let i = 0; i < count; i++) { + const block = await producer.miner.mineBlock(producer.chain.tip, address); + const height = producer.chain.height + 1; + + await Promise.all(nodes.map(async (node) => { + if (node.chain.height < height) + await node.chain.add(block); + })); + + assert(nodes.every(node => node.chain.height === height)); + } +} + +async function rawWebExchange(service, record, raw) { + const circuit = await service.openRoute(record); + const stream = BrontideStream.fromOutbound( + circuit.socket, + service.identityKey, + record.delegation.endpointKey); + const parser = new HTTPMessageParser('response'); + + return new Promise((resolve, reject) => { + let settled = false; + let timer = null; + const finish = (error, response) => { + if (settled) + return; + settled = true; + clearTimeout(timer); + stream.destroy(); + circuit.socket.destroy(); + + if (error) + reject(error); + else + resolve(response); + }; + timer = setTimeout( + () => finish(new Error('Raw HNSR web exchange timed out.')), + common.hnsr.INNER_HANDSHAKE_TIMEOUT); + + circuit.socket.once('close', () => { + finish(new Error('Raw HNSR web circuit closed.')); + }); + stream.once('error', finish); + stream.once('connect', () => stream.write(raw)); + stream.on('data', (data) => { + try { + const messages = parser.feed(data); + + if (messages.length !== 0) + finish(null, messages[0]); + } catch (e) { + finish(e); + } + }); + }); +} + 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-')); @@ -156,6 +228,7 @@ async function main() { relays: [identity(), identity()], rendezvous: [identity(), identity(), identity(), identity()], endpoint: identity(), + webEndpoint: identity(), requester: identity() }; const nodePorts = { @@ -167,13 +240,15 @@ async function main() { ports(base, 5) ], endpoint: ports(base, 6), - requester: ports(base, 7) + requester: ports(base, 7), + webEndpoint: ports(base, 8) }; const opened = []; const wireCounts = {}; const relayWirePayloads = new Map(); const nodes = []; let endpoint = null; + let webEndpoint = null; let requester = null; try { @@ -182,13 +257,21 @@ async function main() { path.join(root, `relay-${index}`), key, nodePorts.relays[index], - {experimentalHnsrRelay: true})); + { + experimentalHnsrRelay: true, + experimentalHnsrWeb: true + })); }); const rendezvous = new Array(4); for (let index = 3; index >= 0; index--) { const extra = {experimentalHnsrRendezvous: true}; + if (index === 0) { + extra.plugins = [walletPlugin]; + extra.walletHttpPort = nodePorts.rendezvous[index].wallet; + } + if (index < 3) { extra.nodes = [nodeAddress( identities.rendezvous[index + 1], @@ -230,6 +313,17 @@ async function main() { { listen: false, experimentalHnsrEndpoint: true, + experimentalHnsrWeb: true, + nodes: [...relayAddresses, rendezvousBootstrap] + })); + webEndpoint = new FullNode(nodeOptions( + path.join(root, 'web-endpoint'), + identities.webEndpoint, + nodePorts.webEndpoint, + { + listen: false, + experimentalHnsrEndpoint: true, + experimentalHnsrWeb: true, nodes: [...relayAddresses, rendezvousBootstrap] })); requester = new FullNode(nodeOptions( @@ -237,17 +331,96 @@ async function main() { identities.requester, nodePorts.requester, {listen: false, nodes: [rendezvousBootstrap]})); - nodes.push(endpoint, requester); + nodes.push(endpoint, webEndpoint, requester); await openNode(endpoint, opened); + await openNode(webEndpoint, opened); await openNode(requester, opened); await waitFor(() => { return identities.relays.every(key => findPeer(endpoint, key)) + && identities.relays.every(key => findPeer(webEndpoint, key)) && findPeer(endpoint, identities.rendezvous[0]) + && findPeer(webEndpoint, identities.rendezvous[0]) && findPeer(requester, identities.rendezvous[0]); }, 'Endpoint and requester bootstrap peers did not authenticate.'); + const rootPrivate = identity(); + const rootKey = publicKey(rootPrivate); + const servicePrivate = identity(); + const serviceKey = publicKey(servicePrivate); + const rootName = 'phase1b'; + const serviceName = 'p2p-site'; + const rootResource = Resource.fromJSON({ + records: [{ + type: 'TXT', + txt: [`hnsr1 k=${base32.encode(rootKey)}`] + }] + }); + const {wdb} = rendezvous[0].require('walletdb'); + const wallet = await wdb.create(); + const miningAddress = await wallet.receiveAddress(); + const [rolloutHeight] = rules.getRollout( + rules.hashName(rootName), + endpoint.network); + + await mineShared( + rendezvous[0], + nodes, + Math.max(3, rolloutHeight), + miningAddress); + await wdb.rescan(0); + await wallet.sendOpen(rootName); + await mineShared( + rendezvous[0], + nodes, + endpoint.network.names.treeInterval + 1, + miningAddress); + await wdb.rescan(0); + await wallet.sendBid(rootName, 100000, 200000); + await mineShared( + rendezvous[0], + nodes, + endpoint.network.names.biddingPeriod, + miningAddress); + await wdb.rescan(0); + await wallet.sendReveal(rootName); + await mineShared( + rendezvous[0], + nodes, + endpoint.network.names.revealPeriod + 1, + miningAddress); + await wdb.rescan(0); + await wallet.sendUpdate(rootName, rootResource); + await mineShared( + rendezvous[0], + nodes, + endpoint.network.names.treeInterval, + miningAddress); + await wdb.rescan(0); + + const nameHeight = rendezvous[0].chain.height; + const authorization = new ServiceAuthorization({ + networkMagic: endpoint.network.magic, + nameHash: rules.hashName(rootName), + serviceName, + profile: profiles.HNS_WEB_V1, + serviceKey, + serial: 1, + validFromHeight: nameHeight, + validUntilHeight: nameHeight + 1000, + maxEndpointLifetime: 3600, + maxRouteLifetime: 900 + }).sign(endpoint.network.magic, rootPrivate); + const endpointAuthority = await endpoint.hnsr.resolveNamedAuthority( + rootName); + const requesterAuthority = await requester.hnsr.resolveNamedAuthority( + rootName); + + assert(endpointAuthority.rootKey.equals(rootKey)); + assert(requesterAuthority.rootKey.equals(rootKey)); + assert(authorization.verify(rootKey, endpoint.network.magic, nameHeight)); + for (const node of nodes) { node.chain.synced = true; relayWirePayloads.set(node, []); @@ -265,12 +438,17 @@ async function main() { const endpointRelays = identities.relays.map( key => findPeer(endpoint, key)); + const webEndpointRelays = identities.relays.map( + key => findPeer(webEndpoint, key)); const endpointRendezvous = findPeer( endpoint, identities.rendezvous[0]); const requesterRendezvous = findPeer( requester, identities.rendezvous[0]); + const webEndpointRendezvous = findPeer( + webEndpoint, + identities.rendezvous[0]); const reservationOptions = { lifetime: 1800, maxCircuits: 4, @@ -325,6 +503,146 @@ async function main() { })); assert(relays.every(node => node.hnsr.reservations.size === 1)); + await endpoint.hnsr.registerWebService( + rootName, + authorization, + async request => ({ + statusCode: 200, + headers: {'Content-Type': 'text/plain; charset=utf-8'}, + body: `fallback:${request.path}` + })); + await webEndpoint.hnsr.registerWebService( + rootName, + authorization, + async request => ({ + statusCode: 200, + headers: {'Content-Type': 'text/plain; charset=utf-8'}, + body: `primary:${request.path}` + })); + + const webReservationOptions = { + profile: profiles.HNS_WEB_V1, + lifetime: 1800, + maxCircuits: 4, + maxBytes: 4 * 1024 * 1024 + }; + const fallbackWebTickets = await Promise.all(endpointRelays.map((peer) => { + return endpoint.hnsr.reserve(peer, webReservationOptions); + })); + const primaryWebTickets = await Promise.all( + webEndpointRelays.map((peer) => { + return webEndpoint.hnsr.reserve(peer, webReservationOptions); + })); + const fallbackWebPublication = await endpoint.hnsr.publishNamedReplicated( + endpointRendezvous, + fallbackWebTickets, + authorization, + servicePrivate, + {lifetime: 900, replicas: 4, minimumStores: 4}); + const primaryWebPublication = await webEndpoint.hnsr.publishNamedReplicated( + webEndpointRendezvous, + primaryWebTickets, + authorization, + servicePrivate, + { + sequence: 10, + endpointSequence: 10, + lifetime: 900, + replicas: 4, + minimumStores: 4 + }); + + assert.strictEqual(fallbackWebPublication.stored.length, 4); + assert.strictEqual(primaryWebPublication.stored.length, 4); + assert(rendezvous.every(node => node.hnsr.store.size === 3)); + + const webURI = `hnsr://${rootName}/${serviceName}/hello?phase=1b`; + const webTranscriptStart = relays.map( + node => relayWirePayloads.get(node).length); + const primaryWebResponse = await requester.hnsr.requestNamedWeb( + requesterRendezvous, + webURI); + + assert.strictEqual(primaryWebResponse.statusCode, 200); + assert.strictEqual( + primaryWebResponse.body.toString('utf8'), + 'primary:/hello?phase=1b'); + assert.strictEqual( + primaryWebResponse.origin.key, + `hnsr:${rules.hashName(rootName).toString('hex')}:${serviceName}:2`); + + const reusableWeb = await requester.hnsr.openNamedWeb( + requesterRendezvous, + webURI); + const reusedResponses = []; + + for (let index = 0; + index < common.hnsr.MAX_WEB_REQUESTS_PER_CIRCUIT; + index++) { + reusedResponses.push(await reusableWeb.session.request( + rootName, + serviceName, + {path: `/reuse/${index + 1}`})); + } + + await assert.rejects( + reusableWeb.session.request(rootName, serviceName, {path: '/reuse/17'}), + /request limit/); + reusableWeb.session.close(); + + assert(reusedResponses.every((response) => { + return response.circuit === reusedResponses[0].circuit; + })); + assert.strictEqual( + reusedResponses[0].body.toString('utf8'), + 'primary:/reuse/1'); + assert.strictEqual( + reusedResponses[reusedResponses.length - 1].body.toString('utf8'), + 'primary:/reuse/16'); + + const wrongAuthority = Buffer.from( + 'GET / HTTP/1.1\r\n' + + `Host: attacker.${rootName}\r\n` + + `HNSR-Authority: ${rootName}\r\n` + + `HNSR-Service: ${serviceName}\r\n` + + 'Content-Length: 0\r\n\r\n', + 'ascii'); + const rejectedAuthority = await rawWebExchange( + requester.hnsr, + primaryWebPublication.record, + wrongAuthority); + assert.strictEqual(rejectedAuthority.statusCode, 421); + + await assert.rejects(async () => { + await endpoint.hnsr.reserve(endpointRelays[0], { + profile: profiles.HNS_WEB_V1, + lifetime: 1800, + maxCircuits: 5, + maxBytes: 1024 + }); + }); + + await closeNode(webEndpoint, opened); + await waitFor( + () => relays.every(node => node.hnsr.reservations.size === 2), + 'Relays did not invalidate the disconnected primary web endpoint.'); + + const failedOverWebResponse = await requester.hnsr.requestNamedWeb( + requesterRendezvous, + webURI); + assert.strictEqual(failedOverWebResponse.statusCode, 200); + assert.strictEqual( + failedOverWebResponse.body.toString('utf8'), + 'fallback:/hello?phase=1b'); + assert.strictEqual(failedOverWebResponse.failures.length, 1); + const webPlaintextObserved = relays.some((node, index) => { + return relayWirePayloads.get(node) + .slice(webTranscriptStart[index]) + .some(raw => raw.includes(Buffer.from('primary:/hello?phase=1b')) + || raw.includes(Buffer.from('fallback:/hello?phase=1b'))); + }); + assert.strictEqual(webPlaintextObserved, false); + await new Promise(resolve => setTimeout(resolve, 1100)); const admission = await Promise.allSettled(new Array(72).fill(null).map( () => requester.hnsr.lookup(requesterRendezvous, key, 1))); @@ -407,7 +725,7 @@ async function main() { const propagationStarted = Date.now(); await endpoint.chain.add(block); await waitFor( - () => requester.chain.height === 1, + () => requester.chain.height === nameHeight + 1, () => { return 'Inner block did not converge (' + `requester=${requester.chain.height}).`; @@ -424,10 +742,10 @@ async function main() { 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.strictEqual(endpoint.chain.height, nameHeight + 1); + assert.strictEqual(requester.chain.height, nameHeight + 1); + assert(relays.every(node => node.chain.height === nameHeight)); + assert(rendezvous.every(node => node.chain.height === nameHeight)); assert(relay.hnsr.relayFrames > loadPackets); assert(relay.hnsr.relayFlushes > 1); assert(relay.hnsr.maxRelayQueuedBytes > common.hnsr.RELAY_BURST); @@ -437,9 +755,9 @@ async function main() { const controlNodeHeights = [...relays, ...rendezvous] .map(node => node.chain.height); - assert(controlNodeHeights.every(height => height === 0)); + assert(controlNodeHeights.every(height => height === nameHeight)); - assert.strictEqual(relay.hnsr.reservations.size, 1); + assert.strictEqual(relay.hnsr.reservations.size, 2); assert.strictEqual( relayWirePayloads.get(relay).slice(payloadStart) .some(raw => raw.includes(blockHash)), @@ -471,7 +789,7 @@ async function main() { ? Buffer.concat(relayWirePayloads.get(relay)) : Buffer.alloc(0); const result = { - schema: 2, + schema: 3, network: 'regtest', assignment: { rendezvousServiceBit: @@ -481,7 +799,7 @@ async function main() { packetType: `0x${common.EXPERIMENTAL_HNSR.toString(16)}` }, topology: { - fullNodes: 8, + fullNodes: 9, relays: 2, rendezvousNodes: 4, endpointListeners: 0, @@ -503,6 +821,40 @@ async function main() { survivingStores: activeRendezvous.map(node => node.hnsr.store.size), rendezvousFailureRecovered: replicatedLookup.records.length === 1 }, + namedWeb: { + rootName, + serviceName, + hnsStateHeight: nameHeight, + canonicalRootKeyAuthenticated: + requesterAuthority.rootKey.equals(rootKey), + serviceAuthorizationVerified: authorization.verify( + rootKey, + endpoint.network.magic, + nameHeight), + namedRouteCopies: primaryWebPublication.stored.length, + namedEndpoints: 2, + profile: 'HNS_WEB_V1', + innerBrontide: true, + initialStatusCode: primaryWebResponse.statusCode, + initialBody: primaryWebResponse.body.toString('utf8'), + connectionReuse: reusedResponses.every((response) => { + return response.circuit === reusedResponses[0].circuit; + }), + reusedRequests: reusedResponses.length, + firstReusedBody: reusedResponses[0].body.toString('utf8'), + lastReusedBody: + reusedResponses[reusedResponses.length - 1].body.toString('utf8'), + origin: primaryWebResponse.origin.key, + authorityMismatchStatus: rejectedAuthority.statusCode, + primaryEndpointStopped: true, + failedEndpointCandidates: failedOverWebResponse.failures.length, + failoverStatusCode: failedOverWebResponse.statusCode, + failoverBody: failedOverWebResponse.body.toString('utf8'), + relayObservedPlaintext: webPlaintextObserved, + maximumBodyBytes: common.hnsr.MAX_WEB_BODY_SIZE, + maximumRequestsPerCircuit: + common.hnsr.MAX_WEB_REQUESTS_PER_CIRCUIT + }, lifecycle: { initialSequence: publication.record.sequence, refreshedSequence: refreshed.record.sequence, @@ -531,11 +883,12 @@ async function main() { }, blockTraffic: { hash: blockHash.toString('hex'), - endpointHeight: 1, + baselineHeight: nameHeight, + endpointHeight: nameHeight + 1, requesterHeight: requester.chain.height, controlNodeHeights, deliveredOnlyByInnerPeer: controlNodeHeights - .every(height => height === 0), + .every(height => height === nameHeight), latencyMs: blockLatency }, saturation: { diff --git a/test/hnsr-test.js b/test/hnsr-test.js index d29d1e777..116a96358 100644 --- a/test/hnsr-test.js +++ b/test/hnsr-test.js @@ -1,14 +1,18 @@ 'use strict'; const assert = require('bsert'); +const base32 = require('bcrypto/lib/encoding/base32'); 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 rules = require('../lib/covenants/rules'); +const {Resource} = require('../lib/dns/resource'); const { ReserveRequest, RelayTicket, + ServiceAuthorization, EndpointDelegation, RouteRecord, RouteStore, @@ -17,7 +21,15 @@ const { rendezvousNodeID, compareDistance, opcodes, - routeKey + profiles, + routeKey, + namedRouteKey, + parseHNSRRootKey, + parseHNSRURI, + webOrigin, + HTTPMessageParser, + encodeWebRequest, + encodeWebResponse } = require('../lib/net/hnsr'); const network = Network.get('regtest'); @@ -88,7 +100,316 @@ function fixture(timestamp = Math.floor(Date.now() / 1000), sequence = 1) { }; } +function namedFixture(timestamp = Math.floor(Date.now() / 1000)) { + const rootPrivate = secp256k1.privateKeyGenerate(); + const rootKey = secp256k1.publicKeyCreate(rootPrivate, true); + const servicePrivate = secp256k1.privateKeyGenerate(); + const serviceKey = secp256k1.publicKeyCreate(servicePrivate, true); + const endpointPrivate = secp256k1.privateKeyGenerate(); + const endpointKey = secp256k1.publicKeyCreate(endpointPrivate, true); + const relayPrivate = secp256k1.privateKeyGenerate(); + const relayKey = secp256k1.publicKeyCreate(relayPrivate, true); + const nameHash = rules.hashName('denuoweb'); + const authorization = new ServiceAuthorization({ + networkMagic: network.magic, + nameHash, + serviceName: 'p2p-site', + profile: profiles.HNS_WEB_V1, + serviceKey, + serial: 7, + validFromHeight: 5, + validUntilHeight: 100, + maxEndpointLifetime: 3600, + maxRouteLifetime: 900 + }).sign(network.magic, rootPrivate); + const ticket = new RelayTicket({ + networkMagic: network.magic, + profile: profiles.HNS_WEB_V1, + hostType: 1, + host: Buffer.alloc(16), + port: network.brontidePort, + relayKey, + endpointKey, + reservationID: Buffer.alloc(16, 0x02), + issuedAt: timestamp, + expiresAt: timestamp + 1800, + maxActiveCircuits: 4, + maxBytesPerCircuit: 1048576, + maxTotalBytes: 4194304 + }).signRelay(relayPrivate).signEndpoint(endpointPrivate); + const delegation = new EndpointDelegation({ + authorizationID: authorization.id(), + endpointKey, + sequence: 3, + issuedAt: timestamp, + expiresAt: timestamp + 900, + maxActiveCircuits: 4, + maxBytesPerCircuit: 1048576 + }).sign(network.magic, servicePrivate); + const key = namedRouteKey( + network.magic, + nameHash, + authorization.serviceName, + authorization.profile); + const record = new RouteRecord({ + authorityType: 1, + routeKey: key, + profile: profiles.HNS_WEB_V1, + sequence: 4, + issuedAt: timestamp, + expiresAt: timestamp + 900, + authorization: authorization.encode(), + delegation, + tickets: [ticket] + }).sign(endpointPrivate); + + return { + rootPrivate, + rootKey, + servicePrivate, + serviceKey, + endpointPrivate, + endpointKey, + authorization, + ticket, + delegation, + key, + record, + timestamp + }; +} + describe('HNSR', function() { + it('should parse exactly one canonical HNSR root key from HNS TXT', () => { + const rootPrivate = secp256k1.privateKeyGenerate(); + const rootKey = secp256k1.publicKeyCreate(rootPrivate, true); + const encoded = base32.encode(rootKey); + const resource = Resource.fromJSON({ + records: [ + {type: 'TXT', txt: ['unrelated=value']}, + {type: 'TXT', txt: [`hnsr1 k=${encoded}`]} + ] + }); + + assert.bufferEqual(parseHNSRRootKey(resource), rootKey); + + resource.records.push(Resource.fromJSON({ + records: [{type: 'TXT', txt: [`hnsr1 k=${encoded}`]}] + }).records[0]); + assert.throws(() => parseHNSRRootKey(resource), /ambiguous/); + + const noncanonical = Resource.fromJSON({ + records: [{type: 'TXT', txt: [`HNSR1 k=${encoded}`]}] + }); + assert.throws(() => parseHNSRRootKey(noncanonical), /no canonical/); + }); + + it('should authenticate and store a named web route trust chain', () => { + const item = namedFixture(); + const decodedAuthorization = ServiceAuthorization.decode( + item.authorization.encode()); + const decodedRecord = RouteRecord.decode(item.record.encode()); + + assert(decodedAuthorization.verify(item.rootKey, network.magic, 50)); + assert.bufferEqual(decodedAuthorization.id(), item.authorization.id()); + assert(decodedRecord.verify(network.magic, item.timestamp, { + rootKey: item.rootKey, + height: 50 + })); + + const store = new RouteStore(network.magic); + store.put(item.key, item.record.encode(), item.timestamp, 'publisher'); + assert.strictEqual(store.get(item.key, 1, item.timestamp).length, 1); + assert.strictEqual(store.sample( + 1, + Buffer.alloc(32, 0x03), + item.timestamp).length, 0); + }); + + it('should reject named routes outside their HNS authorization', () => { + const item = namedFixture(); + const otherRoot = secp256k1.publicKeyCreate( + secp256k1.privateKeyGenerate(), + true); + + assert(!item.record.verify(network.magic, item.timestamp, { + rootKey: otherRoot, + height: 50 + })); + assert(!item.record.verify(network.magic, item.timestamp, { + rootKey: item.rootKey, + height: 101 + })); + + const substituted = RouteRecord.decode(item.record.encode()); + substituted.routeKey = Buffer.alloc(32, 0x04); + substituted.sign(item.endpointPrivate); + assert(!substituted.verify(network.magic, item.timestamp, { + rootKey: item.rootKey, + height: 50 + })); + }); + + it('should reject malformed named authorization chains', () => { + const item = namedFixture(); + const highAuthorization = ServiceAuthorization.decode( + item.authorization.encode()); + + assert.strictEqual( + highAuthorization.verify(item.rootKey, network.magic + 1, 50), + false); + + highAuthorization.rootSignature = highS( + highAuthorization.rootSignature); + assert.strictEqual( + secp256k1.isLowDER(highAuthorization.rootSignature), + false); + assert.strictEqual( + highAuthorization.verify(item.rootKey, network.magic, 50), + false); + + const mismatched = RouteRecord.decode(item.record.encode()); + mismatched.delegation.authorizationID = Buffer.alloc(32, 0x05); + mismatched.delegation.sign(network.magic, item.servicePrivate); + mismatched.sign(item.endpointPrivate); + assert.strictEqual(mismatched.verify(network.magic, item.timestamp, { + rootKey: item.rootKey, + height: 50 + }), false); + + const overlong = RouteRecord.decode(item.record.encode()); + overlong.delegation.expiresAt = item.timestamp + + item.authorization.maxEndpointLifetime + 1; + overlong.delegation.sign(network.magic, item.servicePrivate); + overlong.sign(item.endpointPrivate); + assert.strictEqual(overlong.verify(network.magic, item.timestamp, { + rootKey: item.rootKey, + height: 50 + }), false); + + const raw = item.authorization.encode(); + const unknownVersion = Buffer.from(raw); + unknownVersion[0] = 2; + assert.throws(() => ServiceAuthorization.decode(raw.slice(0, -1))); + assert.throws(() => ServiceAuthorization.decode(Buffer.concat([ + raw, + Buffer.from([0x00]) + ])), /Trailing bytes/); + assert.throws(() => ServiceAuthorization.decode(unknownVersion)); + }); + + it('should derive a stable named browser origin independent of relays', () => { + const item = namedFixture(); + const target = parseHNSRURI( + 'hnsr://denuoweb/p2p-site/articles/one?q=handshake#section'); + const first = webOrigin( + item.authorization.nameHash, + target.serviceName, + profiles.HNS_WEB_V1); + const second = webOrigin( + item.authorization.nameHash, + target.serviceName, + profiles.HNS_WEB_V1); + + assert.strictEqual(target.rootName, 'denuoweb'); + assert.strictEqual(target.serviceName, 'p2p-site'); + assert.strictEqual(target.path, '/articles/one?q=handshake'); + assert.strictEqual(first.key, second.key); + assert(!first.key.includes(item.ticket.relayKey.toString('hex'))); + assert(!first.key.includes(item.endpointKey.toString('hex'))); + assert.notStrictEqual( + first.key, + webOrigin( + rules.hashName('other-name'), + target.serviceName, + profiles.HNS_WEB_V1).key); + assert.notStrictEqual( + first.key, + webOrigin( + item.authorization.nameHash, + 'other-service', + profiles.HNS_WEB_V1).key); + assert.throws( + () => parseHNSRURI('hnsr://denuoweb/P2P-site/'), + /service name/); + }); + + it('should enforce HNSR web authority and bounded HTTP framing', () => { + const request = encodeWebRequest('denuoweb', 'p2p-site', { + method: 'POST', + path: '/submit', + headers: {'Content-Type': 'text/plain'}, + body: 'hello' + }); + const parser = new HTTPMessageParser('request'); + const messages = [ + ...parser.feed(request.slice(0, 11)), + ...parser.feed(request.slice(11)) + ]; + + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].method, 'POST'); + assert.strictEqual(messages[0].headers.get('host'), 'p2p-site.denuoweb'); + assert.strictEqual( + messages[0].headers.get('hnsr-authority'), + 'denuoweb'); + assert.strictEqual(messages[0].headers.get('hnsr-service'), 'p2p-site'); + assert.bufferEqual(messages[0].body, Buffer.from('hello')); + + const responseParser = new HTTPMessageParser('response'); + const response = responseParser.feed(encodeWebResponse({ + statusCode: 201, + reason: 'Created', + body: 'stored' + }))[0]; + assert.strictEqual(response.statusCode, 201); + assert.bufferEqual(response.body, Buffer.from('stored')); + + assert.throws(() => encodeWebRequest('denuoweb', 'p2p-site', { + headers: {Host: 'attacker.invalid'} + }), /reserved/); + assert.throws(() => parser.feed(Buffer.from( + 'POST / HTTP/1.1\r\n' + + 'Host: p2p-site.denuoweb\r\n' + + 'HNSR-Authority: denuoweb\r\n' + + 'HNSR-Service: p2p-site\r\n' + + 'Transfer-Encoding: chunked\r\n\r\n')), + /upgrade is not permitted/); + assert.throws(() => encodeWebResponse({ + body: Buffer.alloc(common.hnsr.MAX_WEB_BODY_SIZE + 1) + }), /response/); + + const pipelined = new HTTPMessageParser('response').feed(Buffer.concat([ + encodeWebResponse({body: 'one'}), + encodeWebResponse({body: 'two'}) + ])); + assert.strictEqual(pipelined.length, 2); + assert.bufferEqual(pipelined[0].body, Buffer.from('one')); + assert.bufferEqual(pipelined[1].body, Buffer.from('two')); + + assert.throws(() => new HTTPMessageParser('request').feed(Buffer.from( + 'GET / HTTP/1.1\r\n' + + 'Host: p2p-site.denuoweb\r\n' + + 'Host: attacker.invalid\r\n' + + 'HNSR-Authority: denuoweb\r\n' + + 'HNSR-Service: p2p-site\r\n' + + 'Content-Length: 0\r\n\r\n')), + /duplicate/); + assert.throws(() => new HTTPMessageParser('request').feed(Buffer.from( + 'POST / HTTP/1.1\r\n' + + 'Host: p2p-site.denuoweb\r\n' + + 'HNSR-Authority: denuoweb\r\n' + + 'HNSR-Service: p2p-site\r\n' + + 'Content-Length: 0\r\n' + + 'Content-Length: 4\r\n\r\ntest')), + /duplicate/); + assert.throws(() => new HTTPMessageParser('request').feed(Buffer.from( + 'GET / HTTP/1.1\r\nX-Fill: ' + + 'a'.repeat(common.hnsr.MAX_WEB_HEADER_SIZE) + + '\r\n\r\n')), + /header exceeds/); + }); + it('should round trip the private HNSR envelope', () => { const context = Buffer.from('0102030405060708', 'hex'); const body = Buffer.from('deadbeef', 'hex');