diff --git a/packages/ipns/README.md b/packages/ipns/README.md index d557d26ac..e22d4f9b5 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -283,6 +283,29 @@ const name = ipns(node) const result = await name.resolveDNSLink('ipfs.io') ``` +## Example - Republishing an existing IPNS record + +The `republishRecord` method allows you to republish an existing IPNS record without +needing the private key. This is useful for relay nodes or when you want to extend +the availability of a record that was created elsewhere. + +```TypeScript +import { createHelia } from 'helia' +import { ipns } from '@helia/ipns' +import { createDelegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' +import { CID } from 'multiformats/cid' + +const helia = await createHelia() +const name = ipns(helia) + +const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' +const parsedCid: CID = CID.parse(ipnsName) +const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') +const record = await delegatedClient.getIPNS(parsedCid) + +await name.republishRecord(ipnsName, record) +``` + # Install ```console diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 88613e6bb..3a640a875 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -253,11 +253,35 @@ * * const result = await name.resolveDNSLink('ipfs.io') * ``` + * + * @example Republishing an existing IPNS record + * + * The `republishRecord` method allows you to republish an existing IPNS record without + * needing the private key. This is useful for relay nodes or when you want to extend + * the availability of a record that was created elsewhere. + * + * ```TypeScript + * import { createHelia } from 'helia' + * import { ipns } from '@helia/ipns' + * import { createDelegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' + * import { CID } from 'multiformats/cid' + * + * const helia = await createHelia() + * const name = ipns(helia) + * + * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' + * const parsedCid: CID = CID.parse(ipnsName) + * const delegatedClient = createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev') + * const record = await delegatedClient.getIPNS(parsedCid) + * + * await name.republishRecord(ipnsName, record) + * ``` */ import { NotFoundError, isPublicKey } from '@libp2p/interface' import { logger } from '@libp2p/logger' -import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord, type IPNSRecord } from 'ipns' +import { peerIdFromCID, peerIdFromString } from '@libp2p/peer-id' +import { createIPNSRecord, extractPublicKeyFromIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord, type IPNSRecord } from 'ipns' import { ipnsSelector } from 'ipns/selector' import { ipnsValidator } from 'ipns/validator' import { base36 } from 'multiformats/bases/base36' @@ -272,7 +296,7 @@ import { localStore, type LocalStore } from './routing/local-store.js' import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC } from './utils.js' import type { IPNSRouting, IPNSRoutingEvents } from './routing/index.js' import type { Routing } from '@helia/interface' -import type { AbortOptions, ComponentLogger, Logger, PrivateKey, PublicKey } from '@libp2p/interface' +import type { AbortOptions, ComponentLogger, Logger, PeerId, PrivateKey, PublicKey } from '@libp2p/interface' import type { Answer, DNS, ResolveDnsProgressEvents } from '@multiformats/dns' import type { Datastore } from 'interface-datastore' import type { MultibaseDecoder } from 'multiformats/bases/interface' @@ -302,7 +326,7 @@ export type ResolveProgressEvents = export type RepublishProgressEvents = ProgressEvent<'ipns:republish:start', unknown> | ProgressEvent<'ipns:republish:success', IPNSRecord> | - ProgressEvent<'ipns:republish:error', { record: IPNSRecord, err: Error }> + ProgressEvent<'ipns:republish:error', { key?: MultihashDigest<0x00 | 0x12>, record: IPNSRecord, err: Error }> export type ResolveDNSLinkProgressEvents = ResolveProgressEvents | @@ -379,6 +403,15 @@ export interface RepublishOptions extends AbortOptions, ProgressOptions { + /** + * Only publish to a local datastore + * + * @default false + */ + offline?: boolean +} + export interface ResolveResult { /** * The CID that was resolved @@ -430,6 +463,14 @@ export interface IPNS { * Periodically republish all IPNS records found in the datastore */ republish(options?: RepublishOptions): void + + /** + * Republish an existing IPNS record without the private key. + * + * Before republishing the record will be validated to ensure it has a valid signature and lifetime(validity) in the future. + * The key is a multihash of the public key or a string representation of the PeerID (either base58btc encoded multihash or base36 encoded CID) + */ + republishRecord(key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord, options?: RepublishRecordOptions): Promise } export type { IPNSRouting } from './routing/index.js' @@ -707,6 +748,58 @@ class DefaultIPNS implements IPNS { return unmarshalIPNSRecord(record) } + + /** + * Convert a string to a PeerId + */ + #getPeerIdFromString (peerIdString: string): PeerId { + // It's either base58btc encoded multihash (identity or sha256) + if (peerIdString.charAt(0) === '1' || peerIdString.charAt(0) === 'Q') { + return peerIdFromString(peerIdString) + } + + // or base36 encoded CID + return peerIdFromCID(CID.parse(peerIdString)) + } + + async republishRecord (key: MultihashDigest<0x00 | 0x12> | string, record: IPNSRecord, options: RepublishRecordOptions = {}): Promise { + let mh: MultihashDigest<0x00 | 0x12> | undefined + try { + mh = extractPublicKeyFromIPNSRecord(record)?.toMultihash() // embedded public key take precedence, if present + if (mh == null) { + // if no public key is embedded in the record, use the key that was passed in + if (typeof key === 'string') { + // Convert string key to MultihashDigest + try { + mh = this.#getPeerIdFromString(key).toMultihash() + } catch (err: any) { + throw new Error(`Invalid string key: ${err.message}`) + } + } else { + mh = key + } + } + + if (mh == null) { + throw new Error('No public key multihash found to determine the routing key') + } + + const routingKey = multihashToIPNSRoutingKey(mh) + const marshaledRecord = marshalIPNSRecord(record) + + await ipnsValidator(routingKey, marshaledRecord) // validate that they key corresponds to the record + + await this.localStore.put(routingKey, marshaledRecord, options) // add to local store + + if (options.offline !== true) { + // publish record to routing + await Promise.all(this.routers.map(async r => { await r.put(routingKey, marshaledRecord, options) })) + } + } catch (err: any) { + options.onProgress?.(new CustomProgressEvent('ipns:republish:error', { key: mh, record, err })) + throw err + } + } } export interface IPNSOptions { diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts new file mode 100644 index 000000000..4efe36e3a --- /dev/null +++ b/packages/ipns/test/republish.spec.ts @@ -0,0 +1,99 @@ +/* eslint-env mocha */ + +import { generateKeyPair } from '@libp2p/crypto/keys' +import { defaultLogger } from '@libp2p/logger' +import { expect } from 'aegir/chai' +import { MemoryDatastore } from 'datastore-core' +import { createIPNSRecord } from 'ipns' +import { base32 } from 'multiformats/bases/base32' +import { base36 } from 'multiformats/bases/base36' +import { CID } from 'multiformats/cid' +import { stubInterface } from 'sinon-ts' +import { ipns } from '../src/index.js' +import type { IPNS, IPNSRouting } from '../src/index.js' +import type { Routing } from '@helia/interface' +import type { DNS } from '@multiformats/dns' +import type { StubbedInstance } from 'sinon-ts' + +describe('republishRecord', () => { + const testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') + let name: IPNS + let customRouting: StubbedInstance + let heliaRouting: StubbedInstance + let dns: StubbedInstance + + beforeEach(async () => { + const datastore = new MemoryDatastore() + customRouting = stubInterface() + customRouting.get.throws(new Error('Not found')) + heliaRouting = stubInterface() + dns = stubInterface() + + name = ipns( + { + datastore, + routing: heliaRouting, + dns, + logger: defaultLogger() + }, + { + routers: [customRouting] + } + ) + }) + + it('should throw an error when attempting to republish with an invalid key', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const otherEd25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + await expect(name.republishRecord(otherEd25519Key.publicKey.toMultihash(), ed25519Record)).to.be.rejected + }) + + it('should republish using the embedded public key', async () => { + const rsaKey = await generateKeyPair('RSA') // RSA will embed the public key in the record + const otherKey = await generateKeyPair('RSA') + const rsaRecord = await createIPNSRecord(rsaKey, testCid, 1n, 24 * 60 * 60 * 1000) + await expect(name.republishRecord(otherKey.publicKey.toMultihash(), rsaRecord)).to.not.be.rejected + }) + + it('should republish a record using provided public key', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + await expect(name.republishRecord(ed25519Key.publicKey.toMultihash(), ed25519Record)).to.not.be.rejected + }) + + it('should republish a record using a string key (base58btc encoded multihash)', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + const keyString = ed25519Key.publicKey.toString() + await expect(name.republishRecord(keyString, ed25519Record)).to.not.be.rejected + }) + + it('should republish a record using a string key (base36 encoded CID)', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + const keyString = ed25519Key.publicKey.toCID().toString(base36) + await expect(name.republishRecord(keyString, ed25519Record)).to.not.be.rejected + }) + + it('should republish a record using a string key (base32 encoded CID)', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + const keyString = ed25519Key.publicKey.toCID().toString(base32) + await expect(name.republishRecord(keyString, ed25519Record)).to.not.be.rejected + }) + + it('should emit progress events on error', async () => { + const ed25519Key = await generateKeyPair('Ed25519') + const otherEd25519Key = await generateKeyPair('Ed25519') + const ed25519Record = await createIPNSRecord(ed25519Key, testCid, 1n, 24 * 60 * 60 * 1000) + + await expect( + name.republishRecord(otherEd25519Key.publicKey.toMultihash(), ed25519Record, { + onProgress: (evt) => { + expect(evt.type).to.equal('ipns:republish:error') + } + }) + ).to.eventually.be.rejected.with.property('name', 'SignatureVerificationError') + }) +})