Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
82526e7
wip: basic working authenticated PHT node put/get
noahlevenson Mar 17, 2026
b748c87
Merge remote-tracking branch 'origin/main' into noah/pht-keyword-search
noahlevenson Mar 17, 2026
10b99ff
make PHT node get decoding work, demo it in keyword-search example
noahlevenson Mar 17, 2026
541c30a
add MVP shard replication and e2e example
noahlevenson Mar 18, 2026
bd59bdf
Merge branch 'main' into noah/pht-keyword-search
noahlevenson Mar 19, 2026
4655170
API refactor: `authenticatedPHTNodeGet` returns the hyperdht node obj…
noahlevenson Mar 20, 2026
4e73fdb
MVP-grade shard storage and replication: announce shard on PHT node put
noahlevenson Mar 23, 2026
aacaeb6
Merge branch 'main' into noah/pht-keyword-search
noahlevenson Mar 23, 2026
fb69df0
improved shard replication announcement behavior
noahlevenson Mar 26, 2026
7e97b3a
don't announce shard replication at the DHT layer
noahlevenson Mar 30, 2026
e9b0b19
introduce `connectionKey` for replica discovery
noahlevenson Apr 2, 2026
f64537b
rm control plane document record storage
noahlevenson Apr 3, 2026
c3f378a
rm control plane document record API
noahlevenson Apr 3, 2026
4f8ff27
rm stale keyword-search example
noahlevenson Apr 3, 2026
ac99f52
refactor `authenticatedPHTNodePut`
noahlevenson Apr 3, 2026
16fc1cb
renaming
noahlevenson Apr 3, 2026
fc26469
PHT node authentication
noahlevenson Apr 3, 2026
1080b2d
PHT node authentication at GET time, renaming and cleanup
noahlevenson Apr 3, 2026
d1a02a7
gate behind an experimental flag
noahlevenson Apr 3, 2026
63e5c1f
prettier
noahlevenson Apr 6, 2026
b5f4f9d
gate persistent `phtNodes` destructor behind experimental flag
noahlevenson Apr 6, 2026
e346234
add tests
noahlevenson Apr 6, 2026
50a7719
verify target
noahlevenson Apr 6, 2026
faae40e
deps
noahlevenson Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const { decode } = require('hypercore-id-encoding')
const RawStreamSet = require('./lib/raw-stream-set')
const ConnectionPool = require('./lib/connection-pool')
const { STREAM_NOT_CONNECTED } = require('./lib/errors')
const { PrefixHashTree } = require('prefix-hash-tree')
const { label, isNode } = require('prefix-hash-tree/node')

const DEFAULTS = {
...DHT.DEFAULTS,
Expand Down Expand Up @@ -60,6 +62,7 @@ class HyperDHT extends DHT {
this._randomPunchInterval = opts.randomPunchInterval || DEFAULTS.randomPunchInterval // min 20s between random punches...
this._randomPunches = 0
this._randomPunchLimit = 1 // set to one for extra safety for now
this._experimentalPHT = opts.experimentalPHT === true

this.once('persistent', () => {
this._persistent = new Persistent(this, persistent)
Expand Down Expand Up @@ -389,6 +392,79 @@ class HyperDHT extends DHT {
return { publicKey: keyPair.publicKey, closestNodes: query.closestNodes, seq, signature }
}

async phtNodeGet(target, opts = {}) {
if (!this._experimentalPHT) return

opts = { ...opts, map: mapPHTNode }

const query = this.query(
{
target,
command: COMMANDS.PHT_NODE_GET,
value: null
},
opts
)

for await (const node of query) {
const { publicKey, treeID, phtNode, signature } = node

const hash = b4a.allocUnsafe(32)
sodium.crypto_generichash(hash, b4a.concat([publicKey, treeID]))
const indexID = b4a.toString(hash, 'hex')
const t = new PrefixHashTree({ indexID })._labelHash(label(phtNode))

if (
isNode(phtNode) &&
t.equals(target) &&
Persistent.verifyPHTNode(signature, treeID, phtNode, publicKey)
)
return node
}

return null
}

async phtNodePut(keyPair, treeID, phtNode, opts = {}) {
if (!this._experimentalPHT) return

const publicKey = opts.publicKey || keyPair.publicKey

const signPHTNode = opts.signPHTNode || Persistent.signPHTNode

const hash = b4a.allocUnsafe(32)
sodium.crypto_generichash(hash, b4a.concat([publicKey, treeID]))
const indexID = b4a.toString(hash, 'hex')
const target = new PrefixHashTree({ indexID })._labelHash(label(phtNode))

const signature = opts.signature || (await signPHTNode(phtNode, treeID, keyPair))
const connectionKey = opts.connectionKey || b4a.alloc(32)

const signed = c.encode(m.phtNodePutRequest, {
publicKey,
treeID,
phtNode,
signature,
connectionKey
})

opts = {
...opts,
map: mapPHTNode,
commit(reply, dht) {
return dht.request(
{ token: reply.token, target, command: COMMANDS.PHT_NODE_PUT, value: signed },
reply.from
)
}
}

const query = this.query({ target, command: COMMANDS.PHT_NODE_GET, value: null }, opts)
await query.finished()

return { target, indexID, closestNodes: query.closestNodes, signature, connectionKey }
}

onrequest(req) {
switch (req.command) {
case COMMANDS.PEER_HANDSHAKE: {
Expand Down Expand Up @@ -436,6 +512,14 @@ class HyperDHT extends DHT {
this._persistent.onimmutableget(req)
return true
}
case COMMANDS.PHT_NODE_PUT: {
this._persistent.onphtnodeput(req)
return true
}
case COMMANDS.PHT_NODE_GET: {
this._persistent.onphtnodeget(req)
return true
}
}

return false
Expand Down Expand Up @@ -580,6 +664,27 @@ function mapMutable(node) {
}
}

function mapPHTNode(node) {
if (!node.value) return null

try {
const p = c.decode(m.phtNodeGetResponse, node.value)
const { publicKey, treeID, phtNode, signature } = p

return {
token: node.token,
from: node.from,
to: node.to,
publicKey,
treeID,
phtNode,
signature
}
} catch {
return null
}
}

function noop() {}

function filterNode(node) {
Expand All @@ -604,6 +709,7 @@ function defaultCacheOpts(opts) {
},
relayAddresses: { maxSize: Math.min(maxSize, 512), maxAge: 0 },
persistent: {
experimentalPHT: opts.experimentalPHT,
records: { maxSize, maxAge },
refreshes: { maxSize, maxAge },
mutables: {
Expand All @@ -614,6 +720,10 @@ function defaultCacheOpts(opts) {
maxSize: (maxSize / 2) | 0,
maxAge: opts.maxAge || 48 * 60 * 60 * 1000 // 48 hours
},
phtNodes: {
maxSize: (maxSize / 2) | 0,
maxAge: opts.maxAge || 48 * 60 * 60 * 1000 // 48 hours
},
bumps: { maxSize, maxAge }
}
}
Expand Down
30 changes: 20 additions & 10 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const COMMANDS = (exports.COMMANDS = {
MUTABLE_PUT: 6,
MUTABLE_GET: 7,
IMMUTABLE_PUT: 8,
IMMUTABLE_GET: 9
IMMUTABLE_GET: 9,
PHT_NODE_PUT: 10,
PHT_NODE_GET: 11
})

exports.BOOTSTRAP_NODES = global.Pear?.config.dht?.bootstrap || [
Expand Down Expand Up @@ -39,19 +41,27 @@ exports.ERROR = {
SEQ_TOO_LOW: 17
}

const [NS_ANNOUNCE, NS_UNANNOUNCE, NS_MUTABLE_PUT, NS_PEER_HANDSHAKE, NS_PEER_HOLEPUNCH] =
crypto.namespace('hyperswarm/dht', [
COMMANDS.ANNOUNCE,
COMMANDS.UNANNOUNCE,
COMMANDS.MUTABLE_PUT,
COMMANDS.PEER_HANDSHAKE,
COMMANDS.PEER_HOLEPUNCH
])
const [
NS_ANNOUNCE,
NS_UNANNOUNCE,
NS_MUTABLE_PUT,
NS_PEER_HANDSHAKE,
NS_PEER_HOLEPUNCH,
NS_PHT_NODE_PUT
] = crypto.namespace('hyperswarm/dht', [
COMMANDS.ANNOUNCE,
COMMANDS.UNANNOUNCE,
COMMANDS.MUTABLE_PUT,
COMMANDS.PEER_HANDSHAKE,
COMMANDS.PEER_HOLEPUNCH,
COMMANDS.PHT_NODE_PUT
])

exports.NS = {
ANNOUNCE: NS_ANNOUNCE,
UNANNOUNCE: NS_UNANNOUNCE,
MUTABLE_PUT: NS_MUTABLE_PUT,
PEER_HANDSHAKE: NS_PEER_HANDSHAKE,
PEER_HOLEPUNCH: NS_PEER_HOLEPUNCH
PEER_HOLEPUNCH: NS_PEER_HOLEPUNCH,
PHT_NODE_PUT: NS_PHT_NODE_PUT
}
66 changes: 66 additions & 0 deletions lib/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,69 @@ exports.mutableGetResponse = {
}
}
}

exports.phtNodeSignable = {
preencode(state, m) {
c.buffer.preencode(state, m.treeID)
c.json.preencode(state, m.phtNode)
},
encode(state, m) {
c.buffer.encode(state, m.treeID)
c.json.encode(state, m.phtNode)
},
decode(state, m) {
return {
treeID: c.buffer.decode(state),
node: c.json.decode(state)
}
}
}

exports.phtNodePutRequest = {
preencode(state, m) {
c.fixed32.preencode(state, m.publicKey)
c.buffer.preencode(state, m.treeID)
c.json.preencode(state, m.phtNode)
c.fixed64.preencode(state, m.signature)
c.fixed32.preencode(state, m.connectionKey)
},
encode(state, m) {
c.fixed32.encode(state, m.publicKey)
c.buffer.encode(state, m.treeID)
c.json.encode(state, m.phtNode)
c.fixed64.encode(state, m.signature)
c.fixed32.encode(state, m.connectionKey)
},
decode(state) {
return {
publicKey: c.fixed32.decode(state),
treeID: c.buffer.decode(state),
phtNode: c.json.decode(state),
signature: c.fixed64.decode(state),
connectionKey: c.fixed32.decode(state)
}
}
}

exports.phtNodeGetResponse = {
preencode(state, m) {
c.fixed32.preencode(state, m.publicKey)
c.buffer.preencode(state, m.treeID)
c.json.preencode(state, m.phtNode)
c.fixed64.preencode(state, m.signature)
},
encode(state, m) {
c.fixed32.encode(state, m.publicKey)
c.buffer.encode(state, m.treeID)
c.json.encode(state, m.phtNode)
c.fixed64.encode(state, m.signature)
},
decode(state) {
return {
publicKey: c.fixed32.decode(state),
treeID: c.buffer.decode(state),
phtNode: c.json.decode(state),
signature: c.fixed64.decode(state)
}
}
}
61 changes: 61 additions & 0 deletions lib/persistent.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const RecordCache = require('record-cache')
const Cache = require('xache')
const b4a = require('b4a')
const unslab = require('unslab')
const { PrefixHashTree } = require('prefix-hash-tree')
const { label, isNode } = require('prefix-hash-tree/node')

const { encodeUnslab } = require('./encode')
const m = require('./messages')
Expand All @@ -21,6 +23,7 @@ module.exports = class Persistent {
this.refreshes = new Cache(opts.refreshes)
this.mutables = new Cache(opts.mutables)
this.immutables = new Cache(opts.immutables)
this.phtNodes = opts.experimentalPHT === true ? new Cache(opts.phtNodes) : null
}

onlookup(req) {
Expand Down Expand Up @@ -227,11 +230,49 @@ module.exports = class Persistent {
req.reply(null)
}

onphtnodeget(req) {
if (!req.target || !this.phtNodes) return

const k = b4a.toString(req.target, 'hex')
const p = this.phtNodes.get(k)

if (p === null) return req.reply(null)

const phtGetResponse = c.encode(m.phtNodeGetResponse, p)
req.reply(phtGetResponse)
}

onphtnodeput(req) {
if (!req.target || !req.token || !req.value || !this.phtNodes) return

const unslabbed = unslab(req.value)
const p = decode(m.phtNodePutRequest, unslabbed)
if (p === null) return

const { publicKey, treeID, phtNode, signature, connectionKey } = p

if (!isNode(phtNode)) return

const hash = b4a.allocUnsafe(32)
sodium.crypto_generichash(hash, b4a.concat([publicKey, treeID]))
const indexID = b4a.toString(hash, 'hex')
const t = new PrefixHashTree({ indexID })._labelHash(label(phtNode))

if (!t.equals(req.target) || !Persistent.verifyPHTNode(signature, treeID, phtNode, publicKey))
return

const k = b4a.toString(req.target, 'hex')
this.phtNodes.set(k, { publicKey, treeID, phtNode, signature })

req.reply(null)
}

destroy() {
this.records.destroy()
this.refreshes.destroy()
this.mutables.destroy()
this.immutables.destroy()
if (this.phtNodes) this.phtNodes.destroy()
}

static signMutable(seq, value, keyPair) {
Expand All @@ -255,6 +296,26 @@ module.exports = class Persistent {
static signUnannounce(target, token, id, ann, keyPair) {
return sign(annSignable(target, token, id, ann, NS.UNANNOUNCE), keyPair)
}

static signPHTNode(phtNode, treeID, keyPair) {
const signable = b4a.allocUnsafe(32 + 32)
const hash = signable.subarray(32)

signable.set(NS.PHT_NODE_PUT, 0)

sodium.crypto_generichash(hash, c.encode(m.phtNodeSignable, { treeID, phtNode }))
return sign(signable, keyPair)
}

static verifyPHTNode(signature, treeID, phtNode, publicKey) {
const signable = b4a.allocUnsafe(32 + 32)
const hash = signable.subarray(32)

signable.set(NS.PHT_NODE_PUT, 0)

sodium.crypto_generichash(hash, c.encode(m.phtNodeSignable, { treeID, phtNode }))
return sodium.crypto_sign_verify_detached(signature, signable, publicKey)
}
}

function verifyMutable(signature, seq, value, publicKey) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"hypercore-id-encoding": "^1.2.0",
"noise-curve-ed": "^2.0.0",
"noise-handshake": "^4.0.0",
"prefix-hash-tree": "^0.0.2",
"record-cache": "^1.1.1",
"safety-catch": "^1.0.1",
"signal-promise": "^1.0.3",
Expand Down
4 changes: 2 additions & 2 deletions test/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ async function toArray(iterable) {
return result
}

async function swarm(t, n = 32, bootstrap = []) {
return createTestnet(n, { bootstrap, teardown: t.teardown })
async function swarm(t, n = 32, bootstrap = [], dhtOpts = {}) {
return createTestnet(n, { bootstrap, teardown: t.teardown }, dhtOpts)
}

async function* spawnFixture(t, args) {
Expand Down
Loading
Loading