diff --git a/src/index.ts b/src/index.ts index 0bb343c..2cb43ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { IpnsEntry } from './pb/ipns.js' import { createCborData, ipnsRecordDataForV1Sig, ipnsRecordDataForV2Sig, normalizeValue } from './utils.js' import type { PrivateKey } from '@libp2p/interface-keys' import type { PeerId } from '@libp2p/interface-peer-id' +import type { CID } from 'multiformats/cid' const log = logger('ipns') const ID_MULTIHASH_CODE = identity.code @@ -79,15 +80,21 @@ const defaultCreateOptions: CreateOptions = { * The IPNS Record validity should follow the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision. * Note: This function does not embed the public key. If you want to do that, use `EmbedPublicKey`. * + * The passed value can be a CID, a PeerID or an arbitrary string path. + * + * * CIDs will be converted to v1 and stored in the record as a string similar to: `/ipfs/${cid}` + * * PeerIDs will create recursive records, eg. the record value will be `/ipns/${cidV1Libp2pKey}` + * * String paths will be stored in the record as-is, but they must start with `"/"` + * * @param {PeerId} peerId - peer id containing private key for signing the record. - * @param {string | Uint8Array} value - content path to be stored in the record. + * @param {CID | PeerId | string} value - content to be stored in the record. * @param {number | bigint} seq - number representing the current version of the record. * @param {number} lifetime - lifetime of the record (in milliseconds). * @param {CreateOptions} options - additional create options. */ -export async function create (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, lifetime: number, options?: CreateV2OrV1Options): Promise -export async function create (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, lifetime: number, options: CreateV2Options): Promise -export async function create (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise { +export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options?: CreateV2OrV1Options): Promise +export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options: CreateV2Options): Promise +export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise { // Validity in ISOString with nanoseconds precision and validity type EOL const expirationDate = new NanoDate(Date.now() + Number(lifetime)) const validityType = IpnsEntry.ValidityType.EOL @@ -101,15 +108,21 @@ export async function create (peerId: PeerId, value: string | Uint8Array, seq: n * Same as create(), but instead of generating a new Date, it receives the intended expiration time * WARNING: nano precision is not standard, make sure the value in seconds is 9 orders of magnitude lesser than the one provided. * + * The passed value can be a CID, a PeerID or an arbitrary string path. + * + * * CIDs will be converted to v1 and stored in the record as a string similar to: `/ipfs/${cid}` + * * PeerIDs will create recursive records, eg. the record value will be `/ipns/${cidV1Libp2pKey}` + * * String paths will be stored in the record as-is, but they must start with `"/"` + * * @param {PeerId} peerId - PeerId containing private key for signing the record. - * @param {string | Uint8Array} value - content path to be stored in the record. + * @param {CID | PeerId | string} value - content to be stored in the record. * @param {number | bigint} seq - number representing the current version of the record. * @param {string} expiration - expiration datetime for record in the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision. * @param {CreateOptions} options - additional creation options. */ -export async function createWithExpiration (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options?: CreateV2OrV1Options): Promise -export async function createWithExpiration (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options: CreateV2Options): Promise -export async function createWithExpiration (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise { +export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options?: CreateV2OrV1Options): Promise +export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options: CreateV2Options): Promise +export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise { const expirationDate = NanoDate.fromString(expiration) const validityType = IpnsEntry.ValidityType.EOL @@ -119,7 +132,7 @@ export async function createWithExpiration (peerId: PeerId, value: string | Uint return _create(peerId, value, seq, validityType, expirationDate, ttlNs, options) } -const _create = async (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { +const _create = async (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { seq = BigInt(seq) const isoValidity = uint8ArrayFromString(expirationDate.toString()) const normalizedValue = normalizeValue(value) diff --git a/src/utils.ts b/src/utils.ts index 8999042..bea9419 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,10 @@ import { unmarshalPublicKey } from '@libp2p/crypto/keys' +import { isPeerId, type PeerId } from '@libp2p/interface-peer-id' import { logger } from '@libp2p/logger' import { peerIdFromBytes, peerIdFromKeys } from '@libp2p/peer-id' import * as cborg from 'cborg' import errCode from 'err-code' +import { base36 } from 'multiformats/bases/base36' import { CID } from 'multiformats/cid' import NanoDate from 'timestamp-nano' import { concat as uint8ArrayConcat } from 'uint8arrays/concat' @@ -13,10 +15,10 @@ import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' import type { IPNSRecord, IPNSRecordV2, IPNSRecordData } from './index.js' import type { PublicKey } from '@libp2p/interface-keys' -import type { PeerId } from '@libp2p/interface-peer-id' const log = logger('ipns:utils') const IPNS_PREFIX = uint8ArrayFromString('/ipns/') +const LIBP2P_CID_CODEC = 114 /** * Convert a JavaScript date into an `RFC3339Nano` formatted @@ -164,7 +166,7 @@ export const unmarshal = (buf: Uint8Array): (IPNSRecord | IPNSRecordV2) => { } const data = parseCborData(message.data) - const value = normalizeValue(data.Value ?? new Uint8Array(0)) + const value = normalizeValue(data.Value) let validity try { @@ -259,22 +261,56 @@ export const parseCborData = (buf: Uint8Array): IPNSRecordData => { } /** - * Normalizes the given record value. It ensures it is a string starting with '/'. - * If the given value is a cid, the returned path will be '/ipfs/{cid}'. + * Normalizes the given record value. It ensures it is a PeerID, a CID or a + * string starting with '/'. PeerIDs become `/ipns/${cidV1Libp2pKey}`, + * CIDs become `/ipfs/${cidAsV1}`. */ -export const normalizeValue = (value: string | Uint8Array): string => { - const str = typeof value === 'string' ? value : uint8ArrayToString(value) +export const normalizeValue = (value?: CID | PeerId | string | Uint8Array): string => { + if (value != null) { + // if we have a PeerId, turn it into an ipns path + if (isPeerId(value)) { + return `/ipns/${value.toCID().toString(base36)}` + } - if (str.startsWith('/')) { - return str - } + // if the value is bytes, stringify it and see if we have a path + if (value instanceof Uint8Array) { + const string = uint8ArrayToString(value) - try { - const cid = CID.parse(str) - return '/ipfs/' + cid.toV1().toString() - } catch (_) { - throw errCode(new Error('Value must be a valid content path starting with /'), ERRORS.ERR_INVALID_VALUE) + if (string.startsWith('/')) { + value = string + } + } + + // if we have a path, check it is a valid path + const string = value.toString().trim() + if (string.startsWith('/') && string.length > 1) { + return string + } + + // if we have a CID, turn it into an ipfs path + const cid = CID.asCID(value) + if (cid != null) { + // PeerID encoded as a CID + if (cid.code === LIBP2P_CID_CODEC) { + return `/ipns/${cid.toString(base36)}` + } + + return `/ipfs/${cid.toV1().toString()}` + } + + // try parsing what we have as CID bytes or a CID string + try { + if (value instanceof Uint8Array) { + return `/ipfs/${CID.decode(value).toV1().toString()}` + } + + return `/ipfs/${CID.parse(string).toV1().toString()}` + } catch { + // fall through + } } + + throw errCode(new Error('Value must be a valid content path starting with /'), ERRORS.ERR_INVALID_VALUE) } const validateCborDataMatchesPbData = (entry: IpnsEntry): void => { diff --git a/test/index.spec.ts b/test/index.spec.ts index 5a8bef9..c3e0671 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,17 +1,20 @@ /* eslint-env mocha */ import { randomBytes } from '@libp2p/crypto' -import { generateKeyPair } from '@libp2p/crypto/keys' +import { generateKeyPair, unmarshalPrivateKey } from '@libp2p/crypto/keys' import { peerIdFromKeys, peerIdFromString } from '@libp2p/peer-id' import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' +import * as cbor from 'cborg' +import { base36 } from 'multiformats/bases/base36' import { base58btc } from 'multiformats/bases/base58' +import { CID } from 'multiformats/cid' import { toString as uint8ArrayToString } from 'uint8arrays' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import * as ERRORS from '../src/errors.js' import * as ipns from '../src/index.js' import { IpnsEntry } from '../src/pb/ipns.js' -import { extractPublicKey, peerIdToRoutingKey, parseCborData, createCborData } from '../src/utils.js' +import { extractPublicKey, peerIdToRoutingKey, parseCborData, createCborData, ipnsRecordDataForV2Sig } from '../src/utils.js' import { ipnsValidator } from '../src/validator.js' import type { PeerId } from '@libp2p/interface-peer-id' @@ -142,37 +145,44 @@ describe('ipns', function () { await ipnsValidator(peerIdToRoutingKey(peerId), ipns.marshal(record)) }) - it('should normalize value when creating an ipns record (string v0 cid)', async () => { - const inputValue = 'QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' - const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' + it('should normalize value when creating an ipns record (arbitrary string path)', async () => { + const inputValue = '/foo/bar/baz' + const expectedValue = '/foo/bar/baz' const record = await ipns.create(peerId, inputValue, 0, 1000000) expect(record.value).to.equal(expectedValue) }) - it('should normalize value when creating an ipns record (string v1 cid)', async () => { - const inputValue = 'bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' - const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + it('should normalize value when creating a recursive ipns record (peer id)', async () => { + const inputValue = await createEd25519PeerId() + const expectedValue = `/ipns/${inputValue.toCID().toString(base36)}` const record = await ipns.create(peerId, inputValue, 0, 1000000) expect(record.value).to.equal(expectedValue) }) - it('should normalize value when creating an ipns record (bytes v0 cid)', async () => { - const inputValue = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') + it('should normalize value when creating a recursive ipns record (peer id as CID)', async () => { + const inputValue = await createEd25519PeerId() + const expectedValue = `/ipns/${inputValue.toCID().toString(base36)}` + const record = await ipns.create(peerId, inputValue.toCID(), 0, 1000000) + expect(record.value).to.equal(expectedValue) + }) + + it('should normalize value when creating an ipns record (v0 cid)', async () => { + const inputValue = CID.parse('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' const record = await ipns.create(peerId, inputValue, 0, 1000000) expect(record.value).to.equal(expectedValue) }) - it('should normalize value when creating an ipns record (bytes v1 cid)', async () => { - const inputValue = uint8ArrayFromString('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') + it('should normalize value when creating an ipns record (v1 cid)', async () => { + const inputValue = CID.parse('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' const record = await ipns.create(peerId, inputValue, 0, 1000000) expect(record.value).to.equal(expectedValue) }) - it('should normalize value when reading an ipns record (bytes v0 cid)', async () => { - const inputValue = 'QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' - const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' + it('should normalize value when reading an ipns record (string v0 cid path)', async () => { + const inputValue = '/ipfs/QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' + const expectedValue = '/ipfs/QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' const record = await ipns.create(peerId, inputValue, 0, 1000000) const pb = IpnsEntry.decode(ipns.marshal(record)) @@ -183,8 +193,8 @@ describe('ipns', function () { expect(modifiedRecord.value).to.equal(expectedValue) }) - it('should normalize value when reading an ipns record (bytes v1 cid)', async () => { - const inputValue = 'bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + it('should normalize value when reading an ipns record (string v1 cid path)', async () => { + const inputValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' const record = await ipns.create(peerId, inputValue, 0, 1000000) @@ -196,6 +206,20 @@ describe('ipns', function () { expect(modifiedRecord.value).to.equal(expectedValue) }) + it('should fail to normalize non-path value', async () => { + const inputValue = 'hello' + + await expect(ipns.create(peerId, inputValue, 0, 1000000)).to.eventually.be.rejected + .with.property('code', ERRORS.ERR_INVALID_VALUE) + }) + + it('should fail to normalize path value that is too short', async () => { + const inputValue = '/' + + await expect(ipns.create(peerId, inputValue, 0, 1000000)).to.eventually.be.rejected + .with.property('code', ERRORS.ERR_INVALID_VALUE) + }) + it('should fail to validate a v1 (deprecated legacy) message', async () => { const sequence = 0 const validity = 1000000 @@ -345,4 +369,32 @@ describe('ipns', function () { bytes: peerId.publicKey }) }) + + it('should unmarshal a record with raw CID bytes', async () => { + // we may encounter these in the wild due to older versions of this module + // but IPNS records should have string path values + + // create a dummy record with an arbitrary string path + const input = await ipns.create(peerId, '/foo', 0n, 10000, { + v1Compatible: false + }) + + // we will store the raw bytes from this CID + const cid = CID.parse('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') + + // override data with raw CID bytes + const data = cbor.decode(input.data) + data.Value = cid.bytes + input.data = cbor.encode(data) + + // re-sign record + const privateKey = await unmarshalPrivateKey(peerId.privateKey ?? new Uint8Array(0)) + const sigData = ipnsRecordDataForV2Sig(input.data) + input.signatureV2 = await privateKey.sign(sigData) + + const buf = ipns.marshal(input) + const record = ipns.unmarshal(buf) + + expect(record).to.have.property('value', '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') + }) })