Skip to content

Commit 15c245e

Browse files
committed
feat: v2-only creation, relaxed verification
1 parent 5139ee5 commit 15c245e

File tree

8 files changed

+230
-161
lines changed

8 files changed

+230
-161
lines changed

src/index.ts

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { unmarshalPrivateKey } from '@libp2p/crypto/keys'
22
import { logger } from '@libp2p/logger'
3+
import * as cborg from 'cborg'
34
import errCode from 'err-code'
45
import { Key } from 'interface-datastore/key'
56
import { base32upper } from 'multiformats/bases/base32'
@@ -8,9 +9,10 @@ import { identity } from 'multiformats/hashes/identity'
89
import NanoDate from 'timestamp-nano'
910
import { equals as uint8ArrayEquals } from 'uint8arrays/equals'
1011
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
12+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
1113
import * as ERRORS from './errors.js'
1214
import { IpnsEntry } from './pb/ipns.js'
13-
import { createCborData, ipnsEntryDataForV1Sig, ipnsEntryDataForV2Sig } from './utils.js'
15+
import { createCborData, ipnsEntryDataForV1Sig, ipnsEntryDataForV2Sig, parseRFC3339 } from './utils.js'
1416
import type { PrivateKey } from '@libp2p/interface-keys'
1517
import type { PeerId } from '@libp2p/interface-peer-id'
1618

@@ -20,24 +22,49 @@ const ID_MULTIHASH_CODE = identity.code
2022
export const namespace = '/ipns/'
2123
export const namespaceLength = namespace.length
2224

23-
export interface IPNSEntry {
24-
value: Uint8Array
25-
signature: Uint8Array // signature of the record
26-
validityType: IpnsEntry.ValidityType // Type of validation being used
27-
validity: Uint8Array // expiration datetime for the record in RFC3339 format
28-
sequence: bigint // number representing the version of the record
29-
ttl?: bigint // ttl in nanoseconds
30-
pubKey?: Uint8Array // the public portion of the key that signed this record (only present if it was not embedded in the IPNS key)
31-
signatureV2?: Uint8Array // the v2 signature of the record
32-
data?: Uint8Array // extensible data
33-
}
25+
export class IPNSRecord {
26+
readonly pb: IpnsEntry
27+
private readonly data: any
28+
29+
constructor (pb: IpnsEntry) {
30+
this.pb = pb
31+
32+
if (pb.data == null) {
33+
throw errCode(new Error('Record data is missing'), ERRORS.ERR_INVALID_RECORD_DATA)
34+
}
35+
36+
this.data = cborg.decode(pb.data)
37+
}
3438

35-
export interface IPNSEntryData {
36-
Value: Uint8Array
37-
Validity: Uint8Array
38-
ValidityType: IpnsEntry.ValidityType
39-
Sequence: bigint
40-
TTL: bigint
39+
value (): string {
40+
return uint8ArrayToString(this.data.Value)
41+
}
42+
43+
validityType (): IpnsEntry.ValidityType {
44+
if (this.data.ValidityType === 0) {
45+
return IpnsEntry.ValidityType.EOL
46+
} else {
47+
throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY)
48+
}
49+
}
50+
51+
validity (): Date {
52+
const validityType = this.validityType()
53+
switch (validityType) {
54+
case IpnsEntry.ValidityType.EOL:
55+
return parseRFC3339(uint8ArrayToString(this.data.Validity))
56+
default:
57+
throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY)
58+
}
59+
}
60+
61+
sequence (): bigint {
62+
return BigInt(this.data.Sequence ?? 0n)
63+
}
64+
65+
ttl (): bigint {
66+
return BigInt(this.data.TTL ?? 0n)
67+
}
4168
}
4269

4370
export interface IDKeys {
@@ -47,6 +74,14 @@ export interface IDKeys {
4774
ipnsKey: Key
4875
}
4976

77+
export interface CreateOptions {
78+
v1Compatible?: boolean
79+
}
80+
81+
const defaultCreateOptions: CreateOptions = {
82+
v1Compatible: true
83+
}
84+
5085
/**
5186
* Creates a new ipns entry and signs it with the given private key.
5287
* The ipns entry validity should follow the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision.
@@ -56,15 +91,16 @@ export interface IDKeys {
5691
* @param {Uint8Array} value - value to be stored in the record.
5792
* @param {number | bigint} seq - number representing the current version of the record.
5893
* @param {number} lifetime - lifetime of the record (in milliseconds).
94+
* @param {CreateOptions} options - additional create options.
5995
*/
60-
export const create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, lifetime: number): Promise<IPNSEntry> => {
96+
export const create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
6197
// Validity in ISOString with nanoseconds precision and validity type EOL
6298
const expirationDate = new NanoDate(Date.now() + Number(lifetime))
6399
const validityType = IpnsEntry.ValidityType.EOL
64100
const [ms, ns] = lifetime.toString().split('.')
65101
const lifetimeNs = (BigInt(ms) * BigInt(100000)) + BigInt(ns ?? '0')
66102

67-
return _create(peerId, value, seq, validityType, expirationDate, lifetimeNs)
103+
return _create(peerId, value, seq, validityType, expirationDate, lifetimeNs, options)
68104
}
69105

70106
/**
@@ -75,18 +111,19 @@ export const create = async (peerId: PeerId, value: Uint8Array, seq: number | bi
75111
* @param {Uint8Array} value - value to be stored in the record.
76112
* @param {number | bigint} seq - number representing the current version of the record.
77113
* @param {string} expiration - expiration datetime for record in the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision.
114+
* @param {CreateOptions} options - additional create options.
78115
*/
79-
export const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string): Promise<IPNSEntry> => {
116+
export const createWithExpiration = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
80117
const expirationDate = NanoDate.fromString(expiration)
81118
const validityType = IpnsEntry.ValidityType.EOL
82119

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

86-
return _create(peerId, value, seq, validityType, expirationDate, ttlNs)
123+
return _create(peerId, value, seq, validityType, expirationDate, ttlNs, options)
87124
}
88125

89-
const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint): Promise<IPNSEntry> => {
126+
const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, expirationDate: NanoDate, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise<IPNSRecord> => {
90127
seq = BigInt(seq)
91128
const isoValidity = uint8ArrayFromString(expirationDate.toString())
92129

@@ -95,22 +132,25 @@ const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint,
95132
}
96133

97134
const privateKey = await unmarshalPrivateKey(peerId.privateKey)
98-
const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity)
99135
const data = createCborData(value, isoValidity, validityType, seq, ttl)
100136
const sigData = ipnsEntryDataForV2Sig(data)
101137
const signatureV2 = await privateKey.sign(sigData)
102138

103-
const entry: IPNSEntry = {
104-
value,
105-
signature: signatureV1,
106-
validityType,
107-
validity: isoValidity,
108-
sequence: seq,
109-
ttl,
139+
const entry: IpnsEntry = {
110140
signatureV2,
111141
data
112142
}
113143

144+
if (options.v1Compatible === true) {
145+
const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity)
146+
entry.value = value
147+
entry.validity = isoValidity
148+
entry.validityType = validityType
149+
entry.signature = signatureV1
150+
entry.sequence = seq
151+
entry.ttl = ttl
152+
}
153+
114154
// if we cannot derive the public key from the PeerId (e.g. RSA PeerIDs),
115155
// we have to embed it in the IPNS record
116156
if (peerId.publicKey != null) {
@@ -122,7 +162,7 @@ const _create = async (peerId: PeerId, value: Uint8Array, seq: number | bigint,
122162
}
123163

124164
log('ipns entry for %b created', value)
125-
return entry
165+
return new IPNSRecord(entry)
126166
}
127167

128168
/**

src/pb/ipns.proto

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,39 @@
1-
// https://github.com/ipfs/go-ipns/blob/master/pb/ipns.proto
1+
// https://github.com/ipfs/boxo/blob/main/ipns/pb/record.proto
22

33
syntax = "proto3";
44

55
message IpnsEntry {
6-
enum ValidityType {
7-
EOL = 0; // setting an EOL says "this record is valid until..."
6+
enum ValidityType {
7+
// setting an EOL says "this record is valid until..."
8+
EOL = 0;
89
}
910

10-
// value to be stored in the record
11-
optional bytes value = 1;
11+
// legacy V1 copy of data[Value]
12+
optional bytes value = 1;
1213

13-
// signature of the record
14-
optional bytes signature = 2;
14+
// legacy V1 field, verify 'signatureV2' instead
15+
optional bytes signatureV1 = 2;
1516

16-
// Type of validation being used
17+
// legacy V1 copies of data[ValidityType] and data[Validity]
1718
optional ValidityType validityType = 3;
18-
19-
// expiration datetime for the record in RFC3339 format
2019
optional bytes validity = 4;
2120

22-
// number representing the version of the record
21+
// legacy V1 copy of data[Sequence]
2322
optional uint64 sequence = 5;
2423

25-
// ttl in nanoseconds
24+
// legacy V1 copy copy of data[TTL]
2625
optional uint64 ttl = 6;
2726

28-
// in order for nodes to properly validate a record upon receipt, they need the public
29-
// key associated with it. For old RSA keys, its easiest if we just send this as part of
30-
// the record itself. For newer ed25519 keys, the public key can be embedded in the
31-
// peerID, making this field unnecessary.
27+
// Optional Public Key to be used for signature verification.
28+
// Used for big keys such as old RSA keys. Including the public key as part of
29+
// the record itself makes it verifiable in offline mode, without any additional lookup.
30+
// For newer Ed25519 keys, the public key is small enough that it can be embedded in the
31+
// IPNS Name itself, making this field unnecessary.
3232
optional bytes pubKey = 7;
3333

34-
// the v2 signature of the record
34+
// (mandatory V2) signature of the IPNS record
3535
optional bytes signatureV2 = 8;
3636

37-
// extensible data
37+
// (mandatory V2) extensible record data in DAG-CBOR format
3838
optional bytes data = 9;
3939
}

src/selector.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,22 @@
1-
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
2-
import { IpnsEntry } from './pb/ipns.js'
3-
import { parseRFC3339 } from './utils.js'
1+
import { unmarshal } from './utils.js'
42
import type { SelectFn } from '@libp2p/interface-dht'
53

64
export const ipnsSelector: SelectFn = (key, data) => {
75
const entries = data.map((buf, index) => ({
8-
entry: IpnsEntry.decode(buf),
6+
entry: unmarshal(buf),
97
index
108
}))
119

1210
entries.sort((a, b) => {
1311
// having a newer signature version is better than an older signature version
14-
if (a.entry.signatureV2 != null && b.entry.signatureV2 == null) {
12+
if (a.entry.pb.signatureV2 != null && b.entry.pb.signatureV2 == null) {
1513
return -1
16-
} else if (a.entry.signatureV2 == null && b.entry.signatureV2 != null) {
14+
} else if (a.entry.pb.signatureV2 == null && b.entry.pb.signatureV2 != null) {
1715
return 1
1816
}
1917

20-
const aSeq = a.entry.sequence ?? 0n
21-
const bSeq = b.entry.sequence ?? 0n
18+
const aSeq = a.entry.sequence()
19+
const bSeq = b.entry.sequence()
2220

2321
// choose later sequence number
2422
if (aSeq > bSeq) {
@@ -27,12 +25,9 @@ export const ipnsSelector: SelectFn = (key, data) => {
2725
return 1
2826
}
2927

30-
const aValidty = a.entry.validity ?? new Uint8Array(0)
31-
const bValidty = b.entry.validity ?? new Uint8Array(0)
32-
3328
// choose longer lived record if sequence numbers the same
34-
const entryAValidityDate = parseRFC3339(uint8ArrayToString(aValidty))
35-
const entryBValidityDate = parseRFC3339(uint8ArrayToString(bValidty))
29+
const entryAValidityDate = a.entry.validity()
30+
const entryBValidityDate = b.entry.validity()
3631

3732
if (entryAValidityDate.getTime() > entryBValidityDate.getTime()) {
3833
return -1

src/utils.ts

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
77
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
88
import * as ERRORS from './errors.js'
99
import { IpnsEntry } from './pb/ipns.js'
10-
import type { IPNSEntry, IPNSEntryData } from './index.js'
10+
import { IPNSRecord } from './index.js'
1111
import type { PublicKey } from '@libp2p/interface-keys'
1212
import type { PeerId } from '@libp2p/interface-peer-id'
1313

@@ -65,7 +65,7 @@ export function parseRFC3339 (time: string): Date {
6565
* Extracts a public key from the passed PeerId, falling
6666
* back to the pubKey embedded in the ipns record
6767
*/
68-
export const extractPublicKey = async (peerId: PeerId, entry: IpnsEntry): Promise<PublicKey> => {
68+
export const extractPublicKey = async (peerId: PeerId, entry: IPNSRecord): Promise<PublicKey> => {
6969
if (entry == null || peerId == null) {
7070
const error = new Error('one or more of the provided parameters are not defined')
7171

@@ -75,15 +75,15 @@ export const extractPublicKey = async (peerId: PeerId, entry: IpnsEntry): Promis
7575

7676
let pubKey: PublicKey | undefined
7777

78-
if (entry.pubKey != null) {
78+
if (entry.pb.pubKey != null) {
7979
try {
80-
pubKey = unmarshalPublicKey(entry.pubKey)
80+
pubKey = unmarshalPublicKey(entry.pb.pubKey)
8181
} catch (err) {
8282
log.error(err)
8383
throw err
8484
}
8585

86-
const otherId = await peerIdFromKeys(entry.pubKey)
86+
const otherId = await peerIdFromKeys(entry.pb.pubKey)
8787

8888
if (!otherId.equals(peerId)) {
8989
throw errCode(new Error('Embedded public key did not match PeerID'), ERRORS.ERR_INVALID_EMBEDDED_KEY)
@@ -117,11 +117,11 @@ export const ipnsEntryDataForV2Sig = (data: Uint8Array): Uint8Array => {
117117
return uint8ArrayConcat([entryData, data])
118118
}
119119

120-
export const marshal = (obj: IPNSEntry): Uint8Array => {
121-
return IpnsEntry.encode(obj)
120+
export const marshal = (obj: IPNSRecord): Uint8Array => {
121+
return IpnsEntry.encode(obj.pb)
122122
}
123123

124-
export const unmarshal = (buf: Uint8Array): IPNSEntry => {
124+
export const unmarshal = (buf: Uint8Array): IPNSRecord => {
125125
const message = IpnsEntry.decode(buf)
126126

127127
// protobufjs returns bigints as numbers
@@ -134,17 +134,7 @@ export const unmarshal = (buf: Uint8Array): IPNSEntry => {
134134
message.ttl = BigInt(message.ttl)
135135
}
136136

137-
return {
138-
value: message.value ?? new Uint8Array(0),
139-
signature: message.signature ?? new Uint8Array(0),
140-
validityType: message.validityType ?? IpnsEntry.ValidityType.EOL,
141-
validity: message.validity ?? new Uint8Array(0),
142-
sequence: message.sequence ?? 0n,
143-
pubKey: message.pubKey,
144-
ttl: message.ttl ?? undefined,
145-
signatureV2: message.signatureV2,
146-
data: message.data
147-
}
137+
return new IPNSRecord(message)
148138
}
149139

150140
export const peerIdToRoutingKey = (peerId: PeerId): Uint8Array => {
@@ -177,25 +167,3 @@ export const createCborData = (value: Uint8Array, validity: Uint8Array, validity
177167

178168
return cborg.encode(data)
179169
}
180-
181-
export const parseCborData = (buf: Uint8Array): IPNSEntryData => {
182-
const data = cborg.decode(buf)
183-
184-
if (data.ValidityType === 0) {
185-
data.ValidityType = IpnsEntry.ValidityType.EOL
186-
} else {
187-
throw errCode(new Error('Unknown validity type'), ERRORS.ERR_UNRECOGNIZED_VALIDITY)
188-
}
189-
190-
if (Number.isInteger(data.Sequence)) {
191-
// sequence must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range
192-
data.Sequence = BigInt(data.Sequence)
193-
}
194-
195-
if (Number.isInteger(data.TTL)) {
196-
// ttl must be a BigInt, but DAG-CBOR doesn't preserve this for Numbers within the safe-integer range
197-
data.TTL = BigInt(data.TTL)
198-
}
199-
200-
return data
201-
}

0 commit comments

Comments
 (0)