Skip to content

Commit fd1481a

Browse files
authored
fix: update record selection rules (#134)
Perform the [same validation as go-ipns](https://github.com/ipfs/go-ipns/blob/a2d4e93f7e8ffc9f996471eb1a24ff12c8484120/ipns.go#L325-L362). - If a record has a v2 sig and the other does not, prefer that record - If the sequence numbers are not equal, prefer the record with the higher sequence number - If the sequence numbers are equal, prefer the record with the longer validity - Otherwise prefer the first record Also validates that embedded keys, where present, match the PeerId from the IPNS Record. Also, also: runs the type checker over the tests. BREAKING CHANGE: extractPublicKey is now async
1 parent 916b637 commit fd1481a

File tree

5 files changed

+142
-28
lines changed

5 files changed

+142
-28
lines changed

src/errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ 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'
1111
exports.ERR_INVALID_RECORD_DATA = 'ERR_INVALID_RECORD_DATA'
12+
exports.ERR_INVALID_EMBEDDED_KEY = 'ERR_INVALID_EMBEDDED_KEY'

src/index.js

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const createWithExpiration = (privateKey, value, seq, expiration) => {
8383
* @param {number} validityType
8484
* @param {NanoDate} expirationDate
8585
* @param {bigint} ttl
86+
* @returns {Promise<IPNSEntry>}
8687
*/
8788
const _create = async (privateKey, value, seq, validityType, expirationDate, ttl) => {
8889
seq = BigInt(seq)
@@ -277,34 +278,44 @@ const embedPublicKey = async (publicKey, entry) => {
277278
}
278279

279280
/**
280-
* Extracts a public key matching `pid` from the ipns record.
281+
* Extracts a public key from the passed PeerId, falling
282+
* back to the pubKey embedded in the ipns record.
281283
*
282284
* @param {PeerId} peerId - peer identifier object.
283285
* @param {IPNSEntry} entry - ipns entry record.
284286
*/
285-
const extractPublicKey = (peerId, entry) => {
287+
const extractPublicKey = async (peerId, entry) => {
286288
if (!entry || !peerId) {
287289
const error = new Error('one or more of the provided parameters are not defined')
288290

289291
log.error(error)
290292
throw errCode(error, ERRORS.ERR_UNDEFINED_PARAMETER)
291293
}
292294

295+
let pubKey
296+
293297
if (entry.pubKey) {
294-
let pubKey
295298
try {
296299
pubKey = crypto.keys.unmarshalPublicKey(entry.pubKey)
297300
} catch (err) {
298301
log.error(err)
299302
throw err
300303
}
301-
return pubKey
304+
305+
const otherId = await PeerId.createFromPubKey(entry.pubKey)
306+
307+
if (!otherId.equals(peerId)) {
308+
throw errCode(new Error('Embedded public key did not match PeerID'), ERRORS.ERR_INVALID_EMBEDDED_KEY)
309+
}
310+
} else if (peerId.pubKey) {
311+
pubKey = peerId.pubKey
302312
}
303313

304-
if (peerId.pubKey) {
305-
return peerId.pubKey
314+
if (pubKey) {
315+
return pubKey
306316
}
307-
throw Object.assign(new Error('no public key is available'), { code: ERRORS.ERR_UNDEFINED_PARAMETER })
317+
318+
throw errCode(new Error('no public key is available'), ERRORS.ERR_UNDEFINED_PARAMETER)
308319
}
309320

310321
/**
@@ -443,7 +454,9 @@ const unmarshal = (buf) => {
443454
validity: object.validity,
444455
sequence: Object.hasOwnProperty.call(object, 'sequence') ? BigInt(`${object.sequence}`) : 0n,
445456
pubKey: object.pubKey,
446-
ttl: Object.hasOwnProperty.call(object, 'ttl') ? BigInt(`${object.ttl}`) : undefined
457+
ttl: Object.hasOwnProperty.call(object, 'ttl') ? BigInt(`${object.ttl}`) : undefined,
458+
signatureV2: object.signatureV2,
459+
data: object.data
447460
}
448461
}
449462

@@ -458,11 +471,12 @@ const validator = {
458471
const peerId = PeerId.createFromBytes(bufferId)
459472

460473
// extract public key
461-
const pubKey = extractPublicKey(peerId, receivedEntry)
474+
const pubKey = await extractPublicKey(peerId, receivedEntry)
462475

463476
// Record validation
464477
await validate(pubKey, receivedEntry)
465478
},
479+
466480
/**
467481
* @param {Uint8Array} dataA
468482
* @param {Uint8Array} dataB
@@ -471,7 +485,25 @@ const validator = {
471485
const entryA = unmarshal(dataA)
472486
const entryB = unmarshal(dataB)
473487

474-
return entryA.sequence > entryB.sequence ? 0 : 1
488+
// having a newer signature version is better than an older signature version
489+
if (entryA.signatureV2 && !entryB.signatureV2) {
490+
return 0
491+
} else if (entryB.signatureV2 && !entryA.signatureV2) {
492+
return 1
493+
}
494+
495+
// choose later sequence number
496+
if (entryA.sequence > entryB.sequence) {
497+
return 0
498+
} else if (entryA.sequence < entryB.sequence) {
499+
return 1
500+
}
501+
502+
// choose longer lived record if sequence numbers the same
503+
const entryAValidityDate = parseRFC3339(uint8ArrayToString(entryA.validity))
504+
const entryBValidityDate = parseRFC3339(uint8ArrayToString(entryB.validity))
505+
506+
return entryBValidityDate.getTime() > entryAValidityDate.getTime() ? 1 : 0
475507
}
476508
}
477509

src/types.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface IPNSEntry {
88
validity: Uint8Array // expiration datetime for the record in RFC3339 format
99
sequence: bigint // number representing the version of the record
1010
ttl?: bigint // ttl in nanoseconds
11-
pubKey?: Uint8Array
11+
pubKey?: Uint8Array // the public portion of the key that signed this record (only present if it was not embedded in the IPNS key)
1212
signatureV2?: Uint8Array // the v2 signature of the record
1313
data?: Uint8Array // extensible data
1414
}

test/index.spec.js

Lines changed: 95 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
const { expect } = require('aegir/utils/chai')
55
const { base58btc } = require('multiformats/bases/base58')
6+
const { base64urlpad } = require('multiformats/bases/base64')
67
const { fromString: uint8ArrayFromString } = require('uint8arrays/from-string')
78
const { concat: uint8ArrayConcat } = require('uint8arrays/concat')
89
const PeerId = require('peer-id')
@@ -21,13 +22,18 @@ describe('ipns', function () {
2122
let ipfsId
2223
/** @type {import('libp2p-crypto').keys.supportedKeys.rsa.RsaPrivateKey} */
2324
let rsa
25+
/** @type {import('libp2p-crypto').keys.supportedKeys.rsa.RsaPrivateKey} */
26+
let rsa2
2427

2528
before(async () => {
2629
rsa = await crypto.keys.generateKeyPair('RSA', 2048)
30+
rsa2 = await crypto.keys.generateKeyPair('RSA', 2048)
31+
32+
const peerId = await PeerId.createFromPubKey(rsa.public.bytes)
2733

2834
ipfsId = {
29-
id: 'QmQ73f8hbM4hKwRYBqeUsPtiwfE2x6WPv9WnzaYt4nYcXf',
30-
publicKey: 'CAASpgIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUOR0AJ2/yO0S/JIkKmYV/QdHzQXi1nrTCCXtEbUDVW5mXZfNf9bKeNDfW3UIIOwVzV6/sRhJqq/8sQAhmzURj1q2onCKgSLzjdePSLtykolQeQGSD+JO7rcxOLx+sTdIyJiclP/tkK2gfo2nrI6pjFTKNzR8VSoJx7gfiqY1N9LBgDsD4WjaOM2pBgzgVUlXpk27Aqvcd+htSWi6JuIZaBhPY/IzEvXwntGH9k7F8VkT6nUBilhqFFSWnz8cNKToCHjyhoozKfqN89S7EGMiNvG4cX4Dc/nVXlZRTAi4PNNewutimujROy2/tNEquC2uAlcAzhRAcLL/ujhEjJYP1AgMBAAE='
35+
id: peerId.toB58String(),
36+
publicKey: base64urlpad.encode(rsa.public.bytes)
3137
}
3238
})
3339

@@ -43,6 +49,8 @@ describe('ipns', function () {
4349
expect(entry).to.have.property('validity')
4450
expect(entry).to.have.property('signature')
4551
expect(entry).to.have.property('validityType')
52+
expect(entry).to.have.property('signatureV2')
53+
expect(entry).to.have.property('data')
4654
})
4755

4856
it('should be able to create a record with a fixed expiration', async () => {
@@ -121,6 +129,19 @@ describe('ipns', function () {
121129
expect(entryDataCreated.validityType).to.equal(unmarshalledData.validityType)
122130
expect(entryDataCreated.signature).to.equalBytes(unmarshalledData.signature)
123131
expect(entryDataCreated.sequence).to.equal(unmarshalledData.sequence)
132+
expect(entryDataCreated.ttl).to.equal(unmarshalledData.ttl)
133+
134+
if (!unmarshalledData.signatureV2) {
135+
throw new Error('No v2 sig found')
136+
}
137+
138+
expect(entryDataCreated.signatureV2).to.equalBytes(unmarshalledData.signatureV2)
139+
140+
if (!unmarshalledData.data) {
141+
throw new Error('No v2 data found')
142+
}
143+
144+
expect(entryDataCreated.data).to.equalBytes(unmarshalledData.data)
124145

125146
return ipns.validate(rsa.public, unmarshalledData)
126147
})
@@ -206,13 +227,13 @@ describe('ipns', function () {
206227
expect.fail('Expected ERR_UNDEFINED_PARAMETER')
207228
})
208229

209-
it('should be able to export a previously embed public key from an ipns record', async () => {
230+
it('should be able to export a previously embedded public key from an ipns record', async () => {
210231
const sequence = 0
211232
const validity = 1000000
212233

213234
const entry = await ipns.create(rsa, cid, sequence, validity)
214235
await ipns.embedPublicKey(rsa.public, entry)
215-
const publicKey = ipns.extractPublicKey(PeerId.createFromB58String(ipfsId.id), entry)
236+
const publicKey = await ipns.extractPublicKey(PeerId.createFromB58String(ipfsId.id), entry)
216237
expect(publicKey.bytes).to.equalBytes(rsa.public.bytes)
217238
})
218239

@@ -245,22 +266,63 @@ describe('ipns', function () {
245266
const keyBytes = base58btc.decode(`z${ipfsId.id}`)
246267
const key = uint8ArrayConcat([uint8ArrayFromString('/ipns/'), keyBytes])
247268

248-
try {
249-
await ipns.validator.validate(marshalledData, key)
250-
} catch (err) {
251-
expect(err).to.exist()
252-
expect(err).to.include({
253-
code: ERRORS.ERR_SIGNATURE_VERIFICATION
254-
})
255-
}
269+
await expect(ipns.validator.validate(marshalledData, key))
270+
.to.eventually.be.rejected().with.property('code', ERRORS.ERR_SIGNATURE_VERIFICATION)
256271
})
257272

258-
it('should use validator.select to select the record with the highest sequence number', async () => {
273+
it('should use validator.validate to verify that a record is not valid when it is passed with the wrong IPNS key', async () => {
259274
const sequence = 0
260275
const validity = 1000000
261276

262277
const entry = await ipns.create(rsa, cid, sequence, validity)
263-
const newEntry = await ipns.create(rsa, cid, (sequence + 1), validity)
278+
await ipns.embedPublicKey(rsa.public, entry)
279+
const marshalledData = ipns.marshal(entry)
280+
281+
const keyBytes = (await PeerId.createFromPrivKey(rsa2.bytes)).toBytes()
282+
const key = uint8ArrayConcat([uint8ArrayFromString('/ipns/'), keyBytes])
283+
284+
await expect(ipns.validator.validate(marshalledData, key))
285+
.to.eventually.be.rejected().with.property('code', ERRORS.ERR_INVALID_EMBEDDED_KEY)
286+
})
287+
288+
it('should use validator.validate to verify that a record is not valid when the wrong key is embedded', async () => {
289+
const sequence = 0
290+
const validity = 1000000
291+
292+
const entry = await ipns.create(rsa, cid, sequence, validity)
293+
await ipns.embedPublicKey(rsa2.public, entry)
294+
const marshalledData = ipns.marshal(entry)
295+
296+
const keyBytes = (await PeerId.createFromPrivKey(rsa.bytes)).toBytes()
297+
const key = uint8ArrayConcat([uint8ArrayFromString('/ipns/'), keyBytes])
298+
299+
await expect(ipns.validator.validate(marshalledData, key))
300+
.to.eventually.be.rejected().with.property('code', ERRORS.ERR_INVALID_EMBEDDED_KEY)
301+
})
302+
303+
it('should use validator.select to select the record with the highest sequence number', async () => {
304+
const sequence = 0
305+
const lifetime = 1000000
306+
307+
const entry = await ipns.create(rsa, cid, sequence, lifetime)
308+
const newEntry = await ipns.create(rsa, cid, (sequence + 1), lifetime)
309+
310+
const marshalledData = ipns.marshal(entry)
311+
const marshalledNewData = ipns.marshal(newEntry)
312+
313+
let valid = ipns.validator.select(marshalledNewData, marshalledData)
314+
expect(valid).to.equal(0) // new data is the selected one
315+
316+
valid = ipns.validator.select(marshalledData, marshalledNewData)
317+
expect(valid).to.equal(1) // new data is the selected one
318+
})
319+
320+
it('should use validator.select to select the record with the longest validity', async () => {
321+
const sequence = 0
322+
const lifetime = 1000000
323+
324+
const entry = await ipns.create(rsa, cid, sequence, lifetime)
325+
const newEntry = await ipns.create(rsa, cid, sequence, (lifetime + 1))
264326

265327
const marshalledData = ipns.marshal(entry)
266328
const marshalledNewData = ipns.marshal(newEntry)
@@ -271,4 +333,23 @@ describe('ipns', function () {
271333
valid = ipns.validator.select(marshalledData, marshalledNewData)
272334
expect(valid).to.equal(1) // new data is the selected one
273335
})
336+
337+
it('should use validator.select to select an older record with a v2 sig when the newer record only uses v1', async () => {
338+
const sequence = 0
339+
const lifetime = 1000000
340+
341+
const entry = await ipns.create(rsa, cid, sequence, lifetime)
342+
343+
const newEntry = await ipns.create(rsa, cid, sequence + 1, lifetime)
344+
delete newEntry.signatureV2
345+
346+
const marshalledData = ipns.marshal(entry)
347+
const marshalledNewData = ipns.marshal(newEntry)
348+
349+
let valid = ipns.validator.select(marshalledNewData, marshalledData)
350+
expect(valid).to.equal(1) // old data is the selected one
351+
352+
valid = ipns.validator.select(marshalledData, marshalledNewData)
353+
expect(valid).to.equal(0) // old data is the selected one
354+
})
274355
})

tsconfig.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
"outDir": "dist"
55
},
66
"include": [
7-
"src"
7+
"src",
8+
"test"
89
],
910
"exclude": [
10-
"src/pb/ipns.js",
11-
"test"
11+
"src/pb/ipns.js"
1212
]
1313
}

0 commit comments

Comments
 (0)