From b329147fa2300e155c5dca276a3b787e1b2489a0 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Thu, 27 Jul 2023 13:37:18 +0200 Subject: [PATCH 01/12] feat: v2-only creation, relaxed verification --- README.md | 61 +++++++---------- src/index.ts | 116 ++++++++++++++++++++----------- src/pb/ipns.proto | 36 +++++----- src/selector.ts | 25 +++---- src/utils.ts | 56 ++++----------- src/validator.ts | 72 +++++++++++-------- test/index.spec.ts | 152 +++++++++++++++++++++++++++-------------- test/selector.spec.ts | 26 +++---- test/validator.spec.ts | 20 +++--- 9 files changed, 311 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index e6cecef..1479f24 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![codecov](https://img.shields.io/codecov/c/github/ipfs/js-ipns.svg?style=flat-square)](https://codecov.io/gh/ipfs/js-ipns) [![CI](https://img.shields.io/github/actions/workflow/status/ipfs/js-ipns/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/ipfs/js-ipns/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) -> ipns record definitions +> IPNS Record definitions. ## Table of contents @@ -54,7 +54,7 @@ This module contains all the necessary code for creating, understanding and vali ```js import * as ipns from 'ipns' -const entryData = await ipns.create(privateKey, value, sequenceNumber, lifetime) +const ipnsRecord = await ipns.create(privateKey, value, sequenceNumber, lifetime) ``` ### Validate record @@ -62,7 +62,7 @@ const entryData = await ipns.create(privateKey, value, sequenceNumber, lifetime) ```js import * as ipns from 'ipns' -await ipns.validate(publicKey, ipnsEntry) +await ipns.validate(publicKey, ipnsRecord) // if no error thrown, the record is valid ``` @@ -71,7 +71,7 @@ await ipns.validate(publicKey, ipnsEntry) ```js import * as ipns from 'ipns' -const ipnsEntryWithEmbedPublicKey = await ipns.embedPublicKey(publicKey, ipnsEntry) +const ipnsRecordWithEmbeddedPublicKey = await ipns.embedPublicKey(publicKey, ipnsRecord) ``` ### Extract public key from record @@ -79,7 +79,7 @@ const ipnsEntryWithEmbedPublicKey = await ipns.embedPublicKey(publicKey, ipnsEnt ```js import * as ipns from 'ipns' -const publicKey = await ipns.extractPublicKey(peerId, ipnsEntry) +const publicKey = await ipns.extractPublicKey(peerId, ipnsRecord) ``` ### Datastore key @@ -90,7 +90,7 @@ import * as ipns from 'ipns' ipns.getLocalKey(peerId) ``` -Returns a key to be used for storing the ipns entry locally, that is: +Returns a key to be used for storing the IPNS record locally, that is: ``` /ipns/${base32()} @@ -101,23 +101,23 @@ Returns a key to be used for storing the ipns entry locally, that is: ```js import * as ipns from 'ipns' -const entryData = await ipns.create(privateKey, value, sequenceNumber, lifetime) +const ipnsRecord = await ipns.create(privateKey, value, sequenceNumber, lifetime) // ... -const marshalledData = ipns.marshal(entryData) +const marshalledData = ipns.marshal(ipnsRecord) // ... ``` -Returns the entry data serialized. +Returns the record data serialized. ### Unmarshal data from proto buffer ```js import * as ipns from 'ipns' -const data = ipns.unmarshal(storedData) +const ipnsRecord = ipns.unmarshal(storedData) ``` -Returns the entry data structure after being serialized. +Returns the `IPNSRecord` after being serialized. ### Validator @@ -131,7 +131,7 @@ Contains an object with `validate (marshalledData, key)` and `select (dataA, dat The `validate` async function aims to verify if an IPNS record is valid. First the record is unmarshalled, then the public key is obtained and finally the record is validated (`signatureV2` of CBOR `data` is verified). -The `select` function is responsible for deciding which ipns record is the best (newer) between two records. Both records are unmarshalled and their sequence numbers are compared. If the first record provided is the newer, the operation result will be `0`, otherwise the operation result will be `1`. +The `select` function is responsible for deciding which IPNS record is the best (newer) between two records. Both records are unmarshalled and their sequence numbers are compared. If the first record provided is the newer, the operation result will be `0`, otherwise the operation result will be `1`. ## API @@ -139,7 +139,7 @@ The `select` function is responsible for deciding which ipns record is the best ```js -ipns.create(privateKey, value, sequenceNumber, lifetime) +ipns.create(privateKey, value, sequenceNumber, lifetime, options) ``` Create an IPNS record for being stored in a protocol buffer. @@ -148,43 +148,32 @@ Create an IPNS record for being stored in a protocol buffer. - `value` (Uint8Array): ipfs path of the object to be published. - `sequenceNumber` (Number): number representing the current version of the record. - `lifetime` (Number): lifetime of the record (in milliseconds). +- `options` (CreateOptions): additional creation options. -Returns a `Promise` that resolves to an object with the entry's properties eg: - -```js -{ - value: Uint8Array, - signature: Uint8Array, // V1 (legacy, ignored) - validityType: 0, - validity: Uint8Array, - sequence: 2, - signatureV2: Uint8Array, // V2 signature of data field - data: Uint8Array // DAG-CBOR that was signed -} -``` +Returns a `Promise` that resolves to an object with a `IPNSRecord`. ### Validate record ```js -ipns.validate(publicKey, ipnsEntry) +ipns.validate(publicKey, ipnsRecord) ``` Validate an IPNS record previously stored in a protocol buffer. - `publicKey` (`PubKey` [RSA Instance](https://github.com/libp2p/js-libp2p-crypto/blob/master/src/keys/rsa-class.js)): key to be used for cryptographic operations. -- `ipnsEntry` (Object): ipns entry record (obtained using the create function). +- `ipnsRecord` (`IPNSRecord`): ipns record record (obtained using the create function). Returns a `Promise`, which may be rejected if the validation was not successful. ### Marshal data with proto buffer ```js -const marshalledData = ipns.marshal(entryData) +const marshalledData = ipns.marshal(ipnsRecord) ``` -Returns the entry data serialized. +Returns the serialized IPNS record. -- `entryData` (Object): ipns entry record (obtained using the create function). +- `ipnsRecord` (`IPNSRecord`): ipns record (obtained using the create function). ### Unmarshal data from proto buffer @@ -192,20 +181,20 @@ Returns the entry data serialized. const data = ipns.unmarshal(storedData) ``` -Returns the entry data structure after being serialized. +Returns a `IPNSRecord` after being serialized. -- `storedData` (Uint8Array): ipns entry record serialized. +- `storedData` (Uint8Array): ipns record serialized. ### Extract public key from record ```js -const publicKey = await ipns.extractPublicKey(peerId, ipnsEntry) +const publicKey = await ipns.extractPublicKey(peerId, ipnsRecord) ``` -Extract a public key from an IPNS entry. +Extract a public key from an IPNS record. - `peerId` (`PeerId` [Instance](https://github.com/libp2p/js-libp2p-peer-id/tree/master/packages/libp2p-peer-id)): peer identifier object. -- `ipnsEntry` (Object): ipns entry record (obtained using the create function). +- `ipnsRecord` (`IPNSRecord`): ipns record (obtained using the create function). Returns a `Promise` which resolves to public key ([`PublicKey`](https://github.com/libp2p/js-libp2p-interfaces/blob/master/packages/interface-keys/src/index.ts) ): may be used for cryptographic operations. diff --git a/src/index.ts b/src/index.ts index 0a9a1f3..c00dcf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { unmarshalPrivateKey } from '@libp2p/crypto/keys' import { logger } from '@libp2p/logger' +import * as cborg from 'cborg' import errCode from 'err-code' import { Key } from 'interface-datastore/key' import { base32upper } from 'multiformats/bases/base32' @@ -8,9 +9,10 @@ import { identity } from 'multiformats/hashes/identity' import NanoDate from 'timestamp-nano' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import { createCborData, ipnsEntryDataForV1Sig, ipnsEntryDataForV2Sig } from './utils.js' +import { createCborData, ipnsRecordDataForV1Sig, ipnsRecordDataForV2Sig, parseRFC3339 } from './utils.js' import type { PrivateKey } from '@libp2p/interface-keys' import type { PeerId } from '@libp2p/interface-peer-id' @@ -20,24 +22,49 @@ const ID_MULTIHASH_CODE = identity.code export const namespace = '/ipns/' export const namespaceLength = namespace.length -export interface IPNSEntry { - value: Uint8Array - signature: Uint8Array // signature of the record - validityType: IpnsEntry.ValidityType // Type of validation being used - validity: Uint8Array // 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 -} +export class IPNSRecord { + readonly pb: IpnsEntry + private readonly data: any + + constructor (pb: IpnsEntry) { + this.pb = pb + + if (pb.data == null) { + throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA) + } + + this.data = cborg.decode(pb.data) + } -export interface IPNSEntryData { - Value: Uint8Array - Validity: Uint8Array - ValidityType: IpnsEntry.ValidityType - Sequence: bigint - TTL: bigint + value (): string { + return uint8ArrayToString(this.data.Value) + } + + validityType (): IpnsEntry.ValidityType { + if (this.data.ValidityType === 0) { + return IpnsEntry.ValidityType.EOL + } else { + throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) + } + } + + validity (): Date { + const validityType = this.validityType() + switch (validityType) { + case IpnsEntry.ValidityType.EOL: + return parseRFC3339(uint8ArrayToString(this.data.Validity)) + default: + throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) + } + } + + sequence (): bigint { + return BigInt(this.data.Sequence ?? 0n) + } + + ttl (): bigint { + return BigInt(this.data.TTL ?? 0n) + } } export interface IDKeys { @@ -47,24 +74,33 @@ export interface IDKeys { ipnsKey: Key } +export interface CreateOptions { + v1Compatible?: boolean +} + +const defaultCreateOptions: CreateOptions = { + v1Compatible: true +} + /** - * Creates a new ipns entry and signs it with the given private key. - * The ipns entry validity should follow the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision. + * Creates a new IPNS record and signs it with the given private key. + * 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`. * * @param {PeerId} peerId - peer id containing private key for signing the record. * @param {Uint8Array} value - value 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 const create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, lifetime: number): Promise => { +export const create = async (peerId: PeerId, value: Uint8Array, 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 const [ms, ns] = lifetime.toString().split('.') const lifetimeNs = (BigInt(ms) * BigInt(100000)) + BigInt(ns ?? '0') - return _create(peerId, value, seq, validityType, expirationDate, lifetimeNs) + return _create(peerId, value, seq, validityType, expirationDate, lifetimeNs, options) } /** @@ -75,18 +111,19 @@ export const create = async (peerId: PeerId, value: Uint8Array, seq: number | bi * @param {Uint8Array} value - value 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 const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string): Promise => { +export const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise => { const expirationDate = NanoDate.fromString(expiration) const validityType = IpnsEntry.ValidityType.EOL const ttlMs = expirationDate.toDate().getTime() - Date.now() const ttlNs = (BigInt(ttlMs) * BigInt(100000)) + BigInt(expirationDate.getNano()) - return _create(peerId, value, seq, validityType, expirationDate, ttlNs) + return _create(peerId, value, seq, validityType, expirationDate, ttlNs, options) } -const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint): Promise => { +const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { seq = BigInt(seq) const isoValidity = uint8ArrayFromString(expirationDate.toString()) @@ -95,34 +132,37 @@ const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, } const privateKey = await unmarshalPrivateKey(peerId.privateKey) - const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity) const data = createCborData(value, isoValidity, validityType, seq, ttl) - const sigData = ipnsEntryDataForV2Sig(data) + const sigData = ipnsRecordDataForV2Sig(data) const signatureV2 = await privateKey.sign(sigData) - const entry: IPNSEntry = { - value, - signature: signatureV1, - validityType, - validity: isoValidity, - sequence: seq, - ttl, + const pb: IpnsEntry = { signatureV2, data } + if (options.v1Compatible === true) { + const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity) + pb.value = value + pb.validity = isoValidity + pb.validityType = validityType + pb.signature = signatureV1 + pb.sequence = seq + pb.ttl = ttl + } + // if we cannot derive the public key from the PeerId (e.g. RSA PeerIDs), // we have to embed it in the IPNS record if (peerId.publicKey != null) { const digest = Digest.decode(peerId.toBytes()) if (digest.code !== ID_MULTIHASH_CODE || !uint8ArrayEquals(peerId.publicKey, digest.digest)) { - entry.pubKey = peerId.publicKey + pb.pubKey = peerId.publicKey } } - log('ipns entry for %b created', value) - return entry + log('ipns record for %b created', value) + return new IPNSRecord(pb) } /** @@ -149,7 +189,7 @@ export { extractPublicKey } from './utils.js' */ const signLegacyV1 = async (privateKey: PrivateKey, value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array): Promise => { try { - const dataForSignature = ipnsEntryDataForV1Sig(value, validityType, validity) + const dataForSignature = ipnsRecordDataForV1Sig(value, validityType, validity) return await privateKey.sign(dataForSignature) } catch (error: any) { diff --git a/src/pb/ipns.proto b/src/pb/ipns.proto index 276e88d..87a7ac3 100644 --- a/src/pb/ipns.proto +++ b/src/pb/ipns.proto @@ -1,39 +1,39 @@ -// https://github.com/ipfs/go-ipns/blob/master/pb/ipns.proto +// https://github.com/ipfs/boxo/blob/main/ipns/pb/record.proto syntax = "proto3"; message IpnsEntry { - enum ValidityType { - EOL = 0; // setting an EOL says "this record is valid until..." + enum ValidityType { + // setting an EOL says "this record is valid until..." + EOL = 0; } - // value to be stored in the record - optional bytes value = 1; + // legacy V1 copy of data[Value] + optional bytes value = 1; - // signature of the record - optional bytes signature = 2; + // legacy V1 field, verify 'signatureV2' instead + optional bytes signatureV1 = 2; - // Type of validation being used + // legacy V1 copies of data[ValidityType] and data[Validity] optional ValidityType validityType = 3; - - // expiration datetime for the record in RFC3339 format optional bytes validity = 4; - // number representing the version of the record + // legacy V1 copy of data[Sequence] optional uint64 sequence = 5; - // ttl in nanoseconds + // legacy V1 copy copy of data[TTL] optional uint64 ttl = 6; - // in order for nodes to properly validate a record upon receipt, they need the public - // key associated with it. For old RSA keys, its easiest if we just send this as part of - // the record itself. For newer ed25519 keys, the public key can be embedded in the - // peerID, making this field unnecessary. + // Optional Public Key to be used for signature verification. + // Used for big keys such as old RSA keys. Including the public key as part of + // the record itself makes it verifiable in offline mode, without any additional lookup. + // For newer Ed25519 keys, the public key is small enough that it can be embedded in the + // IPNS Name itself, making this field unnecessary. optional bytes pubKey = 7; - // the v2 signature of the record + // (mandatory V2) signature of the IPNS record optional bytes signatureV2 = 8; - // extensible data + // (mandatory V2) extensible record data in DAG-CBOR format optional bytes data = 9; } diff --git a/src/selector.ts b/src/selector.ts index b89d15b..d801474 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -1,24 +1,22 @@ -import { toString as uint8ArrayToString } from 'uint8arrays/to-string' -import { IpnsEntry } from './pb/ipns.js' -import { parseRFC3339 } from './utils.js' +import { unmarshal } from './utils.js' import type { SelectFn } from '@libp2p/interface-dht' export const ipnsSelector: SelectFn = (key, data) => { const entries = data.map((buf, index) => ({ - entry: IpnsEntry.decode(buf), + record: unmarshal(buf), index })) entries.sort((a, b) => { // having a newer signature version is better than an older signature version - if (a.entry.signatureV2 != null && b.entry.signatureV2 == null) { + if (a.record.pb.signatureV2 != null && b.record.pb.signatureV2 == null) { return -1 - } else if (a.entry.signatureV2 == null && b.entry.signatureV2 != null) { + } else if (a.record.pb.signatureV2 == null && b.record.pb.signatureV2 != null) { return 1 } - const aSeq = a.entry.sequence ?? 0n - const bSeq = b.entry.sequence ?? 0n + const aSeq = a.record.sequence() + const bSeq = b.record.sequence() // choose later sequence number if (aSeq > bSeq) { @@ -27,18 +25,15 @@ export const ipnsSelector: SelectFn = (key, data) => { return 1 } - const aValidty = a.entry.validity ?? new Uint8Array(0) - const bValidty = b.entry.validity ?? new Uint8Array(0) - // choose longer lived record if sequence numbers the same - const entryAValidityDate = parseRFC3339(uint8ArrayToString(aValidty)) - const entryBValidityDate = parseRFC3339(uint8ArrayToString(bValidty)) + const recordAValidityDate = a.record.validity() + const recordBValidityDate = b.record.validity() - if (entryAValidityDate.getTime() > entryBValidityDate.getTime()) { + if (recordAValidityDate.getTime() > recordBValidityDate.getTime()) { return -1 } - if (entryAValidityDate.getTime() < entryBValidityDate.getTime()) { + if (recordAValidityDate.getTime() < recordBValidityDate.getTime()) { return 1 } diff --git a/src/utils.ts b/src/utils.ts index ae55aef..660178c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,7 @@ import { concat as uint8ArrayConcat } from 'uint8arrays/concat' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import type { IPNSEntry, IPNSEntryData } from './index.js' +import { IPNSRecord } from './index.js' import type { PublicKey } from '@libp2p/interface-keys' import type { PeerId } from '@libp2p/interface-peer-id' @@ -65,8 +65,8 @@ export function parseRFC3339 (time: string): Date { * Extracts a public key from the passed PeerId, falling * back to the pubKey embedded in the ipns record */ -export const extractPublicKey = async (peerId: PeerId, entry: IpnsEntry): Promise => { - if (entry == null || peerId == null) { +export const extractPublicKey = async (peerId: PeerId, record: IPNSRecord): Promise => { + if (record == null || peerId == null) { const error = new Error('one or more of the provided parameters are not defined') log.error(error) @@ -75,15 +75,15 @@ export const extractPublicKey = async (peerId: PeerId, entry: IpnsEntry): Promis let pubKey: PublicKey | undefined - if (entry.pubKey != null) { + if (record.pb.pubKey != null) { try { - pubKey = unmarshalPublicKey(entry.pubKey) + pubKey = unmarshalPublicKey(record.pb.pubKey) } catch (err) { log.error(err) throw err } - const otherId = await peerIdFromKeys(entry.pubKey) + const otherId = await peerIdFromKeys(record.pb.pubKey) if (!otherId.equals(peerId)) { throw errCode(new Error('Embedded public key did not match PeerID'), ERRORS.ERR_INVALID_EMBEDDED_KEY) @@ -102,7 +102,7 @@ export const extractPublicKey = async (peerId: PeerId, entry: IpnsEntry): Promis /** * Utility for creating the record data for being signed */ -export const ipnsEntryDataForV1Sig = (value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array): Uint8Array => { +export const ipnsRecordDataForV1Sig = (value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array): Uint8Array => { const validityTypeBuffer = uint8ArrayFromString(validityType) return uint8ArrayConcat([value, validity, validityTypeBuffer]) @@ -111,17 +111,17 @@ export const ipnsEntryDataForV1Sig = (value: Uint8Array, validityType: IpnsEntry /** * Utility for creating the record data for being signed */ -export const ipnsEntryDataForV2Sig = (data: Uint8Array): Uint8Array => { +export const ipnsRecordDataForV2Sig = (data: Uint8Array): Uint8Array => { const entryData = uint8ArrayFromString('ipns-signature:') return uint8ArrayConcat([entryData, data]) } -export const marshal = (obj: IPNSEntry): Uint8Array => { - return IpnsEntry.encode(obj) +export const marshal = (obj: IPNSRecord): Uint8Array => { + return IpnsEntry.encode(obj.pb) } -export const unmarshal = (buf: Uint8Array): IPNSEntry => { +export const unmarshal = (buf: Uint8Array): IPNSRecord => { const message = IpnsEntry.decode(buf) // protobufjs returns bigints as numbers @@ -134,17 +134,7 @@ export const unmarshal = (buf: Uint8Array): IPNSEntry => { message.ttl = BigInt(message.ttl) } - return { - value: message.value ?? new Uint8Array(0), - signature: message.signature ?? new Uint8Array(0), - validityType: message.validityType ?? IpnsEntry.ValidityType.EOL, - validity: message.validity ?? new Uint8Array(0), - sequence: message.sequence ?? 0n, - pubKey: message.pubKey, - ttl: message.ttl ?? undefined, - signatureV2: message.signatureV2, - data: message.data - } + return new IPNSRecord(message) } export const peerIdToRoutingKey = (peerId: PeerId): Uint8Array => { @@ -177,25 +167,3 @@ export const createCborData = (value: Uint8Array, validity: Uint8Array, validity return cborg.encode(data) } - -export const parseCborData = (buf: Uint8Array): IPNSEntryData => { - const data = cborg.decode(buf) - - if (data.ValidityType === 0) { - data.ValidityType = IpnsEntry.ValidityType.EOL - } else { - throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) - } - - if (Number.isInteger(data.Sequence)) { - // sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range - data.Sequence = BigInt(data.Sequence) - } - - if (Number.isInteger(data.TTL)) { - // ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range - data.TTL = BigInt(data.TTL) - } - - return data -} diff --git a/src/validator.ts b/src/validator.ts index 27a4d9b..455d1a7 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,11 +1,12 @@ import { logger } from '@libp2p/logger' +import * as cborg from 'cborg' import errCode from 'err-code' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import { parseRFC3339, extractPublicKey, ipnsEntryDataForV2Sig, unmarshal, peerIdFromRoutingKey, parseCborData } from './utils.js' -import type { IPNSEntry } from './index.js' +import { parseRFC3339, extractPublicKey, ipnsRecordDataForV2Sig, unmarshal, peerIdFromRoutingKey } from './utils.js' +import type { IPNSRecord } from './index.js' import type { ValidateFn } from '@libp2p/interface-dht' import type { PublicKey } from '@libp2p/interface-keys' @@ -17,27 +18,26 @@ const log = logger('ipns:validator') const MAX_RECORD_SIZE = 1024 * 10 /** - * Validates the given ipns entry against the given public key + * Validates the given IPNS record against the given public key */ -export const validate = async (publicKey: PublicKey, entry: IPNSEntry): Promise => { - const { value, validityType, validity } = entry +export const validate = async (publicKey: PublicKey, record: IPNSRecord): Promise => { + const { value, validityType, validity } = record.pb - let dataForSignature: Uint8Array - let signature: Uint8Array - - // Check v2 signature if it's available, otherwise use the v1 signature - if ((entry.signatureV2 != null) && (entry.data != null)) { - signature = entry.signatureV2 - dataForSignature = ipnsEntryDataForV2Sig(entry.data) - - validateCborDataMatchesPbData(entry) - } else { + // Ensure Signature V2 and Data are present and not empty. + if ((record.pb.signatureV2 == null) || (record.pb.data == null)) { throw errCode(new Error('missing data or signatureV2'), ERRORS.ERR_SIGNATURE_VERIFICATION) } - // Validate Signature + // If Signature V1 is present, ensure that CBOR data matches Protobuf data. + if (record.pb.signature != null || record.pb.value != null) { + validateCborDataMatchesPbData(record) + } + + // Validate Signature V2 let isValid try { + const signature = record.pb.signatureV2 + const dataForSignature = ipnsRecordDataForV2Sig(record.pb.data) isValid = await publicKey.verify(dataForSignature, signature) } catch (err) { isValid = false @@ -67,33 +67,49 @@ export const validate = async (publicKey: PublicKey, entry: IPNSEntry): Promise< throw errCode(new Error('unrecognized validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) } - log('ipns entry for %b is valid', value) + log('ipns record for %b is valid', value) } -const validateCborDataMatchesPbData = (entry: IPNSEntry): void => { - if (entry.data == null) { +const validateCborDataMatchesPbData = (record: IPNSRecord): void => { + if (record.pb.data == null) { throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA) } - const data = parseCborData(entry.data) + const data = cborg.decode(record.pb.data) - if (!uint8ArrayEquals(data.Value, entry.value)) { + if (!uint8ArrayEquals(data.Value, record.pb.value ?? new Uint8Array(0))) { throw errCode(new Error('Field "value" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) } - if (!uint8ArrayEquals(data.Validity, entry.validity)) { + if (!uint8ArrayEquals(data.Validity, record.pb.validity ?? new Uint8Array(0))) { throw errCode(new Error('Field "validity" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) } - if (data.ValidityType !== entry.validityType) { + if (data.ValidityType === 0) { + data.ValidityType = IpnsEntry.ValidityType.EOL + } else { + throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) + } + + if (data.ValidityType !== record.pb.validityType) { throw errCode(new Error('Field "validityType" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) } - if (data.Sequence !== entry.sequence) { + if (Number.isInteger(data.Sequence)) { + // sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range + data.Sequence = BigInt(data.Sequence) + } + + if (data.Sequence !== record.pb.sequence) { throw errCode(new Error('Field "sequence" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) } - if (data.TTL !== entry.ttl) { + if (Number.isInteger(data.TTL)) { + // ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range + data.TTL = BigInt(data.TTL) + } + + if (data.TTL !== record.pb.ttl) { throw errCode(new Error('Field "ttl" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) } } @@ -104,11 +120,11 @@ export const ipnsValidator: ValidateFn = async (key, marshalledData) => { } const peerId = peerIdFromRoutingKey(key) - const receivedEntry = unmarshal(marshalledData) + const receivedRecord = unmarshal(marshalledData) // extract public key - const pubKey = await extractPublicKey(peerId, receivedEntry) + const pubKey = await extractPublicKey(peerId, receivedRecord) // Record validation - await validate(pubKey, receivedEntry) + await validate(pubKey, receivedRecord) } diff --git a/test/index.spec.ts b/test/index.spec.ts index 78a2e5e..d67d3b8 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -7,8 +7,10 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import { base58btc } from 'multiformats/bases/base58' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from '../src/errors.js' import * as ipns from '../src/index.js' +import { IpnsEntry } from '../src/pb/ipns.js' import { unmarshal, marshal, extractPublicKey, peerIdToRoutingKey } from '../src/utils.js' import { ipnsValidator } from '../src/validator.js' import type { PeerId } from '@libp2p/interface-peer-id' @@ -24,123 +26,171 @@ describe('ipns', function () { peerId = await peerIdFromKeys(rsa.public.bytes, rsa.bytes) }) - it('should create an ipns record correctly', async () => { + it('should create an ipns record (V1+V2) correctly', async () => { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) - expect(entry).to.deep.include({ + const record = await ipns.create(peerId, cid, sequence, validity) + + expect(record.value()).to.equal(uint8ArrayToString(cid)) + expect(record.sequence()).to.equal(BigInt(0)) + expect(record.validityType()).to.equal(IpnsEntry.ValidityType.EOL) + expect(record.validity()).to.exist() + expect(record.ttl()).to.equal(BigInt(validity * 100000)) + + expect(record.pb).to.deep.include({ value: cid, sequence: BigInt(sequence) }) - expect(entry).to.have.property('validity') - expect(entry).to.have.property('signature') - expect(entry).to.have.property('validityType') - expect(entry).to.have.property('signatureV2') - expect(entry).to.have.property('data') + expect(record.pb).to.have.property('validity') + expect(record.pb).to.have.property('signature') + expect(record.pb).to.have.property('validityType') + expect(record.pb).to.have.property('signatureV2') + expect(record.pb).to.have.property('data') + }) + + it('should create an ipns record (V2) correctly', async () => { + const sequence = 0 + const validity = 1000000 + + const record = await ipns.create(peerId, cid, sequence, validity, { v1Compatible: false }) + + expect(record.value()).to.equal(uint8ArrayToString(cid)) + expect(record.sequence()).to.equal(BigInt(0)) + expect(record.validityType()).to.equal(IpnsEntry.ValidityType.EOL) + expect(record.validity()).to.exist() + expect(record.ttl()).to.equal(BigInt(validity * 100000)) + + expect(record.pb).to.not.have.property('value') + expect(record.pb).to.not.have.property('sequence') + expect(record.pb).to.not.have.property('validity') + expect(record.pb).to.not.have.property('signature') + expect(record.pb).to.not.have.property('validityType') + expect(record.pb).to.have.property('signatureV2') + expect(record.pb).to.have.property('data') }) - it('should be able to create a record with a fixed expiration', async () => { + it('should be able to create a record (V1+V2) with a fixed expiration', async () => { const sequence = 0 // 2033-05-18T03:33:20.000000000Z const expiration = '2033-05-18T03:33:20.000000000Z' - const entry = await ipns.createWithExpiration(peerId, cid, sequence, expiration) + const record = await ipns.createWithExpiration(peerId, cid, sequence, expiration) + + await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) + expect(record.pb).to.have.property('validity') + expect(record.validity().getTime()).to.equal(new Date('2033-05-18T03:33:20.000000000Z').getTime()) + }) + + it('should be able to create a record (V2) with a fixed expiration', async () => { + const sequence = 0 + // 2033-05-18T03:33:20.000000000Z + const expiration = '2033-05-18T03:33:20.000000000Z' + + const record = await ipns.createWithExpiration(peerId, cid, sequence, expiration, { v1Compatible: false }) + + await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) + expect(record.pb).to.not.have.property('validity') + expect(record.validity().getTime()).to.equal(new Date('2033-05-18T03:33:20.000000000Z').getTime()) + }) + + it('should create an ipns record (V1+V2) and validate it correctly', async () => { + const sequence = 0 + const validity = 1000000 - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(entry)) - expect(entry).to.have.property('validity') - expect(entry.validity).to.equalBytes(uint8ArrayFromString('2033-05-18T03:33:20.000000000Z')) + const record = await ipns.create(peerId, cid, sequence, validity) + await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) }) - it('should create an ipns record and validate it correctly', async () => { + it('should create an ipns record (V2) and validate it correctly', async () => { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(entry)) + const record = await ipns.create(peerId, cid, sequence, validity, { v1Compatible: false }) + await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) }) it('should fail to validate a v1 (deprecated legacy) message', async () => { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, cid, sequence, validity) // remove the extra fields added for v2 sigs - delete entry.data - delete entry.signatureV2 + delete record.pb.data + delete record.pb.signatureV2 // confirm a v1 exists - expect(entry).to.have.property('signature') + expect(record.pb).to.have.property('signature') - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(entry))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_INVALID_RECORD_DATA) }) it('should fail to validate a v2 without v2 signature (ignore v1)', async () => { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, cid, sequence, validity) // remove v2 sig - delete entry.signatureV2 + delete record.pb.signatureV2 // confirm a v1 exists - expect(entry).to.have.property('signature') + expect(record.pb).to.have.property('signature') - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(entry))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) it('should fail to validate a bad record', async () => { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, cid, sequence, validity) // corrupt the record by changing the value to random bytes - entry.value = randomBytes(46) + record.pb.value = randomBytes(46) - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(entry))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) it('should create an ipns record with a validity of 1 nanosecond correctly and it should not be valid 1ms later', async () => { const sequence = 0 const validity = 0.00001 - const entry = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, cid, sequence, validity) await new Promise(resolve => setTimeout(resolve, 1)) - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(entry))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_IPNS_EXPIRED_RECORD) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_IPNS_EXPIRED_RECORD) }) it('should create an ipns record, marshal and unmarshal it, as well as validate it correctly', async () => { const sequence = 0 const validity = 1000000 - const entryDataCreated = await ipns.create(peerId, cid, sequence, validity) + const createdRecord = await ipns.create(peerId, cid, sequence, validity) - const marshalledData = marshal(entryDataCreated) + const marshalledData = marshal(createdRecord) const unmarshalledData = unmarshal(marshalledData) - expect(entryDataCreated.value).to.equalBytes(unmarshalledData.value) - expect(entryDataCreated.validity).to.equalBytes(unmarshalledData.validity) - expect(entryDataCreated.validityType).to.equal(unmarshalledData.validityType) - expect(entryDataCreated.signature).to.equalBytes(unmarshalledData.signature) - expect(entryDataCreated.sequence).to.equal(unmarshalledData.sequence) - expect(entryDataCreated.ttl).to.equal(unmarshalledData.ttl) + expect(createdRecord.pb.value).to.equalBytes(unmarshalledData.pb.value) + expect(createdRecord.pb.validity).to.equalBytes(unmarshalledData.pb.validity) + expect(createdRecord.pb.validityType).to.equal(unmarshalledData.pb.validityType) + expect(createdRecord.pb.signature).to.equalBytes(unmarshalledData.pb.signature) + expect(createdRecord.pb.sequence).to.equal(unmarshalledData.pb.sequence) + expect(createdRecord.pb.ttl).to.equal(unmarshalledData.pb.ttl) - if (unmarshalledData.signatureV2 == null) { + if (unmarshalledData.pb.signatureV2 == null) { throw new Error('No v2 sig found') } - expect(entryDataCreated.signatureV2).to.equalBytes(unmarshalledData.signatureV2) + expect(createdRecord.pb.signatureV2).to.equalBytes(unmarshalledData.pb.signatureV2) - if (unmarshalledData.data == null) { + if (unmarshalledData.pb.data == null) { throw new Error('No v2 data found') } - expect(entryDataCreated.data).to.equalBytes(unmarshalledData.data) + expect(createdRecord.pb.data).to.equalBytes(unmarshalledData.pb.data) await ipnsValidator(peerIdToRoutingKey(peerId), marshal(unmarshalledData)) }) @@ -170,9 +220,9 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, cid, sequence, validity) - expect(entry).to.deep.include({ + expect(record.pb).to.deep.include({ pubKey: peerId.publicKey }) }) @@ -188,19 +238,19 @@ describe('ipns', function () { const validity = 1000000 const ed25519 = await createEd25519PeerId() - const entry = await ipns.create(ed25519, cid, sequence, validity) + const record = await ipns.create(ed25519, cid, sequence, validity) - expect(entry).to.not.have.property('pubKey') // ed25519 keys should not be embedded + expect(record.pb).to.not.have.property('pubKey') // ed25519 keys should not be embedded }) it('validator with no valid public key should error', async () => { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) - delete entry.pubKey + const record = await ipns.create(peerId, cid, sequence, validity) + delete record.pb.pubKey - const marshalledData = marshal(entry) + const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId) await expect(ipnsValidator(key, marshalledData)).to.eventually.be.rejected().with.property('code', ERRORS.ERR_UNDEFINED_PARAMETER) @@ -210,9 +260,9 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, cid, sequence, validity) - const publicKey = await extractPublicKey(peerId, entry) + const publicKey = await extractPublicKey(peerId, record) expect(publicKey).to.deep.include({ bytes: peerId.publicKey }) diff --git a/test/selector.spec.ts b/test/selector.spec.ts index 6fe26b9..ff76b35 100644 --- a/test/selector.spec.ts +++ b/test/selector.spec.ts @@ -24,11 +24,11 @@ describe('selector', function () { const sequence = 0 const lifetime = 1000000 - const entry = await ipns.create(peerId, cid, sequence, lifetime) - const newEntry = await ipns.create(peerId, cid, (sequence + 1), lifetime) + const record = await ipns.create(peerId, cid, sequence, lifetime) + const newRecord = await ipns.create(peerId, cid, (sequence + 1), lifetime) - const marshalledData = marshal(entry) - const marshalledNewData = marshal(newEntry) + const marshalledData = marshal(record) + const marshalledNewData = marshal(newRecord) const key = peerIdToRoutingKey(peerId) @@ -43,11 +43,11 @@ describe('selector', function () { const sequence = 0 const lifetime = 1000000 - const entry = await ipns.create(peerId, cid, sequence, lifetime) - const newEntry = await ipns.create(peerId, cid, sequence, (lifetime + 1)) + const record = await ipns.create(peerId, cid, sequence, lifetime) + const newRecord = await ipns.create(peerId, cid, sequence, (lifetime + 1)) - const marshalledData = marshal(entry) - const marshalledNewData = marshal(newEntry) + const marshalledData = marshal(record) + const marshalledNewData = marshal(newRecord) const key = peerIdToRoutingKey(peerId) @@ -62,13 +62,13 @@ describe('selector', function () { const sequence = 0 const lifetime = 1000000 - const entry = await ipns.create(peerId, cid, sequence, lifetime) + const record = await ipns.create(peerId, cid, sequence, lifetime) - const newEntry = await ipns.create(peerId, cid, sequence + 1, lifetime) - delete newEntry.signatureV2 + const newRecord = await ipns.create(peerId, cid, sequence + 1, lifetime) + delete newRecord.pb.signatureV2 - const marshalledData = marshal(entry) - const marshalledNewData = marshal(newEntry) + const marshalledData = marshal(record) + const marshalledNewData = marshal(newRecord) const key = peerIdToRoutingKey(peerId) diff --git a/test/validator.spec.ts b/test/validator.spec.ts index d2f5a3b..620cb62 100644 --- a/test/validator.spec.ts +++ b/test/validator.spec.ts @@ -32,8 +32,8 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId1, cid, sequence, validity) - const marshalledData = marshal(entry) + const record = await ipns.create(peerId1, cid, sequence, validity) + const marshalledData = marshal(record) const keyBytes = base58btc.decode(`z${peerId1.toString()}`) const key = uint8ArrayConcat([uint8ArrayFromString('/ipns/'), keyBytes]) @@ -45,11 +45,11 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId1, cid, sequence, validity) + const record = await ipns.create(peerId1, cid, sequence, validity) // corrupt the record by changing the value to random bytes - entry.value = randomBytes(entry.value.length) - const marshalledData = marshal(entry) + record.pb.value = randomBytes(record.pb.value?.length ?? 0) + const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId1) @@ -60,8 +60,8 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId1, cid, sequence, validity) - const marshalledData = marshal(entry) + const record = await ipns.create(peerId1, cid, sequence, validity) + const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId2) @@ -72,9 +72,9 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const entry = await ipns.create(peerId1, cid, sequence, validity) - entry.pubKey = peerId2.publicKey - const marshalledData = marshal(entry) + const record = await ipns.create(peerId1, cid, sequence, validity) + record.pb.pubKey = peerId2.publicKey + const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId1) From e2fca17f93996e75afc4dfdbb3ec809bb88cb88b Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Thu, 10 Aug 2023 12:59:38 +0200 Subject: [PATCH 02/12] chore: apply review suggestions Co-authored-by: Marcin Rataj --- README.md | 4 ++-- src/validator.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1479f24..3cfda83 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ import * as ipns from 'ipns' const ipnsRecord = ipns.unmarshal(storedData) ``` -Returns the `IPNSRecord` after being serialized. +Returns the `IPNSRecord` after being deserialized. ### Validator @@ -161,7 +161,7 @@ ipns.validate(publicKey, ipnsRecord) Validate an IPNS record previously stored in a protocol buffer. - `publicKey` (`PubKey` [RSA Instance](https://github.com/libp2p/js-libp2p-crypto/blob/master/src/keys/rsa-class.js)): key to be used for cryptographic operations. -- `ipnsRecord` (`IPNSRecord`): ipns record record (obtained using the create function). +- `ipnsRecord` (`IPNSRecord`): IPNS record (obtained using the create function). Returns a `Promise`, which may be rejected if the validation was not successful. diff --git a/src/validator.ts b/src/validator.ts index 455d1a7..6fb464d 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -28,7 +28,7 @@ export const validate = async (publicKey: PublicKey, record: IPNSRecord): Promis throw errCode(new Error('missing data or signatureV2'), ERRORS.ERR_SIGNATURE_VERIFICATION) } - // If Signature V1 is present, ensure that CBOR data matches Protobuf data. + // If Signature V1 is present, ensure that CBOR data matches Protobuf data (IPIP-428). if (record.pb.signature != null || record.pb.value != null) { validateCborDataMatchesPbData(record) } From d04f96f5b8aafb3c8ed9c900e81fe22744ce8302 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Thu, 10 Aug 2023 13:10:41 +0200 Subject: [PATCH 03/12] refactor: rename signature to signatureV1 --- src/index.ts | 2 +- src/pb/ipns.ts | 8 ++++---- src/validator.ts | 2 +- test/index.spec.ts | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index c00dcf0..1d72822 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,7 +146,7 @@ const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, pb.value = value pb.validity = isoValidity pb.validityType = validityType - pb.signature = signatureV1 + pb.signatureV1 = signatureV1 pb.sequence = seq pb.ttl = ttl } diff --git a/src/pb/ipns.ts b/src/pb/ipns.ts index 867ea37..fb18014 100644 --- a/src/pb/ipns.ts +++ b/src/pb/ipns.ts @@ -10,7 +10,7 @@ import type { Uint8ArrayList } from 'uint8arraylist' export interface IpnsEntry { value?: Uint8Array - signature?: Uint8Array + signatureV1?: Uint8Array validityType?: IpnsEntry.ValidityType validity?: Uint8Array sequence?: bigint @@ -49,9 +49,9 @@ export namespace IpnsEntry { w.bytes(obj.value) } - if (obj.signature != null) { + if (obj.signatureV1 != null) { w.uint32(18) - w.bytes(obj.signature) + w.bytes(obj.signatureV1) } if (obj.validityType != null) { @@ -105,7 +105,7 @@ export namespace IpnsEntry { obj.value = reader.bytes() break case 2: - obj.signature = reader.bytes() + obj.signatureV1 = reader.bytes() break case 3: obj.validityType = IpnsEntry.ValidityType.codec().decode(reader) diff --git a/src/validator.ts b/src/validator.ts index 6fb464d..1ab5162 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -29,7 +29,7 @@ export const validate = async (publicKey: PublicKey, record: IPNSRecord): Promis } // If Signature V1 is present, ensure that CBOR data matches Protobuf data (IPIP-428). - if (record.pb.signature != null || record.pb.value != null) { + if (record.pb.signatureV1 != null || record.pb.value != null) { validateCborDataMatchesPbData(record) } diff --git a/test/index.spec.ts b/test/index.spec.ts index d67d3b8..c29322f 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -43,7 +43,7 @@ describe('ipns', function () { sequence: BigInt(sequence) }) expect(record.pb).to.have.property('validity') - expect(record.pb).to.have.property('signature') + expect(record.pb).to.have.property('signatureV1') expect(record.pb).to.have.property('validityType') expect(record.pb).to.have.property('signatureV2') expect(record.pb).to.have.property('data') @@ -64,7 +64,7 @@ describe('ipns', function () { expect(record.pb).to.not.have.property('value') expect(record.pb).to.not.have.property('sequence') expect(record.pb).to.not.have.property('validity') - expect(record.pb).to.not.have.property('signature') + expect(record.pb).to.not.have.property('signatureV1') expect(record.pb).to.not.have.property('validityType') expect(record.pb).to.have.property('signatureV2') expect(record.pb).to.have.property('data') @@ -121,7 +121,7 @@ describe('ipns', function () { delete record.pb.signatureV2 // confirm a v1 exists - expect(record.pb).to.have.property('signature') + expect(record.pb).to.have.property('signatureV1') await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_INVALID_RECORD_DATA) }) @@ -136,7 +136,7 @@ describe('ipns', function () { delete record.pb.signatureV2 // confirm a v1 exists - expect(record.pb).to.have.property('signature') + expect(record.pb).to.have.property('signatureV1') await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) @@ -176,7 +176,7 @@ describe('ipns', function () { expect(createdRecord.pb.value).to.equalBytes(unmarshalledData.pb.value) expect(createdRecord.pb.validity).to.equalBytes(unmarshalledData.pb.validity) expect(createdRecord.pb.validityType).to.equal(unmarshalledData.pb.validityType) - expect(createdRecord.pb.signature).to.equalBytes(unmarshalledData.pb.signature) + expect(createdRecord.pb.signatureV1).to.equalBytes(unmarshalledData.pb.signatureV1) expect(createdRecord.pb.sequence).to.equal(unmarshalledData.pb.sequence) expect(createdRecord.pb.ttl).to.equal(unmarshalledData.pb.ttl) From 9997cc1c5e05a12e680b0283aab5a16b5bfd3a54 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Thu, 10 Aug 2023 13:13:16 +0200 Subject: [PATCH 04/12] test: should validate a V2 and V1+V2 records --- test/validator.spec.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/validator.spec.ts b/test/validator.spec.ts index 620cb62..c54bea6 100644 --- a/test/validator.spec.ts +++ b/test/validator.spec.ts @@ -28,11 +28,24 @@ describe('validator', function () { peerId2 = await peerIdFromKeys(rsa2.public.bytes, rsa2.bytes) }) - it('should validate a record', async () => { + it('should validate a (V2) record', async () => { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId1, cid, sequence, validity) + const record = await ipns.create(peerId1, cid, sequence, validity, { v1Compatible: false }) + const marshalledData = marshal(record) + + const keyBytes = base58btc.decode(`z${peerId1.toString()}`) + const key = uint8ArrayConcat([uint8ArrayFromString('/ipns/'), keyBytes]) + + await ipnsValidator(key, marshalledData) + }) + + it('should validate a (V1+V2) record', async () => { + const sequence = 0 + const validity = 1000000 + + const record = await ipns.create(peerId1, cid, sequence, validity, { v1Compatible: true }) const marshalledData = marshal(record) const keyBytes = base58btc.decode(`z${peerId1.toString()}`) From b6566f78c817cd64c8ee9ec8d20cecb125cda0f3 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Fri, 11 Aug 2023 16:15:40 +0200 Subject: [PATCH 05/12] refactor: change value from bytes array to string --- README.md | 2 +- src/index.ts | 17 +++++++++-------- test/index.spec.ts | 39 +++++++++++++++++++-------------------- test/selector.spec.ts | 15 +++++++-------- test/validator.spec.ts | 12 ++++++------ 5 files changed, 42 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 3cfda83..c8ed554 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ ipns.create(privateKey, value, sequenceNumber, lifetime, options) Create an IPNS record for being stored in a protocol buffer. - `privateKey` (`PrivKey` [RSA Instance](https://github.com/libp2p/js-libp2p-crypto/blob/master/src/keys/rsa-class.js)): key to be used for cryptographic operations. -- `value` (Uint8Array): ipfs path of the object to be published. +- `value` (string): IPFS path of the object to be published. - `sequenceNumber` (Number): number representing the current version of the record. - `lifetime` (Number): lifetime of the record (in milliseconds). - `options` (CreateOptions): additional creation options. diff --git a/src/index.ts b/src/index.ts index 1d72822..2579fd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,12 +88,12 @@ const defaultCreateOptions: CreateOptions = { * Note: This function does not embed the public key. If you want to do that, use `EmbedPublicKey`. * * @param {PeerId} peerId - peer id containing private key for signing the record. - * @param {Uint8Array} value - value to be stored in the record. + * @param {string} value - value 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 const create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise => { +export const create = async (peerId: PeerId, value: 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 @@ -108,12 +108,12 @@ export const create = async (peerId: PeerId, value: Uint8Array, seq: number | bi * WARNING: nano precision is not standard, make sure the value in seconds is 9 orders of magnitude lesser than the one provided. * * @param {PeerId} peerId - PeerId containing private key for signing the record. - * @param {Uint8Array} value - value to be stored in the record. + * @param {string} value - value 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 const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise => { +export const createWithExpiration = async (peerId: PeerId, value: string, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise => { const expirationDate = NanoDate.fromString(expiration) const validityType = IpnsEntry.ValidityType.EOL @@ -123,16 +123,17 @@ export const createWithExpiration = async (peerId: PeerId, value: Uint8Array, se return _create(peerId, value, seq, validityType, expirationDate, ttlNs, options) } -const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { +const _create = async (peerId: PeerId, value: string, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { seq = BigInt(seq) const isoValidity = uint8ArrayFromString(expirationDate.toString()) + const encodedValue = uint8ArrayFromString(value) if (peerId.privateKey == null) { throw errCode(new Error('Missing private key'), ERRORS.ERR_MISSING_PRIVATE_KEY) } const privateKey = await unmarshalPrivateKey(peerId.privateKey) - const data = createCborData(value, isoValidity, validityType, seq, ttl) + const data = createCborData(encodedValue, isoValidity, validityType, seq, ttl) const sigData = ipnsRecordDataForV2Sig(data) const signatureV2 = await privateKey.sign(sigData) @@ -142,8 +143,8 @@ const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, } if (options.v1Compatible === true) { - const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity) - pb.value = value + const signatureV1 = await signLegacyV1(privateKey, encodedValue, validityType, isoValidity) + pb.value = encodedValue pb.validity = isoValidity pb.validityType = validityType pb.signatureV1 = signatureV1 diff --git a/test/index.spec.ts b/test/index.spec.ts index c29322f..39442c4 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -7,7 +7,6 @@ import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import { base58btc } from 'multiformats/bases/base58' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' -import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from '../src/errors.js' import * as ipns from '../src/index.js' import { IpnsEntry } from '../src/pb/ipns.js' @@ -18,7 +17,7 @@ import type { PeerId } from '@libp2p/interface-peer-id' describe('ipns', function () { this.timeout(20 * 1000) - const cid = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') + const contentPath = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' let peerId: PeerId before(async () => { @@ -30,16 +29,16 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) - expect(record.value()).to.equal(uint8ArrayToString(cid)) + expect(record.value()).to.equal(contentPath) expect(record.sequence()).to.equal(BigInt(0)) expect(record.validityType()).to.equal(IpnsEntry.ValidityType.EOL) expect(record.validity()).to.exist() expect(record.ttl()).to.equal(BigInt(validity * 100000)) expect(record.pb).to.deep.include({ - value: cid, + value: uint8ArrayFromString(contentPath), sequence: BigInt(sequence) }) expect(record.pb).to.have.property('validity') @@ -53,9 +52,9 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity, { v1Compatible: false }) + const record = await ipns.create(peerId, contentPath, sequence, validity, { v1Compatible: false }) - expect(record.value()).to.equal(uint8ArrayToString(cid)) + expect(record.value()).to.equal(contentPath) expect(record.sequence()).to.equal(BigInt(0)) expect(record.validityType()).to.equal(IpnsEntry.ValidityType.EOL) expect(record.validity()).to.exist() @@ -75,7 +74,7 @@ describe('ipns', function () { // 2033-05-18T03:33:20.000000000Z const expiration = '2033-05-18T03:33:20.000000000Z' - const record = await ipns.createWithExpiration(peerId, cid, sequence, expiration) + const record = await ipns.createWithExpiration(peerId, contentPath, sequence, expiration) await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) expect(record.pb).to.have.property('validity') @@ -87,7 +86,7 @@ describe('ipns', function () { // 2033-05-18T03:33:20.000000000Z const expiration = '2033-05-18T03:33:20.000000000Z' - const record = await ipns.createWithExpiration(peerId, cid, sequence, expiration, { v1Compatible: false }) + const record = await ipns.createWithExpiration(peerId, contentPath, sequence, expiration, { v1Compatible: false }) await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) expect(record.pb).to.not.have.property('validity') @@ -98,7 +97,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) }) @@ -106,7 +105,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity, { v1Compatible: false }) + const record = await ipns.create(peerId, contentPath, sequence, validity, { v1Compatible: false }) await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) }) @@ -114,7 +113,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) // remove the extra fields added for v2 sigs delete record.pb.data @@ -130,7 +129,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) // remove v2 sig delete record.pb.signatureV2 @@ -145,7 +144,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) // corrupt the record by changing the value to random bytes record.pb.value = randomBytes(46) @@ -157,7 +156,7 @@ describe('ipns', function () { const sequence = 0 const validity = 0.00001 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) await new Promise(resolve => setTimeout(resolve, 1)) @@ -168,7 +167,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const createdRecord = await ipns.create(peerId, cid, sequence, validity) + const createdRecord = await ipns.create(peerId, contentPath, sequence, validity) const marshalledData = marshal(createdRecord) const unmarshalledData = unmarshal(marshalledData) @@ -220,7 +219,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) expect(record.pb).to.deep.include({ pubKey: peerId.publicKey @@ -238,7 +237,7 @@ describe('ipns', function () { const validity = 1000000 const ed25519 = await createEd25519PeerId() - const record = await ipns.create(ed25519, cid, sequence, validity) + const record = await ipns.create(ed25519, contentPath, sequence, validity) expect(record.pb).to.not.have.property('pubKey') // ed25519 keys should not be embedded }) @@ -247,7 +246,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) delete record.pb.pubKey const marshalledData = marshal(record) @@ -260,7 +259,7 @@ describe('ipns', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId, cid, sequence, validity) + const record = await ipns.create(peerId, contentPath, sequence, validity) const publicKey = await extractPublicKey(peerId, record) expect(publicKey).to.deep.include({ diff --git a/test/selector.spec.ts b/test/selector.spec.ts index ff76b35..c20320e 100644 --- a/test/selector.spec.ts +++ b/test/selector.spec.ts @@ -3,7 +3,6 @@ import { generateKeyPair } from '@libp2p/crypto/keys' import { peerIdFromKeys } from '@libp2p/peer-id' import { expect } from 'aegir/chai' -import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import * as ipns from '../src/index.js' import { ipnsSelector } from '../src/selector.js' import { marshal, peerIdToRoutingKey } from '../src/utils.js' @@ -12,7 +11,7 @@ import type { PeerId } from '@libp2p/interface-peer-id' describe('selector', function () { this.timeout(20 * 1000) - const cid = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') + const contentPath = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' let peerId: PeerId before(async () => { @@ -24,8 +23,8 @@ describe('selector', function () { const sequence = 0 const lifetime = 1000000 - const record = await ipns.create(peerId, cid, sequence, lifetime) - const newRecord = await ipns.create(peerId, cid, (sequence + 1), lifetime) + const record = await ipns.create(peerId, contentPath, sequence, lifetime) + const newRecord = await ipns.create(peerId, contentPath, (sequence + 1), lifetime) const marshalledData = marshal(record) const marshalledNewData = marshal(newRecord) @@ -43,8 +42,8 @@ describe('selector', function () { const sequence = 0 const lifetime = 1000000 - const record = await ipns.create(peerId, cid, sequence, lifetime) - const newRecord = await ipns.create(peerId, cid, sequence, (lifetime + 1)) + const record = await ipns.create(peerId, contentPath, sequence, lifetime) + const newRecord = await ipns.create(peerId, contentPath, sequence, (lifetime + 1)) const marshalledData = marshal(record) const marshalledNewData = marshal(newRecord) @@ -62,9 +61,9 @@ describe('selector', function () { const sequence = 0 const lifetime = 1000000 - const record = await ipns.create(peerId, cid, sequence, lifetime) + const record = await ipns.create(peerId, contentPath, sequence, lifetime) - const newRecord = await ipns.create(peerId, cid, sequence + 1, lifetime) + const newRecord = await ipns.create(peerId, contentPath, sequence + 1, lifetime) delete newRecord.pb.signatureV2 const marshalledData = marshal(record) diff --git a/test/validator.spec.ts b/test/validator.spec.ts index c54bea6..4d3a976 100644 --- a/test/validator.spec.ts +++ b/test/validator.spec.ts @@ -16,7 +16,7 @@ import type { PeerId } from '@libp2p/interface-peer-id' describe('validator', function () { this.timeout(20 * 1000) - const cid = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') + const contentPath = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' let peerId1: PeerId let peerId2: PeerId @@ -32,7 +32,7 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId1, cid, sequence, validity, { v1Compatible: false }) + const record = await ipns.create(peerId1, contentPath, sequence, validity, { v1Compatible: false }) const marshalledData = marshal(record) const keyBytes = base58btc.decode(`z${peerId1.toString()}`) @@ -45,7 +45,7 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId1, cid, sequence, validity, { v1Compatible: true }) + const record = await ipns.create(peerId1, contentPath, sequence, validity, { v1Compatible: true }) const marshalledData = marshal(record) const keyBytes = base58btc.decode(`z${peerId1.toString()}`) @@ -58,7 +58,7 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId1, cid, sequence, validity) + const record = await ipns.create(peerId1, contentPath, sequence, validity) // corrupt the record by changing the value to random bytes record.pb.value = randomBytes(record.pb.value?.length ?? 0) @@ -73,7 +73,7 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId1, cid, sequence, validity) + const record = await ipns.create(peerId1, contentPath, sequence, validity) const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId2) @@ -85,7 +85,7 @@ describe('validator', function () { const sequence = 0 const validity = 1000000 - const record = await ipns.create(peerId1, cid, sequence, validity) + const record = await ipns.create(peerId1, contentPath, sequence, validity) record.pb.pubKey = peerId2.publicKey const marshalledData = marshal(record) From ba7e65c70f66f75a10edb8d4b6ef27f41e903496 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Tue, 22 Aug 2023 12:46:50 +0200 Subject: [PATCH 06/12] feat: normalize value at creation and read time --- src/errors.ts | 1 + src/index.ts | 32 +++++++++++++++---- test/index.spec.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 29dd232..969b5eb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -7,6 +7,7 @@ export const ERR_PEER_ID_FROM_PUBLIC_KEY = 'ERR_PEER_ID_FROM_PUBLIC_KEY' export const ERR_PUBLIC_KEY_FROM_ID = 'ERR_PUBLIC_KEY_FROM_ID' export const ERR_UNDEFINED_PARAMETER = 'ERR_UNDEFINED_PARAMETER' export const ERR_INVALID_RECORD_DATA = 'ERR_INVALID_RECORD_DATA' +export const ERR_INVALID_VALUE = 'ERR_INVALID_VALUE' export const ERR_INVALID_EMBEDDED_KEY = 'ERR_INVALID_EMBEDDED_KEY' export const ERR_MISSING_PRIVATE_KEY = 'ERR_MISSING_PRIVATE_KEY' export const ERR_RECORD_TOO_LARGE = 'ERR_RECORD_TOO_LARGE' diff --git a/src/index.ts b/src/index.ts index 2579fd7..0d28e03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import * as cborg from 'cborg' import errCode from 'err-code' import { Key } from 'interface-datastore/key' import { base32upper } from 'multiformats/bases/base32' +import { CID } from 'multiformats/cid' import * as Digest from 'multiformats/hashes/digest' import { identity } from 'multiformats/hashes/identity' import NanoDate from 'timestamp-nano' @@ -24,7 +25,7 @@ export const namespaceLength = namespace.length export class IPNSRecord { readonly pb: IpnsEntry - private readonly data: any + readonly data: any constructor (pb: IpnsEntry) { this.pb = pb @@ -37,7 +38,7 @@ export class IPNSRecord { } value (): string { - return uint8ArrayToString(this.data.Value) + return normalizeValue(this.data.Value) } validityType (): IpnsEntry.ValidityType { @@ -93,7 +94,7 @@ const defaultCreateOptions: CreateOptions = { * @param {number} lifetime - lifetime of the record (in milliseconds). * @param {CreateOptions} options - additional create options. */ -export const create = async (peerId: PeerId, value: string, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise => { +export const create = async (peerId: PeerId, value: string | Uint8Array, 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 @@ -113,7 +114,7 @@ export const create = async (peerId: PeerId, value: string, seq: number | bigint * @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 const createWithExpiration = async (peerId: PeerId, value: string, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise => { +export const createWithExpiration = async (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise => { const expirationDate = NanoDate.fromString(expiration) const validityType = IpnsEntry.ValidityType.EOL @@ -123,10 +124,10 @@ export const createWithExpiration = async (peerId: PeerId, value: string, seq: n return _create(peerId, value, seq, validityType, expirationDate, ttlNs, options) } -const _create = async (peerId: PeerId, value: string, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { +const _create = async (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { seq = BigInt(seq) const isoValidity = uint8ArrayFromString(expirationDate.toString()) - const encodedValue = uint8ArrayFromString(value) + const encodedValue = uint8ArrayFromString(normalizeValue(value)) if (peerId.privateKey == null) { throw errCode(new Error('Missing private key'), ERRORS.ERR_MISSING_PRIVATE_KEY) @@ -198,3 +199,22 @@ const signLegacyV1 = async (privateKey: PrivateKey, value: Uint8Array, validityT throw errCode(new Error('record signature creation failed'), ERRORS.ERR_SIGNATURE_CREATION) } } + +/** + * 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}'. + */ +const normalizeValue = (value: string | Uint8Array): string => { + const str = typeof value === 'string' ? value : uint8ArrayToString(value) + + if (str.startsWith('/')) { + return str + } + + 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) + } +} diff --git a/test/index.spec.ts b/test/index.spec.ts index 39442c4..607c049 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -109,6 +109,86 @@ describe('ipns', function () { await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) }) + it('should normalize value when creating an ipns record (string v0 cid)', async () => { + const inputValue = 'QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' + const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + expect(record.value()).to.equal(expectedValue) + expect(record.pb).to.deep.include({ + value: uint8ArrayFromString(expectedValue) + }) + }) + + it('should normalize value when creating an ipns record (string v1 cid)', async () => { + const inputValue = 'bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + expect(record.value()).to.equal(expectedValue) + expect(record.pb).to.deep.include({ + value: uint8ArrayFromString(expectedValue) + }) + }) + + it('should normalize value when creating an ipns record (bytes v0 cid)', async () => { + const inputValue = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') + const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + expect(record.value()).to.equal(expectedValue) + expect(record.pb).to.deep.include({ + value: uint8ArrayFromString(expectedValue) + }) + }) + + it('should normalize value when creating an ipns record (bytes v1 cid)', async () => { + const inputValue = uint8ArrayFromString('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') + const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + expect(record.value()).to.equal(expectedValue) + expect(record.pb).to.deep.include({ + value: uint8ArrayFromString(expectedValue) + }) + }) + + it('should normalize value when reading an ipns record (string v0 cid)', async () => { + const inputValue = 'QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' + const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + + // Force old value type. + record.data.Value = inputValue + expect(record.value()).to.equal(expectedValue) + }) + + it('should normalize value when reading an ipns record (string v1 cid)', async () => { + const inputValue = 'bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + + // Force old value type. + record.data.Value = inputValue + expect(record.value()).to.equal(expectedValue) + }) + + it('should normalize value when reading an ipns record (bytes v0 cid)', async () => { + const inputValue = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') + const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + + // Force old value type. + record.data.Value = inputValue + expect(record.value()).to.equal(expectedValue) + }) + + it('should normalize value when reading an ipns record (bytes v1 cid)', async () => { + const inputValue = uint8ArrayFromString('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') + const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' + const record = await ipns.create(peerId, inputValue, 0, 1000000) + + // Force old value type. + record.data.Value = inputValue + expect(record.value()).to.equal(expectedValue) + }) + it('should fail to validate a v1 (deprecated legacy) message', async () => { const sequence = 0 const validity = 1000000 From 2817e33058af0efcdb93ffb3c254e28bb005e893 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Fri, 25 Aug 2023 17:06:48 +0200 Subject: [PATCH 07/12] refactor using interfaces --- README.md | 4 +- package.json | 2 +- src/index.ts | 173 +++++++++++++++--------------- src/selector.ts | 17 ++- src/utils.ts | 155 +++++++++++++++++++++++++-- src/validator.ts | 94 +++------------- test/index.spec.ts | 237 ++++++++++++++++++++--------------------- test/selector.spec.ts | 21 ---- test/validator.spec.ts | 5 +- 9 files changed, 377 insertions(+), 331 deletions(-) diff --git a/README.md b/README.md index c8ed554..b546d87 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![codecov](https://img.shields.io/codecov/c/github/ipfs/js-ipns.svg?style=flat-square)](https://codecov.io/gh/ipfs/js-ipns) [![CI](https://img.shields.io/github/actions/workflow/status/ipfs/js-ipns/js-test-and-release.yml?branch=master\&style=flat-square)](https://github.com/ipfs/js-ipns/actions/workflows/js-test-and-release.yml?query=branch%3Amaster) -> IPNS Record definitions. +> IPNS Record definitions ## Table of contents @@ -62,7 +62,7 @@ const ipnsRecord = await ipns.create(privateKey, value, sequenceNumber, lifetime ```js import * as ipns from 'ipns' -await ipns.validate(publicKey, ipnsRecord) +await ipns.validate(publicKey, marshalledData) // if no error thrown, the record is valid ``` diff --git a/package.json b/package.json index c58e72d..8fcc63b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ipns", "version": "6.0.5", - "description": "ipns record definitions", + "description": "IPNS Record definitions", "author": "Vasco Santos ", "license": "Apache-2.0 OR MIT", "homepage": "https://github.com/ipfs/js-ipns#readme", diff --git a/src/index.ts b/src/index.ts index 0d28e03..0bb343c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,16 @@ import { unmarshalPrivateKey } from '@libp2p/crypto/keys' import { logger } from '@libp2p/logger' -import * as cborg from 'cborg' import errCode from 'err-code' import { Key } from 'interface-datastore/key' import { base32upper } from 'multiformats/bases/base32' -import { CID } from 'multiformats/cid' import * as Digest from 'multiformats/hashes/digest' import { identity } from 'multiformats/hashes/identity' import NanoDate from 'timestamp-nano' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' -import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import { createCborData, ipnsRecordDataForV1Sig, ipnsRecordDataForV2Sig, parseRFC3339 } from './utils.js' +import { createCborData, ipnsRecordDataForV1Sig, ipnsRecordDataForV2Sig, normalizeValue } from './utils.js' import type { PrivateKey } from '@libp2p/interface-keys' import type { PeerId } from '@libp2p/interface-peer-id' @@ -23,49 +20,35 @@ const ID_MULTIHASH_CODE = identity.code export const namespace = '/ipns/' export const namespaceLength = namespace.length -export class IPNSRecord { - readonly pb: IpnsEntry - readonly data: any - - constructor (pb: IpnsEntry) { - this.pb = pb - - if (pb.data == null) { - throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA) - } - - this.data = cborg.decode(pb.data) - } - - value (): string { - return normalizeValue(this.data.Value) - } - - validityType (): IpnsEntry.ValidityType { - if (this.data.ValidityType === 0) { - return IpnsEntry.ValidityType.EOL - } else { - throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) - } - } - - validity (): Date { - const validityType = this.validityType() - switch (validityType) { - case IpnsEntry.ValidityType.EOL: - return parseRFC3339(uint8ArrayToString(this.data.Validity)) - default: - throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) - } - } +export interface IPNSRecord { + 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 +} - sequence (): bigint { - return BigInt(this.data.Sequence ?? 0n) - } +export interface IPNSRecordV2 { + value: string + signatureV2: Uint8Array + validityType: IpnsEntry.ValidityType + validity: NanoDate + sequence: bigint + ttl?: bigint + pubKey?: Uint8Array + data: Uint8Array +} - ttl (): bigint { - return BigInt(this.data.TTL ?? 0n) - } +export interface IPNSRecordData { + Value: Uint8Array + Validity: Uint8Array + ValidityType: IpnsEntry.ValidityType + Sequence: bigint + TTL: bigint } export interface IDKeys { @@ -79,6 +62,14 @@ export interface CreateOptions { v1Compatible?: boolean } +export interface CreateV2OrV1Options { + v1Compatible: true +} + +export interface CreateV2Options { + v1Compatible: false +} + const defaultCreateOptions: CreateOptions = { v1Compatible: true } @@ -89,12 +80,14 @@ const defaultCreateOptions: CreateOptions = { * Note: This function does not embed the public key. If you want to do that, use `EmbedPublicKey`. * * @param {PeerId} peerId - peer id containing private key for signing the record. - * @param {string} value - value to be stored in the record. + * @param {string | Uint8Array} value - content path 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 const create = async (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise => { +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 { // Validity in ISOString with nanoseconds precision and validity type EOL const expirationDate = new NanoDate(Date.now() + Number(lifetime)) const validityType = IpnsEntry.ValidityType.EOL @@ -109,12 +102,14 @@ export const create = async (peerId: PeerId, value: string | Uint8Array, seq: nu * WARNING: nano precision is not standard, make sure the value in seconds is 9 orders of magnitude lesser than the one provided. * * @param {PeerId} peerId - PeerId containing private key for signing the record. - * @param {string} value - value to be stored in the record. + * @param {string | Uint8Array} value - content path 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 const createWithExpiration = async (peerId: PeerId, value: string | Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise => { +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 { const expirationDate = NanoDate.fromString(expiration) const validityType = IpnsEntry.ValidityType.EOL @@ -124,10 +119,11 @@ export const createWithExpiration = async (peerId: PeerId, value: string | Uint8 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: string | Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { seq = BigInt(seq) const isoValidity = uint8ArrayFromString(expirationDate.toString()) - const encodedValue = uint8ArrayFromString(normalizeValue(value)) + const normalizedValue = normalizeValue(value) + const encodedValue = uint8ArrayFromString(normalizedValue) if (peerId.privateKey == null) { throw errCode(new Error('Missing private key'), ERRORS.ERR_MISSING_PRIVATE_KEY) @@ -137,21 +133,7 @@ const _create = async (peerId: PeerId, value: string | Uint8Array, seq: number | const data = createCborData(encodedValue, isoValidity, validityType, seq, ttl) const sigData = ipnsRecordDataForV2Sig(data) const signatureV2 = await privateKey.sign(sigData) - - const pb: IpnsEntry = { - signatureV2, - data - } - - if (options.v1Compatible === true) { - const signatureV1 = await signLegacyV1(privateKey, encodedValue, validityType, isoValidity) - pb.value = encodedValue - pb.validity = isoValidity - pb.validityType = validityType - pb.signatureV1 = signatureV1 - pb.sequence = seq - pb.ttl = ttl - } + let pubKey: Uint8Array | undefined // if we cannot derive the public key from the PeerId (e.g. RSA PeerIDs), // we have to embed it in the IPNS record @@ -159,12 +141,46 @@ const _create = async (peerId: PeerId, value: string | Uint8Array, seq: number | const digest = Digest.decode(peerId.toBytes()) if (digest.code !== ID_MULTIHASH_CODE || !uint8ArrayEquals(peerId.publicKey, digest.digest)) { - pb.pubKey = peerId.publicKey + pubKey = peerId.publicKey } } - log('ipns record for %b created', value) - return new IPNSRecord(pb) + if (options.v1Compatible === true) { + const signatureV1 = await signLegacyV1(privateKey, encodedValue, validityType, isoValidity) + + const record: IPNSRecord = { + value: normalizedValue, + signatureV1, + validity: expirationDate, + validityType, + sequence: seq, + ttl, + signatureV2, + data + } + + if (pubKey != null) { + record.pubKey = pubKey + } + + return record + } else { + const record: IPNSRecordV2 = { + value: normalizedValue, + validity: expirationDate, + validityType, + sequence: seq, + ttl, + signatureV2, + data + } + + if (pubKey != null) { + record.pubKey = pubKey + } + + return record + } } /** @@ -199,22 +215,3 @@ const signLegacyV1 = async (privateKey: PrivateKey, value: Uint8Array, validityT throw errCode(new Error('record signature creation failed'), ERRORS.ERR_SIGNATURE_CREATION) } } - -/** - * 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}'. - */ -const normalizeValue = (value: string | Uint8Array): string => { - const str = typeof value === 'string' ? value : uint8ArrayToString(value) - - if (str.startsWith('/')) { - return str - } - - 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) - } -} diff --git a/src/selector.ts b/src/selector.ts index d801474..b7e1cd2 100644 --- a/src/selector.ts +++ b/src/selector.ts @@ -8,15 +8,12 @@ export const ipnsSelector: SelectFn = (key, data) => { })) entries.sort((a, b) => { - // having a newer signature version is better than an older signature version - if (a.record.pb.signatureV2 != null && b.record.pb.signatureV2 == null) { - return -1 - } else if (a.record.pb.signatureV2 == null && b.record.pb.signatureV2 != null) { - return 1 - } + // Before we'd sort based on the signature version. Unmarshal now fails if + // a record does not have SignatureV2, so that is no longer needed. V1-only + // records haven't been issues in a long time. - const aSeq = a.record.sequence() - const bSeq = b.record.sequence() + const aSeq = a.record.sequence + const bSeq = b.record.sequence // choose later sequence number if (aSeq > bSeq) { @@ -26,8 +23,8 @@ export const ipnsSelector: SelectFn = (key, data) => { } // choose longer lived record if sequence numbers the same - const recordAValidityDate = a.record.validity() - const recordBValidityDate = b.record.validity() + const recordAValidityDate = a.record.validity.toDate() + const recordBValidityDate = b.record.validity.toDate() if (recordAValidityDate.getTime() > recordBValidityDate.getTime()) { return -1 diff --git a/src/utils.ts b/src/utils.ts index 660178c..8999042 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,11 +3,15 @@ import { logger } from '@libp2p/logger' import { peerIdFromBytes, peerIdFromKeys } from '@libp2p/peer-id' import * as cborg from 'cborg' import errCode from 'err-code' +import { CID } from 'multiformats/cid' +import NanoDate from 'timestamp-nano' import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import { IPNSRecord } from './index.js' +import type { IPNSRecord, IPNSRecordV2, IPNSRecordData } from './index.js' import type { PublicKey } from '@libp2p/interface-keys' import type { PeerId } from '@libp2p/interface-peer-id' @@ -65,7 +69,7 @@ export function parseRFC3339 (time: string): Date { * Extracts a public key from the passed PeerId, falling * back to the pubKey embedded in the ipns record */ -export const extractPublicKey = async (peerId: PeerId, record: IPNSRecord): Promise => { +export const extractPublicKey = async (peerId: PeerId, record: IPNSRecord | IPNSRecordV2): Promise => { if (record == null || peerId == null) { const error = new Error('one or more of the provided parameters are not defined') @@ -75,15 +79,15 @@ export const extractPublicKey = async (peerId: PeerId, record: IPNSRecord): Prom let pubKey: PublicKey | undefined - if (record.pb.pubKey != null) { + if (record.pubKey != null) { try { - pubKey = unmarshalPublicKey(record.pb.pubKey) + pubKey = unmarshalPublicKey(record.pubKey) } catch (err) { log.error(err) throw err } - const otherId = await peerIdFromKeys(record.pb.pubKey) + const otherId = await peerIdFromKeys(record.pubKey) if (!otherId.equals(peerId)) { throw errCode(new Error('Embedded public key did not match PeerID'), ERRORS.ERR_INVALID_EMBEDDED_KEY) @@ -117,11 +121,29 @@ export const ipnsRecordDataForV2Sig = (data: Uint8Array): Uint8Array => { return uint8ArrayConcat([entryData, data]) } -export const marshal = (obj: IPNSRecord): Uint8Array => { - return IpnsEntry.encode(obj.pb) +export const marshal = (obj: IPNSRecord | IPNSRecordV2): Uint8Array => { + if ('signatureV1' in obj) { + return IpnsEntry.encode({ + value: uint8ArrayFromString(obj.value), + signatureV1: obj.signatureV1, + validityType: obj.validityType, + validity: uint8ArrayFromString(obj.validity.toString()), + sequence: obj.sequence, + ttl: obj.ttl, + pubKey: obj.pubKey, + signatureV2: obj.signatureV2, + data: obj.data + }) + } else { + return IpnsEntry.encode({ + pubKey: obj.pubKey, + signatureV2: obj.signatureV2, + data: obj.data + }) + } } -export const unmarshal = (buf: Uint8Array): IPNSRecord => { +export const unmarshal = (buf: Uint8Array): (IPNSRecord | IPNSRecordV2) => { const message = IpnsEntry.decode(buf) // protobufjs returns bigints as numbers @@ -134,7 +156,53 @@ export const unmarshal = (buf: Uint8Array): IPNSRecord => { message.ttl = BigInt(message.ttl) } - return new IPNSRecord(message) + // 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)) { + throw errCode(new Error('missing data or signatureV2'), ERRORS.ERR_SIGNATURE_VERIFICATION) + } + + const data = parseCborData(message.data) + const value = normalizeValue(data.Value ?? new Uint8Array(0)) + + let validity + try { + validity = NanoDate.fromDate(parseRFC3339(uint8ArrayToString(data.Validity))) + } catch (e) { + log.error('unrecognized validity format (not an rfc3339 format)') + throw errCode(new Error('unrecognized validity format (not an rfc3339 format)'), ERRORS.ERR_UNRECOGNIZED_FORMAT) + } + + if (message.value != null && message.signatureV1 != null) { + // V1+V2 + validateCborDataMatchesPbData(message) + return { + value, + validityType: IpnsEntry.ValidityType.EOL, + validity, + sequence: data.Sequence, + ttl: data.TTL, + pubKey: message.pubKey, + signatureV1: message.signatureV1, + signatureV2: message.signatureV2, + data: message.data + } + } else if (message.signatureV2 != null) { + // V2-only + return { + value, + validityType: IpnsEntry.ValidityType.EOL, + validity, + sequence: data.Sequence, + ttl: data.TTL, + pubKey: message.pubKey, + signatureV2: message.signatureV2, + data: message.data + } + } else { + throw new Error('invalid record: does not include signatureV1 or signatureV2') + } } export const peerIdToRoutingKey = (peerId: PeerId): Uint8Array => { @@ -167,3 +235,72 @@ export const createCborData = (value: Uint8Array, validity: Uint8Array, validity return cborg.encode(data) } + +export const parseCborData = (buf: Uint8Array): IPNSRecordData => { + const data = cborg.decode(buf) + + if (data.ValidityType === 0) { + data.ValidityType = IpnsEntry.ValidityType.EOL + } else { + throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) + } + + if (Number.isInteger(data.Sequence)) { + // sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range + data.Sequence = BigInt(data.Sequence) + } + + if (Number.isInteger(data.TTL)) { + // ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range + data.TTL = BigInt(data.TTL) + } + + return data +} + +/** + * 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}'. + */ +export const normalizeValue = (value: string | Uint8Array): string => { + const str = typeof value === 'string' ? value : uint8ArrayToString(value) + + if (str.startsWith('/')) { + return str + } + + 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) + } +} + +const validateCborDataMatchesPbData = (entry: IpnsEntry): void => { + if (entry.data == null) { + throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA) + } + + const data = parseCborData(entry.data) + + if (!uint8ArrayEquals(data.Value, entry.value ?? new Uint8Array(0))) { + throw errCode(new Error('Field "value" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) + } + + if (!uint8ArrayEquals(data.Validity, entry.validity ?? new Uint8Array(0))) { + throw errCode(new Error('Field "validity" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) + } + + if (data.ValidityType !== entry.validityType) { + throw errCode(new Error('Field "validityType" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) + } + + if (data.Sequence !== entry.sequence) { + throw errCode(new Error('Field "sequence" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) + } + + if (data.TTL !== entry.ttl) { + throw errCode(new Error('Field "ttl" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) + } +} diff --git a/src/validator.ts b/src/validator.ts index 1ab5162..6c1d981 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,12 +1,8 @@ import { logger } from '@libp2p/logger' -import * as cborg from 'cborg' import errCode from 'err-code' -import { equals as uint8ArrayEquals } from 'uint8arrays/equals' -import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from './errors.js' import { IpnsEntry } from './pb/ipns.js' -import { parseRFC3339, extractPublicKey, ipnsRecordDataForV2Sig, unmarshal, peerIdFromRoutingKey } from './utils.js' -import type { IPNSRecord } from './index.js' +import { extractPublicKey, ipnsRecordDataForV2Sig, unmarshal, peerIdFromRoutingKey } from './utils.js' import type { ValidateFn } from '@libp2p/interface-dht' import type { PublicKey } from '@libp2p/interface-keys' @@ -18,27 +14,20 @@ const log = logger('ipns:validator') const MAX_RECORD_SIZE = 1024 * 10 /** - * Validates the given IPNS record against the given public key + * Validates the given IPNS Record against the given public key. We need a "raw" + * record in order to be able to access to all of its fields. */ -export const validate = async (publicKey: PublicKey, record: IPNSRecord): Promise => { - const { value, validityType, validity } = record.pb - - // Ensure Signature V2 and Data are present and not empty. - if ((record.pb.signatureV2 == null) || (record.pb.data == null)) { - throw errCode(new Error('missing data or signatureV2'), ERRORS.ERR_SIGNATURE_VERIFICATION) - } - - // If Signature V1 is present, ensure that CBOR data matches Protobuf data (IPIP-428). - if (record.pb.signatureV1 != null || record.pb.value != null) { - validateCborDataMatchesPbData(record) - } +export const validate = async (publicKey: PublicKey, buf: Uint8Array): Promise => { + // unmarshal ensures that (1) SignatureV2 and Data are present, (2) that ValidityType + // and Validity are of valid types and have a value, (3) that CBOR data matches protobuf + // if it's a V1+V2 record. + const record = unmarshal(buf) // Validate Signature V2 let isValid try { - const signature = record.pb.signatureV2 - const dataForSignature = ipnsRecordDataForV2Sig(record.pb.data) - isValid = await publicKey.verify(dataForSignature, signature) + const dataForSignature = ipnsRecordDataForV2Sig(record.data) + isValid = await publicKey.verify(dataForSignature, record.signatureV2) } catch (err) { isValid = false } @@ -48,70 +37,17 @@ export const validate = async (publicKey: PublicKey, record: IPNSRecord): Promis } // Validate according to the validity type - if (validity != null && validityType === IpnsEntry.ValidityType.EOL) { - let validityDate - - try { - validityDate = parseRFC3339(uint8ArrayToString(validity)) - } catch (e) { - log.error('unrecognized validity format (not an rfc3339 format)') - throw errCode(new Error('unrecognized validity format (not an rfc3339 format)'), ERRORS.ERR_UNRECOGNIZED_FORMAT) - } - - if (validityDate.getTime() < Date.now()) { + if (record.validityType === IpnsEntry.ValidityType.EOL) { + if (record.validity.toDate().getTime() < Date.now()) { log.error('record has expired') throw errCode(new Error('record has expired'), ERRORS.ERR_IPNS_EXPIRED_RECORD) } - } else if (validityType != null) { + } else if (record.validityType != null) { log.error('unrecognized validity type') throw errCode(new Error('unrecognized validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) } - log('ipns record for %b is valid', value) -} - -const validateCborDataMatchesPbData = (record: IPNSRecord): void => { - if (record.pb.data == null) { - throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA) - } - - const data = cborg.decode(record.pb.data) - - if (!uint8ArrayEquals(data.Value, record.pb.value ?? new Uint8Array(0))) { - throw errCode(new Error('Field "value" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) - } - - if (!uint8ArrayEquals(data.Validity, record.pb.validity ?? new Uint8Array(0))) { - throw errCode(new Error('Field "validity" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) - } - - if (data.ValidityType === 0) { - data.ValidityType = IpnsEntry.ValidityType.EOL - } else { - throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY) - } - - if (data.ValidityType !== record.pb.validityType) { - throw errCode(new Error('Field "validityType" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) - } - - if (Number.isInteger(data.Sequence)) { - // sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range - data.Sequence = BigInt(data.Sequence) - } - - if (data.Sequence !== record.pb.sequence) { - throw errCode(new Error('Field "sequence" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) - } - - if (Number.isInteger(data.TTL)) { - // ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range - data.TTL = BigInt(data.TTL) - } - - if (data.TTL !== record.pb.ttl) { - throw errCode(new Error('Field "ttl" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION) - } + log('ipns record for %b is valid', record.value) } export const ipnsValidator: ValidateFn = async (key, marshalledData) => { @@ -126,5 +62,5 @@ export const ipnsValidator: ValidateFn = async (key, marshalledData) => { const pubKey = await extractPublicKey(peerId, receivedRecord) // Record validation - await validate(pubKey, receivedRecord) + await validate(pubKey, marshalledData) } diff --git a/test/index.spec.ts b/test/index.spec.ts index 607c049..5a8bef9 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -6,11 +6,12 @@ import { peerIdFromKeys, peerIdFromString } from '@libp2p/peer-id' import { createEd25519PeerId } from '@libp2p/peer-id-factory' import { expect } from 'aegir/chai' import { base58btc } from 'multiformats/bases/base58' +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 { unmarshal, marshal, extractPublicKey, peerIdToRoutingKey } from '../src/utils.js' +import { extractPublicKey, peerIdToRoutingKey, parseCborData, createCborData } from '../src/utils.js' import { ipnsValidator } from '../src/validator.js' import type { PeerId } from '@libp2p/interface-peer-id' @@ -31,21 +32,33 @@ describe('ipns', function () { const record = await ipns.create(peerId, contentPath, sequence, validity) - expect(record.value()).to.equal(contentPath) - expect(record.sequence()).to.equal(BigInt(0)) - expect(record.validityType()).to.equal(IpnsEntry.ValidityType.EOL) - expect(record.validity()).to.exist() - expect(record.ttl()).to.equal(BigInt(validity * 100000)) - - expect(record.pb).to.deep.include({ - value: uint8ArrayFromString(contentPath), - sequence: BigInt(sequence) - }) - expect(record.pb).to.have.property('validity') - expect(record.pb).to.have.property('signatureV1') - expect(record.pb).to.have.property('validityType') - expect(record.pb).to.have.property('signatureV2') - expect(record.pb).to.have.property('data') + expect(record.value).to.equal(contentPath) + expect(record.validityType).to.equal(IpnsEntry.ValidityType.EOL) + expect(record.validity).to.exist() + expect(record.sequence).to.equal(BigInt(0)) + expect(record.ttl).to.equal(BigInt(validity * 100000)) + expect(record.signatureV1).to.exist() + expect(record.signatureV2).to.exist() + expect(record.data).to.exist() + + // Protobuf must have all fields! + const pb = IpnsEntry.decode(ipns.marshal(record)) + expect(pb.value).to.equalBytes(uint8ArrayFromString(contentPath)) + expect(pb.validityType).to.equal(IpnsEntry.ValidityType.EOL) + expect(pb.validity).to.exist() + expect(pb.sequence).to.equal(BigInt(sequence)) + expect(pb.ttl).to.equal(BigInt(validity * 100000)) + expect(pb.signatureV1).to.exist() + expect(pb.signatureV2).to.exist() + expect(pb.data).to.exist() + + // Protobuf.Data must have all fields and match! + const data = parseCborData(pb.data ?? new Uint8Array(0)) + expect(data.Value).to.equalBytes(pb.value) + expect(data.ValidityType).to.equal(pb.validityType) + expect(data.Validity).to.equalBytes(pb.validity) + expect(data.Sequence).to.equal(pb.sequence) + expect(data.TTL).to.equal(pb.ttl) }) it('should create an ipns record (V2) correctly', async () => { @@ -54,43 +67,63 @@ describe('ipns', function () { const record = await ipns.create(peerId, contentPath, sequence, validity, { v1Compatible: false }) - expect(record.value()).to.equal(contentPath) - expect(record.sequence()).to.equal(BigInt(0)) - expect(record.validityType()).to.equal(IpnsEntry.ValidityType.EOL) - expect(record.validity()).to.exist() - expect(record.ttl()).to.equal(BigInt(validity * 100000)) - - expect(record.pb).to.not.have.property('value') - expect(record.pb).to.not.have.property('sequence') - expect(record.pb).to.not.have.property('validity') - expect(record.pb).to.not.have.property('signatureV1') - expect(record.pb).to.not.have.property('validityType') - expect(record.pb).to.have.property('signatureV2') - expect(record.pb).to.have.property('data') + expect(record.value).to.equal(contentPath) + expect(record.validityType).to.equal(IpnsEntry.ValidityType.EOL) + expect(record.validity).to.exist() + expect(record.sequence).to.equal(BigInt(0)) + expect(record.ttl).to.equal(BigInt(validity * 100000)) + expect(record.signatureV2).to.exist() + expect(record).to.not.have.property('signatureV1') + expect(record.data).to.exist() + + // PB must only have signature and data. + const pb = IpnsEntry.decode(ipns.marshal(record)) + expect(pb.value).to.not.exist() + expect(pb.validityType).to.not.exist() + expect(pb.validity).to.not.exist() + expect(pb.sequence).to.not.exist() + expect(pb.ttl).to.not.exist() + expect(pb.signatureV1).to.not.exist() + expect(pb.signatureV2).to.exist() + expect(pb.data).to.exist() + + // Protobuf.Data must have all fields and match! + const data = parseCborData(pb.data ?? new Uint8Array(0)) + expect(data.Value).to.equalBytes(uint8ArrayFromString(contentPath)) + expect(data.ValidityType).to.equal(IpnsEntry.ValidityType.EOL) + expect(data.Validity).to.exist() + expect(data.Sequence).to.equal(BigInt(sequence)) + expect(data.TTL).to.equal(BigInt(validity * 100000)) }) it('should be able to create a record (V1+V2) with a fixed expiration', async () => { const sequence = 0 - // 2033-05-18T03:33:20.000000000Z const expiration = '2033-05-18T03:33:20.000000000Z' const record = await ipns.createWithExpiration(peerId, contentPath, sequence, expiration) + const marshalledRecord = ipns.marshal(record) - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) - expect(record.pb).to.have.property('validity') - expect(record.validity().getTime()).to.equal(new Date('2033-05-18T03:33:20.000000000Z').getTime()) + await ipnsValidator(peerIdToRoutingKey(peerId), marshalledRecord) + + const pb = IpnsEntry.decode(marshalledRecord) + expect(pb).to.have.property('validity') + expect(pb.validity).to.equalBytes(uint8ArrayFromString(expiration)) }) it('should be able to create a record (V2) with a fixed expiration', async () => { const sequence = 0 - // 2033-05-18T03:33:20.000000000Z const expiration = '2033-05-18T03:33:20.000000000Z' const record = await ipns.createWithExpiration(peerId, contentPath, sequence, expiration, { v1Compatible: false }) + const marshalledRecord = ipns.marshal(record) + + await ipnsValidator(peerIdToRoutingKey(peerId), marshalledRecord) - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) - expect(record.pb).to.not.have.property('validity') - expect(record.validity().getTime()).to.equal(new Date('2033-05-18T03:33:20.000000000Z').getTime()) + const pb = IpnsEntry.decode(ipns.marshal(record)) + expect(pb).to.not.have.property('validity') + + const data = parseCborData(pb.data ?? new Uint8Array(0)) + expect(data.Validity).to.equalBytes(uint8ArrayFromString(expiration)) }) it('should create an ipns record (V1+V2) and validate it correctly', async () => { @@ -98,7 +131,7 @@ describe('ipns', function () { const validity = 1000000 const record = await ipns.create(peerId, contentPath, sequence, validity) - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) + await ipnsValidator(peerIdToRoutingKey(peerId), ipns.marshal(record)) }) it('should create an ipns record (V2) and validate it correctly', async () => { @@ -106,87 +139,61 @@ describe('ipns', function () { const validity = 1000000 const record = await ipns.create(peerId, contentPath, sequence, validity, { v1Compatible: false }) - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(record)) + 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' const record = await ipns.create(peerId, inputValue, 0, 1000000) - expect(record.value()).to.equal(expectedValue) - expect(record.pb).to.deep.include({ - value: uint8ArrayFromString(expectedValue) - }) + 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' const record = await ipns.create(peerId, inputValue, 0, 1000000) - expect(record.value()).to.equal(expectedValue) - expect(record.pb).to.deep.include({ - value: uint8ArrayFromString(expectedValue) - }) + expect(record.value).to.equal(expectedValue) }) it('should normalize value when creating an ipns record (bytes v0 cid)', async () => { const inputValue = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' const record = await ipns.create(peerId, inputValue, 0, 1000000) - expect(record.value()).to.equal(expectedValue) - expect(record.pb).to.deep.include({ - value: uint8ArrayFromString(expectedValue) - }) + expect(record.value).to.equal(expectedValue) }) it('should normalize value when creating an ipns record (bytes v1 cid)', async () => { const inputValue = uint8ArrayFromString('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' const record = await ipns.create(peerId, inputValue, 0, 1000000) - expect(record.value()).to.equal(expectedValue) - expect(record.pb).to.deep.include({ - value: uint8ArrayFromString(expectedValue) - }) + expect(record.value).to.equal(expectedValue) }) - it('should normalize value when reading an ipns record (string v0 cid)', async () => { + it('should normalize value when reading an ipns record (bytes v0 cid)', async () => { const inputValue = 'QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq' const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' const record = await ipns.create(peerId, inputValue, 0, 1000000) - // Force old value type. - record.data.Value = inputValue - expect(record.value()).to.equal(expectedValue) - }) - - it('should normalize value when reading an ipns record (string v1 cid)', async () => { - const inputValue = 'bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' - const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' - const record = await ipns.create(peerId, inputValue, 0, 1000000) - - // Force old value type. - record.data.Value = inputValue - expect(record.value()).to.equal(expectedValue) - }) - - it('should normalize value when reading an ipns record (bytes v0 cid)', async () => { - const inputValue = uint8ArrayFromString('QmWEekX7EZLUd9VXRNMRXW3LXe4F6x7mB8oPxY5XLptrBq') - const expectedValue = '/ipfs/bafybeidvkqhl6dwsdzx5km7tupo33ywt7czkl5topwogxx6lybko2d7pua' - const record = await ipns.create(peerId, inputValue, 0, 1000000) + const pb = IpnsEntry.decode(ipns.marshal(record)) + pb.data = createCborData(uint8ArrayFromString(inputValue), pb.validity ?? new Uint8Array(0), pb.validityType ?? '', pb.sequence ?? 0n, pb.ttl ?? 0n) + pb.value = uint8ArrayFromString(inputValue) - // Force old value type. - record.data.Value = inputValue - expect(record.value()).to.equal(expectedValue) + const modifiedRecord = ipns.unmarshal(IpnsEntry.encode(pb)) + expect(modifiedRecord.value).to.equal(expectedValue) }) it('should normalize value when reading an ipns record (bytes v1 cid)', async () => { - const inputValue = uint8ArrayFromString('bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu') + const inputValue = 'bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' const expectedValue = '/ipfs/bafkqae3imvwgy3zamzzg63janjzs22lqnzzqu' const record = await ipns.create(peerId, inputValue, 0, 1000000) - // Force old value type. - record.data.Value = inputValue - expect(record.value()).to.equal(expectedValue) + const pb = IpnsEntry.decode(ipns.marshal(record)) + pb.data = createCborData(uint8ArrayFromString(inputValue), pb.validity ?? new Uint8Array(0), pb.validityType ?? '', pb.sequence ?? 0n, pb.ttl ?? 0n) + pb.value = uint8ArrayFromString(inputValue) + + const modifiedRecord = ipns.unmarshal(IpnsEntry.encode(pb)) + expect(modifiedRecord.value).to.equal(expectedValue) }) it('should fail to validate a v1 (deprecated legacy) message', async () => { @@ -194,15 +201,16 @@ describe('ipns', function () { const validity = 1000000 const record = await ipns.create(peerId, contentPath, sequence, validity) + const pb = IpnsEntry.decode(ipns.marshal(record)) // remove the extra fields added for v2 sigs - delete record.pb.data - delete record.pb.signatureV2 + delete pb.data + delete pb.signatureV2 // confirm a v1 exists - expect(record.pb).to.have.property('signatureV1') + expect(pb).to.have.property('signatureV1') - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_INVALID_RECORD_DATA) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), IpnsEntry.encode(pb))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) it('should fail to validate a v2 without v2 signature (ignore v1)', async () => { @@ -210,14 +218,15 @@ describe('ipns', function () { const validity = 1000000 const record = await ipns.create(peerId, contentPath, sequence, validity) + const pb = IpnsEntry.decode(ipns.marshal(record)) // remove v2 sig - delete record.pb.signatureV2 + delete pb.signatureV2 // confirm a v1 exists - expect(record.pb).to.have.property('signatureV1') + expect(pb).to.have.property('signatureV1') - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), IpnsEntry.encode(pb))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) it('should fail to validate a bad record', async () => { @@ -227,9 +236,9 @@ describe('ipns', function () { const record = await ipns.create(peerId, contentPath, sequence, validity) // corrupt the record by changing the value to random bytes - record.pb.value = randomBytes(46) + record.value = uint8ArrayToString(randomBytes(46)) - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), ipns.marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) it('should create an ipns record with a validity of 1 nanosecond correctly and it should not be valid 1ms later', async () => { @@ -240,7 +249,7 @@ describe('ipns', function () { await new Promise(resolve => setTimeout(resolve, 1)) - await expect(ipnsValidator(peerIdToRoutingKey(peerId), marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_IPNS_EXPIRED_RECORD) + await expect(ipnsValidator(peerIdToRoutingKey(peerId), ipns.marshal(record))).to.eventually.be.rejected().with.property('code', ERRORS.ERR_IPNS_EXPIRED_RECORD) }) it('should create an ipns record, marshal and unmarshal it, as well as validate it correctly', async () => { @@ -249,29 +258,19 @@ describe('ipns', function () { const createdRecord = await ipns.create(peerId, contentPath, sequence, validity) - const marshalledData = marshal(createdRecord) - const unmarshalledData = unmarshal(marshalledData) - - expect(createdRecord.pb.value).to.equalBytes(unmarshalledData.pb.value) - expect(createdRecord.pb.validity).to.equalBytes(unmarshalledData.pb.validity) - expect(createdRecord.pb.validityType).to.equal(unmarshalledData.pb.validityType) - expect(createdRecord.pb.signatureV1).to.equalBytes(unmarshalledData.pb.signatureV1) - expect(createdRecord.pb.sequence).to.equal(unmarshalledData.pb.sequence) - expect(createdRecord.pb.ttl).to.equal(unmarshalledData.pb.ttl) - - if (unmarshalledData.pb.signatureV2 == null) { - throw new Error('No v2 sig found') - } + const marshalledData = ipns.marshal(createdRecord) + const unmarshalledData = ipns.unmarshal(marshalledData) - expect(createdRecord.pb.signatureV2).to.equalBytes(unmarshalledData.pb.signatureV2) + expect(createdRecord.value).to.equal(unmarshalledData.value) + expect(createdRecord.validity.toString()).to.equal(unmarshalledData.validity.toString()) + expect(createdRecord.validityType).to.equal(unmarshalledData.validityType) + expect(createdRecord.signatureV1).to.equalBytes('signatureV1' in unmarshalledData ? unmarshalledData.signatureV1 : new Uint8Array(0)) + expect(createdRecord.sequence).to.equal(unmarshalledData.sequence) + expect(createdRecord.ttl).to.equal(unmarshalledData.ttl) + expect(createdRecord.signatureV2).to.equalBytes(unmarshalledData.signatureV2) + expect(createdRecord.data).to.equalBytes(unmarshalledData.data) - if (unmarshalledData.pb.data == null) { - throw new Error('No v2 data found') - } - - expect(createdRecord.pb.data).to.equalBytes(unmarshalledData.pb.data) - - await ipnsValidator(peerIdToRoutingKey(peerId), marshal(unmarshalledData)) + await ipnsValidator(peerIdToRoutingKey(peerId), marshalledData) }) it('should get datastore key correctly', () => { @@ -300,10 +299,10 @@ describe('ipns', function () { const validity = 1000000 const record = await ipns.create(peerId, contentPath, sequence, validity) + expect(record.pubKey).to.equalBytes(peerId.publicKey) - expect(record.pb).to.deep.include({ - pubKey: peerId.publicKey - }) + const pb = IpnsEntry.decode(ipns.marshal(record)) + expect(pb.pubKey).to.equalBytes(peerId.publicKey) }) // It should have a public key embedded for newer ed25519 keys @@ -319,7 +318,7 @@ describe('ipns', function () { const ed25519 = await createEd25519PeerId() const record = await ipns.create(ed25519, contentPath, sequence, validity) - expect(record.pb).to.not.have.property('pubKey') // ed25519 keys should not be embedded + expect(record).to.not.have.property('pubKey') // ed25519 keys should not be embedded }) it('validator with no valid public key should error', async () => { @@ -327,9 +326,9 @@ describe('ipns', function () { const validity = 1000000 const record = await ipns.create(peerId, contentPath, sequence, validity) - delete record.pb.pubKey + delete record.pubKey - const marshalledData = marshal(record) + const marshalledData = ipns.marshal(record) const key = peerIdToRoutingKey(peerId) await expect(ipnsValidator(key, marshalledData)).to.eventually.be.rejected().with.property('code', ERRORS.ERR_UNDEFINED_PARAMETER) diff --git a/test/selector.spec.ts b/test/selector.spec.ts index c20320e..537a214 100644 --- a/test/selector.spec.ts +++ b/test/selector.spec.ts @@ -56,25 +56,4 @@ describe('selector', function () { valid = ipnsSelector(key, [marshalledData, marshalledNewData]) expect(valid).to.equal(1) // new data is the selected one }) - - it('should use validator.select to select an older record with a v2 sig when the newer record only uses v1', async () => { - const sequence = 0 - const lifetime = 1000000 - - const record = await ipns.create(peerId, contentPath, sequence, lifetime) - - const newRecord = await ipns.create(peerId, contentPath, sequence + 1, lifetime) - delete newRecord.pb.signatureV2 - - const marshalledData = marshal(record) - const marshalledNewData = marshal(newRecord) - - const key = peerIdToRoutingKey(peerId) - - let valid = ipnsSelector(key, [marshalledNewData, marshalledData]) - expect(valid).to.equal(1) // old data is the selected one - - valid = ipnsSelector(key, [marshalledData, marshalledNewData]) - expect(valid).to.equal(0) // old data is the selected one - }) }) diff --git a/test/validator.spec.ts b/test/validator.spec.ts index 4d3a976..afab026 100644 --- a/test/validator.spec.ts +++ b/test/validator.spec.ts @@ -7,6 +7,7 @@ import { expect } from 'aegir/chai' import { base58btc } from 'multiformats/bases/base58' import { concat as uint8ArrayConcat } from 'uint8arrays/concat' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import * as ERRORS from '../src/errors.js' import * as ipns from '../src/index.js' import { marshal, peerIdToRoutingKey } from '../src/utils.js' @@ -61,7 +62,7 @@ describe('validator', function () { const record = await ipns.create(peerId1, contentPath, sequence, validity) // corrupt the record by changing the value to random bytes - record.pb.value = randomBytes(record.pb.value?.length ?? 0) + record.value = uint8ArrayToString(randomBytes(record.value?.length ?? 0)) const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId1) @@ -86,7 +87,7 @@ describe('validator', function () { const validity = 1000000 const record = await ipns.create(peerId1, contentPath, sequence, validity) - record.pb.pubKey = peerId2.publicKey + record.pubKey = peerId2.publicKey const marshalledData = marshal(record) const key = peerIdToRoutingKey(peerId1) From cedb3f2261177d166d3f73b311094b1952f2ae6f Mon Sep 17 00:00:00 2001 From: Alex Potsides Date: Tue, 12 Sep 2023 17:14:38 +0100 Subject: [PATCH 08/12] fix!: only accept CIDs, PeerIds or strings as values (#255) This module has accepted `Uint8Array`s as values to store in IPNS records which means it has been mis-used to create records with raw CID bytes. To ensure this doesn't happen in future, accept only CIDs, PeerIds or arbitrary path strings prefixed with `"/"`. BREAKING CHANGE: all /ipns/* keys are now encoded as base36 encoded CIDv1 libp2p-cid --------- Co-authored-by: Marcin Rataj --- src/index.ts | 31 ++++++++++++----- src/utils.ts | 64 ++++++++++++++++++++++++++-------- test/index.spec.ts | 86 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 141 insertions(+), 40 deletions(-) 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') + }) }) From 354d62aab7fcebcf66ebd767f3e9328d9116f5e7 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 14 Sep 2023 11:04:08 +0100 Subject: [PATCH 09/12] test: add conformance tests --- src/index.ts | 96 +++++++++++++++--- src/utils.ts | 9 +- test/conformance.spec.ts | 54 ++++++++++ ...dm8c_v1-v2-broken-signature-v2.ipns-record | Bin 0 -> 334 bytes ...ph4y_v1-v2-broken-signature-v1.ipns-record | Bin 0 -> 334 bytes ...8kd10m97m36bjt66my99hb6103f_v2.ipns-record | Bin 0 -> 188 bytes ...eauqdc6a7c3y7d5i9fi8mk9w_v1-v2.ipns-record | Bin 0 -> 326 bytes ...ozae9kpw_v1-v2-broken-v1-value.ipns-record | Bin 0 -> 377 bytes ...wjimndrkydqm81cqtlb5ak6p7ku_v1.ipns-record | Bin 0 -> 144 bytes 9 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 test/conformance.spec.ts create mode 100644 test/fixtures/k51qzi5uqu5diamp7qnnvs1p1gzmku3eijkeijs3418j23j077zrkok63xdm8c_v1-v2-broken-signature-v2.ipns-record create mode 100644 test/fixtures/k51qzi5uqu5dilgf7gorsh9vcqqq4myo6jd4zmqkuy9pxyxi5fua3uf7axph4y_v1-v2-broken-signature-v1.ipns-record create mode 100644 test/fixtures/k51qzi5uqu5dit2ku9mutlfgwyz8u730on38kd10m97m36bjt66my99hb6103f_v2.ipns-record create mode 100644 test/fixtures/k51qzi5uqu5dlkw8pxuw9qmqayfdeh4kfebhmreauqdc6a7c3y7d5i9fi8mk9w_v1-v2.ipns-record create mode 100644 test/fixtures/k51qzi5uqu5dlmit2tuwdvnx4sbnyqgmvbxftl0eo3f33wwtb9gr7yozae9kpw_v1-v2-broken-v1-value.ipns-record create mode 100644 test/fixtures/k51qzi5uqu5dm4tm0wt8srkg9h9suud4wuiwjimndrkydqm81cqtlb5ak6p7ku_v1.ipns-record diff --git a/src/index.ts b/src/index.ts index db5eced..3c70d62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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 +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 { +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 @@ -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 +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 { +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 @@ -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 => { +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 50af463..9257d4e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 @@ -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) } @@ -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, diff --git a/test/conformance.spec.ts b/test/conformance.spec.ts new file mode 100644 index 0000000..c6b7b14 --- /dev/null +++ b/test/conformance.spec.ts @@ -0,0 +1,54 @@ +/* eslint-env mocha */ + +import { unmarshalPublicKey } from '@libp2p/crypto/keys' +import { expect } from 'aegir/chai' +import loadFixture from 'aegir/fixtures' +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) + + 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.skip('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 record = ipns.unmarshal(buf) + const publicKey = unmarshalPublicKey(record.pubKey ?? 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) + + expect(record.value).to.equal('/ipfs/bafkqadtwgiww63tmpeqhezldn5zgi') + }) +}) diff --git a/test/fixtures/k51qzi5uqu5diamp7qnnvs1p1gzmku3eijkeijs3418j23j077zrkok63xdm8c_v1-v2-broken-signature-v2.ipns-record b/test/fixtures/k51qzi5uqu5diamp7qnnvs1p1gzmku3eijkeijs3418j23j077zrkok63xdm8c_v1-v2-broken-signature-v2.ipns-record new file mode 100644 index 0000000000000000000000000000000000000000..0f278c868ba26c92efacfaf040f198372fc1b057 GIT binary patch literal 334 zcmdG|ef_EiKH*%`Fsi=za8K-t7|2`{7T-&*Z4SoSC*KQnW9i!Qks_LHHmR)a!81eGy?0GCjE(f|Me literal 0 HcmV?d00001 diff --git a/test/fixtures/k51qzi5uqu5dilgf7gorsh9vcqqq4myo6jd4zmqkuy9pxyxi5fua3uf7axph4y_v1-v2-broken-signature-v1.ipns-record b/test/fixtures/k51qzi5uqu5dilgf7gorsh9vcqqq4myo6jd4zmqkuy9pxyxi5fua3uf7axph4y_v1-v2-broken-signature-v1.ipns-record new file mode 100644 index 0000000000000000000000000000000000000000..cb914e98935285d36324920ed73cf82a2f3bf39a GIT binary patch literal 334 zcmdG|ef_EiKH*&CL|z&CDxH%*jkqC@v{YOOs$wk~T6lGS)S)&^0s(F*LF=G`BJ^ z)-$s-F)%fX(qJ%XSn%=CMiVCoqoaLU>++`c72KF_xNyNnKZ%D%w)3t@otPav#XYft zuY5~)Hs@-)ja$BnBxHvMS4S*elkTixbTwsWZ%Zw^%vN@)WXu#yyR4d44`?LDVZge5%_$R15*`JS&+&A01K#iGXMYp literal 0 HcmV?d00001 diff --git a/test/fixtures/k51qzi5uqu5dit2ku9mutlfgwyz8u730on38kd10m97m36bjt66my99hb6103f_v2.ipns-record b/test/fixtures/k51qzi5uqu5dit2ku9mutlfgwyz8u730on38kd10m97m36bjt66my99hb6103f_v2.ipns-record new file mode 100644 index 0000000000000000000000000000000000000000..ddce7382d79027426e6c02ea1bdabfd5078e8468 GIT binary patch literal 188 zcmZ>A$SRu=5vMKA&Us}@ok+LDMuqFSTTDgYuK6_K{ori3Ku+HTpM&2J0OO znRQR?%c*A5FGu8b{{P#!i)$&PSH;rgkPshf1_s8(5-|Y`sbPsZrKu4r`k4i3#rjE! zY1xH|DJA9UndRkX#wEE0sf8J-RXHhnrd8>g8NsQArKx$zsSFuFU70DFC6y7Zq9KFs-}ipe6I< z-fhKtq3<)8&OJ-L=6a9qNXV-9=h;F^xlj1bc2wmyeR4&1spZk7PW6ZRIPOeYviDR2 z^AA1=1|?}DLnC8d0}EY4lMq8AD?@WD17kfiOA`ZAqbLmqgN6kk4{bDYa_|b=k>cFx z{ra+^8`B#dGj%?n3jtkexxY-q5{~@pUNm7{?GxX!)j>y!=UdhlI!^b?a$I29Ae3d{Udom+9wXifbFFBPV1E?i4C9|Y5 P0-u|6V5&kY3sM;X9A9~{ literal 0 HcmV?d00001 diff --git a/test/fixtures/k51qzi5uqu5dlmit2tuwdvnx4sbnyqgmvbxftl0eo3f33wwtb9gr7yozae9kpw_v1-v2-broken-v1-value.ipns-record b/test/fixtures/k51qzi5uqu5dlmit2tuwdvnx4sbnyqgmvbxftl0eo3f33wwtb9gr7yozae9kpw_v1-v2-broken-v1-value.ipns-record new file mode 100644 index 0000000000000000000000000000000000000000..9b4953e9440c39508eb798c0d477b730c1918a43 GIT binary patch literal 377 zcmd-w)6XnOE7ng+Ov^4zOwTFJEvu@?G|I`(H7QI_t;|VHO)9NOG%hJgO{z-It~4&l z&nnJHPc9U4@XO7%eJ|>F$M=6C4|BsZXQK#ni^GwZ>Nf0H(eiTgZH~Fi*z*nfJEy7? z$gYT7?E7T(r>SM4vxS^)eHU?7tW4vRU{I1aGBh&QHL%b%Gzl>@vNANcGBDONvotX< zHHy+;Flbot@z6#SCkL;2r?=LpIlTMd=vlLIZNQEB?$4i!F@+`X`^J)>-?r`LejBYH zSFF5trLK{ZR=dV~=tp2OYn=NhwrgQ*)wR;wS-d7OE=>*z@sVa=U|cK_6TpxfmY7qT z8exa*^^B78^whG7+}xr>pcl*2jg5Iw0W>c&C9|Y50-uj^V5&kY3sM;X1?i5@ literal 0 HcmV?d00001 diff --git a/test/fixtures/k51qzi5uqu5dm4tm0wt8srkg9h9suud4wuiwjimndrkydqm81cqtlb5ak6p7ku_v1.ipns-record b/test/fixtures/k51qzi5uqu5dm4tm0wt8srkg9h9suud4wuiwjimndrkydqm81cqtlb5ak6p7ku_v1.ipns-record new file mode 100644 index 0000000000000000000000000000000000000000..c6e2a0f5c5a7482bb42367715f262e5f85044b37 GIT binary patch literal 144 zcmV;B0B`>aBrj=jW^*rMVPaAk35WqNF6ZZ&#mX%aw3oqH(c zT>wh5G^qr9f#GHkR1G}+apD(BEi>AoF4(LlER1|>po#OObsy8PYkTL*I#d`GdbIAB yYOV&%>sSXE03sVQF)}kPFgPtSG*mG%Ix#moFf%STIW#aeGFm7AFo2-(!m%{xT{cJn literal 0 HcmV?d00001 From 0f6c4c2291beb4d265506de345e1f32e9a2b2b33 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 14 Sep 2023 13:04:18 +0100 Subject: [PATCH 10/12] test: add validation to conformance tests --- src/utils.ts | 2 +- test/conformance.spec.ts | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 9257d4e..9c3bff6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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)) } diff --git a/test/conformance.spec.ts b/test/conformance.spec.ts index c6b7b14..83f7c59 100644 --- a/test/conformance.spec.ts +++ b/test/conformance.spec.ts @@ -1,8 +1,11 @@ /* 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' @@ -19,6 +22,11 @@ describe('conformance', function () { 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') }) @@ -29,10 +37,11 @@ describe('conformance', function () { .with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION) }) - it.skip('should reject a record with v1 and v2 signatures but invalid v2', async () => { + 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 record = ipns.unmarshal(buf) - const publicKey = unmarshalPublicKey(record.pubKey ?? new Uint8Array()) + 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) @@ -49,6 +58,11 @@ describe('conformance', function () { 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') }) }) From 0e081e7dc2ba07d7a0ee1af173a89cfb19a837e7 Mon Sep 17 00:00:00 2001 From: Alex Potsides Date: Fri, 15 Sep 2023 16:42:16 +0100 Subject: [PATCH 11/12] chore: rename type Co-authored-by: Marcin Rataj --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 3c70d62..3d920b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ const ID_MULTIHASH_CODE = identity.code export const namespace = '/ipns/' export const namespaceLength = namespace.length -export interface IPNSRecordV1 { +export interface IPNSRecordV1V2 { /** * value of the record */ From 108a839d874345016ca359d7a3e96f765d56f217 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 15 Sep 2023 16:42:48 +0100 Subject: [PATCH 12/12] chore: update type --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3d920b8..25ba91c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -110,7 +110,7 @@ export interface IPNSRecordV2 { data: Uint8Array } -export type IPNSRecord = IPNSRecordV1 | IPNSRecordV2 +export type IPNSRecord = IPNSRecordV1V2 | IPNSRecordV2 export interface IPNSRecordData { Value: Uint8Array @@ -160,7 +160,7 @@ 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 +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 @@ -188,7 +188,7 @@ 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 +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)