Skip to content
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

fix!: only accept CIDs, PeerIds or strings as values #255

Merged
merged 4 commits into from
Sep 12, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
31 changes: 22 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/${peerId}`
* * 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<IPNSRecord>
export async function create (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, lifetime: number, options: CreateV2Options): Promise<IPNSRecordV2>
export async function create (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord | IPNSRecordV2> {
export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options?: CreateV2OrV1Options): Promise<IPNSRecord>
export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options: CreateV2Options): Promise<IPNSRecordV2>
export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord | IPNSRecordV2> {
// Validity in ISOString with nanoseconds precision and validity type EOL
const expirationDate = new NanoDate(Date.now() + Number(lifetime))
const validityType = IpnsEntry.ValidityType.EOL
Expand All @@ -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/${peerId}`
* * 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<IPNSRecord>
export async function createWithExpiration (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options: CreateV2Options): Promise<IPNSRecordV2>
export async function createWithExpiration (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord | IPNSRecordV2> {
export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options?: CreateV2OrV1Options): Promise<IPNSRecord>
export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options: CreateV2Options): Promise<IPNSRecordV2>
export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord | IPNSRecordV2> {
const expirationDate = NanoDate.fromString(expiration)
const validityType = IpnsEntry.ValidityType.EOL

Expand All @@ -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<IPNSRecord | IPNSRecordV2> => {
const _create = async (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord | IPNSRecordV2> => {
seq = BigInt(seq)
const isoValidity = uint8ArrayFromString(expirationDate.toString())
const normalizedValue = normalizeValue(value)
Expand Down
64 changes: 50 additions & 14 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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 { base58btc } from 'multiformats/bases/base58'
import { CID } from 'multiformats/cid'
import NanoDate from 'timestamp-nano'
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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/${peerId}`, 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 => {
Copy link
Member

@lidel lidel Sep 7, 2023

Choose a reason for hiding this comment

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

nit: iirc we try to do opposite and want to remove use of PeerIDs in IPNS context, as it causes bugs where someone tries to parse 12.. one as CID (example) .

In various places we've been normalizing to CIDv1 with libp2p-key and base36 (this way it looks the same on subdomains, and implementers only need to support CIDs, which is more robust for interop).

@achingbrain would it be ok to switch /ipns/ paths to use CIDv1 with libp2p-key? (base does not matter as much as CIDv1)

Copy link
Member Author

Choose a reason for hiding this comment

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

we try to do opposite and want to remove use of PeerIDs in IPNS context

What I really want to do here is take a PeerID and do the right thing, rather than relying on users to know what the right thing to do is.

it causes bugs where someone tries to parse 12.. one as CID (example) .

Do you mean people are extracting 12foo out of /ipns/12foo and trying to decode it as a CID? Seems like user error rather than a bug if so.

would it be ok to switch /ipns/ paths to use CIDv1 with libp2p-key?

Do you mean accept a PeerId and output "/ipns/base32-encoded-libp2p-key-cid" ?

Maybe outputting "/ipfs/base32-encoded-libp2p-key-cid" would be better? Will Kubo resolve IPNS values like this recursively?

Then {thing} in /ipns/{thing} is always a base58btc encoded PeerID and {thing} in /ipfs/{thing} is always a CID (which could be a libp2p-key)?

Copy link
Member

@hacdias hacdias Sep 7, 2023

Choose a reason for hiding this comment

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

@achingbrain I think the goal moving forward into the future is to always refer to /ipns/{libp2p-encoded-cid} and not as a base56btc: https://specs.ipfs.tech/ipns/ipns-record/#string-representation, including on the gateway.

The current gateway implementation does the redirect even.

Copy link
Member Author

Choose a reason for hiding this comment

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

Ok, as long as we're fine with it being /ipns/{maybe-CID,maybe-base58btcpeerid} I guess?

if (value != null) {
// if we have a PeerId, turn it into an ipns path
if (isPeerId(value)) {
return `/ipns/${value.toString()}`
}

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/${base58btc.encode(cid.multihash.bytes).substring(1)}`
}

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 => {
Expand Down
85 changes: 68 additions & 17 deletions test/index.spec.ts
Copy link
Member

Choose a reason for hiding this comment

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

💭 slight concern is that (iiuc) we are generating test vectors on the fly using JS functions from src/utils.ts, and if we change JS code at any point in the future, we may not catch regression until there is a hiccup somewhere in production when interacting with other implementation.

@achingbrain thoughts on including tests against binary fixtures /fixtures/ipns_records/*.ipns-record in
ipfs/gateway-conformance#157? This will be a solid way to ensure interop with GO and any other languages. We could do it in follow-up PR but

Copy link
Member Author

Choose a reason for hiding this comment

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

Sounds good!

Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
/* 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 { 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'

Expand Down Expand Up @@ -142,37 +144,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.toString()}`
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.toString()}`
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))
Expand All @@ -183,8 +192,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)

Expand All @@ -196,6 +205,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)
})
Comment on lines +216 to +221
Copy link
Member

Choose a reason for hiding this comment

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

ℹ️ sgtm, forces people to provide value under a meaningful namespace 👍


it('should fail to validate a v1 (deprecated legacy) message', async () => {
const sequence = 0
const validity = 1000000
Expand Down Expand Up @@ -345,4 +368,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')
})
})