Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ipns",
"version": "6.0.5",
"description": "IPNS Record definitions",
"version": "6.0.7",
"description": "IPNS record definitions",
"author": "Vasco Santos <vasco.santos@moxy.studio>",
"license": "Apache-2.0 OR MIT",
"homepage": "https://github.com/ipfs/js-ipns#readme",
Expand Down
100 changes: 84 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import * as ERRORS from './errors.js'
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 { PrivateKey } from '@libp2p/interface/keys'
import type { PeerId } from '@libp2p/interface/peer-id'
import type { CID } from 'multiformats/cid'

const log = logger('ipns')
Expand All @@ -21,29 +21,97 @@ const ID_MULTIHASH_CODE = identity.code
export const namespace = '/ipns/'
export const namespaceLength = namespace.length

export interface IPNSRecord {
export interface IPNSRecordV1 {
Comment thread
achingbrain marked this conversation as resolved.
Outdated
/**
* value of the record
*/
value: string
signatureV1: Uint8Array // signature of the record
validityType: IpnsEntry.ValidityType // Type of validation being used
validity: NanoDate // expiration datetime for the record in RFC3339 format
sequence: bigint // number representing the version of the record
ttl?: bigint // ttl in nanoseconds
pubKey?: Uint8Array // the public portion of the key that signed this record (only present if it was not embedded in the IPNS key)
signatureV2: Uint8Array // the v2 signature of the record
data: Uint8Array // extensible data

/**
* signature of the record
*/
signatureV1: Uint8Array

/**
* Type of validation being used
*/
validityType: IpnsEntry.ValidityType

/**
* expiration datetime for the record in RFC3339 format
*/
validity: NanoDate

/**
* number representing the version of the record
*/
sequence: bigint

/**
* ttl in nanoseconds
*/
ttl?: bigint

/**
* the public portion of the key that signed this record (only present if it was not embedded in the IPNS key)
*/
pubKey?: Uint8Array

/**
* the v2 signature of the record
*/
signatureV2: Uint8Array

/**
* extensible data
*/
data: Uint8Array
}

export interface IPNSRecordV2 {
/**
* value of the record
*/
value: string

/**
* the v2 signature of the record
*/
signatureV2: Uint8Array

/**
* Type of validation being used
*/
validityType: IpnsEntry.ValidityType

/**
* expiration datetime for the record in RFC3339 format
*/
validity: NanoDate

/**
* number representing the version of the record
*/
sequence: bigint

/**
* ttl in nanoseconds
*/
ttl?: bigint

/**
* the public portion of the key that signed this record (only present if it was not embedded in the IPNS key)
*/
pubKey?: Uint8Array

/**
* extensible data
*/
data: Uint8Array
}

export type IPNSRecord = IPNSRecordV1 | IPNSRecordV2

export interface IPNSRecordData {
Value: Uint8Array
Validity: Uint8Array
Expand Down Expand Up @@ -92,9 +160,9 @@ const defaultCreateOptions: CreateOptions = {
* @param {number} lifetime - lifetime of the record (in milliseconds).
* @param {CreateOptions} options - additional create options.
*/
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?: CreateV2OrV1Options): Promise<IPNSRecordV1>
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> {
export async function create (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> {
// 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 @@ -120,9 +188,9 @@ export async function create (peerId: PeerId, value: CID | PeerId | string, seq:
* @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: 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?: CreateV2OrV1Options): Promise<IPNSRecordV1>
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> {
export async function createWithExpiration (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> {
const expirationDate = NanoDate.fromString(expiration)
const validityType = IpnsEntry.ValidityType.EOL

Expand All @@ -132,7 +200,7 @@ export async function createWithExpiration (peerId: PeerId, value: CID | PeerId
return _create(peerId, value, seq, validityType, expirationDate, ttlNs, options)
}

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> => {
const _create = async (peerId: PeerId, value: CID | PeerId | string, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
seq = BigInt(seq)
const isoValidity = uint8ArrayFromString(expirationDate.toString())
const normalizedValue = normalizeValue(value)
Expand Down
1 change: 0 additions & 1 deletion src/selector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { unmarshal } from './utils.js'
import type { SelectFn } from '@libp2p/interface-dht'

export function ipnsSelector (key: Uint8Array, data: Uint8Array[]): number {
const entries = data.map((buf, index) => ({
Expand Down
15 changes: 8 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { unmarshalPublicKey } from '@libp2p/crypto/keys'
import { isPeerId, type PeerId } from '@libp2p/interface-peer-id'
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'
Expand All @@ -14,7 +14,7 @@ import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
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 { PublicKey } from '@libp2p/interface/keys'

const log = logger('ipns:utils')
const IPNS_PREFIX = uint8ArrayFromString('/ipns/')
Expand Down Expand Up @@ -62,7 +62,7 @@ export function parseRFC3339 (time: string): Date {
const hour = parseInt(m[4], 10)
const minute = parseInt(m[5], 10)
const second = parseInt(m[6], 10)
const millisecond = parseInt(m[7].slice(0, -6), 10)
const millisecond = parseInt(m[7].padEnd(6, '0').slice(0, 3), 10)

return new Date(Date.UTC(year, month, date, hour, minute, second, millisecond))
}
Expand Down Expand Up @@ -145,7 +145,7 @@ export const marshal = (obj: IPNSRecord | IPNSRecordV2): Uint8Array => {
}
}

export const unmarshal = (buf: Uint8Array): (IPNSRecord | IPNSRecordV2) => {
export function unmarshal (buf: Uint8Array): IPNSRecord {
const message = IpnsEntry.decode(buf)

// protobufjs returns bigints as numbers
Expand All @@ -159,9 +159,9 @@ export const unmarshal = (buf: Uint8Array): (IPNSRecord | IPNSRecordV2) => {
}

// Check if we have the data field. If we don't, we fail. We've been producing
// V1+V2 records for quite a while and we don't support V1-only records anymore
// during validation.
if ((message.signatureV2 == null) || (message.data == null)) {
// V1+V2 records for quite a while and we don't support V1-only records during
// validation any more
if (message.signatureV2 == null || message.data == null) {
throw errCode(new Error('missing data or signatureV2'), ERRORS.ERR_SIGNATURE_VERIFICATION)
}

Expand All @@ -179,6 +179,7 @@ export const unmarshal = (buf: Uint8Array): (IPNSRecord | IPNSRecordV2) => {
if (message.value != null && message.signatureV1 != null) {
// V1+V2
validateCborDataMatchesPbData(message)

return {
value,
validityType: IpnsEntry.ValidityType.EOL,
Expand Down
3 changes: 1 addition & 2 deletions src/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import errCode from 'err-code'
import * as ERRORS from './errors.js'
import { IpnsEntry } from './pb/ipns.js'
import { extractPublicKey, ipnsRecordDataForV2Sig, unmarshal, peerIdFromRoutingKey } from './utils.js'
import type { ValidateFn } from '@libp2p/interface-dht'
import type { PublicKey } from '@libp2p/interface-keys'
import type { PublicKey } from '@libp2p/interface/keys'

const log = logger('ipns:validator')

Expand Down
68 changes: 68 additions & 0 deletions test/conformance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-env mocha */

import { unmarshalPublicKey } from '@libp2p/crypto/keys'
import { peerIdFromCID } from '@libp2p/peer-id'
import { expect } from 'aegir/chai'
import loadFixture from 'aegir/fixtures'
import { base36 } from 'multiformats/bases/base36'
import { CID } from 'multiformats/cid'
import * as ERRORS from '../src/errors.js'
import * as ipns from '../src/index.js'
import { validate } from '../src/validator.js'

describe('conformance', function () {
it('should reject a v1 only record', async () => {
const buf = loadFixture('test/fixtures/k51qzi5uqu5dm4tm0wt8srkg9h9suud4wuiwjimndrkydqm81cqtlb5ak6p7ku_v1.ipns-record')

expect(() => ipns.unmarshal(buf)).to.throw(/missing data or signatureV2/)
.with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION)
})

it('should validate a record with v1 and v2 signatures', async () => {
const buf = loadFixture('test/fixtures/k51qzi5uqu5dlkw8pxuw9qmqayfdeh4kfebhmreauqdc6a7c3y7d5i9fi8mk9w_v1-v2.ipns-record')
const record = ipns.unmarshal(buf)

const cid = CID.parse('k51qzi5uqu5dlkw8pxuw9qmqayfdeh4kfebhmreauqdc6a7c3y7d5i9fi8mk9w', base36)
const peerId = peerIdFromCID(cid)
const publicKey = unmarshalPublicKey(peerId.publicKey ?? new Uint8Array())
await validate(publicKey, buf)

expect(record.value).to.equal('/ipfs/bafkqaddwgevxmmraojswg33smq')
})

it('should reject a record with inconsistent value fields', async () => {
const buf = loadFixture('test/fixtures/k51qzi5uqu5dlmit2tuwdvnx4sbnyqgmvbxftl0eo3f33wwtb9gr7yozae9kpw_v1-v2-broken-v1-value.ipns-record')

expect(() => ipns.unmarshal(buf)).to.throw(/Field "value" did not match/)
.with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION)
})

it('should reject a record with v1 and v2 signatures but invalid v2', async () => {
const buf = loadFixture('test/fixtures/k51qzi5uqu5diamp7qnnvs1p1gzmku3eijkeijs3418j23j077zrkok63xdm8c_v1-v2-broken-signature-v2.ipns-record')
const cid = CID.parse('k51qzi5uqu5diamp7qnnvs1p1gzmku3eijkeijs3418j23j077zrkok63xdm8c', base36)
const peerId = peerIdFromCID(cid)
const publicKey = unmarshalPublicKey(peerId.publicKey ?? new Uint8Array())

await expect(validate(publicKey, buf)).to.eventually.be.rejectedWith(/record signature verification failed/)
.with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION)
})

it('should reject a record with v1 and v2 signatures but invalid v1', async () => {
const buf = loadFixture('test/fixtures/k51qzi5uqu5dilgf7gorsh9vcqqq4myo6jd4zmqkuy9pxyxi5fua3uf7axph4y_v1-v2-broken-signature-v1.ipns-record')
const record = ipns.unmarshal(buf)

expect(record.value).to.equal('/ipfs/bafkqahtwgevxmmrao5uxi2bamjzg623fnyqhg2lhnzqxi5lsmuqhmmi')
})

it('should validate a record with only v2 signature', async () => {
const buf = loadFixture('test/fixtures/k51qzi5uqu5dit2ku9mutlfgwyz8u730on38kd10m97m36bjt66my99hb6103f_v2.ipns-record')
const record = ipns.unmarshal(buf)

const cid = CID.parse('k51qzi5uqu5dit2ku9mutlfgwyz8u730on38kd10m97m36bjt66my99hb6103f', base36)
const peerId = peerIdFromCID(cid)
const publicKey = unmarshalPublicKey(peerId.publicKey ?? new Uint8Array())
await validate(publicKey, buf)

expect(record.value).to.equal('/ipfs/bafkqadtwgiww63tmpeqhezldn5zgi')
})
})
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.