Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions packages/ipns/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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)
```

# Install

```console
Expand Down
99 changes: 96 additions & 3 deletions packages/ipns/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -707,6 +748,58 @@

return unmarshalIPNSRecord(record)
}

/**
* Convert a string to a PeerId
*/
#getPeerIdFromString (peerIdString: string): PeerId {
Copy link
Member Author

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 peerIdFromString

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has shipped

// 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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we require string keys to be fully qualified? E.g. /ipns/...

Copy link
Member Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably strip any leading /ipns/ from the key then?

Copy link
Member Author

Choose a reason for hiding this comment

The 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}`)
}

Check warning on line 777 in packages/ipns/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/ipns/src/index.ts#L776-L777

Added lines #L776 - L777 were not covered by tests
} else {
mh = key
}
}

if (mh == null) {
throw new Error('No public key multihash found to determine the routing key')
}

Check warning on line 785 in packages/ipns/src/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/ipns/src/index.ts#L784-L785

Added lines #L784 - L785 were not covered by tests

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 {
Expand Down
99 changes: 99 additions & 0 deletions packages/ipns/test/republish.spec.ts
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
})

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')
})
})