-
Notifications
You must be signed in to change notification settings - Fork 140
feat: add republish signed ipns records #745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa4bc57
4cdd212
1faa2d0
ffa1d0c
a88b7ee
74c2862
0da6bcc
1cd3409
89dfc4d
60ad2b8
ef6fb9c
87e7efd
42d8fc0
0fd6e7b
3881dac
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<unknown, 114, 0 | 18, 1> = 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 { 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 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 @@ | |
| interval?: number | ||
| } | ||
|
|
||
| export interface RepublishRecordOptions extends AbortOptions, ProgressOptions<RepublishProgressEvents | IPNSRoutingEvents> { | ||
| /** | ||
| * Only publish to a local datastore | ||
| * | ||
| * @default false | ||
| */ | ||
| offline?: boolean | ||
| } | ||
|
|
||
| export interface ResolveResult { | ||
| /** | ||
| * The CID that was resolved | ||
|
|
@@ -430,6 +463,14 @@ | |
| * 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<void> | ||
| } | ||
|
|
||
| export type { IPNSRouting } from './routing/index.js' | ||
|
|
@@ -707,6 +748,58 @@ | |
|
|
||
| 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<void> { | ||
| 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() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we require string keys to be fully qualified? E.g.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mmmmm. According to the spec that's good practice but not required. I'd rather not, since the current APIs don't expect it, so this would be a bit contrived.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably strip any leading
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do in a follow up PR |
||
| } 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 { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<IPNSRouting> | ||
| let heliaRouting: StubbedInstance<Routing> | ||
| let dns: StubbedInstance<DNS> | ||
|
|
||
| beforeEach(async () => { | ||
| const datastore = new MemoryDatastore() | ||
| customRouting = stubInterface<IPNSRouting>() | ||
| customRouting.get.throws(new Error('Not found')) | ||
| heliaRouting = stubInterface<Routing>() | ||
| dns = stubInterface<DNS>() | ||
|
|
||
| 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 | ||
2color marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }) | ||
|
|
||
| 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') | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once libp2p/js-libp2p#3042 is released, we can just use
peerIdFromStringThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It has shipped