diff --git a/package.json b/package.json index 36fc5f9f9..abd9dec2f 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,5 @@ "type": "module", "workspaces": [ "packages/*" - ], - "overrides": { - "playwright-core": "1.55.1" - } + ] } diff --git a/packages/interface/package.json b/packages/interface/package.json index f49094734..8e956c416 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -75,6 +75,7 @@ "@libp2p/interface": "^3.2.0", "@multiformats/dns": "^1.0.13", "@multiformats/multiaddr": "^13.0.1", + "abort-error": "^1.0.2", "interface-blockstore": "^7.0.1", "interface-datastore": "^10.0.1", "multiformats": "^14.0.0", diff --git a/packages/interface/src/errors.ts b/packages/interface/src/errors.ts index 898c2e99c..655e935ee 100644 --- a/packages/interface/src/errors.ts +++ b/packages/interface/src/errors.ts @@ -42,3 +42,8 @@ export class InvalidCodecError extends Error { this.name = 'InvalidCodecError' } } + +export class UnknownCryptoError extends Error { + static name = 'UnknownCryptoError' + name = 'UnknownCryptoError' +} diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 3a854656e..854de7c83 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -15,21 +15,156 @@ */ import type { Blocks } from './blocks.ts' +import type { Keychain } from './keychain.ts' import type { Pins } from './pins.ts' import type { Routing } from './routing.ts' -import type { AbortOptions, ComponentLogger, Libp2p, Metrics, TypedEventEmitter } from '@libp2p/interface' +import type { ComponentLogger, Libp2p, Metrics, TypedEventEmitter } from '@libp2p/interface' import type { DNS } from '@multiformats/dns' +import type { AbortOptions } from 'abort-error' import type { Datastore } from 'interface-datastore' import type { BlockCodec, MultihashHasher } from 'multiformats' -import type { CID } from 'multiformats/cid' +import type { CID, MultihashDigest } from 'multiformats/cid' import type { ProgressEvent, ProgressOptions } from 'progress-events' export interface CodecLoader { - (code: Code): BlockCodec | Promise> + (code: Code, options?: AbortOptions): BlockCodec | Promise> } export interface HasherLoader { - (code: number): MultihashHasher | Promise + (code: number, options?: AbortOptions): MultihashHasher | Promise +} + +export interface CryptoKeyLoader { + (codeOrName: number | string, options?: AbortOptions): CryptoKeyImplementation | Promise +} + +export interface PublicKey { + /** + * The type of the crypto implementation, e.g. `Ed15519` + */ + type: string + + /** + * The code that is used as the `Type` field in the protobuf representation of + * the public/private keys + */ + code: number + + /** + * The raw public key + */ + raw: ArrayBuffer + + /** + * Return a MultihashDigest that represents this key + */ + toMultihash (): MultihashDigest + + /** + * Return the libp2p-key CID that represents this key + */ + toCID (): CID + + /** + * Verify the passed message against it's signature + */ + verify(message: Uint8Array, signature: Uint8Array, options?: AbortOptions): boolean | Promise +} + +export function isPublicKey (obj?: any): obj is PublicKey { + if (obj == null) { + return false + } + + return typeof obj.type === 'string' && typeof obj.code === 'number' && typeof obj.verify === 'function' +} + +export interface PrivateKey { + /** + * The type of the crypto implementation, e.g. `Ed15519` + */ + type: string + + /** + * The code that is used as the `Type` field in the protobuf representation of + * the public/private keys + */ + code: number + + /** + * The raw private key + */ + raw: ArrayBuffer + + /** + * The public key that corresponds to this private key + */ + publicKey: PublicKey + + /** + * Sign the passed message and return a signature + */ + sign(message: Uint8Array, options?: AbortOptions): Uint8Array | Promise> +} + +export function isPrivateKey (obj?: any): obj is PrivateKey { + if (obj == null) { + return false + } + + return typeof obj.type === 'string' && typeof obj.code === 'number' && typeof obj.sign === 'function' && isPublicKey(obj.publicKey) +} + +export interface CipherOptions extends AbortOptions { + iterations?: number + hash?: string + keyLength?: number + algorithm?: string +} + +export interface EncryptionResult { + salt: Uint8Array + iv: Uint8Array + cipherText: Uint8Array +} + +export interface Cipher { + encrypt(data: Uint8Array, options?: AbortOptions): Promise + decrypt(salt: Uint8Array, iv: Uint8Array, cipherText: Uint8Array, options?: CipherOptions): Promise> +} + +export interface CryptoKeyImplementation { + /** + * The type of the crypto implementation, e.g. `Ed15519` + */ + type: string + + /** + * The code that is used as the `Type` field in the protobuf representation of + * the public/private keys + */ + code: number + + /** + * Create a new private key + */ + createPrivateKey(options?: AbortOptions & Record): Promise + + /** + * Convert the passed bytes into a public key. The bytes come from the `.Data` + * field of a `PublicKey` protobuf message. + */ + publicKeyFromArray(key: ArrayBuffer | Uint8Array, options?: AbortOptions): PublicKey | Promise + + /** + * Convert a private key into a string suitable for storing in a datastore + */ + serialize (key: PrivateKey, cipher: Cipher, options?: AbortOptions): Promise + + /** + * Convert a string from a datastore into a private key + */ + deserialize (pem: string, cipher: Cipher, options?: AbortOptions): Promise } /** @@ -56,6 +191,11 @@ export interface Helia { */ events: TypedEventEmitter> + /** + * Secure storage for private keys + */ + keychain: Keychain + /** * Pinning operations for blocks in the blockstore */ @@ -111,6 +251,11 @@ export interface Helia { * the hasher is being fetched from the network. */ getHasher: HasherLoader + + /** + * Cryptography implementations securely sign and verify data + */ + getCryptoKey: CryptoKeyLoader } export type GcEvents = @@ -147,5 +292,6 @@ export interface HeliaEvents { export * from './blocks.ts' export * from './errors.ts' +export * from './keychain.ts' export * from './pins.ts' export * from './routing.ts' diff --git a/packages/interface/src/keychain.ts b/packages/interface/src/keychain.ts new file mode 100644 index 000000000..10e93c073 --- /dev/null +++ b/packages/interface/src/keychain.ts @@ -0,0 +1,107 @@ +import type { PrivateKey } from './index.ts' +import type { AbortOptions } from 'abort-error' + +export interface KeyInfo { + /** + * The hash of the key + */ + id: string + + /** + * The key name + */ + name: string + + /** + * The key type + */ + type?: 'Ed25519' | 'RSA' | string +} + +export interface Keychain { + /** + * Create a key of the passed type and store it under the specified name. A + * cryptography implementation must be configured for the key type. + */ + createKey (name: string, type: 'Ed25519' | 'RSA' | string, options?: AbortOptions & Record): Promise + + /** + * Import a new private key. + * + * The `type` parameter must match a supported cryptography implementation. + * + * The default supported key types are `Ed25519` and `RSA`, others may be + * added through configuration. + * + * @example + * + * ```TypeScript + * const key = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) + * const raw = await crypto.subtle.exportKey('raw', key) + * await helia.keychain.importKey('my-key', 'Ed25519', raw) + * ``` + */ + importKey(name: string, key: PrivateKey, options?: AbortOptions): Promise + + /** + * Export an existing private key. + * + * @example + * + * ```TypeScript + * const raw = await helia.exportKey('my-key') + * const key = await crypto.subtle.importKey('raw', raw, { + * name: 'Ed25519' + * }, true, ['sign', 'verify']) + * ``` + */ + exportKey(name: string, options?: AbortOptions): Promise + + /** + * Removes a key from the keychain. + * + * @example + * + * ```TypeScript + * await helia.keychain.removeKey('keyTest') + * ``` + */ + removeKey(name: string, options?: AbortOptions): Promise + + /** + * Rename a key in the keychain. This is done in a batch commit with rollback + * so errors thrown during the operation will not cause key loss. + * + * @example + * + * ```TypeScript + * await helia.keychain.renameKey('oldName', 'newName') + * ``` + */ + renameKey(oldName: string, newName: string, options?: AbortOptions): Promise + + /** + * List all the keys. + * + * @example + * + * ```TypeScript + * for await (const name of helia.keychain.listKeys()) { + * // ... + * } + * ``` + */ + listKeys(options?: AbortOptions): AsyncGenerator + + /** + * Re-encrypt all keys in the keychain using a crypto graphic key derived + * from the password + * + * @example + * + * ```TypeScript + * await helia.keychain.rotateKeychainPass('newPassword') + * ``` + */ + rotateKeychainPass(password: string, options?: AbortOptions): Promise +} diff --git a/packages/interop/src/fixtures/create-helia.browser.ts b/packages/interop/src/fixtures/create-helia.browser.ts index 66d3afc08..ac86bf525 100644 --- a/packages/interop/src/fixtures/create-helia.browser.ts +++ b/packages/interop/src/fixtures/create-helia.browser.ts @@ -1,5 +1,4 @@ import { bitswap } from '@helia/block-brokers' -import { ipnsValidator, ipnsSelector } from '@helia/ipns' import { kadDHT, removePublicAddressesMapper } from '@libp2p/kad-dht' import { webSockets } from '@libp2p/websockets' import { sha3512 } from '@multiformats/sha3' @@ -30,12 +29,6 @@ export async function createHeliaNode (libp2pOptions?: Libp2pOptions): Promise { }) const key = peerIdFromCID(CID.parse(res.name)) - // @ts-expect-error @libp2p/peer-id needs dep updates - const { cid: resolvedCid } = await name.resolve(key.toMultihash()) - expect(resolvedCid.toString()).to.equal(cid.toString()) + // @ts-expect-error @libp2p/peer-id needs new multiformats + const result = await last(name.resolve(key.toMultihash())) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid}`) }) }) diff --git a/packages/interop/src/ipns-pubsub.spec.ts b/packages/interop/src/ipns-pubsub.spec.ts index b2381501c..9242f8c77 100644 --- a/packages/interop/src/ipns-pubsub.spec.ts +++ b/packages/interop/src/ipns-pubsub.spec.ts @@ -174,7 +174,7 @@ keyTypes.filter(keyType => keyType !== 'RSA').forEach(keyType => { await waitFor(async () => { try { // @ts-expect-error @libp2p/peer-id needs dep updates - resolveResult = await name.resolve(peerId.toMultihash()) + resolveResult = await last(name.resolve(peerId.toMultihash())) return true } catch { @@ -189,7 +189,7 @@ keyTypes.filter(keyType => keyType !== 'RSA').forEach(keyType => { throw new Error('Failed to resolve CID') } - expect(resolveResult.cid.toString()).to.equal(cid.toString()) + expect(uint8ArrayToString(resolveResult.record.value)).to.equal(`/ipfs/${cid}`) }) }) }) diff --git a/packages/interop/src/ipns.spec.ts b/packages/interop/src/ipns.spec.ts index bc09a9e1d..d188dd48e 100644 --- a/packages/interop/src/ipns.spec.ts +++ b/packages/interop/src/ipns.spec.ts @@ -7,6 +7,7 @@ import last from 'it-last' import { CID } from 'multiformats/cid' import * as raw from 'multiformats/codecs/raw' import { sha256 } from 'multiformats/hashes/sha2' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { isElectronMain } from 'wherearewe' import { connect } from './fixtures/connect.ts' import { createHeliaNode } from './fixtures/create-helia.ts' @@ -14,8 +15,9 @@ import { createKuboNode } from './fixtures/create-kubo.ts' import { sortClosestPeers } from './fixtures/create-peer-ids.ts' import { keyTypes } from './fixtures/key-types.ts' import { waitFor } from './fixtures/wait-for.ts' +import type { PrivateKey } from '@helia/interface' import type { IPNS } from '@helia/ipns' -import type { Libp2p, PrivateKey } from '@libp2p/interface' +import type { Libp2p } from '@libp2p/interface' import type { DefaultLibp2pServices, Helia } from 'helia' import type { KuboNode } from 'ipfsd-ctl' @@ -47,11 +49,9 @@ keyTypes.forEach(type => { // find a PeerId that is KAD-closer to the resolver than the publisher when used as an IPNS key while (true) { if (type === 'Ed25519') { - key = await generateKeyPair('Ed25519') - } else if (type === 'secp256k1') { - key = await generateKeyPair('secp256k1') + key = await helia.keychain.createKey('test-key', 'Ed25519') } else { - key = await generateKeyPair('RSA', 2048) + key = await helia.keychain.createKey('test-key', 'RSA') } // @ts-expect-error @libp2p/crypto needs dep updates @@ -175,9 +175,14 @@ keyTypes.forEach(type => { ttl: '1h' }) - const { cid: resolvedCid, record } = await name.resolve(key.publicKey) - expect(resolvedCid.toString()).to.equal(cid.toString()) - expect(record.ttl).to.equal(oneHourNS) + const result = await last(name.resolve(key.publicKey)) + + if (result == null) { + throw new Error('No result found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid}`) + expect(result.record.ttl).to.equal(oneHourNS) }) }) }) diff --git a/packages/ipns/README.md b/packages/ipns/README.md index 1515785e9..781d46ed9 100644 --- a/packages/ipns/README.md +++ b/packages/ipns/README.md @@ -52,9 +52,9 @@ const cid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3, 4])) const { publicKey } = await name.publish('key-1', cid) // resolve the name -const result = await name.resolve(publicKey) - -console.info(result.cid, result.path) +for await (const result of name.resolve(publicKey)) { + console.info(new TextDecoder().decode(result.record.value)) // /ipfs/QmFoo +} ``` ## Example - Publishing a recursive record @@ -82,8 +82,9 @@ const { publicKey } = await name.publish('key-1', cid) const { publicKey: recursivePublicKey } = await name.publish('key-2', publicKey) // resolve the name recursively - it resolves until a CID is found -const result = await name.resolve(recursivePublicKey) -console.info(result.cid.toString() === cid.toString()) // true +for await (const result of name.resolve(recursivePublicKey)) { + console.info(new TextDecoder().decode(result.record.value)) // /ipfs/QmFoo../foo.txt +} ``` ## Example - Publishing a record with a path @@ -111,9 +112,9 @@ const finalDirCid = await fs.cp(fileCid, dirCid, '/foo.txt') const { publicKey } = await name.publish('key-1', `/ipfs/${finalDirCid}/foo.txt`) // resolve the name -const result = await name.resolve(publicKey) - -console.info(result.cid, result.path) // QmFoo.. 'foo.txt' +for await (const result of name.resolve(publicKey)) { + console.info(new TextDecoder().decode(result.record.value)) // /ipfs/QmFoo../foo.txt +} ``` ## Example - Using custom PubSub router @@ -164,47 +165,9 @@ const cid = await fs.addBytes(Uint8Array.from([0, 1, 2, 3, 4])) const { publicKey } = await name.publish('key-1', cid) // resolve the name -const result = await name.resolve(publicKey) -``` - -## Example - Republishing an existing IPNS record - -It is sometimes useful to be able to republish an existing IPNS record -without needing the private key. This allows you to extend the availability -of a record that was created elsewhere. - -```TypeScript -import { createHelia } from 'helia' -import { ipns, ipnsValidator } from '@helia/ipns' -import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' -import { CID } from 'multiformats/cid' -import { multihashToIPNSRoutingKey, marshalIPNSRecord } from 'ipns' -import { defaultLogger } from '@libp2p/logger' - -const helia = await createHelia() -const name = ipns(helia) - -const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' -const parsedCid: CID = CID.parse(ipnsName) -const delegatedClient = delegatedRoutingV1HttpApiClient({ - url: 'https://delegated-ipfs.dev' -})({ - logger: defaultLogger() -}) -const record = await delegatedClient.getIPNS(parsedCid) - -const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash) -const marshaledRecord = marshalIPNSRecord(record) - -// validate that they key corresponds to the record -await ipnsValidator(routingKey, marshaledRecord) - -// publish record to routing -await Promise.all( - name.routers.map(async r => { - await r.put(routingKey, marshaledRecord) - }) -) +for await (const result of name.resolve(publicKey)) { + console.info(new TextDecoder().decode(result.record.value)) +} ``` # Install diff --git a/packages/ipns/package.json b/packages/ipns/package.json index 25d2eec27..c40231ff1 100644 --- a/packages/ipns/package.json +++ b/packages/ipns/package.json @@ -66,7 +66,7 @@ "doc-check": "aegir doc-check", "build": "aegir build", "docs": "aegir docs", - "generate": "protons ./src/pb/metadata.proto", + "generate": "protons ./src/pb/*.proto", "test": "aegir test", "test:chrome": "aegir test -t browser --cov", "test:chrome-webworker": "aegir test -t webworker", @@ -77,31 +77,31 @@ }, "dependencies": { "@helia/interface": "^6.2.1", - "@libp2p/crypto": "^5.1.15", "@libp2p/fetch": "^4.1.0", "@libp2p/interface": "^3.2.0", "@libp2p/kad-dht": "^16.2.0", - "@libp2p/keychain": "^6.0.12", "@libp2p/logger": "^6.2.4", "@libp2p/peer-collections": "^7.0.15", "@libp2p/utils": "^7.0.15", + "abort-error": "^1.0.2", "any-signal": "^4.2.0", + "cborg": "^5.1.1", "delay": "^7.0.0", "interface-datastore": "^10.0.1", - "ipns": "^11.0.0", "multiformats": "^14.0.0", "progress-events": "^1.1.0", "protons-runtime": "^7.0.0", "race-signal": "^2.0.0", + "timestamp-nano": "^1.0.1", "uint8arraylist": "^3.0.2", "uint8arrays": "^6.1.1" }, "devDependencies": { - "@libp2p/crypto": "^5.1.15", - "@libp2p/peer-id": "^6.0.6", + "@helia/utils": "^2.5.2", "aegir": "^48.0.4", "datastore-core": "^12.0.1", "it-drain": "^3.0.12", + "it-last": "^3.0.11", "protons": "^9.0.1", "sinon": "^22.0.0", "sinon-ts": "^2.0.0" diff --git a/packages/ipns/src/errors.ts b/packages/ipns/src/errors.ts index bb7426fb9..5bedf2eb8 100644 --- a/packages/ipns/src/errors.ts +++ b/packages/ipns/src/errors.ts @@ -1,49 +1,64 @@ export class RecordsFailedValidationError extends Error { static name = 'RecordsFailedValidationError' - - constructor (message = 'Records failed validation') { - super(message) - this.name = 'RecordsFailedValidationError' - } + name = 'RecordsFailedValidationError' } export class UnsupportedMultibasePrefixError extends Error { static name = 'UnsupportedMultibasePrefixError' - - constructor (message = 'Unsupported multibase prefix') { - super(message) - this.name = 'UnsupportedMultibasePrefixError' - } + name = 'UnsupportedMultibasePrefixError' } export class UnsupportedMultihashCodecError extends Error { static name = 'UnsupportedMultihashCodecError' - - constructor (message = 'Unsupported multihash codec') { - super(message) - this.name = 'UnsupportedMultihashCodecError' - } + name = 'UnsupportedMultihashCodecError' } export class InvalidValueError extends Error { static name = 'InvalidValueError' - - constructor (message = 'Invalid value') { - super(message) - this.name = 'InvalidValueError' - } + name = 'InvalidValueError' } export class InvalidTopicError extends Error { static name = 'InvalidTopicError' - - constructor (message = 'Invalid topic') { - super(message) - this.name = 'InvalidTopicError' - } + name = 'InvalidTopicError' } export class RecordNotFoundError extends Error { static name = 'RecordNotFoundError' name = 'RecordNotFoundError' } + +export class SignatureCreationError extends Error { + static name = 'SignatureCreationError' + name = 'SignatureCreationError' +} + +export class SignatureVerificationError extends Error { + static name = 'SignatureVerificationError' + name = 'SignatureVerificationError' +} + +export class RecordExpiredError extends Error { + static name = 'RecordExpiredError' + name = 'RecordExpiredError' +} + +export class UnsupportedValidityError extends Error { + static name = 'UnsupportedValidityError' + name = 'UnsupportedValidityError' +} + +export class RecordTooLargeError extends Error { + static name = 'RecordTooLargeError' + name = 'RecordTooLargeError' +} + +export class InvalidRecordDataError extends Error { + static name = 'InvalidRecordDataError' + name = 'InvalidRecordDataError' +} + +export class InvalidEmbeddedPublicKeyError extends Error { + static name = 'InvalidEmbeddedPublicKeyError' + name = 'InvalidEmbeddedPublicKeyError' +} diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index 4f9ae0a92..d8b2aedf8 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -23,9 +23,9 @@ * const { publicKey } = await name.publish('key-1', cid) * * // resolve the name - * const result = await name.resolve(publicKey) - * - * console.info(result.cid, result.path) + * for await (const result of name.resolve(publicKey)) { + * console.info(new TextDecoder().decode(result.record.value)) // /ipfs/QmFoo + * } * ``` * * @example Publishing a recursive record @@ -53,8 +53,9 @@ * const { publicKey: recursivePublicKey } = await name.publish('key-2', publicKey) * * // resolve the name recursively - it resolves until a CID is found - * const result = await name.resolve(recursivePublicKey) - * console.info(result.cid.toString() === cid.toString()) // true + * for await (const result of name.resolve(recursivePublicKey)) { + * console.info(new TextDecoder().decode(result.record.value)) // /ipfs/QmFoo../foo.txt + * } * ``` * * @example Publishing a record with a path @@ -82,9 +83,9 @@ * const { publicKey } = await name.publish('key-1', `/ipfs/${finalDirCid}/foo.txt`) * * // resolve the name - * const result = await name.resolve(publicKey) - * - * console.info(result.cid, result.path) // QmFoo.. 'foo.txt' + * for await (const result of name.resolve(publicKey)) { + * console.info(new TextDecoder().decode(result.record.value)) // /ipfs/QmFoo../foo.txt + * } * ``` * * @example Using custom PubSub router @@ -135,51 +136,12 @@ * const { publicKey } = await name.publish('key-1', cid) * * // resolve the name - * const result = await name.resolve(publicKey) - * ``` - * - * @example Republishing an existing IPNS record - * - * It is sometimes useful to be able to republish an existing IPNS record - * without needing the private key. This allows you to extend the availability - * of a record that was created elsewhere. - * - * ```TypeScript - * import { createHelia } from 'helia' - * import { ipns, ipnsValidator } from '@helia/ipns' - * import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' - * import { CID } from 'multiformats/cid' - * import { multihashToIPNSRoutingKey, marshalIPNSRecord } from 'ipns' - * import { defaultLogger } from '@libp2p/logger' - * - * const helia = await createHelia() - * const name = ipns(helia) - * - * const ipnsName = 'k51qzi5uqu5dktsyfv7xz8h631pri4ct7osmb43nibxiojpttxzoft6hdyyzg4' - * const parsedCid: CID = CID.parse(ipnsName) - * const delegatedClient = delegatedRoutingV1HttpApiClient({ - * url: 'https://delegated-ipfs.dev' - * })({ - * logger: defaultLogger() - * }) - * const record = await delegatedClient.getIPNS(parsedCid) - * - * const routingKey = multihashToIPNSRoutingKey(parsedCid.multihash) - * const marshaledRecord = marshalIPNSRecord(record) - * - * // validate that they key corresponds to the record - * await ipnsValidator(routingKey, marshaledRecord) - * - * // publish record to routing - * await Promise.all( - * name.routers.map(async r => { - * await r.put(routingKey, marshaledRecord) - * }) - * ) + * for await (const result of name.resolve(publicKey)) { + * console.info(new TextDecoder().decode(result.record.value)) + * } * ``` */ -import { ipnsValidator } from 'ipns/validator' import { CID } from 'multiformats/cid' import { IPNSResolver as IPNSResolverClass } from './ipns/resolver.ts' import { IPNS as IPNSClass } from './ipns.ts' @@ -187,12 +149,12 @@ import { localStore } from './local-store.ts' import { helia } from './routing/index.ts' import { localStoreRouting } from './routing/local-store.ts' import type { IPNSResolverComponents } from './ipns/resolver.ts' +import type { IPNSRecord } from './records.ts' import type { IPNSRouting, IPNSRoutingProgressEvents } from './routing/index.ts' -import type { Routing, HeliaEvents } from '@helia/interface' -import type { AbortOptions, ComponentLogger, Libp2p, PeerId, PublicKey, TypedEventEmitter } from '@libp2p/interface' -import type { Keychain } from '@libp2p/keychain' +import type { Routing, HeliaEvents, CryptoKeyLoader, Keychain, PublicKey } from '@helia/interface' +import type { ComponentLogger, TypedEventEmitter } from '@libp2p/interface' +import type { AbortOptions } from 'abort-error' import type { Datastore } from 'interface-datastore' -import type { IPNSRecord } from 'ipns' import type { MultihashDigest } from 'multiformats/hashes/interface' import type { ProgressEvent, ProgressOptions } from 'progress-events' @@ -214,23 +176,31 @@ export type DatastoreProgressEvents = export interface PublishOptions extends AbortOptions, ProgressOptions { /** - * Time duration of the signature validity in ms (default: 48hrs) + * Time duration of the signature validity in ms + * + * @default 172_800_000 */ lifetime?: number /** - * Only publish to a local datastore (default: false) + * Only publish to a local datastore + * + * @default false */ offline?: boolean /** * By default a IPNS V1 and a V2 signature is added to every record. Pass - * false here to only add a V2 signature. (default: true) + * false here to only add a V2 signature. + * + * @default true */ v1Compatible?: boolean /** - * The TTL of the record in ms (default: 5 minutes) + * The TTL of the record in ms + * + * @default 300_000 */ ttl?: number } @@ -257,32 +227,25 @@ export interface ResolveOptions extends AbortOptions, ProgressOptions | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: ResolveOptions): Promise + resolve(key: CID | MultihashDigest, options?: ResolveOptions): AsyncGenerator } export interface IPNS { @@ -308,12 +271,14 @@ export interface IPNS { * Creates and publishes an IPNS record that will resolve the passed value * signed by a key stored in the libp2p keychain under the passed key name. * + * If the key does not exist, a new Ed25519 key will be created. To use a + * different key types, ensure the key is created and stored in the keychain + * before invoking this method. + * * It is possible to create a recursive IPNS record by passing: * - * - A PeerId, - * - A PublicKey - * - A CID with the libp2p-key codec and Identity or SHA256 hash algorithms - * - A Multihash with the Identity or SHA256 hash algorithms + * - A CID with the libp2p-key codec + * - A Multihash * - A string IPNS key (e.g. `/ipns/Qmfoo`) * * @example @@ -332,39 +297,38 @@ export interface IPNS { * console.info(result) // { answer: ... } * ``` */ - publish(keyName: string, value: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId | string, options?: PublishOptions): Promise + publish(keyName: string, value: CID | PublicKey | MultihashDigest | string, options?: PublishOptions): Promise /** - * Accepts a libp2p public key, a CID with the libp2p-key codec and either the - * identity hash (for Ed25519 and secp256k1 public keys) or a SHA256 hash (for - * RSA public keys), or the multihash of a libp2p-key encoded CID, or a - * Ed25519, secp256k1 or RSA PeerId and recursively resolves the IPNS record - * corresponding to that key until a value is found. + * Accepts a multihash of a public key, a libp2p-key CID containing the + * multihash of a public key, or an IPNS name in it's string representation + * and recursively resolves IPNS records until a non-recursive record is found + * (e.g. the value can be parsed as a string that does not start with + * `/ipns/`). */ - resolve(key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options?: ResolveOptions): Promise + resolve(name: CID | PublicKey | MultihashDigest | string, options?: ResolveOptions): AsyncGenerator /** * Stop republishing of an IPNS record * - * This will delete the last signed IPNS record from the datastore, but the - * key will remain in the keychain. + * This will delete the last signed IPNS record from the datastore. * * Note that the record may still be resolved by other peers until it expires - * or is no longer valid. + * or is otherwise no longer valid. */ unpublish(keyName: string, options?: AbortOptions): Promise } export type { IPNSRouting } from './routing/index.ts' - -export type { IPNSRecord } from 'ipns' +export type { IPNSRecord } from './records.ts' export interface IPNSComponents { datastore: Datastore routing: Routing logger: ComponentLogger - libp2p: Libp2p<{ keychain: Keychain }> + keychain: Keychain events: TypedEventEmitter // Helia event bus + getCryptoKey: CryptoKeyLoader } export interface IPNSOptions { @@ -414,5 +378,4 @@ export function ipnsResolver (components: IPNSResolverComponents, options: IPNSR }) } -export { ipnsValidator, type IPNSRoutingProgressEvents } -export { ipnsSelector } from 'ipns/selector' +export type { IPNSRoutingProgressEvents } diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index d03e2cad7..6a371f353 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -1,14 +1,20 @@ import { CID } from 'multiformats/cid' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { IPNSPublisher } from './ipns/publisher.ts' import { IPNSRepublisher } from './ipns/republisher.ts' import { IPNSResolver } from './ipns/resolver.ts' import { localStore } from './local-store.ts' import { helia } from './routing/helia.ts' import { localStoreRouting } from './routing/local-store.ts' -import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, IPNSPublishResult, IPNSResolveResult, PublishOptions, ResolveOptions } from './index.ts' +import { ipnsSelector } from './selector.ts' +import { normalizeKey, normalizeValue, unmarshalIPNSRecord } from './utils.ts' +import { ipnsValidator } from './validator.ts' +import type { IPNSComponents, IPNS as IPNSInterface, IPNSOptions, PublishResult, PublishOptions, ResolveOptions, ResolveResult } from './index.ts' import type { LocalStore } from './local-store.ts' import type { IPNSRouting } from './routing/index.ts' -import type { AbortOptions, PeerId, PublicKey, Startable } from '@libp2p/interface' +import type { PublicKey } from '@helia/interface' +import type { AbortOptions, Libp2p, Startable } from '@libp2p/interface' +import type { ValidateFn, SelectFn } from '@libp2p/kad-dht' import type { MultihashDigest } from 'multiformats/hashes/interface' export class IPNS implements IPNSInterface, Startable { @@ -17,11 +23,13 @@ export class IPNS implements IPNSInterface, Startable { private readonly republisher: IPNSRepublisher private readonly resolver: IPNSResolver private readonly localStore: LocalStore + private readonly components: IPNSComponents private started: boolean constructor (components: IPNSComponents, init: IPNSOptions = {}) { this.localStore = localStore(components.datastore, components.logger.forComponent('helia:ipns:local-store')) - this.started = components.libp2p.status === 'started' + this.components = components + this.started = false this.routers = [ localStoreRouting(this.localStore), @@ -62,6 +70,26 @@ export class IPNS implements IPNSInterface, Startable { this.started = true this.republisher.start() + + for (const component of Object.values(this.components)) { + if (isLibp2p(component)) { + for (const service of Object.values(component.services)) { + if (isKadDHT(service)) { + // @ts-expect-error https://github.com/libp2p/js-libp2p/pull/3506 + service.selectors.ipns = async (key: Uint8Array, values: Uint8Array[]): Promise => { + const records = await Promise.all(values.map(buf => unmarshalIPNSRecord(key, buf, this.components.getCryptoKey))) + + return ipnsSelector(key, records) + } + + service.validators.ipns = async (key: Uint8Array, value: Uint8Array): Promise => { + const record = await unmarshalIPNSRecord(key, value, this.components.getCryptoKey) + await ipnsValidator(record) + } + } + } + } + } } stop (): void { @@ -73,15 +101,28 @@ export class IPNS implements IPNSInterface, Startable { this.republisher.stop() } - async publish (keyName: string, value: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId | string, options: PublishOptions = {}): Promise { - return this.publisher.publish(keyName, value, options) + async publish (keyName: string, value: PublicKey | CID | MultihashDigest | string, options: PublishOptions = {}): Promise { + const string = normalizeValue(value) + const bytes = uint8ArrayFromString(string) + + return this.publisher.publish(keyName, bytes, options) } - async resolve (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: ResolveOptions = {}): Promise { - return this.resolver.resolve(key, options) + async * resolve (key: PublicKey | CID | MultihashDigest | string, options: ResolveOptions = {}): AsyncGenerator { + const { digest } = normalizeKey(key) + + yield * this.resolver.resolve(digest, options) } async unpublish (keyName: string, options?: AbortOptions): Promise { return this.publisher.unpublish(keyName, options) } } + +function isLibp2p (obj?: any): obj is Libp2p { + return obj?.services != null +} + +function isKadDHT (obj?: any): obj is { validators: Record, selectors: Record } { + return obj?.validators != null && obj?.selectors != null +} diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index 1ce05a2a5..a8114b892 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -1,21 +1,20 @@ -import { generateKeyPair } from '@libp2p/crypto/keys' -import { isPeerId } from '@libp2p/interface' -import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' -import { CID } from 'multiformats/cid' +import { base58btc } from 'multiformats/bases/base58' import { CustomProgressEvent } from 'progress-events' import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts' -import type { IPNSPublishResult, PublishOptions } from '../index.ts' +import { createIPNSRecord } from '../records.ts' +import { marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from '../utils.ts' +import type { PublishResult, PublishOptions } from '../index.ts' import type { LocalStore } from '../local-store.ts' import type { IPNSRouting } from '../routing/index.ts' -import type { AbortOptions, ComponentLogger, Libp2p, PeerId, PrivateKey, PublicKey } from '@libp2p/interface' -import type { Keychain } from '@libp2p/keychain' +import type { CryptoKeyLoader, Keychain, PrivateKey } from '@helia/interface' +import type { AbortOptions, ComponentLogger } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' -import type { MultihashDigest } from 'multiformats/hashes/interface' export interface IPNSPublisherComponents { datastore: Datastore logger: ComponentLogger - libp2p: Libp2p<{ keychain: Keychain }> + keychain: Keychain + getCryptoKey: CryptoKeyLoader } export interface IPNSPublisherInit { @@ -27,37 +26,33 @@ export class IPNSPublisher { public readonly routers: IPNSRouting[] private readonly localStore: LocalStore private readonly keychain: Keychain + private readonly getCryptoKey: CryptoKeyLoader constructor (components: IPNSPublisherComponents, init: IPNSPublisherInit) { - this.keychain = components.libp2p.services.keychain + this.keychain = components.keychain this.localStore = init.localStore this.routers = init.routers + this.getCryptoKey = components.getCryptoKey } - async publish (keyName: string, value: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId | string, options: PublishOptions = {}): Promise { + async publish (keyName: string, value: Uint8Array, options: PublishOptions = {}): Promise { try { - const privKey = await this.#loadOrCreateKey(keyName) + const key = await this.#loadOrCreateKey(keyName, options) + const digest = key.publicKey.toMultihash() + const routingKey = multihashToIPNSRoutingKey(digest) let sequenceNumber = 1n - // @ts-expect-error @libp2p/crypto needs new multiformats - const routingKey = multihashToIPNSRoutingKey(privKey.publicKey.toMultihash()) if (await this.localStore.has(routingKey, options)) { // if we have published under this key before, increment the sequence number const { record } = await this.localStore.get(routingKey, options) - const existingRecord = unmarshalIPNSRecord(record) + const existingRecord = await unmarshalIPNSRecord(routingKey, record, this.getCryptoKey, options) sequenceNumber = existingRecord.sequence + 1n } - if (isPeerId(value)) { - // @ts-expect-error @libp2p/peer-id needs new multiformats - value = value.toCID() - } - // convert ttl from milliseconds to nanoseconds as createIPNSRecord expects const ttlNs = options.ttl != null ? BigInt(options.ttl) * 1_000_000n : DEFAULT_TTL_NS const lifetime = options.lifetime ?? DEFAULT_LIFETIME_MS - // @ts-expect-error @libp2p/peer-id needs new multiformats - const record = await createIPNSRecord(privKey, value, sequenceNumber, lifetime, { ...options, ttlNs }) + const record = await createIPNSRecord(key, value, sequenceNumber, lifetime, { ...options, ttlNs }) const marshaledRecord = marshalIPNSRecord(record) if (options.offline === true) { @@ -72,7 +67,8 @@ export class IPNSPublisher { return { record, - publicKey: privKey.publicKey + name: `/ipns/${base58btc.encode(digest.bytes)}`, + publicKey: record.publicKey } } catch (err: any) { options.onProgress?.(new CustomProgressEvent('ipns:publish:error', err)) @@ -80,21 +76,26 @@ export class IPNSPublisher { } } - async #loadOrCreateKey (keyName: string): Promise { + /** + * Create the private key if it is not in the keychain already, defaulting to + * Ed25519 keys + */ + async #loadOrCreateKey (keyName: string, options?: AbortOptions): Promise { try { - return await this.keychain.exportKey(keyName) + return await this.keychain.exportKey(keyName, options) } catch (err: any) { - // If no named key found in keychain, generate and import - const privKey = await generateKeyPair('Ed25519') - await this.keychain.importKey(keyName, privKey) - return privKey + if (err.name === 'NotFoundError') { + // create a new key + return this.keychain.createKey(keyName, 'Ed25519', options) + } else { + throw err + } } } async unpublish (keyName: string, options?: AbortOptions): Promise { - const { publicKey } = await this.keychain.exportKey(keyName) - const digest = publicKey.toMultihash() - // @ts-expect-error @libp2p/peer-id needs new multiformats + const key = await this.keychain.exportKey(keyName, options) + const digest = key.publicKey.toMultihash() const routingKey = multihashToIPNSRoutingKey(digest) await this.localStore.delete(routingKey, options) } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index 4d95c43a2..da4374bb1 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -1,17 +1,19 @@ import { Queue, repeatingTask } from '@libp2p/utils' -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord } from 'ipns' import { DEFAULT_REPUBLISH_CONCURRENCY, DEFAULT_REPUBLISH_INTERVAL_MS, DEFAULT_TTL_NS } from '../constants.ts' +import { createIPNSRecord } from '../records.ts' +import { marshalIPNSRecord, unmarshalIPNSRecord } from '../utils.ts' import { shouldRepublish } from '../utils.ts' +import type { IPNSRecord } from '../index.ts' import type { LocalStore } from '../local-store.ts' import type { IPNSRouting } from '../routing/index.ts' -import type { AbortOptions, ComponentLogger, Libp2p, Logger, PrivateKey } from '@libp2p/interface' -import type { Keychain } from '@libp2p/keychain' +import type { CryptoKeyLoader, Keychain, PrivateKey } from '@helia/interface' +import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface' import type { RepeatingTask } from '@libp2p/utils' -import type { IPNSRecord } from 'ipns' export interface IPNSRepublisherComponents { logger: ComponentLogger - libp2p: Libp2p<{ keychain: Keychain }> + keychain: Keychain + getCryptoKey: CryptoKeyLoader } export interface IPNSRepublisherInit { @@ -27,15 +29,17 @@ export class IPNSRepublisher { private readonly republishTask: RepeatingTask private readonly log: Logger private readonly keychain: Keychain + private readonly getCryptoKey: CryptoKeyLoader private started: boolean = false private readonly republishConcurrency: number constructor (components: IPNSRepublisherComponents, init: IPNSRepublisherInit) { this.log = components.logger.forComponent('helia:ipns') this.localStore = init.localStore - this.keychain = components.libp2p.services.keychain + this.keychain = components.keychain + this.getCryptoKey = components.getCryptoKey this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY - this.started = components.libp2p.status === 'started' + this.started = false this.routers = init.routers ?? [] this.republishTask = repeatingTask(this.#republish.bind(this), init.republishInterval ?? DEFAULT_REPUBLISH_INTERVAL_MS, { @@ -78,18 +82,21 @@ export class IPNSRepublisher { try { const recordsToRepublish: Array<{ routingKey: Uint8Array, record: IPNSRecord }> = [] + let listed = 0 // Find all records using the localStore.list method for await (const { routingKey, record, metadata, created } of this.localStore.list(options)) { + listed++ + if (metadata == null) { // Skip if no metadata is found from before we started // storing metadata or for records republished without a key - this.log(`no metadata found for record ${routingKey.toString()}, skipping`) + this.log('no metadata found for record %b, skipping', routingKey) continue } let ipnsRecord: IPNSRecord try { - ipnsRecord = unmarshalIPNSRecord(record) + ipnsRecord = await unmarshalIPNSRecord(routingKey, record, this.getCryptoKey, options) } catch (err: any) { this.log.error('error unmarshaling record - %e', err) continue @@ -97,7 +104,7 @@ export class IPNSRepublisher { // Only republish records that are within the DHT or record expiry threshold if (!shouldRepublish(ipnsRecord, created)) { - this.log.trace(`skipping record ${routingKey.toString()}within republish threshold`) + this.log.trace('skipping record %b within republish threshold', routingKey) continue } const sequenceNumber = ipnsRecord.sequence + 1n @@ -110,16 +117,23 @@ export class IPNSRepublisher { this.log.error('missing key %s, skipping republishing record - %e', metadata.keyName, err) continue } + try { - const updatedRecord = await createIPNSRecord(privKey, ipnsRecord.value, sequenceNumber, metadata.lifetime, { ...options, ttlNs }) - recordsToRepublish.push({ routingKey, record: updatedRecord }) + const updatedRecord = await createIPNSRecord(privKey, ipnsRecord.value, sequenceNumber, metadata.lifetime, { + ...options, + ttlNs + }) + recordsToRepublish.push({ + routingKey, + record: updatedRecord + }) } catch (err: any) { this.log.error('error creating updated IPNS record for %s - %e', routingKey, err) continue } } - this.log(`found ${recordsToRepublish.length} records to republish`) + this.log(`found ${recordsToRepublish.length}/${listed} records to republish`) // Republish each record for (const { routingKey, record } of recordsToRepublish) { diff --git a/packages/ipns/src/ipns/resolver.ts b/packages/ipns/src/ipns/resolver.ts index 3760b7b66..d9c5adaf5 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -1,33 +1,22 @@ -import { isPeerId, isPublicKey } from '@libp2p/interface' -import { multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' -import { ipnsSelector } from 'ipns/selector' -import { ipnsValidator } from 'ipns/validator' -import { base36 } from 'multiformats/bases/base36' -import { base58btc } from 'multiformats/bases/base58' -import { CID } from 'multiformats/cid' -import * as Digest from 'multiformats/hashes/digest' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DEFAULT_TTL_NS } from '../constants.ts' -import { InvalidValueError, RecordNotFoundError, RecordsFailedValidationError, UnsupportedMultibasePrefixError, UnsupportedMultihashCodecError } from '../errors.ts' -import { isCodec, IDENTITY_CODEC, SHA2_256_CODEC, isLibp2pCID } from '../utils.ts' -import type { IPNSResolveResult, ResolveOptions, ResolveResult } from '../index.ts' +import { RecordNotFoundError, RecordsFailedValidationError } from '../errors.ts' +import { ipnsSelector } from '../selector.ts' +import { multihashToIPNSRoutingKey, unmarshalIPNSRecord, normalizeKey, IPNS_STRING_PREFIX } from '../utils.ts' +import { ipnsValidator } from '../validator.ts' +import type { IPNSRecord, ResolveOptions, ResolveResult } from '../index.ts' import type { LocalStore } from '../local-store.ts' import type { IPNSRouting } from '../routing/index.ts' -import type { Routing } from '@helia/interface' -import type { ComponentLogger, Logger, PeerId, PublicKey } from '@libp2p/interface' +import type { Routing, CryptoKeyLoader } from '@helia/interface' +import type { ComponentLogger, Logger } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' -import type { IPNSRecord } from 'ipns' -import type { MultibaseDecoder } from 'multiformats/bases/interface' import type { MultihashDigest } from 'multiformats/hashes/interface' -const bases: Record> = { - [base36.prefix]: base36, - [base58btc.prefix]: base58btc -} - export interface IPNSResolverComponents { datastore: Datastore routing: Routing logger: ComponentLogger + getCryptoKey: CryptoKeyLoader } export interface IPNResolverInit { @@ -39,81 +28,39 @@ export class IPNSResolver { public readonly routers: IPNSRouting[] private readonly localStore: LocalStore private readonly log: Logger + private getCryptoKey: CryptoKeyLoader constructor (components: IPNSResolverComponents, init: IPNResolverInit) { this.log = components.logger.forComponent('helia:ipns') this.localStore = init.localStore this.routers = init.routers + this.getCryptoKey = components.getCryptoKey } - async resolve (key: CID | PublicKey | MultihashDigest<0x00 | 0x12> | PeerId, options: ResolveOptions = {}): Promise { - const digest = isPublicKey(key) || isPeerId(key) ? key.toMultihash() : isLibp2pCID(key) ? key.multihash : key - // @ts-expect-error @libp2p/peer-id needs new multiformats - const routingKey = multihashToIPNSRoutingKey(digest) - const record = await this.#findIpnsRecord(routingKey, options) - - return { - ...(await this.#resolve(record.value, options)), - record - } - } - - async #resolve (ipfsPath: string, options: ResolveOptions = {}): Promise { - const parts = ipfsPath.split('/') - try { - const scheme = parts[1] - - if (scheme === 'ipns') { - const str = parts[2] - const prefix = str.substring(0, 1) - let buf: Uint8Array | undefined - - if (prefix === '1' || prefix === 'Q') { - buf = base58btc.decode(`z${str}`) - } else if (bases[prefix] != null) { - buf = bases[prefix].decode(str) - } else { - throw new UnsupportedMultibasePrefixError(`Unsupported multibase prefix "${prefix}"`) - } - - let digest: MultihashDigest - - try { - digest = Digest.decode(buf) - } catch { - digest = CID.decode(buf).multihash - } + async * resolve (key: MultihashDigest, options: ResolveOptions = {}): AsyncGenerator { + let { digest } = normalizeKey(key) - if (!isCodec(digest, IDENTITY_CODEC) && !isCodec(digest, SHA2_256_CODEC)) { - throw new UnsupportedMultihashCodecError(`Unsupported multihash codec "${digest.code}"`) - } + while (true) { + const routingKey = multihashToIPNSRoutingKey(digest) + const record = await this.#findIpnsRecord(routingKey, options) - const { cid } = await this.resolve(digest, options) - const path = parts.slice(3).join('/') + yield { + record + } - return { - cid, - path: path === '' ? undefined : path - } - } else if (scheme === 'ipfs') { - const cid = CID.parse(parts[2]) - const path = parts.slice(3).join('/') + const value = uint8ArrayToString(record.value) - return { - cid, - path: path === '' ? undefined : path - } + if (!value.startsWith(IPNS_STRING_PREFIX)) { + // not a recursive record + break } - } catch (err) { - this.log.error('error parsing ipfs path - %e', err) - } - this.log.error('invalid ipfs path %s', ipfsPath) - throw new InvalidValueError('Invalid value') + ({ digest } = normalizeKey(value)) + } } async #findIpnsRecord (routingKey: Uint8Array, options: ResolveOptions = {}): Promise { - const records: Uint8Array[] = [] + const records: IPNSRecord[] = [] const cached = await this.localStore.has(routingKey, options) if (cached) { @@ -122,17 +69,19 @@ export class IPNSResolver { if (options.nocache !== true) { try { // check the local cache first - const { record, created } = await this.localStore.get(routingKey, options) + const { record: marshaledIPNSRecord, created } = await this.localStore.get(routingKey, options) this.log('record retrieved from cache') + // unmarshal the record + const ipnsRecord = await unmarshalIPNSRecord(routingKey, marshaledIPNSRecord, this.getCryptoKey, options) + // validate the record - await ipnsValidator(routingKey, record) + await ipnsValidator(ipnsRecord, options) this.log('record was valid') // check the TTL - const ipnsRecord = unmarshalIPNSRecord(record) // IPNS TTL is in nanoseconds, convert to milliseconds, default to one // hour @@ -156,7 +105,7 @@ export class IPNSResolver { // add the local record to our list of resolved record, and also // search the routing for updates - the most up to date record will be // returned - records.push(record) + records.push(ipnsRecord) } catch (err) { this.log('cached record was invalid - %e', err) await this.localStore.delete(routingKey, options) @@ -177,10 +126,10 @@ export class IPNSResolver { await Promise.all( this.routers.map(async (router) => { - let record: Uint8Array + let marshaledIPNSRecord: Uint8Array try { - record = await router.get(routingKey, { + marshaledIPNSRecord = await router.get(routingKey, { ...options, validate: false }) @@ -192,7 +141,12 @@ export class IPNSResolver { } try { - await ipnsValidator(routingKey, record) + // unmarshal ensures that (1) SignatureV2 and Data are present, (2) that ValidityType + // and Validity are of valid types and have a value, (3) that CBOR data matches protobuf + // if it's a V1+V2 record + const record = await unmarshalIPNSRecord(routingKey, marshaledIPNSRecord, this.getCryptoKey, options) + + await ipnsValidator(record, options) records.push(record) } catch (err) { @@ -213,8 +167,8 @@ export class IPNSResolver { const record = records[ipnsSelector(routingKey, records)] - await this.localStore.put(routingKey, record, options) + await this.localStore.put(routingKey, record.bytes, options) - return unmarshalIPNSRecord(record) + return record } } diff --git a/packages/ipns/src/pb/ipns.proto b/packages/ipns/src/pb/ipns.proto new file mode 100644 index 000000000..65019f917 --- /dev/null +++ b/packages/ipns/src/pb/ipns.proto @@ -0,0 +1,39 @@ +// https://github.com/ipfs/boxo/blob/main/ipns/pb/record.proto + +syntax = "proto3"; + +message IpnsEntry { + enum ValidityType { + // setting an EOL says "this record is valid until..." + EOL = 0; + } + + // legacy V1 copy of data[Value] + optional bytes value = 1; + + // legacy V1 field, verify 'signatureV2' instead + optional bytes signatureV1 = 2; + + // legacy V1 copies of data[ValidityType] and data[Validity] + optional ValidityType validityType = 3; + optional bytes validity = 4; + + // legacy V1 copy of data[Sequence] + optional uint64 sequence = 5; + + // legacy V1 copy copy of data[TTL] + optional uint64 ttl = 6; + + // Optional Public Key to be used for signature verification. + // Used for big keys such as old RSA keys. Including the public key as part of + // the record itself makes it verifiable in offline mode, without any additional lookup. + // For newer Ed25519 keys, the public key is small enough that it can be embedded in the + // IPNS Name itself, making this field unnecessary. + optional bytes publicKey = 7; + + // (mandatory V2) signature of the IPNS record + optional bytes signatureV2 = 8; + + // (mandatory V2) extensible record data in DAG-CBOR format + optional bytes data = 9; +} diff --git a/packages/ipns/src/pb/ipns.ts b/packages/ipns/src/pb/ipns.ts new file mode 100644 index 000000000..f925ed7f5 --- /dev/null +++ b/packages/ipns/src/pb/ipns.ts @@ -0,0 +1,280 @@ +import { decodeMessage, encodeMessage, enumeration, message, streamMessage } from 'protons-runtime' +import type { Codec, DecodeOptions } from 'protons-runtime' +import type { Uint8ArrayList } from 'uint8arraylist' + +export interface IpnsEntry { + value?: Uint8Array + signatureV1?: Uint8Array + validityType?: IpnsEntry.ValidityType + validity?: Uint8Array + sequence?: bigint + ttl?: bigint + publicKey?: Uint8Array + signatureV2?: Uint8Array + data?: Uint8Array +} + +export namespace IpnsEntry { + export enum ValidityType { + EOL = 'EOL' + } + + enum __ValidityTypeValues { + EOL = 0 + } + + export namespace ValidityType { + export const codec = (): Codec => { + return enumeration(__ValidityTypeValues) + } + } + + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.value != null) { + w.uint32(10) + w.bytes(obj.value) + } + + if (obj.signatureV1 != null) { + w.uint32(18) + w.bytes(obj.signatureV1) + } + + if (obj.validityType != null) { + w.uint32(24) + IpnsEntry.ValidityType.codec().encode(obj.validityType, w) + } + + if (obj.validity != null) { + w.uint32(34) + w.bytes(obj.validity) + } + + if (obj.sequence != null) { + w.uint32(40) + w.uint64(obj.sequence) + } + + if (obj.ttl != null) { + w.uint32(48) + w.uint64(obj.ttl) + } + + if (obj.publicKey != null) { + w.uint32(58) + w.bytes(obj.publicKey) + } + + if (obj.signatureV2 != null) { + w.uint32(66) + w.bytes(obj.signatureV2) + } + + if (obj.data != null) { + w.uint32(74) + w.bytes(obj.data) + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length, opts = {}) => { + const obj: any = {} + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + obj.value = reader.bytes() + break + } + case 2: { + obj.signatureV1 = reader.bytes() + break + } + case 3: { + obj.validityType = IpnsEntry.ValidityType.codec().decode(reader) + break + } + case 4: { + obj.validity = reader.bytes() + break + } + case 5: { + obj.sequence = reader.uint64() + break + } + case 6: { + obj.ttl = reader.uint64() + break + } + case 7: { + obj.publicKey = reader.bytes() + break + } + case 8: { + obj.signatureV2 = reader.bytes() + break + } + case 9: { + obj.data = reader.bytes() + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }, function * (reader, length, prefix, opts = {}) { + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + yield { + field: `${prefix}.value`, + value: reader.bytes() + } + break + } + case 2: { + yield { + field: `${prefix}.signatureV1`, + value: reader.bytes() + } + break + } + case 3: { + yield { + field: `${prefix}.validityType`, + value: IpnsEntry.ValidityType.codec().decode(reader) + } + break + } + case 4: { + yield { + field: `${prefix}.validity`, + value: reader.bytes() + } + break + } + case 5: { + yield { + field: `${prefix}.sequence`, + value: reader.uint64() + } + break + } + case 6: { + yield { + field: `${prefix}.ttl`, + value: reader.uint64() + } + break + } + case 7: { + yield { + field: `${prefix}.publicKey`, + value: reader.bytes() + } + break + } + case 8: { + yield { + field: `${prefix}.signatureV2`, + value: reader.bytes() + } + break + } + case 9: { + yield { + field: `${prefix}.data`, + value: reader.bytes() + } + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + }) + } + + return _codec + } + + export interface IpnsEntryValueFieldEvent { + field: '$.value' + value: Uint8Array + } + + export interface IpnsEntrySignatureV1FieldEvent { + field: '$.signatureV1' + value: Uint8Array + } + + export interface IpnsEntryValidityTypeFieldEvent { + field: '$.validityType' + value: IpnsEntry.ValidityType + } + + export interface IpnsEntryValidityFieldEvent { + field: '$.validity' + value: Uint8Array + } + + export interface IpnsEntrySequenceFieldEvent { + field: '$.sequence' + value: bigint + } + + export interface IpnsEntryTtlFieldEvent { + field: '$.ttl' + value: bigint + } + + export interface IpnsEntryPublicKeyFieldEvent { + field: '$.publicKey' + value: Uint8Array + } + + export interface IpnsEntrySignatureV2FieldEvent { + field: '$.signatureV2' + value: Uint8Array + } + + export interface IpnsEntryDataFieldEvent { + field: '$.data' + value: Uint8Array + } + + export function encode (obj: Partial): Uint8Array { + return encodeMessage(obj, IpnsEntry.codec()) + } + + export function decode (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): IpnsEntry { + return decodeMessage(buf, IpnsEntry.codec(), opts) + } + + export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { + return streamMessage(buf, IpnsEntry.codec(), opts) + } +} diff --git a/packages/ipns/src/pb/keys.proto b/packages/ipns/src/pb/keys.proto new file mode 100644 index 000000000..db5c1fd5c --- /dev/null +++ b/packages/ipns/src/pb/keys.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +enum KeyType { + RSA = 0; + Ed25519 = 1; + secp256k1 = 2; + ECDSA = 3; +} + +message PublicKey { + // the proto2 version of this field is "required" which means it will have + // no default value. the default for proto3 is "singular" which omits the + // value on the wire if it's the default so for proto3 we make it "optional" + // to ensure a value is always written on to the wire + optional int32 Type = 1; + + // the proto2 version of this field is "required" which means it will have + // no default value. the default for proto3 is "singular" which omits the + // value on the wire if it's the default so for proto3 we make it "optional" + // to ensure a value is always written on to the wire + optional bytes Data = 2; +} + +message PrivateKey { + // the proto2 version of this field is "required" which means it will have + // no default value. the default for proto3 is "singular" which omits the + // value on the wire if it's the default so for proto3 we make it "optional" + // to ensure a value is always written on to the wire + optional int32 Type = 1; + + // the proto2 version of this field is "required" which means it will have + // no default value. the default for proto3 is "singular" which omits the + // value on the wire if it's the default so for proto3 we make it "optional" + // to ensure a value is always written on to the wire + optional bytes Data = 2; +} diff --git a/packages/ipns/src/pb/keys.ts b/packages/ipns/src/pb/keys.ts new file mode 100644 index 000000000..635a8a8d2 --- /dev/null +++ b/packages/ipns/src/pb/keys.ts @@ -0,0 +1,241 @@ +import { decodeMessage, encodeMessage, enumeration, message, streamMessage } from 'protons-runtime' +import type { Codec, DecodeOptions } from 'protons-runtime' +import type { Uint8ArrayList } from 'uint8arraylist' + +export enum KeyType { + RSA = 'RSA', + Ed25519 = 'Ed25519', + secp256k1 = 'secp256k1', + ECDSA = 'ECDSA' +} + +enum __KeyTypeValues { + RSA = 0, + Ed25519 = 1, + secp256k1 = 2, + ECDSA = 3 +} + +export namespace KeyType { + export const codec = (): Codec => { + return enumeration(__KeyTypeValues) + } +} + +export interface PublicKey { + Type?: number + Data?: Uint8Array +} + +export namespace PublicKey { + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.Type != null) { + w.uint32(8) + w.int32(obj.Type) + } + + if (obj.Data != null) { + w.uint32(18) + w.bytes(obj.Data) + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length, opts = {}) => { + const obj: any = {} + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + obj.Type = reader.int32() + break + } + case 2: { + obj.Data = reader.bytes() + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }, function * (reader, length, prefix, opts = {}) { + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + yield { + field: `${prefix}.Type`, + value: reader.int32() + } + break + } + case 2: { + yield { + field: `${prefix}.Data`, + value: reader.bytes() + } + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + }) + } + + return _codec + } + + export interface PublicKeyTypeFieldEvent { + field: '$.Type' + value: number + } + + export interface PublicKeyDataFieldEvent { + field: '$.Data' + value: Uint8Array + } + + export function encode (obj: Partial): Uint8Array { + return encodeMessage(obj, PublicKey.codec()) + } + + export function decode (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): PublicKey { + return decodeMessage(buf, PublicKey.codec(), opts) + } + + export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { + return streamMessage(buf, PublicKey.codec(), opts) + } +} + +export interface PrivateKey { + Type?: number + Data?: Uint8Array +} + +export namespace PrivateKey { + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.Type != null) { + w.uint32(8) + w.int32(obj.Type) + } + + if (obj.Data != null) { + w.uint32(18) + w.bytes(obj.Data) + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length, opts = {}) => { + const obj: any = {} + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + obj.Type = reader.int32() + break + } + case 2: { + obj.Data = reader.bytes() + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }, function * (reader, length, prefix, opts = {}) { + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + yield { + field: `${prefix}.Type`, + value: reader.int32() + } + break + } + case 2: { + yield { + field: `${prefix}.Data`, + value: reader.bytes() + } + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + }) + } + + return _codec + } + + export interface PrivateKeyTypeFieldEvent { + field: '$.Type' + value: number + } + + export interface PrivateKeyDataFieldEvent { + field: '$.Data' + value: Uint8Array + } + + export function encode (obj: Partial): Uint8Array { + return encodeMessage(obj, PrivateKey.codec()) + } + + export function decode (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): PrivateKey { + return decodeMessage(buf, PrivateKey.codec(), opts) + } + + export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { + return streamMessage(buf, PrivateKey.codec(), opts) + } +} diff --git a/packages/ipns/src/records.ts b/packages/ipns/src/records.ts new file mode 100644 index 000000000..d66f033ce --- /dev/null +++ b/packages/ipns/src/records.ts @@ -0,0 +1,270 @@ +import { logger } from '@libp2p/logger' +import NanoDate from 'timestamp-nano' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { SignatureCreationError } from './errors.ts' +import { IpnsEntry } from './pb/ipns.ts' +import { createCborData, ipnsRecordDataForV1Sig, ipnsRecordDataForV2Sig, marshalIPNSRecord } from './utils.ts' +import type { PrivateKey, PublicKey } from '@helia/interface' +import type { AbortOptions } from 'abort-error' +import type { Key } from 'interface-datastore/key' + +const log = logger('ipns') +const DEFAULT_TTL_NS = 5 * 60 * 1e+9 // 5 Minutes or 300 Seconds, as suggested by https://specs.ipfs.tech/ipns/ipns-record/#ttl-uint64 + +export const namespace = '/ipns/' +export const namespaceLength = namespace.length + +export interface IPNSRecordV1V2 { + /** + * value of the record + */ + value: Uint8Array + + /** + * signature of the record + */ + signatureV1: Uint8Array + + /** + * Type of validation being used + */ + validityType: IpnsEntry.ValidityType + + /** + * expiration datetime for the record in RFC3339 format + */ + validity: string + + /** + * number representing the version of the record + */ + sequence: bigint + + /** + * ttl in nanoseconds + */ + ttl?: bigint + + /** + * the public portion of the key that signed this record + */ + publicKey: PublicKey + + /** + * the v2 signature of the record + */ + signatureV2: Uint8Array + + /** + * extensible data + */ + data: Uint8Array + + /** + * The marshalled record + */ + bytes: Uint8Array +} + +export interface IPNSRecordV2 { + /** + * value of the record + */ + value: Uint8Array + + /** + * the v2 signature of the record + */ + signatureV2: Uint8Array + + /** + * Type of validation being used + */ + validityType: IpnsEntry.ValidityType + + /** + * If the validity type is EOL, this is the expiration datetime for the record + * in RFC3339 format + */ + validity: string + + /** + * number representing the version of the record + */ + sequence: bigint + + /** + * ttl in nanoseconds + */ + ttl?: bigint + + /** + * the public portion of the key that signed this record + */ + publicKey: PublicKey + + /** + * extensible data + */ + data: Uint8Array + + /** + * The marshalled record + */ + bytes: Uint8Array +} + +export type IPNSRecord = IPNSRecordV1V2 | IPNSRecordV2 + +export interface IPNSRecordData { + Value: Uint8Array + Validity: Uint8Array + ValidityType: IpnsEntry.ValidityType + Sequence: bigint + TTL: bigint +} + +export interface IDKeys { + routingPubKey: Key + pkKey: Key + routingKey: Key + ipnsKey: Key +} + +export interface CreateOptions extends AbortOptions { + ttlNs?: number | bigint + v1Compatible?: boolean +} + +export interface CreateV2OrV1Options extends AbortOptions { + v1Compatible: true +} + +export interface CreateV2Options extends AbortOptions { + v1Compatible: false +} + +const defaultCreateOptions: CreateOptions = { + v1Compatible: true, + ttlNs: DEFAULT_TTL_NS +} + +/** + * Creates a new IPNS record and signs it with the given private key. + * The IPNS Record validity should follow the [RFC3339]{@link https://www.ietf.org/rfc/rfc3339.txt} with nanoseconds precision. + * Note: This function does not embed the public key. If you want to do that, use `EmbedPublicKey`. + * + * The passed value can be a CID, a PublicKey or an arbitrary string path e.g. `/ipfs/...` or `/ipns/...`. + * + * CIDs will be converted to v1 and stored in the record as a string similar to: `/ipfs/${cid}` + * PublicKeys will create recursive records, eg. the record value will be `/ipns/${cidV1Libp2pKey}` + * String paths will be stored in the record as-is, but they must start with `"/"` + * + * @param {PrivateKey} privateKey - the private key for signing the record. + * @param {CID | PublicKey | string} value - content to be stored in the record. + * @param {number | bigint} seq - number representing the current version of the record. + * @param {number} lifetime - lifetime of the record (in milliseconds). + * @param {CreateOptions} options - additional create options. + */ +export async function createIPNSRecord (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, lifetime: number, options?: CreateV2OrV1Options): Promise +export async function createIPNSRecord (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, lifetime: number, options: CreateV2Options): Promise +export async function createIPNSRecord (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions): Promise +export async function createIPNSRecord (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, lifetime: number, options: CreateOptions = defaultCreateOptions): Promise { + // Validity in ISOString with nanoseconds precision and validity type EOL + const expirationDate = new NanoDate(Date.now() + Number(lifetime)) + const validityType = IpnsEntry.ValidityType.EOL + const ttlNs = BigInt(options.ttlNs ?? DEFAULT_TTL_NS) + + return _create(privateKey, value, seq, validityType, expirationDate.toString(), ttlNs, options) +} + +/** + * Same as create(), but instead of generating a new Date, it receives the intended expiration time + * WARNING: nano precision is not standard, make sure the value in seconds is 9 orders of magnitude lesser than the one provided. + * + * The passed value can be a CID, a PublicKey or an arbitrary string path e.g. `/ipfs/...` or `/ipns/...`. + * + * CIDs will be converted to v1 and stored in the record as a string similar to: `/ipfs/${cid}` + * PublicKeys will create recursive records, eg. the record value will be `/ipns/${cidV1Libp2pKey}` + * String paths will be stored in the record as-is, but they must start with `"/"` + * + * @param {PrivateKey} privateKey - the private key for signing the record. + * @param {CID | PublicKey | string} value - content to be stored in 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. + * @param {CreateOptions} options - additional creation options. + */ +export async function createIPNSRecordWithExpiration (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, expiration: string, options?: CreateV2OrV1Options): Promise +export async function createIPNSRecordWithExpiration (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateV2Options): Promise +export async function createIPNSRecordWithExpiration (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions): Promise +export async function createIPNSRecordWithExpiration (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, expiration: string, options: CreateOptions = defaultCreateOptions): Promise { + const expirationDate = NanoDate.fromString(expiration) + const validityType = IpnsEntry.ValidityType.EOL + const ttlNs = BigInt(options.ttlNs ?? DEFAULT_TTL_NS) + + return _create(privateKey, value, seq, validityType, expirationDate.toString(), ttlNs, options) +} + +const _create = async (privateKey: PrivateKey, value: Uint8Array, seq: number | bigint, validityType: IpnsEntry.ValidityType, validity: string, ttl: bigint, options: CreateOptions = defaultCreateOptions): Promise => { + seq = BigInt(seq) + const isoValidity = uint8ArrayFromString(validity) + const data = createCborData(value, validityType, isoValidity, seq, ttl) + const sigData = ipnsRecordDataForV2Sig(data) + const signatureV2 = await privateKey.sign(sigData, options) + const publicKey = privateKey.publicKey + + // if we cannot derive the public key from the IPNS name (e.g. RSA PeerIDs), + // we have to embed it in the IPNS record + + let record: any + + if (options.v1Compatible === true) { + const signatureV1 = await signLegacyV1(privateKey, value, validityType, isoValidity) + + record = { + value, + signatureV1, + validity, + validityType, + sequence: seq, + ttl, + signatureV2, + data, + publicKey + } + } else { + record = { + value, + validity, + validityType, + sequence: seq, + ttl, + signatureV2, + data, + publicKey + } + } + + record.bytes = marshalIPNSRecord(record) + + return record +} + +export { unmarshalIPNSRecord } from './utils.ts' +export { marshalIPNSRecord } from './utils.ts' +export { multihashToIPNSRoutingKey } from './utils.ts' +export { multihashFromIPNSRoutingKey } from './utils.ts' + +/** + * Sign ipns record data using the legacy V1 signature scheme + */ +const signLegacyV1 = async (privateKey: PrivateKey, value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array, options?: AbortOptions): Promise => { + try { + const dataForSignature = ipnsRecordDataForV1Sig(value, validityType, validity) + + return await privateKey.sign(dataForSignature, options) + } catch (error: any) { + log.error('record signature creation failed', error) + throw new SignatureCreationError('Record signature creation failed') + } +} diff --git a/packages/ipns/src/routing/pubsub.ts b/packages/ipns/src/routing/pubsub.ts index cedef2a79..5465abd46 100644 --- a/packages/ipns/src/routing/pubsub.ts +++ b/packages/ipns/src/routing/pubsub.ts @@ -4,9 +4,6 @@ import { PeerSet } from '@libp2p/peer-collections' import { Queue } from '@libp2p/utils' import { anySignal } from 'any-signal' import delay from 'delay' -import { multihashToIPNSRoutingKey } from 'ipns' -import { ipnsSelector } from 'ipns/selector' -import { ipnsValidator } from 'ipns/validator' import { CustomProgressEvent } from 'progress-events' import { raceSignal } from 'race-signal' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' @@ -14,9 +11,13 @@ import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { InvalidTopicError } from '../errors.ts' import { localStore } from '../local-store.ts' -import { IPNS_STRING_PREFIX } from '../utils.ts' +import { multihashToIPNSRoutingKey } from '../records.ts' +import { ipnsSelector } from '../selector.ts' +import { IPNS_STRING_PREFIX, unmarshalIPNSRecord } from '../utils.ts' +import { ipnsValidator } from '../validator.ts' import type { GetOptions, IPNSRouting, PutOptions } from './index.ts' import type { LocalStore } from '../local-store.ts' +import type { CryptoKeyLoader } from '@helia/interface' import type { Fetch } from '@libp2p/fetch' import type { PeerId, PublicKey, TypedEventTarget, ComponentLogger, Startable, AbortOptions, Metrics, Libp2p } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' @@ -62,6 +63,7 @@ export interface PubSub extends TypedEventTarget { export interface PubsubRoutingComponents { datastore: Datastore logger: ComponentLogger + getCryptoKey: CryptoKeyLoader metrics?: Metrics libp2p: Pick, 'peerId' | 'register' | 'unregister' | 'services'> } @@ -105,6 +107,7 @@ export class PubSubRouting implements IPNSRouting, Startable { private readonly fetchPeers: PeerSet private shutdownController: AbortController private fetchTopologyId?: string + private getCryptoKey: CryptoKeyLoader constructor (components: PubsubRoutingComponents, init: PubsubRoutingInit = {}) { this.subscriptions = new Set() @@ -115,6 +118,7 @@ export class PubSubRouting implements IPNSRouting, Startable { this.libp2p = components.libp2p this.fetchConcurrency = init.fetchConcurrency ?? 8 this.fetchDelay = init.fetchDelay ?? 0 + this.getCryptoKey = components.getCryptoKey // default libp2p-fetch timeout is 10 seconds - we should have an existing // connection to the peer so this can be shortened @@ -235,23 +239,25 @@ export class PubSubRouting implements IPNSRouting, Startable { } async #handleRecord (topic: string, routingKey: Uint8Array, marshalledRecord: Uint8Array, publish: boolean, options?: AbortOptions): Promise { - await ipnsValidator(routingKey, marshalledRecord) + const record = await unmarshalIPNSRecord(routingKey, marshalledRecord, this.getCryptoKey, options) + await ipnsValidator(record, options) this.shutdownController.signal.throwIfAborted() if (await this.localStore.has(routingKey)) { - const { record: currentRecord } = await this.localStore.get(routingKey, options) + const { record: marshaledCurrentRecord } = await this.localStore.get(routingKey, options) + const currentRecord = await unmarshalIPNSRecord(routingKey, marshaledCurrentRecord, this.getCryptoKey, options) - if (uint8ArrayEquals(currentRecord, marshalledRecord)) { + if (uint8ArrayEquals(marshaledCurrentRecord, record.bytes)) { log.trace('found identical record for %m', routingKey) - return currentRecord + return currentRecord.bytes } - const records = [currentRecord, marshalledRecord] + const records = [currentRecord, record] const index = ipnsSelector(routingKey, records) if (index === 0) { log.trace('found old record for %m', routingKey) - return currentRecord + return currentRecord.bytes } } diff --git a/packages/ipns/src/selector.ts b/packages/ipns/src/selector.ts new file mode 100644 index 000000000..0b76d098f --- /dev/null +++ b/packages/ipns/src/selector.ts @@ -0,0 +1,55 @@ +import NanoDate from 'timestamp-nano' +import { IpnsEntry } from './pb/ipns.ts' +import type { IPNSRecord } from './records.ts' + +/** + * Selects the latest valid IPNS record from an array of marshalled IPNS records. + * + * Records are sorted by: + * 1. Sequence number (higher takes precedence) + * 2. Validity time for EOL records with same sequence number (longer lived record takes precedence) + * + * @param key - The routing key for the IPNS record + * @param data - Array of marshalled IPNS records to select from + * @returns The index of the most valid record from the input array + */ +export function ipnsSelector (key: Uint8Array, data: IPNSRecord[]): number { + const entries = data.map((record, index) => ({ + record, + index + })) + + entries.sort((a, b) => { + // Before we'd sort based on the signature version. Unmarshal now fails if + // a record does not have SignatureV2, so that is no longer needed. V1-only + // records haven't been issues in a long time. + + const aSeq = a.record.sequence + const bSeq = b.record.sequence + + // choose later sequence number + if (aSeq > bSeq) { + return -1 + } else if (aSeq < bSeq) { + return 1 + } + + if (a.record.validityType === IpnsEntry.ValidityType.EOL && b.record.validityType === IpnsEntry.ValidityType.EOL) { + // choose longer lived record if sequence numbers the same + const recordAValidityDate = NanoDate.fromString(a.record.validity).toDate() + const recordBValidityDate = NanoDate.fromString(b.record.validity).toDate() + + if (recordAValidityDate.getTime() > recordBValidityDate.getTime()) { + return -1 + } + + if (recordAValidityDate.getTime() < recordBValidityDate.getTime()) { + return 1 + } + } + + return 0 + }) + + return entries[0].index +} diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index 32e9aaa20..eb1712aae 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -1,15 +1,35 @@ +import { isPublicKey } from '@helia/interface' import { InvalidParametersError } from '@libp2p/interface' +import * as cborg from 'cborg' import { Key } from 'interface-datastore' +import { base36 } from 'multiformats/bases/base36' +import { base58btc } from 'multiformats/bases/base58' +import { CID } from 'multiformats/cid' +import * as Digest from 'multiformats/hashes/digest' +import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { equals as uint8ArrayEquals } from 'uint8arrays/equals' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DHT_EXPIRY_MS, REPUBLISH_THRESHOLD } from './constants.ts' -import type { IPNSRecord } from 'ipns' -import type { CID } from 'multiformats/cid' +import { InvalidEmbeddedPublicKeyError, InvalidRecordDataError, InvalidValueError, RecordTooLargeError, SignatureVerificationError, UnsupportedValidityError } from './errors.ts' +import { IpnsEntry } from './pb/ipns.ts' +import { PublicKey as PublicKeyPB } from './pb/keys.ts' +import type { IPNSRecord, IPNSRecordV2, IPNSRecordData } from './records.ts' +import type { CryptoKeyLoader, PublicKey } from '@helia/interface' +import type { AbortOptions } from '@libp2p/interface' +import type { MultibaseDecoder } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' export const LIBP2P_KEY_CODEC = 0x72 export const IDENTITY_CODEC = 0x0 export const SHA2_256_CODEC = 0x12 +/** + * Limit valid IPNS record sizes to 10kb + */ +const MAX_RECORD_SIZE = 1024 * 10 + +const IPNS_PREFIX = uint8ArrayFromString('/ipns/') export const IPNS_STRING_PREFIX = '/ipns/' export function isCodec (digest: MultihashDigest, codec: T): digest is MultihashDigest { @@ -78,3 +98,359 @@ export function isLibp2pCID (obj?: any): obj is CID { + if (marshalledRecord.byteLength > MAX_RECORD_SIZE) { + throw new RecordTooLargeError('The record is too large') + } + + const message = IpnsEntry.decode(marshalledRecord) + + // Check if we have the data field. If we don't, we fail. We've been producing + // V1+V2 records for quite a while and we don't support V1-only records during + // validation any more + if (message.signatureV2 == null || message.data == null) { + throw new SignatureVerificationError('Missing data or signatureV2') + } + + const data = parseCborData(message.data) + const validity = uint8ArrayToString(data.Validity) + + let publicKey: PublicKey | undefined + + // try to extract public key from routing key + const routingMultihash = multihashFromIPNSRoutingKey(routingKey) + let publicKeyPb: PublicKeyPB | undefined + + // identity hash + if (isCodec(routingMultihash, 0x0)) { + publicKeyPb = PublicKeyPB.decode(routingMultihash.digest) + } + + // otherwise try to load key from message + if (publicKeyPb == null && message.publicKey != null) { + publicKeyPb = PublicKeyPB.decode(message.publicKey) + } + + // load key implementation + if (publicKeyPb?.Type != null && publicKeyPb?.Data != null) { + const crypto = await getCryptoKey(publicKeyPb.Type, options) + publicKey = await crypto.publicKeyFromArray(publicKeyPb.Data) + } + + if (publicKey == null) { + throw new InvalidEmbeddedPublicKeyError('Could not extract public key from IPNS record or routing key') + } + + if (message.value != null && message.signatureV1 != null) { + // V1+V2 + validateCborDataMatchesPbData(message) + + return { + value: data.Value, + validityType: IpnsEntry.ValidityType.EOL, + validity, + sequence: data.Sequence, + ttl: data.TTL, + publicKey, + signatureV1: message.signatureV1, + signatureV2: message.signatureV2, + data: message.data, + bytes: marshalledRecord + } + } else if (message.signatureV2 != null) { + // V2-only + return { + value: data.Value, + validityType: IpnsEntry.ValidityType.EOL, + validity, + sequence: data.Sequence, + ttl: data.TTL, + publicKey, + signatureV2: message.signatureV2, + data: message.data, + bytes: marshalledRecord + } + } else { + throw new Error('invalid record: does not include signatureV1 or signatureV2') + } +} + +export function multihashToIPNSRoutingKey (digest: MultihashDigest): Uint8Array { + return uint8ArrayConcat([ + IPNS_PREFIX, + digest.bytes + ]) +} + +export function multihashFromIPNSRoutingKey (key: Uint8Array): MultihashDigest { + return Digest.decode(key.slice(IPNS_PREFIX.length)) +} + +export function createCborData (value: Uint8Array, validityType: IpnsEntry.ValidityType, validity: Uint8Array, sequence: bigint, ttl: bigint): Uint8Array { + let ValidityType + + if (validityType === IpnsEntry.ValidityType.EOL) { + ValidityType = 0 + } else { + throw new UnsupportedValidityError('The validity type is unsupported') + } + + const data = { + Value: value, + Validity: validity, + ValidityType, + Sequence: sequence, + TTL: ttl + } + + return cborg.encode(data) +} + +export function parseCborData (buf: Uint8Array): IPNSRecordData { + const data = cborg.decode(buf) + + if (data.ValidityType === 0) { + data.ValidityType = IpnsEntry.ValidityType.EOL + } else { + throw new UnsupportedValidityError('The validity type is unsupported') + } + + 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) + } + + return data +} + +export function normalizeByteValue (value: Uint8Array): string { + const string = uint8ArrayToString(value).trim() + + // if we have a path, check it is a valid path + if (string.startsWith('/')) { + return string + } + + // try parsing what we have as CID bytes or a CID string + try { + return `/ipfs/${CID.decode(value).toV1().toString()}` + } catch { + // fall through + } + + try { + return `/ipfs/${CID.parse(string).toV1().toString()}` + } catch { + // fall through + } + + throw new InvalidValueError('Value must be a valid content path starting with /') +} + +/** + * Normalizes the given record value. It ensures it is a PeerID, a CID or a + * string starting with '/'. PeerIDs become `/ipns/${cidV1Libp2pKey}`, + * CIDs become `/ipfs/${cidAsV1}`. + */ +export function normalizeValue (value?: PublicKey | CID | MultihashDigest | string): string { + if (value != null) { + if (isPublicKey(value)) { + return `/ipns/${value.toCID().toV1().toString(base36)}` + } + + const cid = asCID(value) + + // if we have a CID, turn it into an ipfs path + if (cid != null) { + // PeerID encoded as a CID + if (cid.code === LIBP2P_KEY_CODEC) { + return `/ipns/${cid.toV1().toString(base36)}` + } + + return `/ipfs/${cid.toV1().toString()}` + } + + if (hasBytes(value)) { + return `/ipns/${base36.encode(value.bytes)}` + } + + // if we have a path, check it is a valid path + const string = value.toString().trim() + + if (string.startsWith('/ipfs/')) { + const [, name, ...rest] = string.split('/') + .filter(component => component.trim() !== '') + + return `/ipfs/${CID.parse(name).toV1()}${rest.length > 0 ? `/${rest.join('/')}` : ''}` + } + + if (string.startsWith('/') && string.length > 1) { + return string + } + } + + throw new InvalidValueError('Value must be a valid content path starting with /') +} + +function isMultihashDigest (obj: any): obj is MultihashDigest { + return typeof obj.code === 'number' && obj.digest instanceof Uint8Array && typeof obj.size === 'number' && obj.bytes instanceof Uint8Array +} + +export function normalizeKey (key?: PublicKey | CID | MultihashDigest | string): { digest: MultihashDigest, path: string } { + if (key != null) { + if (isPublicKey(key)) { + return { + digest: key.toMultihash(), + path: '/' + } + } + + const cid = asCID(key) + + // if we have a CID, turn it into an ipfs path + if (cid != null) { + // PeerID encoded as a CID + if (cid.code !== LIBP2P_KEY_CODEC) { + throw new InvalidValueError('CIDs must have the `libp2p-key` codec') + } + + return { + digest: cid.multihash, + path: '/' + } + } + + if (isMultihashDigest(key)) { + return { + digest: key, + path: '/' + } + } + + key = key.toString() + + if (key.startsWith('/ipns/')) { + let [,, name, ...rest] = key.split('/') + let codec: MultibaseDecoder = base36 + + // base58btc encoded public key hash or protobuf in identity hash + if (name.startsWith('1') || name.startsWith('Q')) { + name = `z${name}` + codec = base58btc + } + + const buf = codec.decode(name) + let digest: MultihashDigest + + try { + digest = CID.decode(buf).multihash + } catch { + digest = Digest.decode(buf) + } + + return { + digest, + path: `/${rest.join('/')}` + } + } + } + + throw new InvalidValueError('Value must be a valid IPNS path starting with /') +} + +function validateCborDataMatchesPbData (entry: IpnsEntry): void { + if (entry.data == null) { + throw new InvalidRecordDataError('Record data is missing') + } + + const data = parseCborData(entry.data) + + if (!uint8ArrayEquals(data.Value, entry.value ?? new Uint8Array(0))) { + throw new SignatureVerificationError('Field "value" did not match between protobuf and CBOR') + } + + if (!uint8ArrayEquals(data.Validity, entry.validity ?? new Uint8Array(0))) { + throw new SignatureVerificationError('Field "validity" did not match between protobuf and CBOR') + } + + if (data.ValidityType !== entry.validityType) { + throw new SignatureVerificationError('Field "validityType" did not match between protobuf and CBOR') + } + + if (data.Sequence !== entry.sequence) { + throw new SignatureVerificationError('Field "sequence" did not match between protobuf and CBOR') + } + + if (data.TTL !== entry.ttl) { + throw new SignatureVerificationError('Field "ttl" did not match between protobuf and CBOR') + } +} + +function hasBytes (obj?: any): obj is { bytes: Uint8Array } { + return obj.bytes instanceof Uint8Array +} + +function hasToCID (obj?: any): obj is { toCID(): CID } { + return typeof obj?.toCID === 'function' +} + +function asCID (obj?: any): CID | null { + if (hasToCID(obj)) { + return obj.toCID() + } + + // try parsing as a CID string + try { + return CID.parse(obj) + } catch { + // fall through + } + + return CID.asCID(obj) +} diff --git a/packages/ipns/src/validator.ts b/packages/ipns/src/validator.ts new file mode 100644 index 000000000..153ea4a30 --- /dev/null +++ b/packages/ipns/src/validator.ts @@ -0,0 +1,62 @@ +import NanoDate from 'timestamp-nano' +import { RecordExpiredError, SignatureVerificationError, UnsupportedValidityError } from './errors.ts' +import { IpnsEntry } from './pb/ipns.ts' +import { ipnsRecordDataForV2Sig } from './utils.ts' +import type { IPNSRecord } from './index.ts' +import type { AbortOptions } from '@libp2p/interface' + +/** + * Validate the given IPNS record against the given routing key. + * + * @see https://specs.ipfs.tech/ipns/ipns-record/#routing-record for the binary format of the routing key + */ +export async function ipnsValidator (record: IPNSRecord, options?: AbortOptions): Promise { + // Validate Signature V2 + let isValid + + try { + const dataForSignature = ipnsRecordDataForV2Sig(record.data) + isValid = await record.publicKey.verify(dataForSignature, record.signatureV2, options) + } catch (err) { + isValid = false + } + + if (!isValid) { + throw new SignatureVerificationError('Record signature verification failed') + } + + // Validate according to the validity type + if (record.validityType === IpnsEntry.ValidityType.EOL) { + if (NanoDate.fromString(record.validity).toDate().getTime() < Date.now()) { + throw new RecordExpiredError('record has expired') + } + } else if (record.validityType != null) { + throw new UnsupportedValidityError('The validity type is unsupported') + } +} + +/** + * Returns the number of milliseconds until the record expires. + * If the record is already expired, returns 0. + * + * @param record - The IPNS record to validate. + * @returns The number of milliseconds until the record expires, or 0 if the record is already expired. + */ +export function validFor (record: IPNSRecord): number { + if (record.validityType !== IpnsEntry.ValidityType.EOL) { + throw new UnsupportedValidityError() + } + + if (record.validity == null) { + throw new UnsupportedValidityError() + } + + const validUntil = NanoDate.fromString(record.validity).toDate().getTime() + const now = Date.now() + + if (validUntil < now) { + return 0 + } + + return validUntil - now +} diff --git a/packages/ipns/test/fixtures/create-ipns.ts b/packages/ipns/test/fixtures/create-ipns.ts index 35681163f..ebba1aba6 100644 --- a/packages/ipns/test/fixtures/create-ipns.ts +++ b/packages/ipns/test/fixtures/create-ipns.ts @@ -1,12 +1,12 @@ -import { TypedEventEmitter } from '@libp2p/interface' -import { keychain } from '@libp2p/keychain' +import { ed25519Crypto } from '@helia/utils' +import { NotFoundError, TypedEventEmitter } from '@libp2p/interface' import { defaultLogger } from '@libp2p/logger' import { MemoryDatastore } from 'datastore-core' import { stubInterface } from 'sinon-ts' import { IPNS } from '../../src/ipns.ts' +import { getCryptoKey } from './crypto-loader.ts' import type { IPNSRouting } from '../../src/index.ts' -import type { HeliaEvents, Routing } from '@helia/interface' -import type { Keychain, KeychainInit } from '@libp2p/keychain' +import type { HeliaEvents, Routing, Keychain, PrivateKey } from '@helia/interface' import type { Logger } from '@libp2p/logger' import type { Datastore } from 'interface-datastore' import type { StubbedInstance } from 'sinon-ts' @@ -15,7 +15,7 @@ export interface CreateIPNSResult { name: IPNS customRouting: StubbedInstance heliaRouting: StubbedInstance - ipnsKeychain: Keychain + keychain: StubbedInstance datastore: Datastore, log: Logger events: TypedEventEmitter @@ -31,28 +31,41 @@ export async function createIPNS (): Promise { const heliaRouting = stubInterface() const logger = defaultLogger() - const keychainInit: KeychainInit = { - pass: 'very-strong-password' - } - const ipnsKeychain = keychain(keychainInit)({ - // @ts-expect-error @libp2p/keychain needs new multiformats - datastore, - logger - }) - const events = new TypedEventEmitter() + const keys = new Map() + const keychain = stubInterface({ + async createKey (name) { + const key = await ed25519Crypto().createPrivateKey() + keys.set(name, key) + return key + }, + async exportKey (name) { + const key = keys.get(name) + + if (key == null) { + throw new NotFoundError(`No key found for ${name}`) + } + + return key + }, + async importKey (name, key) { + keys.set(name, key) + + return key + }, + async removeKey (name) { + keys.delete(name) + } + }) + const name = new IPNS({ datastore, routing: heliaRouting, - libp2p: { - status: 'stopped', - services: { - keychain: ipnsKeychain - } - } as any, logger, - events + events, + getCryptoKey, + keychain }, { routers: [customRouting] }) @@ -61,7 +74,7 @@ export async function createIPNS (): Promise { name, customRouting, heliaRouting, - ipnsKeychain, + keychain, datastore, log: logger.forComponent('helia:ipns:test'), events diff --git a/packages/ipns/test/fixtures/crypto-loader.ts b/packages/ipns/test/fixtures/crypto-loader.ts new file mode 100644 index 000000000..7755b2e1d --- /dev/null +++ b/packages/ipns/test/fixtures/crypto-loader.ts @@ -0,0 +1,15 @@ +import { ed25519Crypto, rsaCrypto } from '@helia/utils' +import type { CryptoKeyLoader } from '@helia/interface' +import type { AbortOptions } from 'abort-error' + +export const getCryptoKey: CryptoKeyLoader = async (code: number | string, options?: AbortOptions) => { + if (code === 0 || code === 'RSA') { + return rsaCrypto() + } + + if (code === 1 || code === 'Ed25519') { + return ed25519Crypto() + } + + throw new Error(`Unknown crypto implementation ${code}`) +} diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index cb4a98a28..77f77fb87 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -1,9 +1,10 @@ import { start, stop } from '@libp2p/interface' -import { peerIdFromCID } from '@libp2p/peer-id' import { expect } from 'aegir/chai' +import last from 'it-last' import { base36 } from 'multiformats/bases/base36' import { CID } from 'multiformats/cid' import Sinon from 'sinon' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { localStore } from '../src/local-store.ts' import { createIPNS } from './fixtures/create-ipns.ts' import type { CreateIPNSResult } from './fixtures/create-ipns.ts' @@ -94,99 +95,98 @@ describe('publish', () => { it('should publish recursively using a public key', async () => { const keyName1 = 'test-key-6' - const record = await name.publish(keyName1, cid, { + const published = await name.publish(keyName1, cid, { offline: true }) - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) + expect(uint8ArrayToString(published.record.value)).to.equal(`/ipfs/${cid.toV1().toString()}`) const keyName2 = 'test-key-7' - const recursiveRecord = await name.publish(keyName2, record.publicKey, { + const recursiveRecord = await name.publish(keyName2, published.publicKey, { offline: true }) - expect(recursiveRecord.record.value).to.equal(`/ipns/${record.publicKey.toCID().toString(base36)}`) + expect(uint8ArrayToString(recursiveRecord.record.value)).to.equal(`/ipns/${published.publicKey.toCID().toString(base36)}`) - const recursiveResult = await name.resolve(recursiveRecord.publicKey) - expect(recursiveResult.cid.toString()).to.equal(cid.toV1().toString()) + const recursiveResult = await last(name.resolve(recursiveRecord.publicKey)) + + if (recursiveResult == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(recursiveResult.record.value)).to.equal(`/ipfs/${cid.toV1()}`) }) it('should publish recursively using a libp2p-key CID', async () => { const keyName1 = 'test-key-6' - const record = await name.publish(keyName1, cid, { + const published = await name.publish(keyName1, cid, { offline: true }) - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) + expect(uint8ArrayToString(published.record.value)).to.equal(`/ipfs/${cid.toV1().toString()}`) const keyName2 = 'test-key-7' - // @ts-expect-error @libp2p/crypto needs new multiformats - const recursiveRecord = await name.publish(keyName2, record.publicKey.toCID(), { + const recursiveRecord = await name.publish(keyName2, published.publicKey.toCID(), { offline: true }) - expect(recursiveRecord.record.value).to.equal(`/ipns/${record.publicKey.toCID().toString(base36)}`) + expect(uint8ArrayToString(recursiveRecord.record.value)).to.equal(`/ipns/${published.publicKey.toCID().toString(base36)}`) + + const recursiveResult = await last(name.resolve(recursiveRecord.publicKey)) - const recursiveResult = await name.resolve(recursiveRecord.publicKey) - expect(recursiveResult.cid.toString()).to.equal(cid.toV1().toString()) + if (recursiveResult == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(recursiveResult.record.value)).to.equal(`/ipfs/${cid.toV1()}`) }) it('should publish recursively using a multihash', async () => { const keyName1 = 'test-key-8' - const record = await name.publish(keyName1, cid, { + const published = await name.publish(keyName1, cid, { offline: true }) - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) + expect(uint8ArrayToString(published.record.value)).to.equal(`/ipfs/${cid.toV1().toString()}`) const keyName2 = 'test-key-9' - // @ts-expect-error @libp2p/crypto needs new multiformats - const recursiveRecord = await name.publish(keyName2, record.publicKey.toCID().multihash, { + const recursiveRecord = await name.publish(keyName2, published.publicKey.toMultihash(), { offline: true }) - expect(recursiveRecord.record.value).to.equal(`/ipns/${base36.encode(record.publicKey.toCID().multihash.bytes)}`) + expect(uint8ArrayToString(recursiveRecord.record.value)).to.equal(`/ipns/${base36.encode(published.publicKey.toMultihash().bytes)}`) - const recursiveResult = await name.resolve(recursiveRecord.publicKey) - expect(recursiveResult.cid.toString()).to.equal(cid.toV1().toString()) - }) - - it('should publish recursively using a PeerId key', async () => { - const keyName1 = 'test-key-10' - const record = await name.publish(keyName1, cid, { - offline: true - }) + const recursiveResult = await last(name.resolve(recursiveRecord.publicKey)) - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) + if (recursiveResult == null) { + throw new Error('No results found') + } - const keyName2 = 'test-key-11' - const recursiveRecord = await name.publish(keyName2, peerIdFromCID(record.publicKey.toCID()), { - offline: true - }) - - expect(recursiveRecord.record.value).to.equal(`/ipns/${record.publicKey.toCID().toString(base36)}`) - - const recursiveResult = await name.resolve(recursiveRecord.publicKey) - expect(recursiveResult.cid.toString()).to.equal(cid.toV1().toString()) + expect(uint8ArrayToString(recursiveResult.record.value)).to.equal(`/ipfs/${cid.toV1()}`) }) it('should publish recursively using a string IPNS key', async () => { const keyName1 = 'test-key-10' - const record = await name.publish(keyName1, cid, { + const published = await name.publish(keyName1, cid, { offline: true }) - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) + expect(uint8ArrayToString(published.record.value)).to.equal(`/ipfs/${cid.toV1().toString()}`) const keyName2 = 'test-key-11' - const recursiveRecord = await name.publish(keyName2, `/ipns/${record.publicKey.toCID().toString(base36)}`, { + const recursiveRecord = await name.publish(keyName2, `/ipns/${published.publicKey.toCID().toString(base36)}`, { offline: true }) - expect(recursiveRecord.record.value).to.equal(`/ipns/${record.publicKey.toCID().toString(base36)}`) + expect(uint8ArrayToString(recursiveRecord.record.value)).to.equal(`/ipns/${published.publicKey.toCID().toString(base36)}`) + + const recursiveResult = await last(name.resolve(recursiveRecord.publicKey)) - const recursiveResult = await name.resolve(recursiveRecord.publicKey) - expect(recursiveResult.cid.toString()).to.equal(cid.toV1().toString()) + if (recursiveResult == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(recursiveResult.record.value)).to.equal(`/ipfs/${cid.toV1()}`) }) it('should publish record with a path', async () => { @@ -194,16 +194,19 @@ describe('publish', () => { const fullPath = `/ipfs/${cid}/${path}` const keyName = 'test-key-12' - const record = await name.publish(keyName, fullPath, { + const published = await name.publish(keyName, fullPath, { offline: true }) - expect(record.record.value).to.equal(fullPath) + expect(uint8ArrayToString(published.record.value)).to.equal(`/ipfs/${cid.toV1()}${path}`) + + const result = await last(name.resolve(published.publicKey)) - const result = await name.resolve(record.publicKey) + if (result == null) { + throw new Error('No results found') + } - expect(result.cid.toString()).to.equal(cid.toString()) - expect(result.path).to.equal(path) + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}${path}`) }) describe('localStore error handling', () => { diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 579d710ad..5b20e8f91 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -1,13 +1,15 @@ -import { generateKeyPair } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' import { expect } from 'aegir/chai' -import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from 'ipns' import { CID } from 'multiformats/cid' import sinon from 'sinon' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { localStore } from '../src/local-store.ts' +import { createIPNSRecord, marshalIPNSRecord, unmarshalIPNSRecord, multihashToIPNSRoutingKey } from '../src/records.ts' import { createIPNS } from './fixtures/create-ipns.ts' +import { getCryptoKey } from './fixtures/crypto-loader.ts' import type { IPNS } from '../src/ipns.ts' import type { CreateIPNSResult } from './fixtures/create-ipns.ts' +import type { Key } from 'interface-datastore' // Helper to await until a stub is called function waitForStubCall (stub: sinon.SinonStub, callCount = 1): Promise { @@ -23,11 +25,14 @@ function waitForStubCall (stub: sinon.SinonStub, callCount = 1): Promise { }) } +// shorten the default validity so we are always within the republish window +const SHORTENED_VALIDITY = 2 * 60 * 60 * 1000 + describe('republish', () => { const testCid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn') let name: IPNS let result: CreateIPNSResult - let putStubCustom: sinon.SinonStub + let putStubCustom: sinon.SinonStub<[Key, Uint8Array]> let putStubHelia: sinon.SinonStub beforeEach(async () => { @@ -35,7 +40,7 @@ describe('republish', () => { name = result.name // Stub the routers by default - putStubCustom = sinon.stub().resolves() + putStubCustom = sinon.stub<[Key, Uint8Array]>().resolves() putStubHelia = sinon.stub().resolves() // @ts-ignore result.customRouting.put = putStubCustom @@ -51,21 +56,19 @@ describe('republish', () => { describe('basic functionality', () => { it('should start republishing when called', async () => { + // Import the key into the real keychain + const key = await result.keychain.createKey('test-key', 'Ed25519') + // Create a test record and store it in the real datastore - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore using the localStore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -79,28 +82,25 @@ describe('republish', () => { it('should call all routers for republish', async () => { // Create a test record and store it in the real datastore - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore using the localStore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) - // Start republishing - await start(name) await Promise.all([ waitForStubCall(putStubCustom), - waitForStubCall(putStubHelia) + waitForStubCall(putStubHelia), + + // Start republishing + start(name) ]) // Check both routers @@ -111,20 +111,16 @@ describe('republish', () => { }) it('should republish records with valid metadata', async () => { - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -137,16 +133,15 @@ describe('republish', () => { const callArgs = putStubCustom.firstCall.args expect(callArgs[0]).to.deep.equal(routingKey) - const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n }) }) describe('record processing', () => { it('should skip records without metadata', async () => { - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) // Store the record without metadata (simulate old records) @@ -168,7 +163,7 @@ describe('republish', () => { await store.put(routingKey, new Uint8Array([255, 255, 255]), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -180,20 +175,16 @@ describe('republish', () => { }) it('should increment sequence numbers correctly', async () => { - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 5n, 24 * 60 * 60 * 1000) // Start with sequence 5 - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 5n, SHORTENED_VALIDITY) // Start with sequence 5 const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -203,28 +194,24 @@ describe('republish', () => { expect(putStubCustom.called).to.be.true() const callArgs = putStubCustom.firstCall.args - const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) expect(republishedRecord.sequence).to.equal(6n) // Incremented from 5n }) }) describe('TTL and lifetime', () => { it('should use existing TTL from records', async () => { - const key = await generateKeyPair('Ed25519') + const key = await result.keychain.createKey('test-key', 'Ed25519') const customTtl = BigInt(10 * 60 * 1000) * 1_000_000n // 10 minutes in nanoseconds - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000, { ttlNs: customTtl }) - // @ts-expect-error @libp2p/crypto needs new multiformats + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY, { ttlNs: customTtl }) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -236,26 +223,22 @@ describe('republish', () => { const callArgs = putStubCustom.firstCall.args expect(callArgs[0]).to.deep.equal(routingKey) - const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n expect(republishedRecord.ttl).to.equal(customTtl) }) it('should use default TTL when not present', async () => { - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -264,20 +247,16 @@ describe('republish', () => { expect(putStubCustom.called).to.be.true() const callArgs = putStubCustom.firstCall.args - const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) expect(republishedRecord.ttl).to.equal(5n * 60n * 1000n * 1_000_000n) // Default TTL }) it('should use metadata lifetime', async () => { - const key = await generateKeyPair('Ed25519') + const key = await result.keychain.createKey('test-key', 'Ed25519') const customLifetime = 5 * 1000 // 5 seconds - const record = await createIPNSRecord(key, testCid, 1n, customLifetime) - // @ts-expect-error @libp2p/crypto needs new multiformats + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, customLifetime) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key into the real keychain - await result.ipnsKeychain.importKey('test-key', key) - // Store the record in the real datastore const store = localStore(result.datastore, result.log) await store.put(routingKey, marshalIPNSRecord(record), { @@ -295,7 +274,7 @@ describe('republish', () => { expect(putStubCustom.called).to.be.true() const callArgs = putStubCustom.firstCall.args - const republishedRecord = unmarshalIPNSRecord(callArgs[1]) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) // Check that the validity is set to the custom lifetime const actualValidity = new Date(republishedRecord.validity) @@ -307,9 +286,8 @@ describe('republish', () => { describe('error handling', () => { it('should skip republishing records with missing key', async () => { - const key = await generateKeyPair('Ed25519') - const record = await createIPNSRecord(key, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) // Store the record in the real datastore (but don't import the key) @@ -317,7 +295,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'missing-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -384,20 +362,16 @@ describe('republish', () => { }) it('should handle corrupt record data during republish iteration', async () => { - const key = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await result.keychain.createKey('test-key', 'Ed25519') const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) - // Import the key - await result.ipnsKeychain.importKey('test-key', key) - const store = localStore(result.datastore, result.log) // Store corrupt record data that will fail to unmarshal await store.put(routingKey, new Uint8Array([255, 255, 255]), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -410,31 +384,25 @@ describe('republish', () => { }) it('should continue republishing other records when one record fails', async () => { - const key1 = await generateKeyPair('Ed25519') - const key2 = await generateKeyPair('Ed25519') - const record2 = await createIPNSRecord(key2, testCid, 1n, 24 * 60 * 60 * 1000) - // @ts-expect-error @libp2p/crypto needs new multiformats + const key1 = await result.keychain.createKey('test-key-1', 'Ed25519') + const key2 = await result.keychain.createKey('test-key-2', 'Ed25519') + const record2 = await createIPNSRecord(key2, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey1 = multihashToIPNSRoutingKey(key1.publicKey.toMultihash()) - // @ts-expect-error @libp2p/crypto needs new multiformats const routingKey2 = multihashToIPNSRoutingKey(key2.publicKey.toMultihash()) - // Import both keys - await result.ipnsKeychain.importKey('test-key-1', key1) - await result.ipnsKeychain.importKey('test-key-2', key2) - const store = localStore(result.datastore, result.log) // Store one valid record and one corrupt record await store.put(routingKey1, new Uint8Array([255, 255, 255]), { metadata: { keyName: 'test-key-1', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) await store.put(routingKey2, marshalIPNSRecord(record2), { metadata: { keyName: 'test-key-2', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 94fb4902b..9b556e408 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -1,17 +1,19 @@ -import { generateKeyPair } from '@libp2p/crypto/keys' import { start, stop } from '@libp2p/interface' import { Record } from '@libp2p/kad-dht' -import { peerIdFromCID } from '@libp2p/peer-id' import { expect } from 'aegir/chai' import { Key } from 'interface-datastore' -import { createIPNSRecord, createIPNSRecordWithExpiration, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' import drain from 'it-drain' +import last from 'it-last' import { CID } from 'multiformats/cid' import Sinon from 'sinon' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { createIPNSRecord, createIPNSRecordWithExpiration, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from '../src/records.ts' import { createIPNS } from './fixtures/create-ipns.ts' +import { getCryptoKey } from './fixtures/crypto-loader.ts' import type { IPNS } from '../src/index.ts' import type { Routing } from '@helia/interface' +import type { Keychain } from '@helia/interface' import type { Datastore } from 'interface-datastore' import type { StubbedInstance } from 'sinon-ts' @@ -22,6 +24,7 @@ describe('resolve', () => { let customRouting: any let datastore: Datastore let heliaRouting: StubbedInstance + let keychain: Keychain beforeEach(async () => { const result = await createIPNS() @@ -29,6 +32,7 @@ describe('resolve', () => { customRouting = result.customRouting heliaRouting = result.heliaRouting datastore = result.datastore + keychain = result.keychain await start(name) }) @@ -46,8 +50,13 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) - const resolvedValue = await name.resolve(publicKey) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + const result = await last(name.resolve(publicKey)) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true() @@ -62,26 +71,13 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) - // @ts-expect-error @libp2p/crypto needs new multiformats - const resolvedValue = await name.resolve(publicKey.toCID()) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + const result = await last(name.resolve(publicKey.toCID())) - expect(heliaRouting.get.called).to.be.true() - expect(customRouting.get.called).to.be.true() - }) + if (result == null) { + throw new Error('No results found') + } - it('should resolve a record using a PeerId', async () => { - const keyName = 'test-key' - const { record, publicKey } = await name.publish(keyName, cid) - - // empty the datastore to ensure we resolve using the routing - await drain(datastore.deleteMany(datastore.queryKeys({}))) - - heliaRouting.get.resolves(marshalIPNSRecord(record)) - - const peerId = peerIdFromCID(publicKey.toCID()) - const resolvedValue = await name.resolve(peerId) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true() @@ -94,10 +90,15 @@ describe('resolve', () => { expect(heliaRouting.put.called).to.be.true() expect(customRouting.put.called).to.be.true() - const resolvedValue = await name.resolve(publicKey, { + const result = await last(name.resolve(publicKey, { offline: true - }) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + })) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) expect(heliaRouting.get.called).to.be.false() expect(customRouting.get.called).to.be.false() @@ -112,10 +113,15 @@ describe('resolve', () => { heliaRouting.get.resolves(marshalIPNSRecord(record)) - const resolvedValue = await name.resolve(publicKey, { + const result = await last(name.resolve(publicKey, { nocache: true - }) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + })) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) expect(heliaRouting.get.called).to.be.true() expect(customRouting.get.called).to.be.true() @@ -130,8 +136,13 @@ describe('resolve', () => { const keyName = 'test-key' const { publicKey } = await name.publish(keyName, cid) - const resolvedValue = await name.resolve(publicKey) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + const result = await last(name.resolve(publicKey)) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) expect(heliaRouting.get.called).to.be.false() expect(customRouting.get.called).to.be.false() @@ -145,8 +156,13 @@ describe('resolve', () => { const { publicKey: publicKey2 } = await name.publish(keyName2, cid) const { publicKey: publicKey1 } = await name.publish(keyName1, publicKey2) - const resolvedValue = await name.resolve(publicKey1) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + const result = await last(name.resolve(publicKey1)) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) }) it('should resolve a recursive record with path', async () => { @@ -156,8 +172,13 @@ describe('resolve', () => { const { publicKey: publicKey2 } = await name.publish(keyName2, cid) const { publicKey: publicKey1 } = await name.publish(keyName1, publicKey2) - const resolvedValue = await name.resolve(publicKey1) - expect(resolvedValue.cid.toString()).to.equal(cid.toV1().toString()) + const result = await last(name.resolve(publicKey1)) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) }) it('should emit progress events', async function () { @@ -165,40 +186,43 @@ describe('resolve', () => { const keyName = 'test-key' const { publicKey } = await name.publish(keyName, cid) - await name.resolve(publicKey, { + await drain(name.resolve(publicKey, { onProgress - }) + })) expect(onProgress).to.have.property('called', true) }) it('should cache a record', async function () { - const key = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await keychain.createKey('test-key', 'Ed25519') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) expect(datastore.has(dhtKey)).to.be.false('already had record') - const record = await createIPNSRecord(key, cid, 0n, 60000) + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 0n, 60000) const marshalledRecord = marshalIPNSRecord(record) customRouting.get.withArgs(customRoutingKey).resolves(marshalledRecord) - const result = await name.resolve(key.publicKey) - expect(result.cid.toString()).to.equal(cid.toV1().toString(), 'incorrect record resolved') + const result = await last(name.resolve(key.publicKey)) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) expect(datastore.has(dhtKey)).to.be.true('did not cache record locally') }) it('should cache the most recent record', async function () { - const key = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await keychain.createKey('test-key', 'Ed25519') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) - const marshalledRecordA = marshalIPNSRecord(await createIPNSRecord(key, cid, 0n, 60000)) - const marshalledRecordB = marshalIPNSRecord(await createIPNSRecord(key, cid, 10n, 60000)) + const marshalledRecordA = marshalIPNSRecord(await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 0n, 60000)) + const marshalledRecordB = marshalIPNSRecord(await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 10n, 60000)) // records should not match expect(marshalledRecordA).to.not.equalBytes(marshalledRecordB) @@ -207,8 +231,13 @@ describe('resolve', () => { await datastore.put(dhtKey, marshalledRecordA) customRouting.get.withArgs(customRoutingKey).resolves(marshalledRecordB) - const result = await name.resolve(key.publicKey) - expect(result.cid.toString()).to.equal(cid.toV1().toString(), 'incorrect record resolved') + const result = await last(name.resolve(key.publicKey)) + + if (result == null) { + throw new Error('No results found') + } + + expect(uint8ArrayToString(result.record.value)).to.equal(`/ipfs/${cid.toV1()}`) const cached = await datastore.get(dhtKey) const record = Record.deserialize(cached) @@ -221,64 +250,63 @@ describe('resolve', () => { const keyName = 'test-key' const { record, publicKey } = await name.publish(keyName, cid) - const result = await name.resolve(publicKey) + const result = await last(name.resolve(publicKey)) - expect(result).to.have.deep.property('record') + if (result == null) { + throw new Error('No results found') + } + + expect(result).to.have.property('record') expect(marshalIPNSRecord(result.record)).to.deep.equal(marshalIPNSRecord(record)) }) it('should not search the routing for updated IPNS records when a locally cached copy is within the TTL', async () => { - const key = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await keychain.createKey('test-key', 'Ed25519') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) // create a record with a valid lifetime and a non-expired TTL - const ipnsRecord = await createIPNSRecord(key, cid, 1, Math.pow(2, 10), { + const ipnsRecord = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 1, Math.pow(2, 10), { ttlNs: 10_000_000_000 }) const dhtRecord = new Record(customRoutingKey, marshalIPNSRecord(ipnsRecord), new Date(Date.now())) await datastore.put(dhtKey, dhtRecord.serialize()) - const result = await name.resolve(key.publicKey) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(marshalIPNSRecord(ipnsRecord))) + const result = await last(name.resolve(key.publicKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), getCryptoKey)) // should not have searched the routing expect(customRouting.get.called).to.be.false() }) it('should search the routing for updated IPNS records when a locally cached copy has passed the TTL', async () => { - const key = await generateKeyPair('Ed25519') - - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await keychain.createKey('test-key', 'Ed25519') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) // create a record with a valid lifetime but an expired ttl - const ipnsRecord = await createIPNSRecord(key, cid, 1, Math.pow(2, 10), { + const ipnsRecord = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 1, Math.pow(2, 10), { ttlNs: 10 }) const dhtRecord = new Record(customRoutingKey, marshalIPNSRecord(ipnsRecord), new Date(Date.now() - 1000)) await datastore.put(dhtKey, dhtRecord.serialize()) - const result = await name.resolve(key.publicKey) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(marshalIPNSRecord(ipnsRecord))) + const result = await last(name.resolve(key.publicKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), getCryptoKey)) // should have searched the routing expect(customRouting.get.called).to.be.true() }) it('should search the routing for updated IPNS records when a locally cached copy has passed the TTL and choose the record with a higher sequence number', async () => { - const key = await generateKeyPair('Ed25519') - - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await keychain.createKey('test-key', 'Ed25519') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) // create a record with a valid lifetime but an expired ttl - const ipnsRecord = await createIPNSRecord(key, cid, 10, Math.pow(2, 10), { + const ipnsRecord = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 10, Math.pow(2, 10), { ttlNs: 10 }) const dhtRecord = new Record(customRoutingKey, marshalIPNSRecord(ipnsRecord), new Date(Date.now() - 1000)) @@ -286,27 +314,25 @@ describe('resolve', () => { await datastore.put(dhtKey, dhtRecord.serialize()) // the routing returns a valid record with an higher sequence number - const ipnsRecordFromRouting = await createIPNSRecord(key, cid, 11, Math.pow(2, 10), { + const ipnsRecordFromRouting = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 11, Math.pow(2, 10), { ttlNs: 10_000_000 }) customRouting.get.withArgs(customRoutingKey).resolves(marshalIPNSRecord(ipnsRecordFromRouting)) - const result = await name.resolve(key.publicKey) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(marshalIPNSRecord(ipnsRecordFromRouting))) + const result = await last(name.resolve(key.publicKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), getCryptoKey)) // should have searched the routing expect(customRouting.get.called).to.be.true() }) it('should search the routing when a locally cached copy has an expired lifetime', async () => { - const key = await generateKeyPair('Ed25519') - - // @ts-expect-error @libp2p/crypto needs new multiformats + const key = await keychain.createKey('test-key', 'Ed25519') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) // create a record with an expired lifetime but valid TTL - const ipnsRecord = await createIPNSRecordWithExpiration(key, cid, 10, new Date(Date.now() - Math.pow(2, 10)).toString(), { + const ipnsRecord = await createIPNSRecordWithExpiration(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 10, new Date(Date.now() - Math.pow(2, 10)).toString(), { ttlNs: 10_000_000 }) const dhtRecord = new Record(customRoutingKey, marshalIPNSRecord(ipnsRecord), new Date(Date.now())) @@ -314,13 +340,13 @@ describe('resolve', () => { await datastore.put(dhtKey, dhtRecord.serialize()) // the routing returns a valid record with an higher sequence number - const ipnsRecordFromRouting = await createIPNSRecord(key, cid, 11, Math.pow(2, 10), { + const ipnsRecordFromRouting = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${cid.toV1()}`), 11, Math.pow(2, 10), { ttlNs: 10_000_000 }) customRouting.get.withArgs(customRoutingKey).resolves(marshalIPNSRecord(ipnsRecordFromRouting)) - const result = await name.resolve(key.publicKey) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(marshalIPNSRecord(ipnsRecordFromRouting))) + const result = await last(name.resolve(key.publicKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), getCryptoKey)) // should have searched the routing expect(customRouting.get.called).to.be.true() diff --git a/packages/ipns/test/routing/pubsub.spec.ts b/packages/ipns/test/routing/pubsub.spec.ts index 6f5183e79..dfc3f0e75 100644 --- a/packages/ipns/test/routing/pubsub.spec.ts +++ b/packages/ipns/test/routing/pubsub.spec.ts @@ -1,21 +1,25 @@ -import { generateKeyPair } from '@libp2p/crypto/keys' +import { Keychain } from '@helia/utils' import { start, stop, TypedEventEmitter } from '@libp2p/interface' import { defaultLogger } from '@libp2p/logger' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core' import delay from 'delay' -import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' import Sinon from 'sinon' import { stubInterface } from 'sinon-ts' import { toString } from 'uint8arrays' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DEFAULT_LIFETIME_MS } from '../../src/constants.ts' import { localStore } from '../../src/local-store.ts' +import { createIPNSRecord, marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } from '../../src/records.ts' import { PubSubRouting } from '../../src/routing/pubsub.ts' +import { getCryptoKey } from '../fixtures/crypto-loader.ts' import type { IPNSRecord } from '../../src/index.ts' import type { LocalStore } from '../../src/local-store.ts' import type { Message, PubSub, PubSubEvents, Subscription } from '../../src/routing/pubsub.ts' +import type { PrivateKey } from '@helia/interface' import type { Fetch } from '@libp2p/fetch' -import type { Ed25519PrivateKey, Libp2p, PeerId } from '@libp2p/interface' +import type { Libp2p, PeerId } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' import type { StubbedInstance } from 'sinon-ts' @@ -28,10 +32,11 @@ describe('pubsub routing', () => { let pubsubRouter: PubSubRouting let routingKey: Uint8Array let topic: string - let privateKey: Ed25519PrivateKey + let privateKey: PrivateKey let record: IPNSRecord let target: TypedEventEmitter let libp2p: StubbedInstance, fetch: StubbedInstance }>> + let keychain: Keychain beforeEach(async () => { datastore = new MemoryDatastore() @@ -57,14 +62,20 @@ describe('pubsub routing', () => { pubsubRouter = new PubSubRouting({ datastore, logger, - libp2p + libp2p, + getCryptoKey }) - privateKey = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats + keychain = new Keychain({ + datastore, + logger, + getCryptoKey + }) + + privateKey = await keychain.createKey('test-key', 'Ed25519') routingKey = multihashToIPNSRoutingKey(privateKey.publicKey.toMultihash()) topic = `/record/${toString(routingKey, 'base64url')}` - record = await createIPNSRecord(privateKey, '/test', 1n, DEFAULT_LIFETIME_MS) + record = await createIPNSRecord(privateKey, uint8ArrayFromString('/test'), 1n, DEFAULT_LIFETIME_MS) await start(pubsubRouter) }) @@ -127,7 +138,7 @@ describe('pubsub routing', () => { await expect(pubsubRouter.get(routingKey)).to.eventually.be.rejected .with.property('name', 'NotFoundError') - const newRecord = await createIPNSRecord(privateKey, '/test2', 2n, DEFAULT_LIFETIME_MS) + const newRecord = await createIPNSRecord(privateKey, uint8ArrayFromString('/test2'), 2n, DEFAULT_LIFETIME_MS) message.data = marshalIPNSRecord(newRecord) target.safeDispatchEvent('message', event) @@ -135,9 +146,9 @@ describe('pubsub routing', () => { await delay(100) const result = await store.get(routingKey) - const updatedRecord = unmarshalIPNSRecord(result.record) + const updatedRecord = await unmarshalIPNSRecord(routingKey, result.record, getCryptoKey) expect(updatedRecord.sequence).to.equal(2n) - expect(updatedRecord.value).to.equal('/test2') + expect(uint8ArrayToString(updatedRecord.value)).to.equal('/test2') }) it('skips the message if duplicate record', async () => { diff --git a/packages/utils/package.json b/packages/utils/package.json index ba3116cfe..2f71059b8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -45,7 +45,8 @@ "test:firefox": "aegir test -t browser -- --browser firefox", "test:firefox-webworker": "aegir test -t webworker -- --browser firefox", "test:node": "aegir test -t node --cov", - "test:electron-main": "aegir test -t electron-main" + "test:electron-main": "aegir test -t electron-main", + "generate": "protons src/keychain/keys.proto" }, "dependencies": { "@helia/interface": "^6.2.1", @@ -53,10 +54,10 @@ "@ipld/dag-json": "^11.0.0", "@ipld/dag-pb": "^4.1.5", "@libp2p/interface": "^3.2.0", - "@libp2p/keychain": "^6.0.12", "@libp2p/utils": "^7.0.15", "@multiformats/dns": "^1.0.13", "@multiformats/multiaddr": "^13.0.1", + "abort-error": "^1.0.2", "any-signal": "^4.2.0", "blockstore-core": "^7.0.1", "cborg": "^5.1.0", @@ -72,11 +73,15 @@ "multiformats": "^14.0.0", "p-defer": "^4.0.1", "progress-events": "^1.1.0", + "protons-runtime": "^7.0.0", "race-signal": "^2.0.0", + "sanitize-filename": "^1.6.4", + "uint8arraylist": "^3.0.2", "uint8arrays": "^6.1.1" }, "devDependencies": { "@libp2p/crypto": "^5.1.15", + "@libp2p/keychain": "^6.1.1", "@libp2p/logger": "^6.2.4", "@libp2p/peer-id": "^6.0.6", "@types/sinon": "^21.0.1", @@ -85,6 +90,7 @@ "delay": "^7.0.0", "it-all": "^3.0.11", "it-map": "^3.1.5", + "protons": "^9.0.1", "sinon": "^22.0.0", "sinon-ts": "^2.0.0" }, diff --git a/packages/utils/src/crypto/der.ts b/packages/utils/src/crypto/der.ts new file mode 100644 index 000000000..7b5e3cd6f --- /dev/null +++ b/packages/utils/src/crypto/der.ts @@ -0,0 +1,270 @@ +import { Uint8ArrayList } from 'uint8arraylist' +import { withArrayBuffer } from 'uint8arrays' + +interface Context { + offset: number +} + +const TAG_MASK = parseInt('11111', 2) +const LONG_LENGTH_MASK = parseInt('10000000', 2) +const LONG_LENGTH_BYTES_MASK = parseInt('01111111', 2) + +interface Decoder { + (buf: Uint8Array, context: Context): any +} + +const decoders: Record = { + 0x0: readSequence, + 0x1: readSequence, + 0x2: readInteger, + 0x3: readBitString, + 0x4: readOctetString, + 0x5: readNull, + 0x6: readObjectIdentifier, + 0x10: readSequence, + 0x16: readSequence, + 0x30: readSequence +} + +export function decodeDer (buf: Uint8Array, context: Context = { offset: 0 }): any { + const tag = buf[context.offset] & TAG_MASK + context.offset++ + + if (decoders[tag] != null) { + return decoders[tag](buf, context) + } + + throw new Error('No decoder for tag 0x' + tag.toString(16).padStart(2, '0')) +} + +function readLength (buf: Uint8Array, context: Context): number { + let length = 0 + + if ((buf[context.offset] & LONG_LENGTH_MASK) === LONG_LENGTH_MASK) { + // long length + const count = buf[context.offset] & LONG_LENGTH_BYTES_MASK + let str = '0x' + context.offset++ + + for (let i = 0; i < count; i++, context.offset++) { + str += buf[context.offset].toString(16).padStart(2, '0') + } + + length = parseInt(str, 16) + } else { + length = buf[context.offset] + context.offset++ + } + + return length +} + +function readSequence (buf: Uint8Array, context: Context): any[] { + readLength(buf, context) + const entries: any[] = [] + + while (true) { + if (context.offset >= buf.byteLength) { + break + } + + const result = decodeDer(buf, context) + + if (result === null) { + break + } + + entries.push(result) + } + + return entries +} + +function readInteger (buf: Uint8Array, context: Context): Uint8Array { + const length = readLength(buf, context) + const start = context.offset + const end = context.offset + length + + const vals: number[] = [] + + for (let i = start; i < end; i++) { + if (i === start && buf[i] === 0) { + continue + } + + vals.push(buf[i]) + } + + context.offset += length + + return Uint8Array.from(vals) +} + +function readObjectIdentifier (buf: Uint8Array, context: Context): string { + const count = readLength(buf, context) + const finalOffset = context.offset + count + + const byte = buf[context.offset] + context.offset++ + + let val1 = 0 + let val2 = 0 + + if (byte < 40) { + val1 = 0 + val2 = byte + } else if (byte < 80) { + val1 = 1 + val2 = byte - 40 + } else { + val1 = 2 + val2 = byte - 80 + } + + let oid = `${val1}.${val2}` + let num: number[] = [] + + while (context.offset < finalOffset) { + const byte = buf[context.offset] + context.offset++ + + // remove msb + num.push(byte & 0b01111111) + + if (byte < 128) { + num.reverse() + + // reached the end of the encoding + let val = 0 + + for (let i = 0; i < num.length; i++) { + val += num[i] << (i * 7) + } + + oid += `.${val}` + num = [] + } + } + + return oid +} + +function readNull (buf: Uint8Array, context: Context): null { + context.offset++ + + return null +} + +function readBitString (buf: Uint8Array, context: Context): any { + const length = readLength(buf, context) + const unusedBits = buf[context.offset] + context.offset++ + const bytes = buf.subarray(context.offset, context.offset + length - 1) + context.offset += length + + if (unusedBits !== 0) { + // need to shift all bytes along by this many bits + throw new Error('Unused bits in bit string is unimplemented') + } + + return bytes +} + +function readOctetString (buf: Uint8Array, context: Context): any { + const length = readLength(buf, context) + const bytes = buf.subarray(context.offset, context.offset + length) + context.offset += length + + return bytes +} + +function encodeNumber (value: number): Uint8ArrayList { + let number = value.toString(16) + + if (number.length % 2 === 1) { + number = '0' + number + } + + const array = new Uint8ArrayList() + + for (let i = 0; i < number.length; i += 2) { + array.append(Uint8Array.from([parseInt(`${number[i]}${number[i + 1]}`, 16)])) + } + + return array +} + +function encodeLength (bytes: { byteLength: number }): Uint8Array | Uint8ArrayList { + if (bytes.byteLength < 128) { + return Uint8Array.from([bytes.byteLength]) + } + + // long length + const length = encodeNumber(bytes.byteLength) + + return new Uint8ArrayList( + Uint8Array.from([ + length.byteLength | LONG_LENGTH_MASK + ]), + length + ) +} + +export function encodeInteger (value: Uint8Array | Uint8ArrayList): Uint8ArrayList { + const contents = new Uint8ArrayList() + + const mask = 0b10000000 + const positive = (value.subarray()[0] & mask) === mask + + if (positive) { + contents.append(Uint8Array.from([0])) + } + + contents.append(value) + + return new Uint8ArrayList( + Uint8Array.from([0x02]), + encodeLength(contents), + contents + ) +} + +export function encodeBitString (value: Uint8Array | Uint8ArrayList): Uint8ArrayList { + // unused bits is always 0 with full-byte-only values + const unusedBits = Uint8Array.from([0]) + + const contents = new Uint8ArrayList( + unusedBits, + value + ) + + return new Uint8ArrayList( + Uint8Array.from([0x03]), + encodeLength(contents), + contents + ) +} + +export function encodeOctetString (value: Uint8Array | Uint8ArrayList): Uint8ArrayList { + return new Uint8ArrayList( + Uint8Array.from([0x04]), + encodeLength(value), + value + ) +} + +export function encodeSequence (values: Array, tag = 0x30): Uint8ArrayList { + const output = new Uint8ArrayList() + + for (const buf of values) { + output.append( + withArrayBuffer(buf.subarray()) + ) + } + + return new Uint8ArrayList( + Uint8Array.from([tag]), + encodeLength(output), + output + ) +} diff --git a/packages/utils/src/crypto/ed25519.ts b/packages/utils/src/crypto/ed25519.ts new file mode 100644 index 000000000..f6262ec21 --- /dev/null +++ b/packages/utils/src/crypto/ed25519.ts @@ -0,0 +1,196 @@ +import { InvalidParametersError, InvalidPrivateKeyError } from '@libp2p/interface' +import { CID } from 'multiformats' +import { base64 } from 'multiformats/bases/base64' +import { identity } from 'multiformats/hashes/identity' +import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' +import { toString as uint8arrayToString } from 'uint8arrays/to-string' +import { withArrayBuffer as uint8ArrayWithArrayBuffer } from 'uint8arrays/with-array-buffer' +import { PrivateKeyMessage } from '../keychain/keys.ts' +import type { Cipher, CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' +import type { AbortOptions } from 'abort-error' +import type { MultihashDigest } from 'multiformats' + +const PRIVATE_KEY_LENGTH = 32 +const PUBLIC_KEY_LENGTH = 32 + +class Ed25519PublicKey implements PublicKey { + public type = 'Ed25519' + public code = 1 + public raw: ArrayBuffer + + constructor (raw: ArrayBuffer) { + if (raw.byteLength > PUBLIC_KEY_LENGTH) { + throw new InvalidParametersError(`Public key was too long ${raw.byteLength} > ${PUBLIC_KEY_LENGTH}`) + } + + this.raw = raw + } + + toMultihash (): MultihashDigest { + const buf = PrivateKeyMessage.encode({ + Type: this.code, + Data: new Uint8Array(this.raw.slice()) + }) + + return identity.digest(buf) + } + + toCID (): CID { + return CID.createV1(0x72, this.toMultihash()) + } + + async verify (message: Uint8Array, signature: Uint8Array, options?: AbortOptions): Promise { + const key = await crypto.subtle.importKey('raw', this.raw, { name: 'Ed25519' }, false, ['verify']) + const isValid = await crypto.subtle.verify({ name: 'Ed25519' }, key, uint8ArrayWithArrayBuffer(signature), uint8ArrayWithArrayBuffer(message)) + options?.signal?.throwIfAborted() + + return isValid + } +} + +class Ed25519PrivateKey implements PrivateKey { + public type = 'Ed25519' + public code = 1 + public raw: ArrayBuffer + public publicKey: PublicKey + + constructor (raw: ArrayBuffer, publicKey: PublicKey) { + if (raw.byteLength < PRIVATE_KEY_LENGTH) { + throw new InvalidPrivateKeyError(`Incorrect key length, got ${raw.byteLength} expected ${PRIVATE_KEY_LENGTH}`) + } + + this.raw = truncateKey(raw) + this.publicKey = publicKey + } + + async sign (message: Uint8Array, options?: AbortOptions): Promise> { + const key = await crypto.subtle.importKey('jwk', { + crv: 'Ed25519', + kty: 'OKP', + x: uint8arrayToString(new Uint8Array(this.publicKey.raw), 'base64url'), + d: uint8arrayToString(new Uint8Array(this.raw), 'base64url'), + ext: true, + key_ops: ['sign'] + }, { + name: 'Ed25519' + }, true, ['sign']) + const sig = await crypto.subtle.sign({ + name: 'Ed25519' + }, key, uint8ArrayWithArrayBuffer(message)) + options?.signal?.throwIfAborted() + + return new Uint8Array(sig, 0, sig.byteLength) + } +} + +class Ed25519Crypto implements CryptoKeyImplementation { + type = 'Ed25519' + code = 1 + + async createPrivateKey (options?: AbortOptions & Record): Promise { + const key = await crypto.subtle.generateKey('Ed25519', true, ['sign', 'verify']) + const buf = await crypto.subtle.exportKey('pkcs8', key.privateKey) + + // raw key is last 32 bytes of pkcs8 wrapper + const raw = new Uint8Array(buf, buf.byteLength - PRIVATE_KEY_LENGTH, PRIVATE_KEY_LENGTH).slice() + return new Ed25519PrivateKey(raw.buffer, await derivePublicKey(raw.buffer, options)) + } + + async publicKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { + const publicKey = new Ed25519PublicKey(key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).slice().buffer) + options?.signal?.throwIfAborted() + + return publicKey + } + + async serialize (key: PrivateKey, cipher: Cipher, options?: AbortOptions): Promise { + const buf = PrivateKeyMessage.encode({ + Type: key.code, + Data: uint8ArrayConcat([ + new Uint8Array(key.raw.slice()), + new Uint8Array(key.publicKey.raw.slice()) + ], 64) + }) + + const result = await cipher.encrypt(buf, options) + + return base64.encode(uint8ArrayConcat([ + result.salt, + result.iv, + result.cipherText + ], result.salt.byteLength + result.iv.byteLength + result.cipherText.byteLength)) + } + + async deserialize (pem: string, cipher: Cipher, options?: AbortOptions): Promise { + const decoded = base64.decode(pem) + const salt = decoded.subarray(0, 16) + const iv = decoded.subarray(16, 16 + 12) + const cipherText = decoded.subarray(16 + 12) + + const plainText = await cipher.decrypt(salt, iv, cipherText) + const pb = PrivateKeyMessage.decode(plainText) + + if (pb.Data == null) { + throw new InvalidPrivateKeyError('Protobuf message did not contain private key') + } + + const raw = pb.Data.slice(0, 32).buffer + return new Ed25519PrivateKey(raw, await derivePublicKey(raw, options)) + } +} + +export function ed25519Crypto (): CryptoKeyImplementation { + return new Ed25519Crypto() +} + +/** + * for legacy reasons the public key is sometimes appended to the private key so + * truncate the Uint8Array to handle this case + */ +function truncateKey (input: ArrayBuffer): ArrayBuffer { + const key = new ArrayBuffer(PRIVATE_KEY_LENGTH) + const view = new Uint8Array(key) + view.set(new Uint8Array(input, 0, PRIVATE_KEY_LENGTH)) + + return key +} + +async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promise { + let publicKey: ArrayBuffer + + // if the public key is appended to the private key, just return that + if (raw.byteLength === 64) { + publicKey = new Uint8Array(raw, PRIVATE_KEY_LENGTH).slice().buffer + } else { + const privateKey = truncateKey(raw) + const pkcs8 = convertRawX25519KeyToPKCS(privateKey) + const key = await crypto.subtle.importKey('pkcs8', pkcs8, { + name: 'Ed25519' + }, true, ['sign']) + + const exported = await crypto.subtle.exportKey('jwk', key) + + if (exported.x == null) { + throw new InvalidPrivateKeyError('Public key was missing from JWK export') + } + + publicKey = uint8arrayFromString(exported.x ?? '', 'base64url').buffer + } + + options?.signal?.throwIfAborted() + + return new Ed25519PublicKey(publicKey) +} + +const PKCS8_HEADER = Uint8Array.from([ + 48, 46, 2, 1, 0, 48, 5, 6, 3, 43, 101, 112, 4, 34, 4 +]) + +function convertRawX25519KeyToPKCS (privateKey: ArrayBuffer): Uint8Array { + return uint8ArrayConcat([ + PKCS8_HEADER, + Uint8Array.from([privateKey.byteLength]), + new Uint8Array(privateKey) + ], PKCS8_HEADER.byteLength + 1 + privateKey.byteLength) +} diff --git a/packages/utils/src/crypto/index.ts b/packages/utils/src/crypto/index.ts new file mode 100644 index 000000000..dff5df29e --- /dev/null +++ b/packages/utils/src/crypto/index.ts @@ -0,0 +1,2 @@ +export { ed25519Crypto } from './ed25519.ts' +export { rsaCrypto } from './rsa.ts' diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts new file mode 100644 index 000000000..c80f5e4e7 --- /dev/null +++ b/packages/utils/src/crypto/rsa.ts @@ -0,0 +1,293 @@ +import { InvalidParametersError } from '@libp2p/interface' +import { CID } from 'multiformats' +import { base64 } from 'multiformats/bases/base64' +import { sha256 } from 'multiformats/hashes/sha2' +import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { withArrayBuffer as uint8ArrayWithArrayBuffer } from 'uint8arrays/with-array-buffer' +import { PrivateKeyMessage } from '../keychain/keys.ts' +import { decodeDer, encodeInteger, encodeSequence } from './der.ts' +import type { Cipher, CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' +import type { AbortOptions } from '@libp2p/interface' +import type { MultihashDigest } from 'multiformats' + +export const MAX_RSA_KEY_SIZE = 8192 + +class RSAPublicKey implements PublicKey { + public type = 'RSA' + public code = 0 + public raw: ArrayBuffer + private digest: MultihashDigest + + constructor (raw: ArrayBuffer, digest: MultihashDigest) { + this.raw = raw + this.digest = digest + } + + toMultihash (): MultihashDigest { + return this.digest + } + + toCID (): CID { + return CID.createV1(0x72, this.toMultihash()) + } + + async verify (message: Uint8Array, signature: Uint8Array, options?: AbortOptions): Promise { + const key = await crypto.subtle.importKey('jwk', { + key_ops: ['verify'], + ext: true, + alg: 'RS256', + kty: 'RSA', + n: uint8ArrayToString(new Uint8Array(this.raw), 'base64url'), + /* spell-checker:disable-next-line */ + e: 'AQAB' + }, { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256' + } + }, false, ['verify']) + const result = await crypto.subtle.verify({ + name: 'RSASSA-PKCS1-v1_5' + }, key, uint8ArrayWithArrayBuffer(signature), uint8ArrayWithArrayBuffer(message)) + options?.signal?.throwIfAborted() + + return result + } +} + +class RSAPrivateKey implements PrivateKey { + public type = 'RSA' + public code = 0 + public raw: ArrayBuffer + public publicKey: PublicKey + + constructor (pkcs8: ArrayBuffer, publicKey: PublicKey) { + this.raw = pkcs8 + this.publicKey = publicKey + } + + async sign (message: Uint8Array, options?: AbortOptions): Promise> { + const key = await crypto.subtle.importKey('pkcs8', this.raw, { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256' + } + }, false, ['sign']) + const sig = await crypto.subtle.sign({ + name: 'RSASSA-PKCS1-v1_5' + }, key, uint8ArrayWithArrayBuffer(message)) + + options?.signal?.throwIfAborted() + + return new Uint8Array(sig, 0, sig.byteLength) + } +} + +class RSACrypto implements CryptoKeyImplementation { + public type = 'RSA' + public code = 0 + + async createPrivateKey (options?: AbortOptions & Record): Promise { + const privateKey = await crypto.subtle.generateKey({ + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { + name: 'SHA-256' + } + }, true, ['sign', 'verify']) + const rawPrivateKey = await crypto.subtle.exportKey('pkcs8', privateKey.privateKey) + const exported = await crypto.subtle.exportKey('jwk', privateKey.publicKey) + const publicKey = uint8arrayFromString(exported.n ?? '', 'base64url') + + options?.signal?.throwIfAborted() + + return new RSAPrivateKey(rawPrivateKey, new RSAPublicKey(publicKey.buffer, await sha256.digest(new Uint8Array(publicKey)))) + } + + async publicKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { + const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer + const digest = await sha256.digest(new Uint8Array(raw)) + + options?.signal?.throwIfAborted() + + return new RSAPublicKey(raw, digest) + } + + async serialize (key: PrivateKey, cipher: Cipher, options?: AbortOptions): Promise { + const pkcs8 = await crypto.subtle.importKey('pkcs8', key.raw, { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256' + } + }, true, ['sign']) + const jwk = await crypto.subtle.exportKey('jwk', pkcs8) + const pkcs1 = jwkToPkcs1(jwk) + + const buf = PrivateKeyMessage.encode({ + Type: key.code, + Data: pkcs1 + }) + + const result = await cipher.encrypt(buf, options) + + return base64.encode(uint8ArrayConcat([ + result.salt, + result.iv, + result.cipherText + ], result.salt.byteLength + result.iv.byteLength + result.cipherText.byteLength)) + } + + async deserialize (pem: string, cipher: Cipher, options?: AbortOptions): Promise { + if (!pem.includes('-----BEGIN ENCRYPTED PRIVATE KEY-----')) { + const decoded = base64.decode(`${pem}`) + const salt = decoded.subarray(0, 16) + const iv = decoded.subarray(16, 16 + 12) + const cypherText = decoded.subarray(16 + 12) + const plainText = await cipher.decrypt(salt, iv, cypherText, options) + const pb = PrivateKeyMessage.decode(plainText) + + if (pb.Type !== 0) { + throw new Error('Incorrect type in protobuf message') + } + + if (pb.Data == null) { + throw new Error('Data field was missing from protobuf message') + } + + const pkcs1Decoded = decodeDer(pb.Data) + const jwk = pkcs1MessageToJwk(pkcs1Decoded) + + if (rsaKeySize(jwk) > MAX_RSA_KEY_SIZE) { + throw new InvalidParametersError('Key size is too large') + } + + const importedJWK = await crypto.subtle.importKey('jwk', jwk, { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256' + } + }, true, ['sign']) + const pkcs8 = await crypto.subtle.exportKey('pkcs8', importedJWK) + const publicKey = uint8arrayFromString(jwk.n ?? '', 'base64url') + const digest = await sha256.digest(new Uint8Array(publicKey)) + + options?.signal?.throwIfAborted() + + return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, digest)) + } + + pem = pem.replaceAll('-----BEGIN ENCRYPTED PRIVATE KEY-----', '') + pem = pem.replaceAll('-----END ENCRYPTED PRIVATE KEY-----', '') + pem = pem.replaceAll('\r', '') + pem = pem.replaceAll('\n', '') + + const decoded = base64.decode(`m${pem}`) + const der = decodeDer(decoded) + + const salt = der[0][1][0][1][0] + const iterations = toNumber(der[0][1][0][1][1]) + const keyLength = toNumber(der[0][1][0][1][2]) + const iv = der[0][1][0][1][4][1] + const keyData = der[0][1][0][1][4][2] + + const plainText = await cipher.decrypt(salt, iv, keyData, { + iterations, + keyLength: keyLength * 8, + hash: 'SHA-512', + algorithm: 'AES-CBC', + signal: options?.signal + }) + + const keyWrapper = decodeDer(plainText) + const pkcs1 = keyWrapper[2] + + const pkcs1Decoded = decodeDer(pkcs1) + const jwk = pkcs1MessageToJwk(pkcs1Decoded) + + if (rsaKeySize(jwk) > MAX_RSA_KEY_SIZE) { + throw new InvalidParametersError('Key size is too large') + } + + const importedJWK = await crypto.subtle.importKey('jwk', jwk, { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256' + } + }, true, ['sign']) + const pkcs8 = await crypto.subtle.exportKey('pkcs8', importedJWK) + const publicKey = uint8arrayFromString(jwk.n ?? '', 'base64url') + const digest = await sha256.digest(new Uint8Array(publicKey)) + + options?.signal?.throwIfAborted() + + return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, digest)) + } +} + +export function rsaCrypto (): CryptoKeyImplementation { + return new RSACrypto() +} + +function toNumber (buf: Uint8Array): number { + if (buf.length === 0) { + return 0 + } + + const str = [...buf] + .map(n => n.toString(16).padStart(2, '0')) + .join('') + + return parseInt(str, 16) +} + +/** + * Convert private key PKCS#1 in ASN1 DER format to JWK + */ +function pkcs1MessageToJwk (message: Uint8Array[]): JsonWebKey { + return { + kty: 'RSA', + n: uint8ArrayToString(message[1], 'base64url'), + e: uint8ArrayToString(message[2], 'base64url'), + d: uint8ArrayToString(message[3], 'base64url'), + p: uint8ArrayToString(message[4], 'base64url'), + q: uint8ArrayToString(message[5], 'base64url'), + dp: uint8ArrayToString(message[6], 'base64url'), + dq: uint8ArrayToString(message[7], 'base64url'), + qi: uint8ArrayToString(message[8], 'base64url') + } +} + +/** + * Convert a JWK private key into PKCS#1 in ASN1 DER format + */ +function jwkToPkcs1 (jwk: JsonWebKey): Uint8Array { + if (jwk.n == null || jwk.e == null || jwk.d == null || jwk.p == null || jwk.q == null || jwk.dp == null || jwk.dq == null || jwk.qi == null) { + throw new InvalidParametersError('JWK was missing components') + } + + return encodeSequence([ + encodeInteger(Uint8Array.from([0])), + encodeInteger(uint8ArrayFromString(jwk.n, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.e, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.d, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.p, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.q, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.dp, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.dq, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.qi, 'base64url')) + ]).subarray() +} + +export function rsaKeySize (jwk: JsonWebKey): number { + if (jwk.kty !== 'RSA') { + throw new InvalidParametersError('Invalid key type') + } else if (jwk.n == null) { + throw new InvalidParametersError('Invalid key modulus') + } + const modulus = uint8ArrayFromString(jwk.n, 'base64url') + return modulus.length * 8 +} diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index fd700ac83..27c2e5532 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -32,3 +32,13 @@ export class BlockNotFoundWhileOfflineError extends Error { static name = 'BlockNotFoundWhileOfflineError' name = 'BlockNotFoundWhileOfflineError' } + +export class UnsupportedCryptographyImplementationError extends Error { + static name = 'UnsupportedCryptographyImplementationError' + name = 'UnsupportedCryptographyImplementationError' +} + +export class DecryptionFailedError extends Error { + static name = 'DecryptionFailedError' + name = 'DecryptionFailedError' +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3d996c750..4070a7fbe 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -9,19 +9,21 @@ import { contentRoutingSymbol, peerRoutingSymbol, start, stop, TypedEventEmitter import { dns } from '@multiformats/dns' import drain from 'it-drain' import { CustomProgressEvent } from 'progress-events' +import { Keychain } from './keychain.ts' import { PinsImpl } from './pins.ts' import { Routing as RoutingClass } from './routing.ts' import { BlockStorage } from './storage.ts' import { assertDatastoreVersionIsCurrent } from './utils/datastore-version.ts' import { getCodec } from './utils/get-codec.ts' +import { getCryptoKey } from './utils/get-crypto.ts' import { getHasher } from './utils/get-hasher.ts' import { NetworkedStorage } from './utils/networked-storage.ts' +import type { KeychainInit } from './keychain.ts' import type { BlockStorageInit } from './storage.ts' -import type { CodecLoader, GCOptions, HasherLoader, Helia as HeliaInterface, HeliaEvents, Routing } from '@helia/interface' +import type { CodecLoader, GCOptions, HasherLoader, Helia as HeliaInterface, HeliaEvents, Routing, CryptoKeyLoader, CryptoKeyImplementation } from '@helia/interface' import type { BlockBroker } from '@helia/interface/blocks' import type { Pins } from '@helia/interface/pins' import type { ComponentLogger, ContentRouting, Libp2p, Logger, Metrics, PeerRouting } from '@libp2p/interface' -import type { KeychainInit } from '@libp2p/keychain' import type { DNS } from '@multiformats/dns' import type { Blockstore } from 'interface-blockstore' import type { Datastore } from 'interface-datastore' @@ -38,6 +40,10 @@ export type { BlockStorage, BlockStorageInit } export { breadthFirstWalker, depthFirstWalker, naturalOrderWalker } from './graph-walker.ts' export type { GraphWalkerComponents, GraphWalkerInit, GraphNode, GraphWalker } from './graph-walker.ts' +export { ed25519Crypto } from './crypto/ed25519.ts' +export { rsaCrypto } from './crypto/rsa.ts' +export { Keychain } from './keychain.ts' + /** * Options used to create a Helia node. */ @@ -109,6 +115,16 @@ export interface HeliaInit { */ blockBrokers: Array<(components: any) => BlockBroker> + /** + * A list of pre-supported public/private key implementations + */ + cryptoKeys?: Array + + /** + * Dynamically load a cryptography implementation + */ + loadCrypto?: CryptoKeyLoader + /** * Garbage collection requires preventing blockstore writes during searches * for unpinned blocks as DAGs are typically pinned after they've been @@ -187,9 +203,11 @@ interface Components { blockBrokers: BlockBroker[] routing: Routing dns: DNS + keychain: Keychain metrics?: Metrics getCodec: CodecLoader getHasher: HasherLoader + getCryptoKey: CryptoKeyLoader } export class Helia implements HeliaInterface { @@ -202,7 +220,9 @@ export class Helia implements HeliaInterface { public routing: Routing public getCodec: CodecLoader public getHasher: HasherLoader + public getCryptoKey: CryptoKeyLoader public dns: DNS + public keychain: Keychain public metrics?: Metrics private readonly log: Logger @@ -211,12 +231,13 @@ export class Helia implements HeliaInterface { this.log = this.logger.forComponent('helia') this.getHasher = getHasher(init.hashers, init.loadHasher) this.getCodec = getCodec(init.codecs, init.loadCodec) + this.getCryptoKey = getCryptoKey(init.cryptoKeys, init.loadCrypto) this.dns = init.dns ?? dns() this.metrics = init.metrics this.libp2p = init.libp2p this.events = new TypedEventEmitter>() - // @ts-expect-error routing is not set + // @ts-expect-error routing and keychain are not set const components: Components = { blockstore: init.blockstore, datastore: init.datastore, @@ -225,11 +246,14 @@ export class Helia implements HeliaInterface { blockBrokers: [], getHasher: this.getHasher, getCodec: this.getCodec, + getCryptoKey: this.getCryptoKey, dns: this.dns, metrics: this.metrics, ...(init.components ?? {}) } + this.keychain = components.keychain = new Keychain(components, init.keychain) + this.routing = components.routing = new RoutingClass(components, { routers: (init.routers ?? []).flatMap((router: Partial | ((components: any) => Partial)) => { if (typeof router === 'function') { diff --git a/packages/utils/src/keychain.ts b/packages/utils/src/keychain.ts new file mode 100644 index 000000000..b479aecd9 --- /dev/null +++ b/packages/utils/src/keychain.ts @@ -0,0 +1,590 @@ +import { InvalidParametersError, serviceCapabilities } from '@libp2p/interface' +import { Key } from 'interface-datastore/key' +import { base58btc } from 'multiformats/bases/base58' +import { base64 } from 'multiformats/bases/base64' +import { sha256 } from 'multiformats/hashes/sha2' +import sanitize from 'sanitize-filename' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { toString as uint8ArrayToString } from 'uint8arrays/to-string' +import { withArrayBuffer } from 'uint8arrays/with-array-buffer' +import { DecryptionFailedError } from './errors.ts' +import { PrivateKeyMessage } from './keychain/keys.ts' +import type { Keychain as KeychainInterface, KeyInfo, PrivateKey, CryptoKeyLoader, CryptoKeyImplementation, Cipher, CipherOptions, EncryptionResult } from '@helia/interface' +import type { ComponentLogger, Logger } from '@libp2p/interface' +import type { AbortOptions } from 'abort-error' +import type { Datastore } from 'interface-datastore' +import type { Batch } from 'interface-datastore' + +const keyPrefix = '/pkcs8/' +const infoPrefix = '/info/' + +/** + * Default options for key derivation for the keychain Data Encryption Key. + * + * Inherited from js-libp2p. + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + */ +const KEYCHAIN_DEK_INIT = { + iterations: 10_000, + salt: uint8ArrayFromString('you should override this value with a crypto secure random number'), + hash: 'SHA-512', + algorithm: 'AES-GCM' +} + +/** + * Each private key is encrypted at rest with a Data Encryption Key created + * from these parameters. + * + * Inherited from js-libp2p. + */ +const PRIVATE_KEY_DEK_INIT = { + iterations: 32_767, + saltLength: 16, + ivLength: 12, + hash: 'SHA-256', + keyLength: 128, + algorithm: 'AES-GCM' +} + +const MIN_PASS_LENGTH = 20 + +// NIST SP 800-132 +const NIST = { + minKeyLength: 112 / 8, + minSaltLength: 128 / 8, + minIterations: 1_000 +} + +const KEY_LENGTHS: Record = { + 'SHA-256': 128, + 'SHA-384': 192, + 'SHA-512': 256 +} + +export interface DEKConfig { + hash: string + salt: string + iterationCount: number + keyLength: number +} + +export interface KeychainInit { + /** + * The password is used to derive a key which encrypts the keychain at rest + */ + password?: string + + /** + * Specify a non-default PBK2 function salt + */ + salt?: string + + /** + * How many iterations to use when deriving a key from the password + * + * @default 10_000 + */ + iterations?: number + + /** + * The hash type + * + * @default SHA2-512 + */ + hash?: 'SHA-256' | 'SHA-384' | 'SHA-512' + + /** + * The 'self' key is the private key of the node from which the peer id is + * derived. + * + * It cannot be renamed or removed. + * + * By default it is stored under the 'self' key, to use a different name, pass + * this option. + * + * @default 'self' + */ + selfKey?: string +} + +export interface KeychainComponents { + datastore: Datastore + logger: ComponentLogger + getCryptoKey: CryptoKeyLoader +} + +function validateKeyName (name: string): boolean { + if (name == null) { + return false + } + + if (typeof name !== 'string') { + return false + } + + return name === sanitize(name.trim()) && name.length > 0 +} + +/** + * Converts a key name into a datastore name + */ +function dsName (name: string): Key { + return new Key(keyPrefix + name) +} + +/** + * Converts a key name into a datastore info name + */ +function dsInfoName (name: string): Key { + return new Key(infoPrefix + name) +} + +export async function keyId (key: PrivateKey, options?: AbortOptions): Promise { + const pb = PrivateKeyMessage.encode({ + Type: key.code, + Data: new Uint8Array(key.raw) + }) + const hash = await sha256.digest(pb) + + options?.signal?.throwIfAborted() + + return base58btc.encode(hash.bytes).substring(1) +} + +function getSalt (salt?: string | Uint8Array): Uint8Array | undefined { + if (typeof salt === 'string') { + return uint8ArrayFromString(salt) + } + + if (salt instanceof Uint8Array) { + return withArrayBuffer(salt) + } +} + +/** + * Manages the life cycle of a key. Keys are encrypted at rest using PKCS #8. + * + * A key in the store has two entries + * - '/info/*key-name*', contains the KeyInfo for the key + * - '/pkcs8/*key-name*', contains the PKCS #8 for the key + */ +export class Keychain implements KeychainInterface { + private readonly components: KeychainComponents + private readonly log: Logger + private readonly self: string + private cipher: Cipher + private salt: Uint8Array + private keychainDekOptions: DeriveKeyOptions + private privateKeyDekOptions: PrivateKeyDeriveKeyOptions + + /** + * Creates a new instance of a key chain + */ + constructor (components: KeychainComponents, init: KeychainInit = {}) { + this.components = components + this.log = components.logger.forComponent('libp2p:keychain') + this.self = init.selfKey ?? 'self' + this.salt = getSalt(init.salt) ?? KEYCHAIN_DEK_INIT.salt + + this.keychainDekOptions = { + iterations: init.iterations ?? KEYCHAIN_DEK_INIT.iterations, + hash: init.hash ?? KEYCHAIN_DEK_INIT.hash, + keyLength: KEY_LENGTHS[init.hash ?? KEYCHAIN_DEK_INIT.hash], + algorithm: KEYCHAIN_DEK_INIT.algorithm + } + this.privateKeyDekOptions = { + iterations: PRIVATE_KEY_DEK_INIT.iterations, + hash: PRIVATE_KEY_DEK_INIT.hash, + saltLength: PRIVATE_KEY_DEK_INIT.saltLength, + ivLength: PRIVATE_KEY_DEK_INIT.ivLength, + keyLength: PRIVATE_KEY_DEK_INIT.keyLength, + algorithm: PRIVATE_KEY_DEK_INIT.algorithm + } + + // Enforce NIST SP 800-132 + if (init.password != null && init.password.length < MIN_PASS_LENGTH) { + throw new Error('password must be least 20 characters') + } + /* + if (this.keyLength < NIST.minKeyLength) { + throw new Error(`dek.keyLength must be least ${NIST.minKeyLength} bytes`) + } +*/ + if (this.salt.byteLength != null && this.salt.byteLength < NIST.minSaltLength) { + throw new Error(`salt must be least ${NIST.minSaltLength} bytes`) + } + + if (init.iterations != null && init.iterations < NIST.minIterations) { + throw new Error(`iterations must be least ${NIST.minIterations}`) + } + + if (KEY_LENGTHS[this.keychainDekOptions.hash] == null) { + throw new InvalidParametersError('Unsupported hash') + } + + this.cipher = createAESCipher(init.password ?? '', this.salt, this.keychainDekOptions, this.privateKeyDekOptions) + } + + readonly [Symbol.toStringTag] = '@libp2p/keychain' + + readonly [serviceCapabilities]: string[] = [ + '@libp2p/keychain' + ] + + async createKey (name: string, type: 'Ed25519' | 'RSA' | string, options?: AbortOptions & Record): Promise { + const crypto = await this.components.getCryptoKey(type, options) + const key = await crypto.createPrivateKey(options) + + return this.importKey(name, key, options) + } + + async importKey (name: string, key: PrivateKey, options?: AbortOptions): Promise { + if (!validateKeyName(name)) { + throw new InvalidParametersError(`Invalid key name '${name}'`) + } + + if (key == null) { + throw new InvalidParametersError('Key is required') + } + + const exists = await this.components.datastore.has(dsName(name), options) + + if (exists) { + throw new InvalidParametersError(`Key '${name}' already exists`) + } + + const batch = this.components.datastore.batch() + await this._importKey(name, key, this.cipher, batch, options) + await batch.commit(options) + + return key + } + + private async _importKey (name: string, privateKey: PrivateKey, cipher: Cipher, batch: Batch, options?: AbortOptions): Promise { + const cryptoImpl = await this.components.getCryptoKey(privateKey.code, options) + const pem = await cryptoImpl.serialize(privateKey, cipher, options) + + const keyInfo = { + name, + type: privateKey.type, + id: await keyId(privateKey, options) + } + + batch.put(dsName(name), uint8ArrayFromString(pem)) + batch.put(dsInfoName(name), uint8ArrayFromString(JSON.stringify(keyInfo))) + } + + async exportKey (name: string, options?: AbortOptions): Promise { + if (!validateKeyName(name)) { + throw new InvalidParametersError(`Invalid key name '${name}'`) + } + + return this._exportKey(name, this.cipher, options) + } + + private async _exportKey (name: string, cipher: Cipher, options?: AbortOptions): Promise { + const infoBuf = await this.components.datastore.get(dsInfoName(name), options) + const keyBuf = await this.components.datastore.get(dsName(name), options) + const pem = uint8ArrayToString(keyBuf) + + const info: KeyInfo = JSON.parse(uint8ArrayToString(infoBuf)) + let cryptoImpl: CryptoKeyImplementation | undefined + + if (info.type != null) { + cryptoImpl = await this.components.getCryptoKey(info.type, options) + } else { + // legacy @libp2p/keychain does not store the type of key so guess + if (pem.includes('BEGIN ENCRYPTED PRIVATE KEY')) { + cryptoImpl = await this.components.getCryptoKey('RSA', options) + } else { + const decoded = base64.decode(pem) + const salt = decoded.subarray(0, 16) + const iv = decoded.subarray(16, 16 + 12) + const cipherText = decoded.subarray(16 + 12) + const plainText = await cipher.decrypt(salt, iv, cipherText, options) + const pb = PrivateKeyMessage.decode(plainText) + + if (pb.Type != null) { + cryptoImpl = await this.components.getCryptoKey(pb.Type, options) + } + } + } + + if (cryptoImpl == null) { + throw new DecryptionFailedError('Unknown key type') + } + + try { + const key = await cryptoImpl.deserialize(pem, cipher, options) + options?.signal?.throwIfAborted() + + return key + } catch (err: any) { + if (err.name === 'OperationError') { + throw new DecryptionFailedError(err.message) + } + + throw err + } + } + + async removeKey (name: string, options?: AbortOptions): Promise { + if (!validateKeyName(name) || name === this.self) { + throw new InvalidParametersError(`Invalid key name '${name}'`) + } + + const batch = this.components.datastore.batch() + batch.delete(dsName(name)) + batch.delete(dsInfoName(name)) + await batch.commit(options) + } + + /** + * List all the keys + */ + async * listKeys (options?: AbortOptions): AsyncGenerator { + const query = { + prefix: infoPrefix + } + + for await (const value of this.components.datastore.query(query, options)) { + yield JSON.parse(uint8ArrayToString(value.value)) + } + } + + /** + * Rename a key + * + * @param {string} oldName - The old local key name; must already exist. + * @param {string} newName - The new local key name; must not already exist. + * @returns {Promise} + */ + async renameKey (oldName: string, newName: string, options?: AbortOptions): Promise { + if (!validateKeyName(oldName) || oldName === this.self) { + throw new InvalidParametersError(`Invalid old key name '${oldName}'`) + } + + if (!validateKeyName(newName) || newName === this.self) { + throw new InvalidParametersError(`Invalid new key name '${newName}'`) + } + + const oldDatastoreName = dsName(oldName) + const newDatastoreName = dsName(newName) + const oldInfoName = dsInfoName(oldName) + const newInfoName = dsInfoName(newName) + + const exists = await this.components.datastore.has(newDatastoreName, options) + + if (exists) { + throw new InvalidParametersError(`Key '${newName}' already exists`) + } + + const pem = await this.components.datastore.get(oldDatastoreName, options) + const res = await this.components.datastore.get(oldInfoName, options) + + const keyInfo: KeyInfo = JSON.parse(uint8ArrayToString(res)) + keyInfo.name = newName + + const batch = this.components.datastore.batch() + batch.put(newDatastoreName, pem) + batch.put(newInfoName, uint8ArrayFromString(JSON.stringify(keyInfo))) + batch.delete(oldDatastoreName) + batch.delete(oldInfoName) + + await batch.commit(options) + } + + /** + * Rotate keychain password and re-encrypt all associated keys + */ + async rotateKeychainPass (password: string, options?: AbortOptions): Promise { + if (typeof password !== 'string') { + throw new InvalidParametersError(`Invalid new pass type '${typeof password}'`) + } + + if (password.length < MIN_PASS_LENGTH) { + throw new InvalidParametersError(`Invalid pass length ${password.length}, must be at least ${MIN_PASS_LENGTH}`) + } + + this.log('recreating keychain') + + const oldCipher = this.cipher + const newCipher = this.cipher = createAESCipher(password, this.salt, this.keychainDekOptions, this.privateKeyDekOptions) + + const batch = this.components.datastore.batch() + + for await (const info of this.listKeys(options)) { + const key = await this._exportKey(info.name, oldCipher) + + // Update stored key + await this._importKey(info.name, key, newCipher, batch, options) + } + + await batch.commit(options) + + this.log('keychain reconstructed') + } +} + +/** + * WebKit on Linux does not support deriving a key from an empty PBKDF2 key. + * So, as a workaround, we provide the generated key as a constant. + * + * Generated via: + * + * ```ts + * const key = await crypto.subtle.importKey('raw', new Uint8Array(0), { + * name: 'PBKDF2' + * }, false, ['deriveKey']) + * + * const derivedKey = await crypto.subtle.deriveKey({ + * name: 'PBKDF2', + * salt: new Uint8Array(16), + * iterations: 32767, + * hash: { + * name: 'SHA-256' + * } + * }, key, { + * name: 'AES-GCM', + * length: 128 + * }, true, ['encrypt', 'decrypt']) + * + * const jwk = await crypto.subtle.exportKey('jwk', derivedKey) + * ``` + */ +const derivedEmptyPasswordKey = { + alg: 'A128GCM', + ext: true, + /* spell-checker:disable-next-line */ + k: 'scm9jmO_4BJAgdwWGVulLg', + key_ops: ['encrypt', 'decrypt'], + kty: 'oct' +} + +interface DeriveKeyOptions { + iterations: number + hash: string + keyLength: number + algorithm: string +} + +interface PrivateKeyDeriveKeyOptions extends DeriveKeyOptions { + /** + * A random salt will be generated of this many bytes + * + * @default 16 + */ + saltLength: number + + /** + * A random initialization vector will be generated of this many bytes + * + * @default 12 + */ + ivLength: number +} + +// Based on code from https://github.com/luke-park/SecureCompatibleEncryptionExamples + +function createAESCipher (password: string, salt: Uint8Array, keychainDekOpts: DeriveKeyOptions, privateKeyDekOpts: PrivateKeyDeriveKeyOptions): Cipher { + let keychainDek: string | undefined + + async function deriveKey (password: string, salt: Uint8Array, usages: KeyUsage[], opts: DeriveKeyOptions): Promise { + let cryptoKey: CryptoKey + const pass = uint8ArrayFromString(password) + const rawKey = await crypto.subtle.importKey('raw', pass, { + name: 'PBKDF2' + }, false, ['deriveKey']) + + try { + cryptoKey = await crypto.subtle.deriveKey({ + name: 'PBKDF2', + salt: withArrayBuffer(salt), + iterations: opts.iterations, + hash: { + name: opts.hash + } + }, rawKey, { + name: opts.algorithm ?? 'AES-GCM', + length: opts.keyLength + }, true, usages) + } catch (err) { + if (password === '') { + cryptoKey = await crypto.subtle.importKey('jwk', derivedEmptyPasswordKey, { + name: opts.algorithm ?? 'AES-GCM' + }, true, usages) + } else { + throw err + } + } + + return cryptoKey + } + + async function createKeychainDek (): Promise { + if (password === '') { + return password + } + + const key = await deriveKey(password, salt, ['encrypt', 'decrypt'], keychainDekOpts) + const jwk = await crypto.subtle.exportKey('jwk', key) + + return jwk.k ?? '' + } + + /** + * Encrypt data using the derived encryption key + */ + async function encrypt (data: Uint8Array, opts?: AbortOptions): Promise { + if (keychainDek == null) { + keychainDek = await createKeychainDek() + } + + const salt = crypto.getRandomValues(new Uint8Array(privateKeyDekOpts.saltLength)) + const iv = crypto.getRandomValues(new Uint8Array(privateKeyDekOpts.ivLength)) + const cryptoKey = await deriveKey(keychainDek, salt, ['encrypt'], privateKeyDekOpts) + const ciphertext = await crypto.subtle.encrypt({ + name: 'AES-GCM', + iv + }, cryptoKey, data) + + opts?.signal?.throwIfAborted() + + return { + salt, + iv, + cipherText: new Uint8Array(ciphertext) + } + } + + /** + * Decrypt data using the derived encryption key + */ + async function decrypt (salt: Uint8Array, iv: Uint8Array, cipherText: Uint8Array, opts?: CipherOptions): Promise> { + if (keychainDek == null) { + keychainDek = await createKeychainDek() + } + + const cryptoKey = await deriveKey(keychainDek, salt, ['decrypt'], { + iterations: opts?.iterations ?? privateKeyDekOpts.iterations, + keyLength: opts?.keyLength ?? privateKeyDekOpts.keyLength, + hash: opts?.hash ?? privateKeyDekOpts.hash, + algorithm: opts?.algorithm ?? 'AES-GCM' + }) + + const plaintext = await crypto.subtle.decrypt({ + name: opts?.algorithm ?? 'AES-GCM', + iv: withArrayBuffer(iv) + }, cryptoKey, withArrayBuffer(cipherText)) + + opts?.signal?.throwIfAborted() + + return new Uint8Array(plaintext) + } + + return { + encrypt, + decrypt + } +} diff --git a/packages/utils/src/keychain/keys.proto b/packages/utils/src/keychain/keys.proto new file mode 100644 index 000000000..194678c2d --- /dev/null +++ b/packages/utils/src/keychain/keys.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +enum KeyType { + RSA = 0; + Ed25519 = 1; + secp256k1 = 2; + ECDSA = 3; +} + +message PublicKeyMessage { + optional int32 Type = 1; + optional bytes Data = 2; +} + +message PrivateKeyMessage { + optional int32 Type = 1; + optional bytes Data = 2; +} diff --git a/packages/utils/src/keychain/keys.ts b/packages/utils/src/keychain/keys.ts new file mode 100644 index 000000000..f3a641795 --- /dev/null +++ b/packages/utils/src/keychain/keys.ts @@ -0,0 +1,241 @@ +import { decodeMessage, encodeMessage, enumeration, message, streamMessage } from 'protons-runtime' +import type { Codec, DecodeOptions } from 'protons-runtime' +import type { Uint8ArrayList } from 'uint8arraylist' + +export enum KeyType { + RSA = 'RSA', + Ed25519 = 'Ed25519', + secp256k1 = 'secp256k1', + ECDSA = 'ECDSA' +} + +enum __KeyTypeValues { + RSA = 0, + Ed25519 = 1, + secp256k1 = 2, + ECDSA = 3 +} + +export namespace KeyType { + export const codec = (): Codec => { + return enumeration(__KeyTypeValues) + } +} + +export interface PublicKeyMessage { + Type?: number + Data?: Uint8Array +} + +export namespace PublicKeyMessage { + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.Type != null) { + w.uint32(8) + w.int32(obj.Type) + } + + if (obj.Data != null) { + w.uint32(18) + w.bytes(obj.Data) + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length, opts = {}) => { + const obj: any = {} + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + obj.Type = reader.int32() + break + } + case 2: { + obj.Data = reader.bytes() + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }, function * (reader, length, prefix, opts = {}) { + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + yield { + field: `${prefix}.Type`, + value: reader.int32() + } + break + } + case 2: { + yield { + field: `${prefix}.Data`, + value: reader.bytes() + } + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + }) + } + + return _codec + } + + export interface PublicKeyMessageTypeFieldEvent { + field: '$.Type' + value: number + } + + export interface PublicKeyMessageDataFieldEvent { + field: '$.Data' + value: Uint8Array + } + + export function encode (obj: Partial): Uint8Array { + return encodeMessage(obj, PublicKeyMessage.codec()) + } + + export function decode (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): PublicKeyMessage { + return decodeMessage(buf, PublicKeyMessage.codec(), opts) + } + + export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { + return streamMessage(buf, PublicKeyMessage.codec(), opts) + } +} + +export interface PrivateKeyMessage { + Type?: number + Data?: Uint8Array +} + +export namespace PrivateKeyMessage { + let _codec: Codec + + export const codec = (): Codec => { + if (_codec == null) { + _codec = message((obj, w, opts = {}) => { + if (opts.lengthDelimited !== false) { + w.fork() + } + + if (obj.Type != null) { + w.uint32(8) + w.int32(obj.Type) + } + + if (obj.Data != null) { + w.uint32(18) + w.bytes(obj.Data) + } + + if (opts.lengthDelimited !== false) { + w.ldelim() + } + }, (reader, length, opts = {}) => { + const obj: any = {} + + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + obj.Type = reader.int32() + break + } + case 2: { + obj.Data = reader.bytes() + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + + return obj + }, function * (reader, length, prefix, opts = {}) { + const end = length == null ? reader.len : reader.pos + length + + while (reader.pos < end) { + const tag = reader.uint32() + + switch (tag >>> 3) { + case 1: { + yield { + field: `${prefix}.Type`, + value: reader.int32() + } + break + } + case 2: { + yield { + field: `${prefix}.Data`, + value: reader.bytes() + } + break + } + default: { + reader.skipType(tag & 7) + break + } + } + } + }) + } + + return _codec + } + + export interface PrivateKeyMessageTypeFieldEvent { + field: '$.Type' + value: number + } + + export interface PrivateKeyMessageDataFieldEvent { + field: '$.Data' + value: Uint8Array + } + + export function encode (obj: Partial): Uint8Array { + return encodeMessage(obj, PrivateKeyMessage.codec()) + } + + export function decode (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): PrivateKeyMessage { + return decodeMessage(buf, PrivateKeyMessage.codec(), opts) + } + + export function stream (buf: Uint8Array | Uint8ArrayList, opts?: DecodeOptions): Generator { + return streamMessage(buf, PrivateKeyMessage.codec(), opts) + } +} diff --git a/packages/utils/src/utils/constants.ts b/packages/utils/src/utils/constants.ts new file mode 100644 index 000000000..35354f22c --- /dev/null +++ b/packages/utils/src/utils/constants.ts @@ -0,0 +1,3 @@ +export const SALT_LENGTH = 16 +export const KEY_SIZE = 32 +export const ITERATIONS = 10000 diff --git a/packages/utils/src/utils/get-codec.ts b/packages/utils/src/utils/get-codec.ts index 17bcc8dd3..20fe2db80 100644 --- a/packages/utils/src/utils/get-codec.ts +++ b/packages/utils/src/utils/get-codec.ts @@ -1,5 +1,3 @@ -/* eslint max-depth: ["error", 7] */ - import { UnknownCodecError } from '@helia/interface' import * as dagCbor from '@ipld/dag-cbor' import * as dagJson from '@ipld/dag-json' @@ -7,9 +5,10 @@ import * as dagPb from '@ipld/dag-pb' import * as json from 'multiformats/codecs/json' import * as raw from 'multiformats/codecs/raw' import { isPromise } from './is-promise.ts' +import type { CodecLoader } from '@helia/interface' import type { BlockCodec } from 'multiformats/codecs/interface' -export function getCodec (initialCodecs: Array> = [], loadCodec?: (code: number) => BlockCodec | Promise>): (code: Code) => BlockCodec | Promise> { +export function getCodec (initialCodecs: Array> = [], loadCodec?: CodecLoader): CodecLoader { const codecs: Record> = { [dagPb.code]: dagPb, [raw.code]: raw, diff --git a/packages/utils/src/utils/get-crypto.ts b/packages/utils/src/utils/get-crypto.ts new file mode 100644 index 000000000..82bebb379 --- /dev/null +++ b/packages/utils/src/utils/get-crypto.ts @@ -0,0 +1,42 @@ +import { UnknownCryptoError } from '@helia/interface' +import { ed25519Crypto, rsaCrypto } from '../crypto/index.ts' +import { isPromise } from './is-promise.ts' +import type { CryptoKeyImplementation, CryptoKeyLoader } from '@helia/interface' + +export function getCryptoKey (initialCryptos: Array = [], loadCrypto?: CryptoKeyLoader): CryptoKeyLoader { + const cryptos: Record = {} + + initialCryptos = [ + ed25519Crypto(), + rsaCrypto(), + ...initialCryptos + ] + + initialCryptos.forEach(crypto => { + cryptos[crypto.type] = crypto + cryptos[crypto.code] = crypto + }) + + return async (nameOrCode) => { + let crypto = cryptos[nameOrCode] + + if (crypto == null && loadCrypto != null) { + const res = loadCrypto(nameOrCode) + + if (isPromise(res)) { + crypto = await res + } else { + crypto = res + } + + cryptos[crypto.type] = crypto + cryptos[crypto.code] = crypto + } + + if (crypto != null) { + return crypto + } + + throw new UnknownCryptoError(`Could not load crypto for ${crypto}`) + } +} diff --git a/packages/utils/src/utils/get-hasher.ts b/packages/utils/src/utils/get-hasher.ts index d2eb45e17..c8b317dbb 100644 --- a/packages/utils/src/utils/get-hasher.ts +++ b/packages/utils/src/utils/get-hasher.ts @@ -2,9 +2,10 @@ import { UnknownHashAlgorithmError } from '@helia/interface' import { identity } from 'multiformats/hashes/identity' import { sha256, sha512 } from 'multiformats/hashes/sha2' import { isPromise } from './is-promise.ts' +import type { HasherLoader } from '@helia/interface' import type { MultihashHasher } from 'multiformats/hashes/interface' -export function getHasher (initialHashers: MultihashHasher[] = [], loadHasher?: (code: number) => MultihashHasher | Promise): (code: number) => MultihashHasher | Promise { +export function getHasher (initialHashers: MultihashHasher[] = [], loadHasher?: HasherLoader): HasherLoader { const hashers: Record = { [sha256.code]: sha256, [sha512.code]: sha512, diff --git a/packages/utils/test/fixtures/crypto-loader.ts b/packages/utils/test/fixtures/crypto-loader.ts new file mode 100644 index 000000000..87e1d0349 --- /dev/null +++ b/packages/utils/test/fixtures/crypto-loader.ts @@ -0,0 +1,16 @@ +import { ed25519Crypto, rsaCrypto } from '../../src/crypto/index.ts' +import { UnsupportedCryptographyImplementationError } from '../../src/errors.ts' +import type { CryptoKeyLoader } from '@helia/interface' +import type { AbortOptions } from 'abort-error' + +export const getCryptoKey: CryptoKeyLoader = async (code: number | string, options?: AbortOptions) => { + if (code === 0 || code === 'RSA') { + return rsaCrypto() + } + + if (code === 1 || code === 'Ed25519') { + return ed25519Crypto() + } + + throw new UnsupportedCryptographyImplementationError(`Unknown crypto implementation ${code}`) +} diff --git a/packages/utils/test/keychain.spec.ts b/packages/utils/test/keychain.spec.ts new file mode 100644 index 000000000..76c19d6ab --- /dev/null +++ b/packages/utils/test/keychain.spec.ts @@ -0,0 +1,532 @@ +import { isPrivateKey, isPublicKey } from '@helia/interface' +import { generateKeyPair } from '@libp2p/crypto/keys' +import { start } from '@libp2p/interface' +import { keychain as libp2pKeychainFactory } from '@libp2p/keychain' +import { defaultLogger } from '@libp2p/logger' +import { expect } from 'aegir/chai' +import { MemoryDatastore } from 'datastore-core/memory' +import all from 'it-all' +import { Keychain as KeychainClass } from '../src/keychain.ts' +import { getCryptoKey } from './fixtures/crypto-loader.ts' +import type { Keychain } from '../src/index.js' +import type { KeychainInit } from '../src/keychain.ts' +import type { PrivateKey } from '@helia/interface' +import type { ComponentLogger } from '@libp2p/interface' +import type { Keychain as Libp2pKeychain } from '@libp2p/keychain' +import type { Datastore } from 'interface-datastore' + +const SUPPORTED_KEYS: Array<'RSA' | 'Ed25519'> = [ + 'Ed25519', + 'RSA' +] + +describe('keychain', () => { + const password = 'this is not a secure phrase' + /* spell-checker:disable-next-line */ + const rsaKeyName = 'tajné jméno' + /* spell-checker:disable-next-line */ + const renamedRsaKeyName = 'ชื่อลับ' + let logger: ComponentLogger + let datastore: Datastore + + beforeEach(() => { + logger = defaultLogger() + datastore = new MemoryDatastore() + }) + + it('can override the self key name', async () => { + const selfKey = 'other-key' + const keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }, { + selfKey + }) + await start(keychain) + + const crypto = await getCryptoKey('Ed25519') + const privateKey = await crypto.createPrivateKey() + + await keychain.importKey(selfKey, privateKey) + await expect(keychain.removeKey(selfKey)).to.eventually.be.rejected() + + await keychain.importKey('self', privateKey) + await expect(keychain.removeKey('self')).to.eventually.not.be.rejected() + }) + + it('needs a NIST SP 800-132 non-weak pass phrase', async () => { + await expect(async function () { + return new KeychainClass({ + datastore, + logger, + getCryptoKey + }, { + password: '< 20 character' + }) + }()).to.eventually.be.rejected() + }) + + it('supports supported hashing algorithms', async () => { + const ok = new KeychainClass({ + datastore, + logger, + getCryptoKey + }, { + password, + hash: 'SHA-256', + salt: 'salt-salt-salt-salt', + iterations: 1000 + }) + expect(ok).to.exist() + }) + + it('does not support unsupported hashing algorithms', async () => { + await expect(async function () { + return new KeychainClass({ + datastore, + logger, + getCryptoKey + }, { + // @ts-expect-error invalid parameter + hash: 'my-hash' + }) + }()).to.eventually.be.rejected() + }) + + it('can list keys without a password', async () => { + const keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + await expect(all(keychain.listKeys())).to.eventually.have.lengthOf(0) + }) + + it('can remove a key without a password', async () => { + const keychainWithoutPassword = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychainWithoutPassword) + const keychainWithPassword = new KeychainClass({ + datastore, + logger, + getCryptoKey + }, { + password: `hello-${Date.now()}-${Date.now()}` + }) + await start(keychainWithPassword) + const name = `key-${Math.random()}` + + const crypto = await getCryptoKey('Ed25519') + const privateKey = await crypto.createPrivateKey() + await keychainWithPassword.importKey(name, privateKey) + + let keys = await all(keychainWithoutPassword.listKeys()) + expect(keys).to.have.lengthOf(1) + expect(keys).to.have.nested.property('[0].name', name) + + await keychainWithoutPassword.removeKey(name) + keys = await all(keychainWithoutPassword.listKeys()) + expect(keys).to.have.lengthOf(0) + }) + + it('should validate key names before removing', async () => { + const keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + const errors = await Promise.all([ + keychain.removeKey('../../nasty').catch(err => err), + keychain.removeKey('').catch(err => err), + keychain.removeKey(' ').catch(err => err), + // @ts-expect-error invalid parameters + keychain.removeKey(null).catch(err => err), + // @ts-expect-error invalid parameters + keychain.removeKey(undefined).catch(err => err) + ]) + + expect(errors).to.have.length(5) + errors.forEach(error => { + expect(error).to.have.property('name', 'InvalidParametersError') + }) + }) + + it('does not overwrite existing key', async () => { + const keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + const keyName = 'my-key' + const privateKey = await keychain.createKey(keyName, 'Ed25519') + + await expect(keychain.importKey(keyName, privateKey)).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + describe('query', () => { + let keychain: Keychain + let privateKey: PrivateKey + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + privateKey = await keychain.createKey(rsaKeyName, 'RSA') + }) + + it('finds all existing keys', async () => { + const keys = await all(keychain.listKeys()) + expect(keys).to.exist() + const myKey = keys.find((k) => k.name.normalize() === rsaKeyName.normalize()) + expect(myKey).to.exist() + }) + + it('exports a key by name', async () => { + const key = await keychain.exportKey(rsaKeyName) + expect(key).to.exist() + expect(key).to.deep.equal(privateKey) + }) + + it('returns the key\'s name', async () => { + const keys = await all(keychain.listKeys()) + expect(keys).to.exist() + keys.forEach((key) => { + expect(key).to.have.property('name') + expect(key).to.have.property('type') + }) + }) + }) + + describe('exported key', () => { + let keychain: Keychain + let privateKey: PrivateKey + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + privateKey = await keychain.createKey(rsaKeyName, 'RSA') + }) + + it('requires the key name', async () => { + // @ts-expect-error invalid parameters + await expect(keychain.exportKey(undefined, 'password')).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + it('can be imported', async () => { + const imported = await keychain.importKey('imported-key', privateKey) + expect(imported).to.deep.equal(privateKey) + + const exported = await keychain.exportKey('imported-key') + expect(exported).to.deep.equal(privateKey) + }) + + it('requires the key', async () => { + // @ts-expect-error invalid parameters + await expect(keychain.importKey('imported-key', undefined)).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + it('cannot be imported as an existing key name', async () => { + await expect(keychain.importKey(rsaKeyName, privateKey)).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + }) + + describe('rename', () => { + let keychain: Keychain + let privateKey: PrivateKey + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + privateKey = await keychain.createKey(rsaKeyName, 'RSA') + }) + + it('requires an existing key name', async () => { + await expect(keychain.renameKey('not-there', renamedRsaKeyName)).to.eventually.be.rejected + .with.property('name', 'NotFoundError') + }) + + it('requires a valid new key name', async () => { + await expect(keychain.renameKey(rsaKeyName, '..\not-valid')).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + it('does not overwrite existing key', async () => { + await expect(keychain.renameKey(rsaKeyName, rsaKeyName)).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + it('creates the new key name', async () => { + await keychain.renameKey(rsaKeyName, renamedRsaKeyName) + const key = await keychain.exportKey(renamedRsaKeyName) + expect(key).to.exist() + }) + + it('removes the existing key name', async () => { + await keychain.renameKey(rsaKeyName, renamedRsaKeyName) + const exported = await keychain.exportKey(renamedRsaKeyName) + expect(exported).to.deep.equal(privateKey) + + // Try to find the changed key + await expect(keychain.exportKey(rsaKeyName)).to.eventually.be.rejected() + }) + + it('throws with invalid key names', async () => { + // @ts-expect-error invalid parameters + await expect(keychain.renameKey(rsaKeyName, undefined)).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + }) + + describe('key removal', () => { + let keychain: Keychain + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + }) + + it('cannot remove the "self" key', async () => { + await expect(keychain.removeKey('self')).to.eventually.be.rejected + .with.property('name', 'InvalidParametersError') + }) + + it('can remove an unknown key', async () => { + await keychain.removeKey('not-there') + }) + + it('can remove a known key', async () => { + await keychain.removeKey(rsaKeyName) + + await expect(keychain.exportKey(rsaKeyName)).to.eventually.be.rejected + .with.property('name', 'NotFoundError') + }) + }) + + describe('rotate keychain passphrase', () => { + let oldPass: string + let options: KeychainInit + let keychain: Keychain + + beforeEach(async () => { + oldPass = `hello-${Date.now()}-${Date.now()}` + options = { + password: oldPass, + /* spell-checker:disable-next-line */ + salt: '3Nd/Ya4ENB3bcByNKptb4IR', + iterations: 10000, + hash: 'SHA-512' + } + + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }, options) + await start(keychain) + }) + + it('should validate newPass is a string', async () => { + // @ts-expect-error invalid parameters + await expect(keychain.rotateKeychainPass(1234567890)).to.eventually.be.rejected() + }) + + it('should validate newPass is at least 20 characters', async () => { + try { + await keychain.rotateKeychainPass('not20Chars') + } catch (err: any) { + expect(err).to.exist() + } + }) + + it('can rotate keychain passphrase', async () => { + const newPassword = 'newInsecurePassphrase' + const keyName = 'test-key' + const key = await keychain.createKey(keyName, 'Ed25519') + + await keychain.rotateKeychainPass(newPassword) + + const key2 = await keychain.exportKey(keyName) + expect(key2).to.deep.equal(key) + + // cannot load with old password + const keychainWithOldPassword = new KeychainClass({ + datastore, + logger, + getCryptoKey + }, options) + await start(keychainWithOldPassword) + + await expect(keychainWithOldPassword.exportKey(keyName)).to.eventually.be.rejected + .with.property('name', 'DecryptionFailedError') + + // new password should work + const keychainWithNewPassword = new KeychainClass({ + datastore, + logger, + getCryptoKey + }, { + ...options, + password: newPassword + }) + await start(keychainWithNewPassword) + + await expect(keychainWithNewPassword.exportKey(keyName)).to.eventually.deep.equal(key) + }) + }) + + SUPPORTED_KEYS.forEach(type => { + describe(`${type} keys`, () => { + let keychain: Keychain + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + }) + + const keyName = 'my custom key' + + it(`can create a ${type} key`, async () => { + const privateKey = await keychain.createKey(keyName, type) + + expect(privateKey).to.be.ok() + expect(privateKey).to.have.property('code').that.is.a('number') + expect(privateKey).to.have.property('type', type) + expect(privateKey).to.have.property('raw').that.is.an.instanceOf(ArrayBuffer) + + expect(isPrivateKey(privateKey)).to.be.true() + expect(isPublicKey(privateKey.publicKey)).to.be.true() + }) + + it('can export/import a key', async () => { + const crypto = await getCryptoKey(type) + const privateKey = await crypto.createPrivateKey() + + await keychain.importKey(keyName, privateKey) + + const exportedKey = await keychain.exportKey(keyName) + + // remove it so we can re-import it + await keychain.removeKey(keyName) + const importedKey = await keychain.importKey(keyName, exportedKey) + + expect(importedKey).to.deep.equal(privateKey) + }) + + it('can sign and verify', async () => { + const keyName = 'my-key' + const privateKey = await keychain.createKey(keyName, type) + const message = Uint8Array.from([0, 1, 2, 3, 4]) + const sig = await privateKey.sign(message) + + await expect(privateKey.publicKey.verify(message, sig)).to.eventually.be.true() + }) + }) + }) + + describe('Unsupported keys', () => { + let keychain: Keychain + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + }) + + const keyName = 'my custom ECDSA key' + + it('does not support un-configured keys', async () => { + await expect(keychain.createKey(keyName, 'ECDSA')).to.eventually.be.rejected + .with.property('name', 'UnsupportedCryptographyImplementationError') + }) + }) + + describe('@libp2p/keychain compatibility', () => { + let keychain: Keychain + let libp2pKeychain: Libp2pKeychain + + beforeEach(async () => { + keychain = new KeychainClass({ + datastore, + logger, + getCryptoKey + }) + await start(keychain) + + libp2pKeychain = libp2pKeychainFactory()({ + // @ts-expect-error libp2p needs new interface-datastore + datastore, + logger + }) + }) + + SUPPORTED_KEYS.forEach(type => { + it(`should read ${type} libp2p keychain keys`, async () => { + const keyName = 'my-key' + const libp2pPrivateKey = await generateKeyPair(type) + await libp2pKeychain.importKey(keyName, libp2pPrivateKey) + const heliaPrivateKey = await keychain.exportKey(keyName) + + const message = Uint8Array.from([0, 1, 2, 3, 4]) + + const heliaSig = await heliaPrivateKey.sign(message) + expect(await libp2pPrivateKey.publicKey.verify(message, heliaSig)).to.be.true() + + const libp2pSig = await libp2pPrivateKey.sign(message) + expect(await heliaPrivateKey.publicKey.verify(message, libp2pSig)).to.be.true() + }) + + it(`should write ${type} libp2p keychain keys`, async () => { + const keyName = 'my-key' + const heliaPrivateKey = await keychain.createKey(keyName, type) + const libp2pPrivateKey = await libp2pKeychain.exportKey(keyName) + + const message = Uint8Array.from([0, 1, 2, 3, 4]) + + const heliaSig = await heliaPrivateKey.sign(message) + expect(await libp2pPrivateKey.publicKey.verify(message, heliaSig)).to.be.true() + + const libp2pSig = await libp2pPrivateKey.sign(message) + expect(await heliaPrivateKey.publicKey.verify(message, libp2pSig)).to.be.true() + }) + }) + }) +})