Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: validate v2 ipns signatures #121

Merged
merged 4 commits into from
Jun 10, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does js cborg sorts the entries while encoding the data?

I know that in go they needed to sort manually the entries: ipfs/go-ipns@3deb032#diff-3586bf1db53da12e45eb5e5072b15b3fc5ada0b80883e9945c10cead73f1ca91R76

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes it does, and the Go ppl have to do it because @warpfork refuses to make it a codec concern or option in ipld-prime (which I will continue to moan at him about).

> const data = { value: new Uint8Array([1,2,3]), validity: new Uint8Array([3,4,5]), validityType: 1, sequence: 10000n, ttl: 1000000000000n }
> Buffer.from(cborg.encode(data)).toString('hex')
'a56374746c1b000000e8d4a510006576616c7565430102036873657175656e63651927106876616c6964697479430304056c76616c69646974795479706501'
$ cborg hex2diag a56374746c1b000000e8d4a510006576616c7565430102036873657175656e63651927106876616c6964697479430304056c76616c69646974795479706501
a5                                                # map(5)
  63                                              #   string(3)
    74746c                                        #     "ttl"
  1b 000000e8d4a51000                             #   uint(1000000000000)
  65                                              #   string(5)
    76616c7565                                    #     "value"
  43                                              #   bytes(3)
    010203                                        #     "\x01\x02\x03"
  68                                              #   string(8)
    73657175656e6365                              #     "sequence"
  19 2710                                         #   uint(10000)
  68                                              #   string(8)
    76616c6964697479                              #     "validity"
  43                                              #   bytes(3)
    030405                                        #     "\x03\x04\x05"
  6c                                              #   string(12)
    76616c696469747954797065                      #     "validityType"
  01                                              #   uint(1)

it's the RFC7049 sorting rules - length then bytewise.

it'd be great to have some fixture data to compare between implementations though to ensure this is consistent.

}

/**
* 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)
achingbrain marked this conversation as resolved.
Show resolved Hide resolved

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: 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: BigInt(object.sequence.toString()),
pubKey: object.pubKey,
ttl: BigInt(object.ttl.toString())
}
}

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