diff --git a/.aegir.js b/.aegir.js new file mode 100644 index 0000000..1bd8831 --- /dev/null +++ b/.aegir.js @@ -0,0 +1,8 @@ +'use strict' + +/** @type {import('aegir').PartialOptions} */ +module.exports = { + build: { + bundlesizeMax: '143KB' + } +} diff --git a/package.json b/package.json index b30d85f..424651f 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/errors.js b/src/errors.js index 3b6853c..66c1403 100644 --- a/src/errors.js +++ b/src/errors.js @@ -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' diff --git a/src/index.js b/src/index.js index 7dd08f4..83f2b77 100644 --- a/src/index.js +++ b/src/index.js @@ -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'), { @@ -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) } /** @@ -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. * @@ -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 } @@ -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. * @@ -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) { @@ -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 * @@ -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() } /** @@ -322,7 +431,6 @@ const unmarshal = (buf) => { const object = ipnsEntryProto.toObject(message, { defaults: false, arrays: true, - longs: Number, objects: false }) @@ -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 } } diff --git a/src/pb/ipns.d.ts b/src/pb/ipns.d.ts index c5ccb7a..d1e5498 100644 --- a/src/pb/ipns.d.ts +++ b/src/pb/ipns.d.ts @@ -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); @@ -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. */ @@ -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 diff --git a/src/pb/ipns.js b/src/pb/ipns.js index ed96a66..63d348c 100644 --- a/src/pb/ipns.js +++ b/src/pb/ipns.js @@ -15,13 +15,15 @@ $root.IpnsEntry = (function() { * Properties of an IpnsEntry. * @exports IIpnsEntry * @interface IIpnsEntry - * @property {Uint8Array} value IpnsEntry value - * @property {Uint8Array} signature IpnsEntry signature + * @property {Uint8Array|null} [value] IpnsEntry value + * @property {Uint8Array|null} [signature] IpnsEntry signature * @property {IpnsEntry.ValidityType|null} [validityType] IpnsEntry validityType * @property {Uint8Array|null} [validity] IpnsEntry validity - * @property {number|null} [sequence] IpnsEntry sequence - * @property {number|null} [ttl] IpnsEntry ttl + * @property {number|Long|null} [sequence] IpnsEntry sequence + * @property {number|Long|null} [ttl] IpnsEntry ttl * @property {Uint8Array|null} [pubKey] IpnsEntry pubKey + * @property {Uint8Array|null} [signatureV2] IpnsEntry signatureV2 + * @property {Uint8Array|null} [data] IpnsEntry data */ /** @@ -73,7 +75,7 @@ $root.IpnsEntry = (function() { /** * IpnsEntry sequence. - * @member {number} sequence + * @member {number|Long} sequence * @memberof IpnsEntry * @instance */ @@ -81,7 +83,7 @@ $root.IpnsEntry = (function() { /** * IpnsEntry ttl. - * @member {number} ttl + * @member {number|Long} ttl * @memberof IpnsEntry * @instance */ @@ -95,6 +97,22 @@ $root.IpnsEntry = (function() { */ IpnsEntry.prototype.pubKey = $util.newBuffer([]); + /** + * IpnsEntry signatureV2. + * @member {Uint8Array} signatureV2 + * @memberof IpnsEntry + * @instance + */ + IpnsEntry.prototype.signatureV2 = $util.newBuffer([]); + + /** + * IpnsEntry data. + * @member {Uint8Array} data + * @memberof IpnsEntry + * @instance + */ + IpnsEntry.prototype.data = $util.newBuffer([]); + /** * Encodes the specified IpnsEntry message. Does not implicitly {@link IpnsEntry.verify|verify} messages. * @function encode @@ -107,8 +125,10 @@ $root.IpnsEntry = (function() { IpnsEntry.encode = function encode(m, w) { if (!w) w = $Writer.create(); - w.uint32(10).bytes(m.value); - w.uint32(18).bytes(m.signature); + if (m.value != null && Object.hasOwnProperty.call(m, "value")) + w.uint32(10).bytes(m.value); + if (m.signature != null && Object.hasOwnProperty.call(m, "signature")) + w.uint32(18).bytes(m.signature); if (m.validityType != null && Object.hasOwnProperty.call(m, "validityType")) w.uint32(24).int32(m.validityType); if (m.validity != null && Object.hasOwnProperty.call(m, "validity")) @@ -119,6 +139,10 @@ $root.IpnsEntry = (function() { w.uint32(48).uint64(m.ttl); if (m.pubKey != null && Object.hasOwnProperty.call(m, "pubKey")) w.uint32(58).bytes(m.pubKey); + if (m.signatureV2 != null && Object.hasOwnProperty.call(m, "signatureV2")) + w.uint32(66).bytes(m.signatureV2); + if (m.data != null && Object.hasOwnProperty.call(m, "data")) + w.uint32(74).bytes(m.data); return w; }; @@ -161,15 +185,17 @@ $root.IpnsEntry = (function() { case 7: m.pubKey = r.bytes(); break; + case 8: + m.signatureV2 = r.bytes(); + break; + case 9: + m.data = r.bytes(); + break; default: r.skipType(t & 7); break; } } - if (!m.hasOwnProperty("value")) - throw $util.ProtocolError("missing required 'value'", { instance: m }); - if (!m.hasOwnProperty("signature")) - throw $util.ProtocolError("missing required 'signature'", { instance: m }); return m; }; @@ -235,6 +261,18 @@ $root.IpnsEntry = (function() { else if (d.pubKey.length) m.pubKey = d.pubKey; } + if (d.signatureV2 != null) { + if (typeof d.signatureV2 === "string") + $util.base64.decode(d.signatureV2, m.signatureV2 = $util.newBuffer($util.base64.length(d.signatureV2)), 0); + else if (d.signatureV2.length) + m.signatureV2 = d.signatureV2; + } + if (d.data != null) { + if (typeof d.data === "string") + $util.base64.decode(d.data, m.data = $util.newBuffer($util.base64.length(d.data)), 0); + else if (d.data.length) + m.data = d.data; + } return m; }; @@ -291,6 +329,20 @@ $root.IpnsEntry = (function() { if (o.bytes !== Array) d.pubKey = $util.newBuffer(d.pubKey); } + if (o.bytes === String) + d.signatureV2 = ""; + else { + d.signatureV2 = []; + if (o.bytes !== Array) + d.signatureV2 = $util.newBuffer(d.signatureV2); + } + if (o.bytes === String) + d.data = ""; + else { + d.data = []; + if (o.bytes !== Array) + d.data = $util.newBuffer(d.data); + } } if (m.value != null && m.hasOwnProperty("value")) { d.value = o.bytes === String ? $util.base64.encode(m.value, 0, m.value.length) : o.bytes === Array ? Array.prototype.slice.call(m.value) : m.value; @@ -319,6 +371,12 @@ $root.IpnsEntry = (function() { if (m.pubKey != null && m.hasOwnProperty("pubKey")) { d.pubKey = o.bytes === String ? $util.base64.encode(m.pubKey, 0, m.pubKey.length) : o.bytes === Array ? Array.prototype.slice.call(m.pubKey) : m.pubKey; } + if (m.signatureV2 != null && m.hasOwnProperty("signatureV2")) { + d.signatureV2 = o.bytes === String ? $util.base64.encode(m.signatureV2, 0, m.signatureV2.length) : o.bytes === Array ? Array.prototype.slice.call(m.signatureV2) : m.signatureV2; + } + if (m.data != null && m.hasOwnProperty("data")) { + d.data = o.bytes === String ? $util.base64.encode(m.data, 0, m.data.length) : o.bytes === Array ? Array.prototype.slice.call(m.data) : m.data; + } return d; }; diff --git a/src/pb/ipns.proto b/src/pb/ipns.proto index c192920..6f8ae2b 100644 --- a/src/pb/ipns.proto +++ b/src/pb/ipns.proto @@ -1,10 +1,12 @@ +// https://github.com/ipfs/go-ipns/blob/master/pb/ipns.proto + message IpnsEntry { enum ValidityType { EOL = 0; // setting an EOL says "this record is valid until..." } - required bytes value = 1; - required bytes signature = 2; + optional bytes value = 1; + optional bytes signature = 2; optional ValidityType validityType = 3; optional bytes validity = 4; @@ -18,4 +20,8 @@ message IpnsEntry { // the record itself. For newer ed25519 keys, the public key can be embedded in the // peerID, making this field unnecessary. optional bytes pubKey = 7; + + optional bytes signatureV2 = 8; + + optional bytes data = 9; } diff --git a/src/types.d.ts b/src/types.d.ts index 2cdebf1..ace6d11 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -6,6 +6,9 @@ export interface IPNSEntry { signature: Uint8Array, // signature of the record validityType: ValidityType, // Type of validation being used validity: Uint8Array, // expiration datetime for the record in RFC3339 format - sequence: number // number representing the version of the record + sequence: BigInt // number representing the version of the record + ttl?: BigInt // ttl in nanoseconds pubKey?: Uint8Array + signatureV2?: Uint8Array // the v2 signature of the record + data?: Uint8Array // extensible data } diff --git a/test/index.spec.js b/test/index.spec.js index 547f497..d26aa63 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -38,7 +38,7 @@ describe('ipns', function () { const entry = await ipns.create(rsa, cid, sequence, validity) expect(entry).to.deep.include({ value: cid, - sequence: sequence + sequence: BigInt(sequence) }) expect(entry).to.have.property('validity') expect(entry).to.have.property('signature') @@ -65,6 +65,19 @@ describe('ipns', function () { return ipns.validate(rsa.public, entry) }) + it('should validate a v1 message', async () => { + const sequence = 0 + const validity = 1000000 + + const entry = await ipns.create(rsa, cid, sequence, validity) + + // extra fields added for v2 sigs + delete entry.data + delete entry.signatureV2 + + return ipns.validate(rsa.public, entry) + }) + it('should fail to validate a bad record', async () => { const sequence = 0 const validity = 1000000 @@ -74,14 +87,7 @@ describe('ipns', function () { // corrupt the record by changing the value to random bytes entry.value = crypto.randomBytes(46) - try { - await ipns.validate(rsa.public, entry) - } catch (err) { - expect(err).to.exist() - expect(err).to.include({ - code: ERRORS.ERR_SIGNATURE_VERIFICATION - }) - } + return expect(ipns.validate(rsa.public, entry)).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 () => { @@ -165,20 +171,20 @@ describe('ipns', function () { }) }) - // It should have a public key embeded for newer ed25519 keys + // It should have a public key embedded for newer ed25519 keys // https://github.com/ipfs/go-ipns/blob/d51115b4b14ed7fcca5472aadff0fee6772aca8c/ipns.go#L81 // https://github.com/ipfs/go-ipns/blob/d51115b4b14ed7fcca5472aadff0fee6772aca8c/ipns_test.go // https://github.com/libp2p/go-libp2p-peer/blob/7f219a1e70011a258c5d3e502aef6896c60d03ce/peer.go#L80 // IDFromEd25519PublicKey is not currently implement on js-libp2p-peer // https://github.com/libp2p/go-libp2p-peer/pull/30 - it.skip('should be able to extract a public key directly from the peer', async () => { + it('should be able to extract a public key directly from the peer', async () => { const sequence = 0 const validity = 1000000 const ed25519 = await crypto.keys.generateKeyPair('ed25519', 2048) const entry = await ipns.create(ed25519, cid, sequence, validity) const entryWithKey = ipns.embedPublicKey(ed25519.public, entry) - expect(entryWithKey).to.not.exist() // Should be null + expect(entryWithKey).to.not.have.property('pubKey') // ed25519 keys should not be embedded }) it('validator with no valid public key should error', async () => {