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

feat!: opt-in V2-only records, IPIP-428 verification #234

Merged
merged 14 commits into from
Sep 15, 2023
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 <[email protected]>",
"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 {
/**
* 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.