feat!: keychain and configurable crypto#1041
Merged
Merged
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/ipnsfor ease of maintenance.Removes
@libp2p/keychaindep and adds a.keychainproperty to Helia for securely storing private keys.BREAKING CHANGE:
secp256k1support has been removed from the default config, it must now be configured separatelyChange checklist