Skip to content

Commit

Permalink
feat: v2-only creation, relaxed verification
Browse files Browse the repository at this point in the history
  • Loading branch information
hacdias committed Aug 2, 2023
1 parent 5139ee5 commit 15c245e
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 161 deletions.
104 changes: 72 additions & 32 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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, ipnsEntryDataForV1Sig, ipnsEntryDataForV2Sig, parseRFC3339 } from './utils.js'
import type { PrivateKey } from '@libp2p/interface-keys'
import type { PeerId } from '@libp2p/interface-peer-id'

Expand All @@ -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 {
Expand All @@ -47,6 +74,14 @@ 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.
Expand All @@ -56,15 +91,16 @@ export interface IDKeys {
* @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<IPNSEntry> => {
export const create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
// Validity in ISOString with nanoseconds precision and validity type EOL
const expirationDate = new NanoDate(Date.now() + Number(lifetime))
const validityType = IpnsEntry.ValidityType.EOL
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)
}

/**
Expand All @@ -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 create options.
*/
export const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string): Promise<IPNSEntry> => {
export const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
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<IPNSEntry> => {
const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
seq = BigInt(seq)
const isoValidity = uint8ArrayFromString(expirationDate.toString())

Expand All @@ -95,22 +132,25 @@ 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 signatureV2 = await privateKey.sign(sigData)

const entry: IPNSEntry = {
value,
signature: signatureV1,
validityType,
validity: isoValidity,
sequence: seq,
ttl,
const entry: IpnsEntry = {
signatureV2,
data
}

if (options.v1Compatible === true) {
const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity)
entry.value = value
entry.validity = isoValidity
entry.validityType = validityType
entry.signature = signatureV1
entry.sequence = seq
entry.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) {
Expand All @@ -122,7 +162,7 @@ const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint,
}

log('ipns entry for %b created', value)
return entry
return new IPNSRecord(entry)
}

/**
Expand Down
36 changes: 18 additions & 18 deletions src/pb/ipns.proto
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 8 additions & 13 deletions src/selector.ts
Original file line number Diff line number Diff line change
@@ -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),
entry: 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.entry.pb.signatureV2 != null && b.entry.pb.signatureV2 == null) {
return -1
} else if (a.entry.signatureV2 == null && b.entry.signatureV2 != null) {
} else if (a.entry.pb.signatureV2 == null && b.entry.pb.signatureV2 != null) {
return 1
}

const aSeq = a.entry.sequence ?? 0n
const bSeq = b.entry.sequence ?? 0n
const aSeq = a.entry.sequence()
const bSeq = b.entry.sequence()

// choose later sequence number
if (aSeq > bSeq) {
Expand All @@ -27,12 +25,9 @@ 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 entryAValidityDate = a.entry.validity()
const entryBValidityDate = b.entry.validity()

if (entryAValidityDate.getTime() > entryBValidityDate.getTime()) {
return -1
Expand Down
50 changes: 9 additions & 41 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -65,7 +65,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, entry: IpnsEntry): Promise<PublicKey> => {
export const extractPublicKey = async (peerId: PeerId, entry: IPNSRecord): Promise<PublicKey> => {
if (entry == null || peerId == null) {
const error = new Error('one or more of the provided parameters are not defined')

Expand All @@ -75,15 +75,15 @@ export const extractPublicKey = async (peerId: PeerId, entry: IpnsEntry): Promis

let pubKey: PublicKey | undefined

if (entry.pubKey != null) {
if (entry.pb.pubKey != null) {
try {
pubKey = unmarshalPublicKey(entry.pubKey)
pubKey = unmarshalPublicKey(entry.pb.pubKey)
} catch (err) {
log.error(err)
throw err
}

const otherId = await peerIdFromKeys(entry.pubKey)
const otherId = await peerIdFromKeys(entry.pb.pubKey)

if (!otherId.equals(peerId)) {
throw errCode(new Error('Embedded public key did not match PeerID'), ERRORS.ERR_INVALID_EMBEDDED_KEY)
Expand Down Expand Up @@ -117,11 +117,11 @@ export const ipnsEntryDataForV2Sig = (data: Uint8Array): Uint8Array => {
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
Expand All @@ -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 => {
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit 15c245e

Please sign in to comment.