Skip to content

Commit d1421f9

Browse files
achingbrainrvagg
andauthored
feat: validate v2 ipns signatures (#121)
Co-authored-by: Rod Vagg <[email protected]>
1 parent 6d690d6 commit d1421f9

File tree

9 files changed

+260
-55
lines changed

9 files changed

+260
-55
lines changed

.aegir.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict'
2+
3+
/** @type {import('aegir').PartialOptions} */
4+
module.exports = {
5+
build: {
6+
bundlesizeMax: '143KB'
7+
}
8+
}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"types": "dist/src/index.d.ts",
88
"scripts": {
99
"prepare": "run-s prepare:*",
10-
"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",
10+
"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",
1111
"prepare:proto-types": "pbts -o src/pb/ipns.d.ts src/pb/ipns.js",
1212
"prepare:types": "aegir build --no-bundle",
1313
"lint": "aegir lint",
@@ -41,10 +41,12 @@
4141
},
4242
"homepage": "https://github.com/ipfs/js-ipns#readme",
4343
"dependencies": {
44+
"cborg": "^1.3.3",
4445
"debug": "^4.2.0",
4546
"err-code": "^3.0.1",
4647
"interface-datastore": "^4.0.0",
4748
"libp2p-crypto": "^0.19.0",
49+
"long": "^4.0.0",
4850
"multibase": "^4.0.2",
4951
"multihashes": "^4.0.2",
5052
"peer-id": "^0.14.2",

src/errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ exports.ERR_UNRECOGNIZED_FORMAT = 'ERR_UNRECOGNIZED_FORMAT'
88
exports.ERR_PEER_ID_FROM_PUBLIC_KEY = 'ERR_PEER_ID_FROM_PUBLIC_KEY'
99
exports.ERR_PUBLIC_KEY_FROM_ID = 'ERR_PUBLIC_KEY_FROM_ID'
1010
exports.ERR_UNDEFINED_PARAMETER = 'ERR_UNDEFINED_PARAMETER'
11+
exports.ERR_INVALID_RECORD_DATA = 'ERR_INVALID_RECORD_DATA'

src/index.js

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const multibase = require('multibase')
1010
const uint8ArrayFromString = require('uint8arrays/from-string')
1111
const uint8ArrayToString = require('uint8arrays/to-string')
1212
const uint8ArrayConcat = require('uint8arrays/concat')
13+
const uint8ArrayEquals = require('uint8arrays/equals')
14+
const cborg = require('cborg')
15+
const Long = require('long')
1316

1417
const debug = require('debug')
1518
const log = Object.assign(debug('jsipns'), {
@@ -39,14 +42,17 @@ const namespace = '/ipns/'
3942
*
4043
* @param {PrivateKey} privateKey - private key for signing the record.
4144
* @param {Uint8Array} value - value to be stored in the record.
42-
* @param {number} seq - number representing the current version of the record.
45+
* @param {number | bigint} seq - number representing the current version of the record.
4346
* @param {number} lifetime - lifetime of the record (in milliseconds).
4447
*/
4548
const create = (privateKey, value, seq, lifetime) => {
4649
// Validity in ISOString with nanoseconds precision and validity type EOL
47-
const isoValidity = new NanoDate(Date.now() + Number(lifetime)).toString()
50+
const expirationDate = new NanoDate(Date.now() + Number(lifetime))
4851
const validityType = ipnsEntryProto.ValidityType.EOL
49-
return _create(privateKey, value, seq, uint8ArrayFromString(isoValidity), validityType)
52+
const [ms, ns] = lifetime.toString().split('.')
53+
const lifetimeNs = BigInt(ms) * 100000n + BigInt(ns || 0)
54+
55+
return _create(privateKey, value, seq, validityType, expirationDate, lifetimeNs)
5056
}
5157

5258
/**
@@ -55,36 +61,69 @@ const create = (privateKey, value, seq, lifetime) => {
5561
*
5662
* @param {PrivateKey} privateKey - private key for signing the record.
5763
* @param {Uint8Array} value - value to be stored in the record.
58-
* @param {number} seq - number representing the current version of the record.
64+
* @param {number | bigint} seq - number representing the current version of the record.
5965
* @param {string} expiration - expiration datetime for record in the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision.
6066
*/
6167
const createWithExpiration = (privateKey, value, seq, expiration) => {
68+
const expirationDate = NanoDate.fromString(expiration)
6269
const validityType = ipnsEntryProto.ValidityType.EOL
63-
return _create(privateKey, value, seq, uint8ArrayFromString(expiration), validityType)
70+
71+
const ttlMs = expirationDate.toDate().getTime() - Date.now()
72+
const ttlNs = (BigInt(ttlMs) * 100000n) + BigInt(expirationDate.getNano())
73+
74+
return _create(privateKey, value, seq, validityType, expirationDate, ttlNs)
6475
}
6576

6677
/**
6778
* @param {PrivateKey} privateKey
6879
* @param {Uint8Array} value
69-
* @param {number} seq
70-
* @param {Uint8Array} isoValidity
80+
* @param {number | bigint} seq
7181
* @param {number} validityType
82+
* @param {NanoDate} expirationDate
83+
* @param {bigint} ttl
7284
*/
73-
const _create = async (privateKey, value, seq, isoValidity, validityType) => {
74-
const signature = await sign(privateKey, value, validityType, isoValidity)
85+
const _create = async (privateKey, value, seq, validityType, expirationDate, ttl) => {
86+
seq = BigInt(seq)
87+
const isoValidity = uint8ArrayFromString(expirationDate.toString())
88+
const signatureV1 = await sign(privateKey, value, validityType, isoValidity)
89+
const data = createCborData(value, isoValidity, validityType, seq, ttl)
90+
const sigData = ipnsEntryDataForV2Sig(data)
91+
const signatureV2 = await privateKey.sign(sigData)
7592

7693
const entry = {
7794
value,
78-
signature: signature,
95+
signature: signatureV1,
7996
validityType: validityType,
8097
validity: isoValidity,
81-
sequence: seq
98+
sequence: seq,
99+
ttl,
100+
signatureV2,
101+
data
82102
}
83103

84104
log(`ipns entry for ${value} created`)
85105
return entry
86106
}
87107

108+
/**
109+
* @param {Uint8Array} value
110+
* @param {Uint8Array} validity
111+
* @param {number} validityType
112+
* @param {bigint} sequence
113+
* @param {bigint} ttl
114+
*/
115+
const createCborData = (value, validity, validityType, sequence, ttl) => {
116+
const data = {
117+
value,
118+
validity,
119+
validityType,
120+
sequence,
121+
ttl
122+
}
123+
124+
return cborg.encode(data)
125+
}
126+
88127
/**
89128
* Validates the given ipns entry against the given public key.
90129
*
@@ -93,12 +132,26 @@ const _create = async (privateKey, value, seq, isoValidity, validityType) => {
93132
*/
94133
const validate = async (publicKey, entry) => {
95134
const { value, validityType, validity } = entry
96-
const dataForSignature = ipnsEntryDataForSig(value, validityType, validity)
135+
136+
/** @type {Uint8Array} */
137+
let dataForSignature
138+
let signature
139+
140+
// Check v2 signature if it's available, otherwise use the v1 signature
141+
if (entry.signatureV2 && entry.data) {
142+
signature = entry.signatureV2
143+
dataForSignature = ipnsEntryDataForV2Sig(entry.data)
144+
145+
validateCborDataMatchesPbData(entry)
146+
} else {
147+
signature = entry.signature
148+
dataForSignature = ipnsEntryDataForV1Sig(value, validityType, validity)
149+
}
97150

98151
// Validate Signature
99152
let isValid
100153
try {
101-
isValid = await publicKey.verify(dataForSignature, entry.signature)
154+
isValid = await publicKey.verify(dataForSignature, signature)
102155
} catch (err) {
103156
isValid = false
104157
}
@@ -130,12 +183,53 @@ const validate = async (publicKey, entry) => {
130183
log(`ipns entry for ${value} is valid`)
131184
}
132185

186+
/**
187+
* @param {IPNSEntry} entry
188+
*/
189+
const validateCborDataMatchesPbData = (entry) => {
190+
if (!entry.data) {
191+
throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA)
192+
}
193+
194+
const data = cborg.decode(entry.data)
195+
196+
if (Number.isInteger(data.sequence)) {
197+
// sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range
198+
data.sequence = BigInt(data.sequence)
199+
}
200+
201+
if (Number.isInteger(data.ttl)) {
202+
// ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range
203+
data.ttl = BigInt(data.ttl)
204+
}
205+
206+
if (!uint8ArrayEquals(data.value, entry.value)) {
207+
throw errCode(new Error('Field "value" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION)
208+
}
209+
210+
if (!uint8ArrayEquals(data.validity, entry.validity)) {
211+
throw errCode(new Error('Field "validity" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION)
212+
}
213+
214+
if (data.validityType !== entry.validityType) {
215+
throw errCode(new Error('Field "validityType" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION)
216+
}
217+
218+
if (data.sequence !== entry.sequence) {
219+
throw errCode(new Error('Field "sequence" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION)
220+
}
221+
222+
if (data.ttl !== entry.ttl) {
223+
throw errCode(new Error('Field "ttl" did not match between protobuf and CBOR'), ERRORS.ERR_SIGNATURE_VERIFICATION)
224+
}
225+
}
226+
133227
/**
134228
* Embed the given public key in the given entry. While not strictly required,
135229
* some nodes (eg. DHT servers) may reject IPNS entries that don't embed their
136230
* public keys as they may not be able to validate them efficiently.
137-
* As a consequence of nodes needing to validade a record upon receipt, they need
138-
* the public key associated with it. For olde RSA keys, it is easier if we just
231+
* As a consequence of nodes needing to validate a record upon receipt, they need
232+
* the public key associated with it. For old RSA keys, it is easier if we just
139233
* send this as part of the record itself. For newer ed25519 keys, the public key
140234
* can be embedded in the peerId.
141235
*
@@ -254,7 +348,7 @@ const getIdKeys = (pid) => {
254348
*/
255349
const sign = (privateKey, value, validityType, validity) => {
256350
try {
257-
const dataForSignature = ipnsEntryDataForSig(value, validityType, validity)
351+
const dataForSignature = ipnsEntryDataForV1Sig(value, validityType, validity)
258352

259353
return privateKey.sign(dataForSignature)
260354
} catch (error) {
@@ -285,12 +379,23 @@ const getValidityType = (validityType) => {
285379
* @param {number} validityType
286380
* @param {Uint8Array} validity
287381
*/
288-
const ipnsEntryDataForSig = (value, validityType, validity) => {
382+
const ipnsEntryDataForV1Sig = (value, validityType, validity) => {
289383
const validityTypeBuffer = uint8ArrayFromString(getValidityType(validityType))
290384

291385
return uint8ArrayConcat([value, validity, validityTypeBuffer])
292386
}
293387

388+
/**
389+
* Utility for creating the record data for being signed
390+
*
391+
* @param {Uint8Array} data
392+
*/
393+
const ipnsEntryDataForV2Sig = (data) => {
394+
const entryData = uint8ArrayFromString('ipns-signature:')
395+
396+
return uint8ArrayConcat([entryData, data])
397+
}
398+
294399
/**
295400
* Utility for extracting the public key from a peer-id
296401
*
@@ -310,7 +415,11 @@ const extractPublicKeyFromId = (peerId) => {
310415
* @param {IPNSEntry} obj
311416
*/
312417
const marshal = (obj) => {
313-
return ipnsEntryProto.encode(obj).finish()
418+
return ipnsEntryProto.encode({
419+
...obj,
420+
sequence: Long.fromString(obj.sequence.toString()),
421+
ttl: obj.ttl == null ? undefined : Long.fromString(obj.ttl.toString())
422+
}).finish()
314423
}
315424

316425
/**
@@ -322,7 +431,6 @@ const unmarshal = (buf) => {
322431
const object = ipnsEntryProto.toObject(message, {
323432
defaults: false,
324433
arrays: true,
325-
longs: Number,
326434
objects: false
327435
})
328436

@@ -331,8 +439,9 @@ const unmarshal = (buf) => {
331439
signature: object.signature,
332440
validityType: object.validityType,
333441
validity: object.validity,
334-
sequence: object.sequence,
335-
pubKey: object.pubKey
442+
sequence: Object.hasOwnProperty.call(object, 'sequence') ? BigInt(`${object.sequence}`) : 0n,
443+
pubKey: object.pubKey,
444+
ttl: Object.hasOwnProperty.call(object, 'ttl') ? BigInt(`${object.ttl}`) : undefined
336445
}
337446
}
338447

src/pb/ipns.d.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import * as $protobuf from "protobufjs";
33
export interface IIpnsEntry {
44

55
/** IpnsEntry value */
6-
value: Uint8Array;
6+
value?: (Uint8Array|null);
77

88
/** IpnsEntry signature */
9-
signature: Uint8Array;
9+
signature?: (Uint8Array|null);
1010

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

1717
/** IpnsEntry sequence */
18-
sequence?: (number|null);
18+
sequence?: (number|Long|null);
1919

2020
/** IpnsEntry ttl */
21-
ttl?: (number|null);
21+
ttl?: (number|Long|null);
2222

2323
/** IpnsEntry pubKey */
2424
pubKey?: (Uint8Array|null);
25+
26+
/** IpnsEntry signatureV2 */
27+
signatureV2?: (Uint8Array|null);
28+
29+
/** IpnsEntry data */
30+
data?: (Uint8Array|null);
2531
}
2632

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

4854
/** IpnsEntry sequence. */
49-
public sequence: number;
55+
public sequence: (number|Long);
5056

5157
/** IpnsEntry ttl. */
52-
public ttl: number;
58+
public ttl: (number|Long);
5359

5460
/** IpnsEntry pubKey. */
5561
public pubKey: Uint8Array;
5662

63+
/** IpnsEntry signatureV2. */
64+
public signatureV2: Uint8Array;
65+
66+
/** IpnsEntry data. */
67+
public data: Uint8Array;
68+
5769
/**
5870
* Encodes the specified IpnsEntry message. Does not implicitly {@link IpnsEntry.verify|verify} messages.
5971
* @param m IpnsEntry message or plain object to encode

0 commit comments

Comments
 (0)