Skip to content

Commit

Permalink
feat: validate v2 ipns signatures (#121)
Browse files Browse the repository at this point in the history
Co-authored-by: Rod Vagg <[email protected]>
  • Loading branch information
achingbrain and rvagg authored Jun 10, 2021
1 parent 6d690d6 commit d1421f9
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 55 deletions.
8 changes: 8 additions & 0 deletions .aegir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict'

/** @type {import('aegir').PartialOptions} */
module.exports = {
build: {
bundlesizeMax: '143KB'
}
}
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"types": "dist/src/index.d.ts",
"scripts": {
"prepare": "run-s prepare:*",
"prepare:proto": "pbjs -t static-module -w commonjs -r ipfs-ipns --force-number --no-verify --no-delimited --no-create --no-beautify --no-defaults --lint eslint-disable -o src/pb/ipns.js src/pb/ipns.proto",
"prepare:proto": "pbjs -t static-module -w commonjs -r ipfs-ipns --no-verify --no-delimited --no-create --no-beautify --no-defaults --lint eslint-disable -o src/pb/ipns.js src/pb/ipns.proto",
"prepare:proto-types": "pbts -o src/pb/ipns.d.ts src/pb/ipns.js",
"prepare:types": "aegir build --no-bundle",
"lint": "aegir lint",
Expand Down Expand Up @@ -41,10 +41,12 @@
},
"homepage": "https://github.com/ipfs/js-ipns#readme",
"dependencies": {
"cborg": "^1.3.3",
"debug": "^4.2.0",
"err-code": "^3.0.1",
"interface-datastore": "^4.0.0",
"libp2p-crypto": "^0.19.0",
"long": "^4.0.0",
"multibase": "^4.0.2",
"multihashes": "^4.0.2",
"peer-id": "^0.14.2",
Expand Down
1 change: 1 addition & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ exports.ERR_UNRECOGNIZED_FORMAT = 'ERR_UNRECOGNIZED_FORMAT'
exports.ERR_PEER_ID_FROM_PUBLIC_KEY = 'ERR_PEER_ID_FROM_PUBLIC_KEY'
exports.ERR_PUBLIC_KEY_FROM_ID = 'ERR_PUBLIC_KEY_FROM_ID'
exports.ERR_UNDEFINED_PARAMETER = 'ERR_UNDEFINED_PARAMETER'
exports.ERR_INVALID_RECORD_DATA = 'ERR_INVALID_RECORD_DATA'
151 changes: 130 additions & 21 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const multibase = require('multibase')
const uint8ArrayFromString = require('uint8arrays/from-string')
const uint8ArrayToString = require('uint8arrays/to-string')
const uint8ArrayConcat = require('uint8arrays/concat')
const uint8ArrayEquals = require('uint8arrays/equals')
const cborg = require('cborg')
const Long = require('long')

const debug = require('debug')
const log = Object.assign(debug('jsipns'), {
Expand Down Expand Up @@ -39,14 +42,17 @@ const namespace = '/ipns/'
*
* @param {PrivateKey} privateKey - private key for signing the record.
* @param {Uint8Array} value - value to be stored in the record.
* @param {number} seq - number representing the current version of the record.
* @param {number | bigint} seq - number representing the current version of the record.
* @param {number} lifetime - lifetime of the record (in milliseconds).
*/
const create = (privateKey, value, seq, lifetime) => {
// Validity in ISOString with nanoseconds precision and validity type EOL
const isoValidity = new NanoDate(Date.now() + Number(lifetime)).toString()
const expirationDate = new NanoDate(Date.now() + Number(lifetime))
const validityType = ipnsEntryProto.ValidityType.EOL
return _create(privateKey, value, seq, uint8ArrayFromString(isoValidity), validityType)
const [ms, ns] = lifetime.toString().split('.')
const lifetimeNs = BigInt(ms) * 100000n + BigInt(ns || 0)

return _create(privateKey, value, seq, validityType, expirationDate, lifetimeNs)
}

/**
Expand All @@ -55,36 +61,69 @@ const create = (privateKey, value, seq, lifetime) => {
*
* @param {PrivateKey} privateKey - private key for signing the record.
* @param {Uint8Array} value - value to be stored in the record.
* @param {number} seq - number representing the current version of 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.
*/
const createWithExpiration = (privateKey, value, seq, expiration) => {
const expirationDate = NanoDate.fromString(expiration)
const validityType = ipnsEntryProto.ValidityType.EOL
return _create(privateKey, value, seq, uint8ArrayFromString(expiration), validityType)

const ttlMs = expirationDate.toDate().getTime() - Date.now()
const ttlNs = (BigInt(ttlMs) * 100000n) + BigInt(expirationDate.getNano())

return _create(privateKey, value, seq, validityType, expirationDate, ttlNs)
}

/**
* @param {PrivateKey} privateKey
* @param {Uint8Array} value
* @param {number} seq
* @param {Uint8Array} isoValidity
* @param {number | bigint} seq
* @param {number} validityType
* @param {NanoDate} expirationDate
* @param {bigint} ttl
*/
const _create = async (privateKey, value, seq, isoValidity, validityType) => {
const signature = await sign(privateKey, value, validityType, isoValidity)
const _create = async (privateKey, value, seq, validityType, expirationDate, ttl) => {
seq = BigInt(seq)
const isoValidity = uint8ArrayFromString(expirationDate.toString())
const signatureV1 = await sign(privateKey, value, validityType, isoValidity)
const data = createCborData(value, isoValidity, validityType, seq, ttl)
const sigData = ipnsEntryDataForV2Sig(data)
const signatureV2 = await privateKey.sign(sigData)

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

log(`ipns entry for ${value} created`)
return entry
}

/**
* @param {Uint8Array} value
* @param {Uint8Array} validity
* @param {number} validityType
* @param {bigint} sequence
* @param {bigint} ttl
*/
const createCborData = (value, validity, validityType, sequence, ttl) => {
const data = {
value,
validity,
validityType,
sequence,
ttl
}

return cborg.encode(data)
}

/**
* Validates the given ipns entry against the given public key.
*
Expand All @@ -93,12 +132,26 @@ const _create = async (privateKey, value, seq, isoValidity, validityType) => {
*/
const validate = async (publicKey, entry) => {
const { value, validityType, validity } = entry
const dataForSignature = ipnsEntryDataForSig(value, validityType, validity)

/** @type {Uint8Array} */
let dataForSignature
let signature

// Check v2 signature if it's available, otherwise use the v1 signature
if (entry.signatureV2 && entry.data) {
signature = entry.signatureV2
dataForSignature = ipnsEntryDataForV2Sig(entry.data)

validateCborDataMatchesPbData(entry)
} else {
signature = entry.signature
dataForSignature = ipnsEntryDataForV1Sig(value, validityType, validity)
}

// Validate Signature
let isValid
try {
isValid = await publicKey.verify(dataForSignature, entry.signature)
isValid = await publicKey.verify(dataForSignature, signature)
} catch (err) {
isValid = false
}
Expand Down Expand Up @@ -130,12 +183,53 @@ const validate = async (publicKey, entry) => {
log(`ipns entry for ${value} is valid`)
}

/**
* @param {IPNSEntry} entry
*/
const validateCborDataMatchesPbData = (entry) => {
if (!entry.data) {
throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA)
}

const data = cborg.decode(entry.data)

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)
}

if (!uint8ArrayEquals(data.value, entry.value)) {
throw errCode(new Error('Field "value" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION)
}

if (!uint8ArrayEquals(data.validity, entry.validity)) {
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)
}
}

/**
* Embed the given public key in the given entry. While not strictly required,
* some nodes (eg. DHT servers) may reject IPNS entries that don't embed their
* public keys as they may not be able to validate them efficiently.
* As a consequence of nodes needing to validade a record upon receipt, they need
* the public key associated with it. For olde RSA keys, it is easier if we just
* As a consequence of nodes needing to validate a record upon receipt, they need
* the public key associated with it. For old RSA keys, it is easier if we just
* send this as part of the record itself. For newer ed25519 keys, the public key
* can be embedded in the peerId.
*
Expand Down Expand Up @@ -254,7 +348,7 @@ const getIdKeys = (pid) => {
*/
const sign = (privateKey, value, validityType, validity) => {
try {
const dataForSignature = ipnsEntryDataForSig(value, validityType, validity)
const dataForSignature = ipnsEntryDataForV1Sig(value, validityType, validity)

return privateKey.sign(dataForSignature)
} catch (error) {
Expand Down Expand Up @@ -285,12 +379,23 @@ const getValidityType = (validityType) => {
* @param {number} validityType
* @param {Uint8Array} validity
*/
const ipnsEntryDataForSig = (value, validityType, validity) => {
const ipnsEntryDataForV1Sig = (value, validityType, validity) => {
const validityTypeBuffer = uint8ArrayFromString(getValidityType(validityType))

return uint8ArrayConcat([value, validity, validityTypeBuffer])
}

/**
* Utility for creating the record data for being signed
*
* @param {Uint8Array} data
*/
const ipnsEntryDataForV2Sig = (data) => {
const entryData = uint8ArrayFromString('ipns-signature:')

return uint8ArrayConcat([entryData, data])
}

/**
* Utility for extracting the public key from a peer-id
*
Expand All @@ -310,7 +415,11 @@ const extractPublicKeyFromId = (peerId) => {
* @param {IPNSEntry} obj
*/
const marshal = (obj) => {
return ipnsEntryProto.encode(obj).finish()
return ipnsEntryProto.encode({
...obj,
sequence: Long.fromString(obj.sequence.toString()),
ttl: obj.ttl == null ? undefined : Long.fromString(obj.ttl.toString())
}).finish()
}

/**
Expand All @@ -322,7 +431,6 @@ const unmarshal = (buf) => {
const object = ipnsEntryProto.toObject(message, {
defaults: false,
arrays: true,
longs: Number,
objects: false
})

Expand All @@ -331,8 +439,9 @@ const unmarshal = (buf) => {
signature: object.signature,
validityType: object.validityType,
validity: object.validity,
sequence: object.sequence,
pubKey: object.pubKey
sequence: Object.hasOwnProperty.call(object, 'sequence') ? BigInt(`${object.sequence}`) : 0n,
pubKey: object.pubKey,
ttl: Object.hasOwnProperty.call(object, 'ttl') ? BigInt(`${object.ttl}`) : undefined
}
}

Expand Down
24 changes: 18 additions & 6 deletions src/pb/ipns.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import * as $protobuf from "protobufjs";
export interface IIpnsEntry {

/** IpnsEntry value */
value: Uint8Array;
value?: (Uint8Array|null);

/** IpnsEntry signature */
signature: Uint8Array;
signature?: (Uint8Array|null);

/** IpnsEntry validityType */
validityType?: (IpnsEntry.ValidityType|null);
Expand All @@ -15,13 +15,19 @@ export interface IIpnsEntry {
validity?: (Uint8Array|null);

/** IpnsEntry sequence */
sequence?: (number|null);
sequence?: (number|Long|null);

/** IpnsEntry ttl */
ttl?: (number|null);
ttl?: (number|Long|null);

/** IpnsEntry pubKey */
pubKey?: (Uint8Array|null);

/** IpnsEntry signatureV2 */
signatureV2?: (Uint8Array|null);

/** IpnsEntry data */
data?: (Uint8Array|null);
}

/** Represents an IpnsEntry. */
Expand All @@ -46,14 +52,20 @@ export class IpnsEntry implements IIpnsEntry {
public validity: Uint8Array;

/** IpnsEntry sequence. */
public sequence: number;
public sequence: (number|Long);

/** IpnsEntry ttl. */
public ttl: number;
public ttl: (number|Long);

/** IpnsEntry pubKey. */
public pubKey: Uint8Array;

/** IpnsEntry signatureV2. */
public signatureV2: Uint8Array;

/** IpnsEntry data. */
public data: Uint8Array;

/**
* Encodes the specified IpnsEntry message. Does not implicitly {@link IpnsEntry.verify|verify} messages.
* @param m IpnsEntry message or plain object to encode
Expand Down
Loading

0 comments on commit d1421f9

Please sign in to comment.