Skip to content

feat!: keychain and configurable crypto#1041

Merged
achingbrain merged 15 commits into
mainfrom
feat/configurable-crypto
May 28, 2026
Merged

feat!: keychain and configurable crypto#1041
achingbrain merged 15 commits into
mainfrom
feat/configurable-crypto

Conversation

@achingbrain

@achingbrain achingbrain commented May 15, 2026

Copy link
Copy Markdown
Member

Adds a configuration option to allow dynamically loading crypto implementations on-demand.

Ships with only webcrypto-supported crypto implementations (e.g. Ed25519, RSA, ECDSA), anything else (e.g. secp256k1) must be configured separately.

Imports ipns code into @helia/ipns for ease of maintenance.

Removes @libp2p/keychain dep and adds a .keychain property to Helia for securely storing private keys.

BREAKING CHANGE: secp256k1 support has been removed from the default config, it must now be configured separately

Change checklist

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation if necessary (this includes comments as well)
  • I have added tests that prove my fix is effective or that my feature works

Adds a configuration option to allow dynamically loading crypto
implementations on-demand.

Ships with only webcrypto-supported crypto implementations (e.g.
`Ed25519`, `RSA`), anything else (e.g. `secp256k1`, `ECDSA`) must
be configured separately.

BREAKING CHANGE: `secp256k1` and `ECDSA` support have been removed from the default bundle, they must now be configured separately
@achingbrain achingbrain changed the title feat!: configurable crypto and keychain feat!: keychain and configurable crypto May 15, 2026
@achingbrain achingbrain marked this pull request as ready for review May 22, 2026 14:37
@achingbrain achingbrain requested a review from a team as a code owner May 22, 2026 14:37
@achingbrain achingbrain merged commit 73a28ed into main May 28, 2026
69 of 85 checks passed
@achingbrain achingbrain deleted the feat/configurable-crypto branch May 28, 2026 12:14
Rinse12 added a commit to pkcprotocol/pkc-js that referenced this pull request Jun 8, 2026
…olve() limits

helia-for-pkc.ts resolves IPNS one hop at a time via the routers rather than
@helia/ipns resolve(). Document why this is required and add a regression test
that empirically backs each claim:

- resolve() recurses to the terminal /ipfs/ CID; 9.2.x exposes no single-hop API
  and its #findIpnsRecord primitive is private.
- The router-level walk yields the same value as resolve() and still populates
  the pubsub router's record cache (no active cache/TTL bypassed: pkc resolves
  with nocache:true, and IPNS is pubsub-only).
- resolve()'s onProgress stream never emits ipns:resolve:* events (verified live,
  single-hop and multi-hop), so progress events cannot reconstruct the chain.

Upstream main reworked resolve() into an async generator that yields each hop's
record (ipfs/helia#1041) but it is unreleased; add a TODO to adopt it on upgrade.
Rinse12 added a commit to pkcprotocol/pkc-js that referenced this pull request Jun 9, 2026
)

* feat(community): support loading delegated IPNS communities (anchor -> minter)

Resolve communities published via a delegated IPNS chain where an anchor
IPNS record points to a minter IPNS record that points to the CommunityIpfs
CID (issue #93). Identity stays the anchor; the content is signed by the
terminal (minter) key, bound to the anchor by the verified IPNS record chain.

- resolveIpnsToCidP2P resolves hop-by-hop (recursive:false) and returns
  { cid, ipnsHops }; kubo's recursive resolve hides intermediate names.
- Helia wrapper resolves a single record per call via the resolver routers
  so each hop's pubsub topic is warmed before its record is fetched.
- Verification uses the terminal name for the content signature while keeping
  identity (address/publicKey/ipnsName) anchored to ipnsHops[0].
- Untrusted gateways: independently fetch+validate the ?format=ipns-record
  chain to bind anchor -> terminal before accepting the served record.
- New RemoteCommunity.ipnsHops runtime field (also surfaced over RPC).
- New non-retriable errors: ERR_IPNS_RECURSION_DEPTH_EXCEEDED,
  ERR_RESOLVED_IPNS_TO_UNSUPPORTED_VALUE, ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID.
- Tests construct the delegated chain directly (publishing is out of scope)
  and cover P2P + gateway success/negative paths and single-hop regression.
- docs/protocol/delegated-ipns.md plus cross-references.

* fix(community): address review feedback on delegated IPNS loading + tests

- bind gateway-resolved ipnsHops to the winning record by extracting a pure
  selectWinningGatewayCommunity() (gateways race and can resolve different chains)
- harden isIpnsPath to reject a bare "/ipns/"
- rethrow aborts instead of remapping them to ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID
- add ipnsHops to CommunityIpfsReservedFields
- destroy delegated test helper PKCs in finally; generalize the helper to N hops
- reword the publishToIpnsValue verifyResolves comment; add a language hint to the docs fence
- tests: multi-hop (3-hop) P2P + gateway loading; unit tests for gateway winner
  selection, resolveIpnsToCidP2P unsupported-value/depth branches, and isIpnsPath

Known limitation (documented TODO, not fixed): delegated-community identity does
not propagate over RPC yet (the client derives publicKey before ipnsHops is
applied), so the publicKey/ipnsHops identity assertions are skipped under RPC.

* fix(community): anchor delegated-IPNS identity over RPC + ipnsHops handling

Loading a delegated community (anchor -> minter) over RPC anchored its
identity to the minter instead of the anchor: rpc-remote-community applied
the CommunityIpfs record (which derives publicKey/address from ipnsHops[0])
before ipnsHops was merged from runtimeFields, so publicKey fell back to the
minter signature and address (immutable) stuck as the minter. Apply ipnsHops
from runtimeFields before initCommunityIpfsPropsNoMerge in all three RPC paths
(update handler + both mirroring paths) so the anchor identity is derived.

Also carry ipnsHops through createCommunity(instance) cloning so a recreated
delegated community keeps its anchor identity instead of drifting to the minter.

ipnsHops is resolution provenance: only an instance that resolved a community
has it, so a key owner (local) has none. Strip it in the test equality helper
(like nameResolved) to fix local-vs-remote and loaded-vs-recreated comparisons.

Tests:
- delegated-ipns: remove the !isRpc guards; assert publicKey/ipnsHops identity
  under the RPC config (server transmits the resolved chain to the client).
- add ipns-hops.community.test: pin down ipnsHops across community kinds
  (LocalCommunity/RpcLocalCommunity = undefined, RemoteCommunity = [address],
  clones preserved).

* perf(community): load delegated communities over gateways with a single plain GET

A delegated community (anchor -> minter -> /ipfs/cid) was resolved over an HTTP
gateway with a plain GET for the content plus one ?format=ipns-record fetch per
hop to independently validate the anchor -> minter binding. Loading an IPNS over
a gateway should be a single call; the per-hop walk added a sequential round-trip
per hop.

Empirically, a plain GET /ipns/<anchor> makes the gateway recurse the chain
internally and returns only the final content, and no response header carries the
intermediate hops (verified on 2-hop and 3-hop chains). So the chain cannot be
validated in one request. We now do a single plain GET on the gateway path, derive
the terminal from the content signer, and report ipnsHops as [anchor, terminal];
the anchor -> minter binding is trusted to the gateway's recursion there. This is a
deliberate speed trade-off limited to delegated communities on the gateway path:
normal communities stay self-securing (content signed by the anchor) and the P2P
paths keep full per-hop verification.

Remove the now-dead chain-walk code (_resolveIpnsChainViaGateway,
fetchAndValidateIpnsRecordFromGateway, ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID),
document the rationale, and add a regression test asserting a delegated gateway
load makes exactly one plain GET and zero ?format=ipns-record fetches.

Upstream request to restore trustless single-call loading: ipfs/kubo#11351

* feat(community): cap delegated IPNS at a single anchor -> minter hop

For now only a single anchor -> minter delegation is followed over the
P2P paths. Replace MAX_IPNS_RECURSION_DEPTH (32) with MAX_IPNS_HOPS (1)
and reject longer chains with ERR_IPNS_MAX_HOPS_EXCEEDED (renamed from
ERR_IPNS_RECURSION_DEPTH_EXCEEDED). Gateways recurse the chain internally
and expose only the final content, so the cap is unenforceable there.

Add a deterministic unit test for the finite 2-hop rejection and convert
the integration test to assert rejection over P2P/RPC while still loading
over a gateway as [anchor, terminal].

* test(community): add env-gated delegated-IPNS load timing benchmark

Measures the wall-clock cost of the extra anchor -> minter hop across the
three loading mechanisms (kubo RPC, helia/libp2p-js, gateway) by loading the
same community record direct (minter name, 1 hop) vs delegated (anchor name,
2 hops). Gated behind BENCH_IPNS=1 so it stays out of normal CI.

Document the results in docs/protocol/delegated-ipns.md: gateway has zero
delegation cost (single GET, internal recursion), kubo adds ~0.7ms/hop, helia
adds ~3.2ms/hop (~1.5x). Note the helia ratio as a future optimization.

* fix(community): verify delegated IPNS chain over gateways instead of trusting recursion

A delegated community (anchor -> minter -> /ipfs/cid) loaded over an HTTP gateway was
trusted to the gateway's own recursion: the terminal name was derived from the content's
signature and then verified against itself, so a malicious gateway could return any
validly-signed community for an anchor request and the client could not detect the
substitution. Normal (non-delegated) communities were unaffected (content signed by the
anchor itself), but delegated identity was effectively gateway-trusted.

Restore independent per-hop validation on the gateway path, two-tier:

- Tier 1 (always): a single plain GET /ipns/<anchor>. If the content is signed by the
  anchor itself the community is non-delegated and the content signature alone secures it
  - one request, no chain walk (unchanged for normal communities).
- Tier 2 (only when the content is signed by a different key): independently follow and
  validate each record of the chain via ?format=ipns-record (ipnsValidator checks every
  record's signature against the routing key derived from its name), confirm the terminal
  record's CID matches the served body, and confirm the body's signer equals the terminal.
  A gateway cannot forge a record, so it cannot substitute a different community.

Enforce the same MAX_IPNS_HOPS=1 cap on the gateway chain walk as the P2P paths, so a
>1-hop chain is rejected with ERR_IPNS_MAX_HOPS_EXCEEDED on every path. Restore
fetchAndValidateIpnsRecordFromGateway, _resolveIpnsChainViaGateway, and the (non-retriable)
ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID error.

Add an always-on malicious gateway (test-server.js, port 14007) that serves a forged
(validly-signed-but-wrong-key) IPNS record, plus a test asserting the load is rejected with
ERR_GATEWAY_IPNS_RECORD_CHAIN_INVALID. Update the delegated-ipns protocol doc and refresh
the benchmark table (a delegated gateway load now pays the per-hop validation round-trips).

* test(community): cover delegated-IPNS gateway/chain/domain edge cases

Fill test gaps in delegated IPNS loading:
- resolveIpnsToCidP2P: undefined-value branch (ERR_RESOLVED_IPNS_P2P_TO_UNDEFINED)
- fetchAndValidateIpnsRecordFromGateway: non-200, network error, abort
  passthrough, signature-failure, success (new gateway-ipns-chain unit suite)
- _resolveIpnsChainViaGateway value branches (invalid CID, unsupported path)
- non-retriable classification of the three delegated-IPNS errors
- gateway body whose CID mismatches the validated chain terminal
- ipns-over-pubsub identity derived from anchor, not minter
- domain-addressed delegated community (record name must equal the domain)
- helia resolver router-error aggregation (currentName + routerErrors)

* docs(community): defer double-hop IPNS optimization until production measurement

The BENCH_IPNS deltas are sub-10ms only because the test setup has no DHT,
so there is nothing actionable to tune against yet. Record that the
optimization is intentionally deferred until the production path (DHT /
router / gateway RTT) can be measured, and list the candidate approaches
(gateway Tier-2 concurrent validation, Helia concurrent fetch, anchor to
minter binding cache) so the analysis need not be re-derived. Tracked
alongside #93.

* test(community): cover delegated-IPNS domain loading across all non-RPC configs

Iterate the delegated-IPNS domain suite over every available non-RPC config
(kubo local/remote, helia libp2p-js, gateway) via getAvailablePKCConfigsToTestAgainst,
and add coverage for the { name }, { publicKey, name }=anchor, and
{ publicKey, name }=minter identity shapes alongside the existing address case.

Records are generated once and shared (they are config-independent), and the mock
resolver is supplied via pkcOptions. httpRoutersOptions: [] is applied only to kubo
configs, since helia requires real http routers and the gateway resolves via URLs.

* test(community): move delegated-IPNS benchmark to scripts/, stabilize failure tests

Address CodeRabbit feedback on the delegated-IPNS PR:

- Move the env-gated BENCH_IPNS timing benchmark out of the vitest suite into
  scripts/bench-delegated-ipns.js (run with the test server up). Running the
  script is the opt-in, so the brittle Boolean(BENCH_IPNS) truthy gate (which
  enabled on "0"/"false") is gone. Update docs/protocol/delegated-ipns.md to
  point at the script.
- Route the five expected-failure load tests through createCommunity()+update()
  via a new loadCommunityExpectingError helper instead of the one-shot
  getCommunity(), per the coding guideline, removing CI flake risk from
  one-shot transport timing.

* refactor(community): type gateway fetch entries instead of any

Address CodeRabbit feedback on CommunityGatewayFetch: replace the Promise<any>
and timeoutId: any holes with a named GatewayFetchResult type
({ res; resText } | { error }) and ReturnType<typeof setTimeout>, matching what
_fetchWithGateway resolves to. The now-redundant cast at the consumer is dropped,
and the select-winning-gateway unit test mock is updated to the tightened types.

* test(helia): pin IPNS single-hop resolution behavior and document resolve() limits

helia-for-pkc.ts resolves IPNS one hop at a time via the routers rather than
@helia/ipns resolve(). Document why this is required and add a regression test
that empirically backs each claim:

- resolve() recurses to the terminal /ipfs/ CID; 9.2.x exposes no single-hop API
  and its #findIpnsRecord primitive is private.
- The router-level walk yields the same value as resolve() and still populates
  the pubsub router's record cache (no active cache/TTL bypassed: pkc resolves
  with nocache:true, and IPNS is pubsub-only).
- resolve()'s onProgress stream never emits ipns:resolve:* events (verified live,
  single-hop and multi-hop), so progress events cannot reconstruct the chain.

Upstream main reworked resolve() into an async generator that yields each hop's
record (ipfs/helia#1041) but it is unreleased; add a TODO to adopt it on upgrade.

* test(community): verify ipnsHops survives destructuring and JSON round-trip

Pin down that RemoteCommunity.ipnsHops, an enumerable getter, is exposed
via plain JS destructuring and survives JSON.parse(JSON.stringify(...)),
mirroring the existing updateCid serialization test.

* feat(community): clarify delegated-IPNS chain errors with hop role + forgery context

The delegated-IPNS resolution errors were vague: a chain failure did not say
which record (anchor vs minter) was at fault, a gateway signature failure did
not indicate forgery, and the P2P path emitted no clear forgery signal at all.

- Gateway chain walker: thread per-hop recordContext { hopRole, hopIndex,
  anchorIpnsName } into fetchAndValidateIpnsRecordFromGateway so every error it
  raises names the offending record; reword the signature-failure reason to call
  out a forged or tampered record.
- P2P resolver (resolveIpnsToCidP2P): label ERR_RESOLVED_IPNS_P2P_TO_UNDEFINED,
  ERR_IPNS_MAX_HOPS_EXCEEDED and ERR_RESOLVED_IPNS_TO_UNSUPPORTED_VALUE with
  hopRole/hopIndex, matching the gateway path. Attach a note to opaque
  resolver-level failures explaining that over P2P signature validation happens
  inside kubo/helia, so a forged record surfaces as a resolution failure rather
  than an explicit forgery error.
- Enrich ERR_THE_COMMUNITY_IPNS_RECORD_POINTS_TO_DIFFERENT_ADDRESS_THAN_WE_EXPECTED
  with a reason, recordSignerAddress, expectedTerminalMinter, isDelegatedChain
  and a matchChecks triple so it is clear which identity check failed.

Tests: add an end-to-end test that forges the minter (terminal) IPNS record over
a gateway (anchor valid, minter forged) and asserts rejection at the second hop;
strengthen the anchor-forgery test and the P2P resolver branches to assert the
new hopRole/hopIndex; add an explicit assertion binding the anchor IPNS record
signer to community.publicKey. Update docs/protocol/delegated-ipns.md.

All changes are additive to error.details — no error codes, control flow, or
retriability classification change.

* fix(community): cap delegated-IPNS gateway record reads at 10KiB

The raw IPNS-record fetch helpers in util.ts buffered the whole gateway
response via res.arrayBuffer() with no size limit. The ipns validator's
10KiB MAX_RECORD_SIZE check only runs after buffering, so an untrusted
gateway could exhaust memory by streaming a huge body for
/ipns/<name>?format=ipns-record before validation.

Add a shared bounded reader (MAX_IPNS_RECORD_SIZE = 10KiB): reject on an
over-cap Content-Length and enforce a hard byte ceiling while streaming,
cancelling the stream on overflow. Apply to both the gateway and local
kubo record fetch. Also distinguish an expired record (RecordExpiredError)
from a forged/tampered one in the chain-validation error reason.

Tests: oversized Content-Length, oversized streamed body (asserts the
stream is cancelled), and expired-EOL record.

Docs: correct delegated-ipns.md (anchor EOL is a liveness cliff, not
infinite; gateways assumed to serve ?format=ipns-record; document the
absence of sequence anti-rollback on the anchor->minter binding, tracked
in #118).

* fix(community): bound node-fetch IPNS record read instead of buffering first

The getReader-missing (node-fetch) fallback in readRawIpnsRecordBody called res.arrayBuffer()
and only checked the length afterward, so a missing or dishonest Content-Length could buffer an
oversized body before the 10KiB cap rejected it -- contradicting the "cap before buffering"
docstring. Stream the node-fetch body via its async-iterable Node stream, enforcing the ceiling
per chunk; fall back to buffer-then-check only for bodies that are neither a WHATWG stream nor
async-iterable. Share the chunk-join logic in a concatBodyChunks helper.

* test(community): stop community in finally in loadCommunityExpectingError

Wrap update() and the error-wait in try/finally so community.stop() always runs, even if
update() throws, preventing a leaked retry loop from bleeding into later tests. The no-error
hang case stays bounded by vitest's per-test timeout.

* chore(community): trace nameResolved transitions to diagnose RPC flake (#119)

Temporary debug-namespaced logging at every nameResolved set site (client + server) capturing
the value, the instance address/name/publicKey, and the source community for mirror events.
Goal: identify what spuriously flips nameResolved=true on the key-addressed signers[0] community
under RPC (#119). Removed once the root cause is confirmed.

* fix(community): don't inherit nameResolved across a publicKey-shared mirror (#119)

nameResolved reflects whether a community's OWN name resolved to its publicKey. The
_updatingCommunities pool is keyed by publicKey, so a community loaded by a raw IPNS key can mirror
a sibling that shares its key but resolved a DIFFERENT name (e.g. a ".bso" domain that migrates to
the same key). The mirror copied the sibling's nameResolved verbatim -- via both the explicit set
and deepMergeRuntimeFields -- so a name-less, key-addressed community spuriously showed
nameResolved=true (the intermittent RPC-only failure of the "page comment author ..." test).

Gate adoption on this.name === source.name in a shared _adoptMirroredNameResolved helper used by all
three mirror sites (RPC x2 + non-RPC); on a name mismatch, restore the value held before mirroring,
undoing any nameResolved merged in via deepMergeRuntimeFields. A name-less community keeps
nameResolved===undefined.

Adds a regression test (reproduced the leak 6/6 in-suite before the fix) and removes the temporary
nameResolved tracing added for diagnosis in the previous commit.

Closes #119
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant