From c02dc0b7b8e24222762e692d977d5d06420089ad Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 15 May 2026 15:45:04 +0300 Subject: [PATCH 01/10] feat!: configurable crypto Adds a configuration option to allow dynamically loading crypto implementations on-demand. Ships with only webcrypto-supported crypto implementations (e.g. `Ed25519`, `RSA`), anything else (e.g. `secp256k1`, `ECDSA`) must be configured separately. BREAKING CHANGE: `secp256k1` and `ECDSA` support have been removed from the default bundle, they must now be configured separately --- packages/interface/package.json | 1 + packages/interface/src/errors.ts | 5 + packages/interface/src/index.ts | 132 +++++- packages/interface/src/keychain.ts | 102 ++++ .../src/fixtures/create-helia.browser.ts | 7 - packages/interop/src/fixtures/create-helia.ts | 7 - packages/interop/src/fixtures/key-types.ts | 1 - packages/interop/src/ipns-http.spec.ts | 13 +- packages/interop/src/ipns-pubsub.spec.ts | 4 +- packages/interop/src/ipns.spec.ts | 21 +- packages/ipns/README.md | 61 +-- packages/ipns/package.json | 12 +- packages/ipns/src/errors.ts | 65 ++- packages/ipns/src/index.ts | 147 +++--- packages/ipns/src/ipns.ts | 55 ++- packages/ipns/src/ipns/publisher.ts | 62 +-- packages/ipns/src/ipns/republisher.ts | 31 +- packages/ipns/src/ipns/resolver.ts | 130 ++--- packages/ipns/src/pb/ipns.proto | 39 ++ packages/ipns/src/pb/ipns.ts | 280 +++++++++++ packages/ipns/src/pb/keys.proto | 36 ++ packages/ipns/src/pb/keys.ts | 241 ++++++++++ packages/ipns/src/records.ts | 270 +++++++++++ packages/ipns/src/routing/pubsub.ts | 26 +- packages/ipns/src/selector.ts | 55 +++ packages/ipns/src/utils.ts | 358 +++++++++++++- packages/ipns/src/validator.ts | 62 +++ packages/ipns/test/fixtures/create-ipns.ts | 30 +- packages/ipns/test/fixtures/crypto-loader.ts | 15 + packages/ipns/test/publish.spec.ts | 71 +-- packages/ipns/test/republish.spec.ts | 106 ++--- packages/ipns/test/resolve.spec.ts | 176 ++++--- packages/ipns/test/routing/pubsub.spec.ts | 30 +- packages/utils/package.json | 9 +- packages/utils/src/crypto/ed25519.ts | 136 ++++++ packages/utils/src/crypto/index.ts | 2 + packages/utils/src/crypto/rsa.ts | 119 +++++ packages/utils/src/errors.ts | 5 + packages/utils/src/index.ts | 30 +- packages/utils/src/keychain.ts | 447 ++++++++++++++++++ packages/utils/src/keychain/keys.proto | 18 + packages/utils/src/keychain/keys.ts | 241 ++++++++++ packages/utils/src/utils/constants.ts | 3 + packages/utils/src/utils/get-codec.ts | 5 +- packages/utils/src/utils/get-crypto.ts | 42 ++ packages/utils/src/utils/get-hasher.ts | 3 +- 46 files changed, 3140 insertions(+), 571 deletions(-) create mode 100644 packages/interface/src/keychain.ts create mode 100644 packages/ipns/src/pb/ipns.proto create mode 100644 packages/ipns/src/pb/ipns.ts create mode 100644 packages/ipns/src/pb/keys.proto create mode 100644 packages/ipns/src/pb/keys.ts create mode 100644 packages/ipns/src/records.ts create mode 100644 packages/ipns/src/selector.ts create mode 100644 packages/ipns/src/validator.ts create mode 100644 packages/ipns/test/fixtures/crypto-loader.ts create mode 100644 packages/utils/src/crypto/ed25519.ts create mode 100644 packages/utils/src/crypto/index.ts create mode 100644 packages/utils/src/crypto/rsa.ts create mode 100644 packages/utils/src/keychain.ts create mode 100644 packages/utils/src/keychain/keys.proto create mode 100644 packages/utils/src/keychain/keys.ts create mode 100644 packages/utils/src/utils/constants.ts create mode 100644 packages/utils/src/utils/get-crypto.ts 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..81cae03e2 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -15,21 +15,134 @@ */ 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' && obj.raw instanceof Uint8Array && + typeof obj.toMultihash === 'function' && 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' && obj.raw instanceof Uint8Array && + isPublicKey(obj.publicKey) && obj.sign === 'function' +} + +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 raw bytes into a public key + */ + publicKeyFromArray(key: ArrayBuffer | Uint8Array, options?: AbortOptions): PublicKey | Promise + + /** + * Convert the passed raw bytes into a private key + */ + privateKeyFromArray(key: ArrayBuffer | Uint8Array, options?: AbortOptions): PrivateKey | Promise } /** @@ -56,6 +169,11 @@ export interface Helia { */ events: TypedEventEmitter> + /** + * Secure storage for private keys + */ + keychain: Keychain + /** * Pinning operations for blocks in the blockstore */ @@ -111,6 +229,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 +270,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..c856bdd19 --- /dev/null +++ b/packages/interface/src/keychain.ts @@ -0,0 +1,102 @@ +import type { PrivateKey } from './index.ts' +import type { AbortOptions } from 'abort-error' + +export interface KeyInfo { + /** + * 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..f3f83531c 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,32 +26,29 @@ 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 @@ -72,7 +68,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 +77,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..c8e236249 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, { @@ -89,7 +93,7 @@ export class IPNSRepublisher { } 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 @@ -110,9 +114,16 @@ 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 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..94604b2e4 100644 --- a/packages/ipns/src/routing/pubsub.ts +++ b/packages/ipns/src/routing/pubsub.ts @@ -4,9 +4,7 @@ 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 { multihashToIPNSRoutingKey } from '../records.ts' import { CustomProgressEvent } from 'progress-events' import { raceSignal } from 'race-signal' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' @@ -14,9 +12,12 @@ 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 { 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..8d9964a1a 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 { digest } from 'multiformats' +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 { 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,337 @@ 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().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.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('/') && 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 (value?: PublicKey | CID | MultihashDigest | string): { digest: MultihashDigest, path: string } { + if (value != null) { + if (isPublicKey(value)) { + return { + digest: value.toMultihash(), + path: '/' + } + } + + 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) { + throw new InvalidValueError('CIDs must have the `libp2p-key` codec') + } + + return { + digest: cid.multihash, + path: '/' + } + } + + if (isMultihashDigest(value)) { + return { + digest: value, + path: '/' + } + } + + value = value.toString() + + if (value.startsWith('/ipns/')) { + const parts = value.split('/') + const codec = parts[1].startsWith('1') ? base58btc : base36 + + return { + digest: digest.decode(codec.decode(value[1])), + path: `/${parts.slice(2).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..290760ba5 100644 --- a/packages/ipns/test/fixtures/create-ipns.ts +++ b/packages/ipns/test/fixtures/create-ipns.ts @@ -1,12 +1,11 @@ import { TypedEventEmitter } from '@libp2p/interface' -import { keychain } from '@libp2p/keychain' import { defaultLogger } from '@libp2p/logger' import { MemoryDatastore } from 'datastore-core' +import Sinon from 'sinon' import { stubInterface } from 'sinon-ts' import { IPNS } from '../../src/ipns.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 } from '@helia/interface' import type { Logger } from '@libp2p/logger' import type { Datastore } from 'interface-datastore' import type { StubbedInstance } from 'sinon-ts' @@ -15,7 +14,7 @@ export interface CreateIPNSResult { name: IPNS customRouting: StubbedInstance heliaRouting: StubbedInstance - ipnsKeychain: Keychain + keychain: StubbedInstance datastore: Datastore, log: Logger events: TypedEventEmitter @@ -31,28 +30,17 @@ 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 getCryptoKey = Sinon.stub() + const keychain = stubInterface() const name = new IPNS({ datastore, routing: heliaRouting, - libp2p: { - status: 'stopped', - services: { - keychain: ipnsKeychain - } - } as any, logger, - events + events, + getCryptoKey, + keychain }, { routers: [customRouting] }) @@ -61,7 +49,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..5c1766719 --- /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 === 'Ed15519') { + 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..5125daf29 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,21 +95,26 @@ describe('publish', () => { it('should publish recursively using a public key', async () => { const keyName1 = 'test-key-6' - const record = await name.publish(keyName1, cid, { + const { record } = await name.publish(keyName1, cid, { offline: true }) - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) + expect(uint8ArrayToString(record.value)).to.equal(`/ipfs/${cid.toV1().toString()}`) const keyName2 = 'test-key-7' const recursiveRecord = await name.publish(keyName2, record.publicKey, { offline: true }) - expect(recursiveRecord.record.value).to.equal(`/ipns/${record.publicKey.toCID().toString(base36)}`) + expect(uint8ArrayToString(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()) + 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 () => { @@ -120,15 +126,19 @@ describe('publish', () => { expect(record.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(), { 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()) + 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 multihash', async () => { @@ -140,34 +150,19 @@ describe('publish', () => { expect(record.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, { offline: true }) expect(recursiveRecord.record.value).to.equal(`/ipns/${base36.encode(record.publicKey.toCID().multihash.bytes)}`) - const recursiveResult = await name.resolve(recursiveRecord.publicKey) - expect(recursiveResult.cid.toString()).to.equal(cid.toV1().toString()) - }) + const recursiveResult = await last(name.resolve(recursiveRecord.publicKey)) - it('should publish recursively using a PeerId key', async () => { - const keyName1 = 'test-key-10' - const record = await name.publish(keyName1, cid, { - offline: true - }) + if (recursiveResult == null) { + throw new Error('No results found') + } - expect(record.record.value).to.equal(`/ipfs/${cid.toV1().toString()}`) - - 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 () => { @@ -185,8 +180,13 @@ describe('publish', () => { 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()) + 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 record with a path', async () => { @@ -200,10 +200,13 @@ describe('publish', () => { expect(record.record.value).to.equal(fullPath) - const result = await name.resolve(record.publicKey) + const result = await last(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..9687bd49d 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 { @@ -27,7 +29,7 @@ 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 +37,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,15 +53,13 @@ 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, 24 * 60 * 60 * 1000) 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), { @@ -79,14 +79,10 @@ 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, 24 * 60 * 60 * 1000) 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), { @@ -111,14 +107,10 @@ 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, 24 * 60 * 60 * 1000) 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), { @@ -137,16 +129,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, 24 * 60 * 60 * 1000) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) // Store the record without metadata (simulate old records) @@ -180,14 +171,10 @@ 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, 24 * 60 * 60 * 1000) // 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), { @@ -203,22 +190,18 @@ 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, 24 * 60 * 60 * 1000, { 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), { @@ -236,20 +219,16 @@ 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, 24 * 60 * 60 * 1000) 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), { @@ -264,20 +243,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 +270,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 +282,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, 24 * 60 * 60 * 1000) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) // Store the record in the real datastore (but don't import the key) @@ -384,13 +358,9 @@ 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 @@ -410,18 +380,12 @@ 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, 24 * 60 * 60 * 1000) 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 diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 94fb4902b..88605e753 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', 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', 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', 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', 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..8018a4eec 100644 --- a/packages/ipns/test/routing/pubsub.spec.ts +++ b/packages/ipns/test/routing/pubsub.spec.ts @@ -1,21 +1,24 @@ -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 { 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 +31,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 +61,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 +137,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,7 +145,7 @@ 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') }) diff --git a/packages/utils/package.json b/packages/utils/package.json index ba3116cfe..6eb7f530a 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,7 +73,10 @@ "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": { @@ -85,6 +89,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/ed25519.ts b/packages/utils/src/crypto/ed25519.ts new file mode 100644 index 000000000..88fa651da --- /dev/null +++ b/packages/utils/src/crypto/ed25519.ts @@ -0,0 +1,136 @@ +import { CID } from 'multiformats' +import { identity } from 'multiformats/hashes/identity' +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 type { CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' +import type { AbortOptions } from 'abort-error' +import type { MultihashDigest } from 'multiformats' + +class Ed25519PublicKey implements PublicKey { + public type = 'Ed25519' + public code = 1 + public raw: ArrayBuffer + + constructor (raw: ArrayBuffer) { + this.raw = raw + } + + toMultihash (): MultihashDigest { + return identity.digest(new Uint8Array(this.raw)) + } + + 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) { + this.raw = raw + this.publicKey = publicKey + } + + async sign (message: Uint8Array, options?: AbortOptions): Promise> { + const privateKey = truncateKey(this.raw) + + const key = await crypto.subtle.importKey('jwk', { + crv: 'Ed25519', + kty: 'OKP', + // x: uint8arrayToString(privateKey.subarray(32), 'base64url'), + d: uint8arrayToString(new Uint8Array(privateKey), '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 bytes = crypto.getRandomValues(new Uint8Array(32)) + return new Ed25519PrivateKey(bytes.buffer, await derivePublicKey(bytes.buffer, options)) + } + + async publicKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { + const publicKey = new Ed25519PublicKey(key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer) + options?.signal?.throwIfAborted() + + return publicKey + } + + async privateKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { + const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).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(32) + const view = new Uint8Array(key) + view.set(new Uint8Array(input, 0, 32)) + + 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, 32).slice().buffer + } else { + const privateKey = truncateKey(raw) + + const key = await crypto.subtle.importKey('jwk', { + crv: 'Ed25519', + kty: 'OKP', + // x: uint8arrayToString(privateKey.subarray(32), 'base64url'), + d: uint8arrayToString(new Uint8Array(privateKey), 'base64url'), + ext: true, + key_ops: ['sign'] + }, { + name: 'Ed25519' + }, true, ['sign']) + options?.signal?.throwIfAborted() + + const exported = await crypto.subtle.exportKey('jwk', key) + options?.signal?.throwIfAborted() + + publicKey = uint8arrayFromString(exported.x ?? '', 'base64url').buffer + } + + return new Ed25519PublicKey(publicKey) +} 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..d65ac7448 --- /dev/null +++ b/packages/utils/src/crypto/rsa.ts @@ -0,0 +1,119 @@ +import { CID } from 'multiformats' +import { sha256 } from 'multiformats/hashes/sha2' +import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' +import { withArrayBuffer as uint8ArrayWithArrayBuffer } from 'uint8arrays/with-array-buffer' +import type { CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' +import type { AbortOptions } from '@libp2p/interface' +import type { MultihashDigest } from 'multiformats' + +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('raw', this.raw, { + 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 (raw: ArrayBuffer, publicKey: PublicKey) { + this.raw = raw + this.publicKey = publicKey + } + + async sign (message: Uint8Array, options?: AbortOptions): Promise> { + const key = await crypto.subtle.importKey('raw', 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 window.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 window.crypto.subtle.exportKey('pkcs8', privateKey.privateKey) + const rawPublicKey = await window.crypto.subtle.exportKey('spki', privateKey.privateKey) + + return new RSAPrivateKey(rawPrivateKey, new RSAPublicKey(rawPublicKey, await sha256.digest(new Uint8Array(rawPublicKey)))) + } + + async publicKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { + const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer + + return new RSAPublicKey(raw, await sha256.digest(new Uint8Array(raw))) + } + + async privateKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { + const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer + + return new RSAPrivateKey(raw, await derivePublicKey(raw, options)) + } +} + +export function rsaCrypto (): CryptoKeyImplementation { + return new RSACrypto() +} + +async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promise { + const key = await crypto.subtle.importKey('raw', raw, { + name: 'RSASSA-PKCS1-v1_5', + hash: { + name: 'SHA-256' + } + }, false, ['sign']) + options?.signal?.throwIfAborted() + + const exported = await crypto.subtle.exportKey('jwk', key) + options?.signal?.throwIfAborted() + + const publicKey = uint8arrayFromString(exported.x ?? '', 'base64url') + const digest = await sha256.digest(new Uint8Array(publicKey.buffer)) + + return new RSAPublicKey(publicKey.buffer, digest) +} diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index fd700ac83..c1fa4e5ce 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -32,3 +32,8 @@ export class BlockNotFoundWhileOfflineError extends Error { static name = 'BlockNotFoundWhileOfflineError' name = 'BlockNotFoundWhileOfflineError' } + +export class UnsupportedCryptographyImplementationError extends Error { + static name = 'UnsupportedCryptographyImplementationError' + name = 'UnsupportedCryptographyImplementationError' +} 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..8f69b2e76 --- /dev/null +++ b/packages/utils/src/keychain.ts @@ -0,0 +1,447 @@ +import { InvalidParametersError, NotFoundError, NotStartedError, 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 as uint8ArrayWithArrayBuffer } from 'uint8arrays/with-array-buffer' +import { PrivateKeyMessage } from './keychain/keys.ts' +import type { Keychain as KeychainInterface, KeyInfo, PrivateKey, CryptoKeyLoader } 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/' +const privates = new WeakMap() + +/** + * Default options for key derivation + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + */ +const DEK_INIT = { + keyLength: 512 / 8, + iterations: 10_000, + salt: 'you should override this value with a crypto secure random number', + hash: 'sha2-512' +} + +const MIN_PASS_LENGTH = 20 + +// NIST SP 800-132 +const NIST = { + minKeyLength: 112 / 8, + minSaltLength: 128 / 8, + minIterations: 1_000 +} + +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 + + /** + * Random initialization vector + */ + salt?: string + + /** + * How many iterations to use when deriving a key from the password + * + * @default 10_000 + */ + iterations?: number + + /** + * The default key length in bytes + * + * @default 64 + */ + keyLength?: number + + /** + * The hash type + * + * @default SHA2-512 + */ + hash?: string + + /** + * 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: ArrayBuffer | Uint8Array): Promise { + const hash = await sha256.digest(key instanceof Uint8Array ? key : new Uint8Array(key, 0, key.byteLength)) + + return base58btc.encode(hash.bytes).substring(1) +} + +/** + * 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 key?: CryptoKey + private salt: Uint8Array + private iterations: number + private keyLength: number + private hash: string + private password: string + + /** + * 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 = uint8ArrayFromString(init.salt ?? DEK_INIT.salt) + this.iterations = init.iterations ?? DEK_INIT.iterations + this.keyLength = init.keyLength ?? DEK_INIT.keyLength + this.hash = init.hash ?? 'SHA2-512' + this.password = init.password ?? '' + + // Enforce NIST SP 800-132 + if (this.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 (this.iterations < NIST.minIterations) { + throw new Error(`iterations must be least ${NIST.minIterations}`) + } + } + + readonly [Symbol.toStringTag] = '@libp2p/keychain' + + readonly [serviceCapabilities]: string[] = [ + '@libp2p/keychain' + ] + + async start (): Promise { + this.key = await this.generateSaltedKey(this.password ?? '') + } + + private async generateSaltedKey (pass: string): Promise { + const key = await crypto.subtle.importKey('raw', uint8ArrayFromString(pass), { + name: 'PBKDF2' + }, false, ['deriveKey', 'deriveBits']) + return crypto.subtle.deriveKey({ + name: 'PBKDF2', + salt: uint8ArrayWithArrayBuffer(this.salt), + iterations: this.iterations, + hash: this.hash + }, key, { + name: 'HMAC', + hash: this.hash, + length: this.keyLength + }, true, ['encrypt', 'decrypt']) + } + + 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 findKeyByName (name: string, options?: AbortOptions): Promise { + if (!validateKeyName(name)) { + throw new InvalidParametersError(`Invalid key name '${name}'`) + } + + const datastoreName = dsInfoName(name) + + try { + const res = await this.components.datastore.get(datastoreName, options) + return JSON.parse(uint8ArrayToString(res)) + } catch (err: any) { + this.log.error('could not read key from datastore - %e', err) + throw new NotFoundError(`Key '${name}' does not exist.`) + } + } + + async findKeyById (id: string): Promise { + const query = { + prefix: infoPrefix + } + + for await (const value of this.components.datastore.query(query)) { + const key = JSON.parse(uint8ArrayToString(value.value)) + + if (key.id === id) { + return key + } + } + + throw new InvalidParametersError(`Key with id '${id}' does not exist.`) + } + + 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') + } + + if (this.key == null) { + throw new NotStartedError() + } + + const exists = await this.components.datastore.has(dsName(name), options) + + if (exists) { + throw new InvalidParametersError(`Key '${name}' already exists`) + } + + const cached = privates.get(this) + + if (cached == null) { + throw new InvalidParametersError('dek missing') + } + + const batch = this.components.datastore.batch() + await this._importKey(name, key, this.key, batch, options) + await batch.commit(options) + + return key + } + + private async _importKey (name: string, privateKey: PrivateKey, key: CryptoKey, batch: Batch, options?: AbortOptions): Promise { + const data = new Uint8Array(privateKey.raw.slice()) + + const protobuf = PrivateKeyMessage.encode({ + Type: privateKey.code, + Data: data + }) + + const cipherText = await window.crypto.subtle.encrypt({ + name: 'AES-GCM' + // iv: window.crypto.getRandomValues(new Uint8Array(12)) + }, key, protobuf) + options?.signal?.throwIfAborted() + + const buf = new Uint8Array(cipherText) + const pem = base64.encode(buf) + const keyInfo = { + name, + type: privateKey.type + } + + 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}'`) + } + + const cached = privates.get(this) + + if (cached == null) { + throw new InvalidParametersError('dek missing') + } + + const key = cached.key + + return this._exportKey(name, key, options) + } + + private async _exportKey (name: string, key: CryptoKey, options?: AbortOptions): Promise { + const res = await this.components.datastore.get(dsName(name), options) + const info = await this.components.datastore.get(dsInfoName(name), options) + const pem = uint8ArrayToString(res) + const buf = base64.decode(pem) + + const raw = await window.crypto.subtle.decrypt({ + name: 'AES-GCM' + // iv: window.crypto.getRandomValues(new Uint8Array(12)) + }, key, buf) + options?.signal?.throwIfAborted() + + return { + ...JSON.parse(uint8ArrayToString(info)), + raw + } + } + + 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 = 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 cached = privates.get(this) + + if (cached == null) { + throw new InvalidParametersError('dek missing') + } + + const oldKey = cached.key + const newKey = await this.generateSaltedKey(password) + + const batch = this.components.datastore.batch() + + for await (const info of this.listKeys(options)) { + const key = await this._exportKey(info.name, oldKey) + + // Update stored key + await this._importKey(info.name, key, newKey, batch, options) + } + + await batch.commit(options) + + privates.set(this, { + ...cached, + key: newKey + }) + + this.log('keychain reconstructed') + } +} 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, From 5663f19eb905eaa0192d9e334e690bc4df44b2da Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 15 May 2026 17:05:54 +0300 Subject: [PATCH 02/10] chore: linting --- packages/ipns/src/routing/pubsub.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ipns/src/routing/pubsub.ts b/packages/ipns/src/routing/pubsub.ts index 94604b2e4..5465abd46 100644 --- a/packages/ipns/src/routing/pubsub.ts +++ b/packages/ipns/src/routing/pubsub.ts @@ -4,7 +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 '../records.ts' import { CustomProgressEvent } from 'progress-events' import { raceSignal } from 'race-signal' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' @@ -12,6 +11,7 @@ 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 { multihashToIPNSRoutingKey } from '../records.ts' import { ipnsSelector } from '../selector.ts' import { IPNS_STRING_PREFIX, unmarshalIPNSRecord } from '../utils.ts' import { ipnsValidator } from '../validator.ts' From e6dc615f5c36d9ea80f2eb664aa825d6df686d08 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 19 May 2026 16:47:10 +0300 Subject: [PATCH 03/10] chore: keychain tests --- packages/utils/src/crypto/ed25519.ts | 52 +- packages/utils/src/crypto/rsa.ts | 37 +- packages/utils/src/errors.ts | 5 + packages/utils/src/keychain.ts | 144 +++--- packages/utils/test/fixtures/crypto-loader.ts | 16 + packages/utils/test/keychain.spec.ts | 476 ++++++++++++++++++ 6 files changed, 621 insertions(+), 109 deletions(-) create mode 100644 packages/utils/test/fixtures/crypto-loader.ts create mode 100644 packages/utils/test/keychain.spec.ts diff --git a/packages/utils/src/crypto/ed25519.ts b/packages/utils/src/crypto/ed25519.ts index 88fa651da..dda12d887 100644 --- a/packages/utils/src/crypto/ed25519.ts +++ b/packages/utils/src/crypto/ed25519.ts @@ -1,5 +1,7 @@ +import { InvalidPrivateKeyError } from '@libp2p/interface' import { CID } from 'multiformats' import { identity } from 'multiformats/hashes/identity' +import { concat as uin8ArrayConcat } 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' @@ -7,6 +9,8 @@ import type { CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/inte import type { AbortOptions } from 'abort-error' import type { MultihashDigest } from 'multiformats' +const PRIVATE_KEY_LENGTH = 32 + class Ed25519PublicKey implements PublicKey { public type = 'Ed25519' public code = 1 @@ -40,6 +44,10 @@ class Ed25519PrivateKey implements PrivateKey { 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 = raw this.publicKey = publicKey } @@ -50,7 +58,7 @@ class Ed25519PrivateKey implements PrivateKey { const key = await crypto.subtle.importKey('jwk', { crv: 'Ed25519', kty: 'OKP', - // x: uint8arrayToString(privateKey.subarray(32), 'base64url'), + x: uint8arrayToString(new Uint8Array(this.publicKey.raw), 'base64url'), d: uint8arrayToString(new Uint8Array(privateKey), 'base64url'), ext: true, key_ops: ['sign'] @@ -71,8 +79,12 @@ class Ed25519Crypto implements CryptoKeyImplementation { code = 1 async createPrivateKey (options?: AbortOptions & Record): Promise { - const bytes = crypto.getRandomValues(new Uint8Array(32)) - return new Ed25519PrivateKey(bytes.buffer, await derivePublicKey(bytes.buffer, options)) + 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 { @@ -83,8 +95,7 @@ class Ed25519Crypto implements CryptoKeyImplementation { } async privateKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { - const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer - + const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).slice().buffer return new Ed25519PrivateKey(raw, await derivePublicKey(raw, options)) } } @@ -98,9 +109,9 @@ export function ed25519Crypto (): CryptoKeyImplementation { * truncate the Uint8Array to handle this case */ function truncateKey (input: ArrayBuffer): ArrayBuffer { - const key = new ArrayBuffer(32) + const key = new ArrayBuffer(PRIVATE_KEY_LENGTH) const view = new Uint8Array(key) - view.set(new Uint8Array(input, 0, 32)) + view.set(new Uint8Array(input, 0, PRIVATE_KEY_LENGTH)) return key } @@ -110,20 +121,11 @@ async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promi // if the public key is appended to the private key, just return that if (raw.byteLength === 64) { - publicKey = new Uint8Array(raw, 32).slice().buffer + publicKey = new Uint8Array(raw, PRIVATE_KEY_LENGTH).slice().buffer } else { const privateKey = truncateKey(raw) - - const key = await crypto.subtle.importKey('jwk', { - crv: 'Ed25519', - kty: 'OKP', - // x: uint8arrayToString(privateKey.subarray(32), 'base64url'), - d: uint8arrayToString(new Uint8Array(privateKey), 'base64url'), - ext: true, - key_ops: ['sign'] - }, { - name: 'Ed25519' - }, true, ['sign']) + const pkcs8 = convertRawX25519KeyToPKCS(privateKey) + const key = await crypto.subtle.importKey('pkcs8', pkcs8, 'Ed25519', true, ['sign']) options?.signal?.throwIfAborted() const exported = await crypto.subtle.exportKey('jwk', key) @@ -134,3 +136,15 @@ async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promi 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 uin8ArrayConcat([ + PKCS8_HEADER, + Uint8Array.from([privateKey.byteLength]), + new Uint8Array(privateKey) + ], PKCS8_HEADER.byteLength + 1 + privateKey.byteLength) +} diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts index d65ac7448..92128ad4e 100644 --- a/packages/utils/src/crypto/rsa.ts +++ b/packages/utils/src/crypto/rsa.ts @@ -1,6 +1,7 @@ import { CID } from 'multiformats' import { sha256 } from 'multiformats/hashes/sha2' 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 type { CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' import type { AbortOptions } from '@libp2p/interface' @@ -26,7 +27,14 @@ class RSAPublicKey implements PublicKey { } async verify (message: Uint8Array, signature: Uint8Array, options?: AbortOptions): Promise { - const key = await crypto.subtle.importKey('raw', this.raw, { + const key = await crypto.subtle.importKey('jwk', { + key_ops: ['verify'], + ext: true, + alg: 'RS256', + kty: 'RSA', + n: uint8ArrayToString(new Uint8Array(this.raw), 'base64url'), + e: 'AQAB' + }, { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' @@ -53,13 +61,15 @@ class RSAPrivateKey implements PrivateKey { } async sign (message: Uint8Array, options?: AbortOptions): Promise> { - const key = await crypto.subtle.importKey('raw', this.raw, { + 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)) + const sig = await crypto.subtle.sign({ + name: 'RSASSA-PKCS1-v1_5' + }, key, uint8ArrayWithArrayBuffer(message)) options?.signal?.throwIfAborted() return new Uint8Array(sig, 0, sig.byteLength) @@ -71,16 +81,19 @@ class RSACrypto implements CryptoKeyImplementation { public code = 0 async createPrivateKey (options?: AbortOptions & Record): Promise { - const privateKey = await window.crypto.subtle.generateKey({ + const privateKey = await crypto.subtle.generateKey({ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), - hash: { name: 'SHA-256' } + hash: { + name: 'SHA-256' + } }, true, ['sign', 'verify']) - const rawPrivateKey = await window.crypto.subtle.exportKey('pkcs8', privateKey.privateKey) - const rawPublicKey = await window.crypto.subtle.exportKey('spki', privateKey.privateKey) + const rawPrivateKey = await crypto.subtle.exportKey('pkcs8', privateKey.privateKey) + const exported = await crypto.subtle.exportKey('jwk', privateKey.publicKey) + const publicKey = uint8arrayFromString(exported.n ?? '', 'base64url') - return new RSAPrivateKey(rawPrivateKey, new RSAPublicKey(rawPublicKey, await sha256.digest(new Uint8Array(rawPublicKey)))) + return new RSAPrivateKey(rawPrivateKey, new RSAPublicKey(publicKey.buffer, await sha256.digest(new Uint8Array(publicKey)))) } async publicKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { @@ -90,7 +103,7 @@ class RSACrypto implements CryptoKeyImplementation { } async privateKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { - const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer + const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).slice().buffer return new RSAPrivateKey(raw, await derivePublicKey(raw, options)) } @@ -101,18 +114,18 @@ export function rsaCrypto (): CryptoKeyImplementation { } async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promise { - const key = await crypto.subtle.importKey('raw', raw, { + const key = await crypto.subtle.importKey('pkcs8', raw, { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } - }, false, ['sign']) + }, true, ['sign']) options?.signal?.throwIfAborted() const exported = await crypto.subtle.exportKey('jwk', key) options?.signal?.throwIfAborted() - const publicKey = uint8arrayFromString(exported.x ?? '', 'base64url') + const publicKey = uint8arrayFromString(exported.n ?? '', 'base64url') const digest = await sha256.digest(new Uint8Array(publicKey.buffer)) return new RSAPublicKey(publicKey.buffer, digest) diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index c1fa4e5ce..27c2e5532 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -37,3 +37,8 @@ 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/keychain.ts b/packages/utils/src/keychain.ts index 8f69b2e76..78ce0c1f7 100644 --- a/packages/utils/src/keychain.ts +++ b/packages/utils/src/keychain.ts @@ -1,12 +1,14 @@ -import { InvalidParametersError, NotFoundError, NotStartedError, serviceCapabilities } from '@libp2p/interface' +import { InvalidParametersError, NotStartedError, 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 { 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 { DecryptionFailedError } from './errors.ts' import { PrivateKeyMessage } from './keychain/keys.ts' import type { Keychain as KeychainInterface, KeyInfo, PrivateKey, CryptoKeyLoader } from '@helia/interface' import type { ComponentLogger, Logger } from '@libp2p/interface' @@ -16,9 +18,6 @@ import type { Batch } from 'interface-datastore' const keyPrefix = '/pkcs8/' const infoPrefix = '/info/' -const privates = new WeakMap() /** * Default options for key derivation @@ -41,6 +40,14 @@ const NIST = { minIterations: 1_000 } +const KEY_LENGTHS: Record = { + 'SHA-256': 64, + 'SHA-384': 128, + 'SHA-512': 256 +} + +const SALT_LENGTH = 16 + export interface DEKConfig { hash: string salt: string @@ -78,7 +85,7 @@ export interface KeychainInit { * * @default SHA2-512 */ - hash?: string + hash?: 'SHA-256' | 'SHA-384' | 'SHA-512' /** * The 'self' key is the private key of the node from which the peer id is @@ -148,7 +155,7 @@ export class Keychain implements KeychainInterface { private salt: Uint8Array private iterations: number private keyLength: number - private hash: string + private hash: 'SHA-256' | 'SHA-384' | 'SHA-512' private password: string /** @@ -161,11 +168,11 @@ export class Keychain implements KeychainInterface { this.salt = uint8ArrayFromString(init.salt ?? DEK_INIT.salt) this.iterations = init.iterations ?? DEK_INIT.iterations this.keyLength = init.keyLength ?? DEK_INIT.keyLength - this.hash = init.hash ?? 'SHA2-512' + this.hash = init.hash ?? 'SHA-512' this.password = init.password ?? '' // Enforce NIST SP 800-132 - if (this.password.length < MIN_PASS_LENGTH) { + if (init.password != null && this.password.length < MIN_PASS_LENGTH) { throw new Error('password must be least 20 characters') } @@ -180,6 +187,10 @@ export class Keychain implements KeychainInterface { if (this.iterations < NIST.minIterations) { throw new Error(`iterations must be least ${NIST.minIterations}`) } + + if (KEY_LENGTHS[this.hash] == null) { + throw new InvalidParametersError('Unsupported hash') + } } readonly [Symbol.toStringTag] = '@libp2p/keychain' @@ -192,19 +203,24 @@ export class Keychain implements KeychainInterface { this.key = await this.generateSaltedKey(this.password ?? '') } + async stop (): Promise { + + } + private async generateSaltedKey (pass: string): Promise { const key = await crypto.subtle.importKey('raw', uint8ArrayFromString(pass), { name: 'PBKDF2' - }, false, ['deriveKey', 'deriveBits']) + }, false, ['deriveKey']) return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: uint8ArrayWithArrayBuffer(this.salt), iterations: this.iterations, hash: this.hash }, key, { - name: 'HMAC', + // name: 'HMAC', + name: 'AES-GCM', hash: this.hash, - length: this.keyLength + length: KEY_LENGTHS[this.hash] }, true, ['encrypt', 'decrypt']) } @@ -215,38 +231,6 @@ export class Keychain implements KeychainInterface { return this.importKey(name, key, options) } - async findKeyByName (name: string, options?: AbortOptions): Promise { - if (!validateKeyName(name)) { - throw new InvalidParametersError(`Invalid key name '${name}'`) - } - - const datastoreName = dsInfoName(name) - - try { - const res = await this.components.datastore.get(datastoreName, options) - return JSON.parse(uint8ArrayToString(res)) - } catch (err: any) { - this.log.error('could not read key from datastore - %e', err) - throw new NotFoundError(`Key '${name}' does not exist.`) - } - } - - async findKeyById (id: string): Promise { - const query = { - prefix: infoPrefix - } - - for await (const value of this.components.datastore.query(query)) { - const key = JSON.parse(uint8ArrayToString(value.value)) - - if (key.id === id) { - return key - } - } - - throw new InvalidParametersError(`Key with id '${id}' does not exist.`) - } - async importKey (name: string, key: PrivateKey, options?: AbortOptions): Promise { if (!validateKeyName(name)) { throw new InvalidParametersError(`Invalid key name '${name}'`) @@ -266,12 +250,6 @@ export class Keychain implements KeychainInterface { throw new InvalidParametersError(`Key '${name}' already exists`) } - const cached = privates.get(this) - - if (cached == null) { - throw new InvalidParametersError('dek missing') - } - const batch = this.components.datastore.batch() await this._importKey(name, key, this.key, batch, options) await batch.commit(options) @@ -281,19 +259,24 @@ export class Keychain implements KeychainInterface { private async _importKey (name: string, privateKey: PrivateKey, key: CryptoKey, batch: Batch, options?: AbortOptions): Promise { const data = new Uint8Array(privateKey.raw.slice()) - const protobuf = PrivateKeyMessage.encode({ Type: privateKey.code, Data: data }) - const cipherText = await window.crypto.subtle.encrypt({ - name: 'AES-GCM' - // iv: window.crypto.getRandomValues(new Uint8Array(12)) + const iv = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) + const cipherText = await crypto.subtle.encrypt({ + name: 'AES-GCM', + iv }, key, protobuf) options?.signal?.throwIfAborted() - const buf = new Uint8Array(cipherText) + // prepend the iv to the buffer + const buf = uint8ArrayConcat([ + iv, + new Uint8Array(cipherText) + ], iv.byteLength + cipherText.byteLength) + const pem = base64.encode(buf) const keyInfo = { name, @@ -309,33 +292,42 @@ export class Keychain implements KeychainInterface { throw new InvalidParametersError(`Invalid key name '${name}'`) } - const cached = privates.get(this) - - if (cached == null) { - throw new InvalidParametersError('dek missing') + if (this.key == null) { + throw new NotStartedError() } - const key = cached.key - - return this._exportKey(name, key, options) + return this._exportKey(name, this.key, options) } private async _exportKey (name: string, key: CryptoKey, options?: AbortOptions): Promise { const res = await this.components.datastore.get(dsName(name), options) - const info = await this.components.datastore.get(dsInfoName(name), options) const pem = uint8ArrayToString(res) const buf = base64.decode(pem) + const iv = buf.subarray(0, SALT_LENGTH) + let raw: ArrayBuffer - const raw = await window.crypto.subtle.decrypt({ - name: 'AES-GCM' - // iv: window.crypto.getRandomValues(new Uint8Array(12)) - }, key, buf) - options?.signal?.throwIfAborted() + try { + raw = await crypto.subtle.decrypt({ + name: 'AES-GCM', + iv + }, key, buf.subarray(SALT_LENGTH)) + options?.signal?.throwIfAborted() + } catch (err: any) { + if (err.name === 'OperationError') { + throw new DecryptionFailedError(err.message) + } + + throw err + } + + const privateKeyPb = PrivateKeyMessage.decode(new Uint8Array(raw)) - return { - ...JSON.parse(uint8ArrayToString(info)), - raw + if (privateKeyPb.Type == null || privateKeyPb.Data == null) { + throw new InvalidParametersError('Decoded private key protobuf did not have Type and/or Data fields') } + + const cryptoImplementation = await this.components.getCryptoKey(privateKeyPb.Type) + return cryptoImplementation.privateKeyFromArray(privateKeyPb.Data) } async removeKey (name: string, options?: AbortOptions): Promise { @@ -417,13 +409,12 @@ export class Keychain implements KeychainInterface { } this.log('recreating keychain') - const cached = privates.get(this) - if (cached == null) { - throw new InvalidParametersError('dek missing') + if (this.key == null) { + throw new NotStartedError() } - const oldKey = cached.key + const oldKey = this.key const newKey = await this.generateSaltedKey(password) const batch = this.components.datastore.batch() @@ -437,10 +428,7 @@ export class Keychain implements KeychainInterface { await batch.commit(options) - privates.set(this, { - ...cached, - key: newKey - }) + this.key = newKey this.log('keychain reconstructed') } 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..e3c5a6d24 --- /dev/null +++ b/packages/utils/test/keychain.spec.ts @@ -0,0 +1,476 @@ +import { start } from '@libp2p/interface' +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 { Datastore } from 'interface-datastore' + +const SUPPORTED_KEYS = [ + 'RSA', + 'Ed25519' +] + +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, + keyLength: 14 + }) + 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, + keyLength: 64, + 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) + }) + + 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') + }) + }) +}) From dc9158f805d0bb81f0c85873010b219da0c99b7b Mon Sep 17 00:00:00 2001 From: achingbrain Date: Wed, 20 May 2026 17:00:03 +0300 Subject: [PATCH 04/10] chore: keychain tests --- packages/interface/src/index.ts | 24 +- packages/interface/src/keychain.ts | 7 +- packages/utils/package.json | 1 + packages/utils/src/crypto/der.ts | 270 ++++++++++++++++++ packages/utils/src/crypto/ed25519.ts | 45 ++- packages/utils/src/crypto/rsa.ts | 180 ++++++++++-- packages/utils/src/keychain.ts | 400 ++++++++++++++++++--------- packages/utils/test/keychain.spec.ts | 73 ++++- 8 files changed, 837 insertions(+), 163 deletions(-) create mode 100644 packages/utils/src/crypto/der.ts diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 81cae03e2..a91cf0fa7 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -117,6 +117,18 @@ export function isPrivateKey (obj?: any): obj is PrivateKey { isPublicKey(obj.publicKey) && obj.sign === 'function' } +export interface CipherOptions { + iterations?: number + hash?: string + keyLength?: number + algorithm?: string +} + +export interface Cipher { + encrypt(data: Uint8Array): Promise> + decrypt(salt: Uint8Array, iv: Uint8Array, cipherText: Uint8Array, options?: CipherOptions): Promise> +} + export interface CryptoKeyImplementation { /** * The type of the crypto implementation, e.g. `Ed15519` @@ -135,14 +147,20 @@ export interface CryptoKeyImplementation { createPrivateKey(options?: AbortOptions & Record): Promise /** - * Convert the passed raw bytes into a public key + * 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 the passed raw bytes into a private key + * Convert a private key into a string suitable for storing in a datastore + */ + serialize (key: PrivateKey, cipher: Cipher): Promise + + /** + * Convert a string from a datastore into a private key */ - privateKeyFromArray(key: ArrayBuffer | Uint8Array, options?: AbortOptions): PrivateKey | Promise + deserialize (pem: string, cipher: Cipher): Promise } /** diff --git a/packages/interface/src/keychain.ts b/packages/interface/src/keychain.ts index c856bdd19..10e93c073 100644 --- a/packages/interface/src/keychain.ts +++ b/packages/interface/src/keychain.ts @@ -2,6 +2,11 @@ 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 */ @@ -10,7 +15,7 @@ export interface KeyInfo { /** * The key type */ - type: 'Ed25519' | 'RSA' | string + type?: 'Ed25519' | 'RSA' | string } export interface Keychain { diff --git a/packages/utils/package.json b/packages/utils/package.json index 6eb7f530a..2f71059b8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -81,6 +81,7 @@ }, "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", 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 index dda12d887..ef107666a 100644 --- a/packages/utils/src/crypto/ed25519.ts +++ b/packages/utils/src/crypto/ed25519.ts @@ -1,11 +1,14 @@ import { InvalidPrivateKeyError } from '@libp2p/interface' import { CID } from 'multiformats' +import { base64 } from 'multiformats/bases/base64' import { identity } from 'multiformats/hashes/identity' import { concat as uin8ArrayConcat } from 'uint8arrays/concat' +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 type { CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' +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' @@ -44,22 +47,20 @@ class Ed25519PrivateKey implements PrivateKey { public publicKey: PublicKey constructor (raw: ArrayBuffer, publicKey: PublicKey) { - if (raw.byteLength !== PRIVATE_KEY_LENGTH) { + if (raw.byteLength < PRIVATE_KEY_LENGTH) { throw new InvalidPrivateKeyError(`Incorrect key length, got ${raw.byteLength} expected ${PRIVATE_KEY_LENGTH}`) } - this.raw = raw + this.raw = truncateKey(raw) this.publicKey = publicKey } async sign (message: Uint8Array, options?: AbortOptions): Promise> { - const privateKey = truncateKey(this.raw) - const key = await crypto.subtle.importKey('jwk', { crv: 'Ed25519', kty: 'OKP', x: uint8arrayToString(new Uint8Array(this.publicKey.raw), 'base64url'), - d: uint8arrayToString(new Uint8Array(privateKey), 'base64url'), + d: uint8arrayToString(new Uint8Array(this.raw), 'base64url'), ext: true, key_ops: ['sign'] }, { @@ -94,9 +95,35 @@ class Ed25519Crypto implements CryptoKeyImplementation { return publicKey } - async privateKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { - const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).slice().buffer - return new Ed25519PrivateKey(raw, await derivePublicKey(raw, options)) + async serialize (key: PrivateKey, cipher: Cipher): Promise { + const buf = PrivateKeyMessage.encode({ + Type: key.code, + Data: uint8ArrayConcat([ + new Uint8Array(key.raw.slice()), + new Uint8Array(key.publicKey.raw.slice()) + ], 64) + }) + + const cipherText = await cipher.encrypt(buf) + + return base64.encode(cipherText) + } + + async deserialize (pem: string, cipher: Cipher): 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().buffer + return new Ed25519PrivateKey(raw, await derivePublicKey(raw)) } } diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts index 92128ad4e..3006a492a 100644 --- a/packages/utils/src/crypto/rsa.ts +++ b/packages/utils/src/crypto/rsa.ts @@ -1,12 +1,19 @@ +import { InvalidParametersError } from '@libp2p/interface' import { CID } from 'multiformats' +import { base64 } from 'multiformats/bases/base64' import { sha256 } from 'multiformats/hashes/sha2' 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 type { CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' +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 @@ -55,8 +62,8 @@ class RSAPrivateKey implements PrivateKey { public raw: ArrayBuffer public publicKey: PublicKey - constructor (raw: ArrayBuffer, publicKey: PublicKey) { - this.raw = raw + constructor (pkcs8: ArrayBuffer, publicKey: PublicKey) { + this.raw = pkcs8 this.publicKey = publicKey } @@ -102,10 +109,103 @@ class RSACrypto implements CryptoKeyImplementation { return new RSAPublicKey(raw, await sha256.digest(new Uint8Array(raw))) } - async privateKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { - const raw = key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).slice().buffer + async serialize (key: PrivateKey, cipher: Cipher): 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 cipherText = await cipher.encrypt(buf) - return new RSAPrivateKey(raw, await derivePublicKey(raw, options)) + return base64.encode(cipherText) + } + + async deserialize (pem: string, cipher: Cipher): 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) + 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') + + return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, await sha256.digest(new Uint8Array(publicKey)))) + } + + 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' + }) + + 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') + + return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, await sha256.digest(new Uint8Array(publicKey)))) } } @@ -113,20 +213,62 @@ export function rsaCrypto (): CryptoKeyImplementation { return new RSACrypto() } -async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promise { - const key = await crypto.subtle.importKey('pkcs8', raw, { - name: 'RSASSA-PKCS1-v1_5', - hash: { - name: 'SHA-256' - } - }, true, ['sign']) - options?.signal?.throwIfAborted() +function toNumber (buf: Uint8Array): number { + if (buf.length === 0) { + return 0 + } + + const str = [...buf] + .map(n => n.toString(16).padStart(2, '0')) + .join('') - const exported = await crypto.subtle.exportKey('jwk', key) - options?.signal?.throwIfAborted() + return parseInt(str, 16) +} - const publicKey = uint8arrayFromString(exported.n ?? '', 'base64url') - const digest = await sha256.digest(new Uint8Array(publicKey.buffer)) +/** + * 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') + } +} - return new RSAPublicKey(publicKey.buffer, digest) +/** + * 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/keychain.ts b/packages/utils/src/keychain.ts index 78ce0c1f7..b3fbdde5a 100644 --- a/packages/utils/src/keychain.ts +++ b/packages/utils/src/keychain.ts @@ -1,4 +1,4 @@ -import { InvalidParametersError, NotStartedError, serviceCapabilities } from '@libp2p/interface' +import { InvalidParametersError, serviceCapabilities } from '@libp2p/interface' import { Key } from 'interface-datastore/key' import { base58btc } from 'multiformats/bases/base58' import { base64 } from 'multiformats/bases/base64' @@ -7,10 +7,10 @@ import sanitize from 'sanitize-filename' 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 { 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 } from '@helia/interface' +import type { Keychain as KeychainInterface, KeyInfo, PrivateKey, CryptoKeyLoader, CryptoKeyImplementation, Cipher, CipherOptions } from '@helia/interface' import type { ComponentLogger, Logger } from '@libp2p/interface' import type { AbortOptions } from 'abort-error' import type { Datastore } from 'interface-datastore' @@ -20,15 +20,32 @@ const keyPrefix = '/pkcs8/' const infoPrefix = '/info/' /** - * Default options for key derivation + * 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 DEK_INIT = { - keyLength: 512 / 8, +const KEYCHAIN_DEK_INIT = { iterations: 10_000, - salt: 'you should override this value with a crypto secure random number', - hash: 'sha2-512' + 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 @@ -41,13 +58,11 @@ const NIST = { } const KEY_LENGTHS: Record = { - 'SHA-256': 64, - 'SHA-384': 128, + 'SHA-256': 128, + 'SHA-384': 192, 'SHA-512': 256 } -const SALT_LENGTH = 16 - export interface DEKConfig { hash: string salt: string @@ -62,7 +77,7 @@ export interface KeychainInit { password?: string /** - * Random initialization vector + * Specify a non-default PBK2 function salt */ salt?: string @@ -73,13 +88,6 @@ export interface KeychainInit { */ iterations?: number - /** - * The default key length in bytes - * - * @default 64 - */ - keyLength?: number - /** * The hash type * @@ -133,30 +141,40 @@ function dsInfoName (name: string): Key { return new Key(infoPrefix + name) } -export async function keyId (key: ArrayBuffer | Uint8Array): Promise { - const hash = await sha256.digest(key instanceof Uint8Array ? key : new Uint8Array(key, 0, key.byteLength)) - +export async function keyId (key: PrivateKey): Promise { + const pb = PrivateKeyMessage.encode({ + Type: key.code, + Data: new Uint8Array(key.raw) + }) + const hash = await sha256.digest(pb) 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 key?: CryptoKey - private salt: Uint8Array - private iterations: number - private keyLength: number - private hash: 'SHA-256' | 'SHA-384' | 'SHA-512' - private password: string + private cipher: Cipher + private salt: Uint8Array + private keychainDekOptions: DeriveKeyOptions + private privateKeyDekOptions: PrivateKeyDeriveKeyOptions /** * Creates a new instance of a key chain @@ -165,32 +183,45 @@ export class Keychain implements KeychainInterface { this.components = components this.log = components.logger.forComponent('libp2p:keychain') this.self = init.selfKey ?? 'self' - this.salt = uint8ArrayFromString(init.salt ?? DEK_INIT.salt) - this.iterations = init.iterations ?? DEK_INIT.iterations - this.keyLength = init.keyLength ?? DEK_INIT.keyLength - this.hash = init.hash ?? 'SHA-512' - this.password = init.password ?? '' + 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 && this.password.length < MIN_PASS_LENGTH) { + 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 (this.iterations < NIST.minIterations) { + if (init.iterations != null && init.iterations < NIST.minIterations) { throw new Error(`iterations must be least ${NIST.minIterations}`) } - if (KEY_LENGTHS[this.hash] == null) { + 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' @@ -199,31 +230,6 @@ export class Keychain implements KeychainInterface { '@libp2p/keychain' ] - async start (): Promise { - this.key = await this.generateSaltedKey(this.password ?? '') - } - - async stop (): Promise { - - } - - private async generateSaltedKey (pass: string): Promise { - const key = await crypto.subtle.importKey('raw', uint8ArrayFromString(pass), { - name: 'PBKDF2' - }, false, ['deriveKey']) - return crypto.subtle.deriveKey({ - name: 'PBKDF2', - salt: uint8ArrayWithArrayBuffer(this.salt), - iterations: this.iterations, - hash: this.hash - }, key, { - // name: 'HMAC', - name: 'AES-GCM', - hash: this.hash, - length: KEY_LENGTHS[this.hash] - }, true, ['encrypt', 'decrypt']) - } - 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) @@ -240,10 +246,6 @@ export class Keychain implements KeychainInterface { throw new InvalidParametersError('Key is required') } - if (this.key == null) { - throw new NotStartedError() - } - const exists = await this.components.datastore.has(dsName(name), options) if (exists) { @@ -251,36 +253,21 @@ export class Keychain implements KeychainInterface { } const batch = this.components.datastore.batch() - await this._importKey(name, key, this.key, batch, options) + await this._importKey(name, key, this.cipher, batch, options) await batch.commit(options) return key } - private async _importKey (name: string, privateKey: PrivateKey, key: CryptoKey, batch: Batch, options?: AbortOptions): Promise { - const data = new Uint8Array(privateKey.raw.slice()) - const protobuf = PrivateKeyMessage.encode({ - Type: privateKey.code, - Data: data - }) - - const iv = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) - const cipherText = await crypto.subtle.encrypt({ - name: 'AES-GCM', - iv - }, key, protobuf) + private async _importKey (name: string, privateKey: PrivateKey, cipher: Cipher, batch: Batch, options?: AbortOptions): Promise { + const cryptoImpl = await this.components.getCryptoKey(privateKey.code) + const pem = await cryptoImpl.serialize(privateKey, cipher) options?.signal?.throwIfAborted() - // prepend the iv to the buffer - const buf = uint8ArrayConcat([ - iv, - new Uint8Array(cipherText) - ], iv.byteLength + cipherText.byteLength) - - const pem = base64.encode(buf) const keyInfo = { name, - type: privateKey.type + type: privateKey.type, + id: await keyId(privateKey) } batch.put(dsName(name), uint8ArrayFromString(pem)) @@ -292,26 +279,46 @@ export class Keychain implements KeychainInterface { throw new InvalidParametersError(`Invalid key name '${name}'`) } - if (this.key == null) { - throw new NotStartedError() - } - - return this._exportKey(name, this.key, options) + return this._exportKey(name, this.cipher, options) } - private async _exportKey (name: string, key: CryptoKey, options?: AbortOptions): Promise { - const res = await this.components.datastore.get(dsName(name), options) - const pem = uint8ArrayToString(res) - const buf = base64.decode(pem) - const iv = buf.subarray(0, SALT_LENGTH) - let raw: ArrayBuffer + 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) + 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 { - raw = await crypto.subtle.decrypt({ - name: 'AES-GCM', - iv - }, key, buf.subarray(SALT_LENGTH)) + const key = await cryptoImpl.deserialize(pem, cipher) options?.signal?.throwIfAborted() + + return key } catch (err: any) { if (err.name === 'OperationError') { throw new DecryptionFailedError(err.message) @@ -319,15 +326,6 @@ export class Keychain implements KeychainInterface { throw err } - - const privateKeyPb = PrivateKeyMessage.decode(new Uint8Array(raw)) - - if (privateKeyPb.Type == null || privateKeyPb.Data == null) { - throw new InvalidParametersError('Decoded private key protobuf did not have Type and/or Data fields') - } - - const cryptoImplementation = await this.components.getCryptoKey(privateKeyPb.Type) - return cryptoImplementation.privateKeyFromArray(privateKeyPb.Data) } async removeKey (name: string, options?: AbortOptions): Promise { @@ -384,7 +382,7 @@ export class Keychain implements KeychainInterface { const pem = await this.components.datastore.get(oldDatastoreName, options) const res = await this.components.datastore.get(oldInfoName, options) - const keyInfo = JSON.parse(uint8ArrayToString(res)) + const keyInfo: KeyInfo = JSON.parse(uint8ArrayToString(res)) keyInfo.name = newName const batch = this.components.datastore.batch() @@ -410,26 +408,178 @@ export class Keychain implements KeychainInterface { this.log('recreating keychain') - if (this.key == null) { - throw new NotStartedError() - } - - const oldKey = this.key - const newKey = await this.generateSaltedKey(password) + 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, oldKey) + const key = await this._exportKey(info.name, oldCipher) // Update stored key - await this._importKey(info.name, key, newKey, batch, options) + await this._importKey(info.name, key, newCipher, batch, options) } await batch.commit(options) - this.key = newKey - 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): 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) + + return uint8ArrayConcat([ + salt, + iv, + new Uint8Array(ciphertext) + ], salt.byteLength + iv.byteLength + ciphertext.byteLength) + } + + /** + * 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)) + + return new Uint8Array(plaintext) + } + + return { + encrypt, + decrypt + } +} diff --git a/packages/utils/test/keychain.spec.ts b/packages/utils/test/keychain.spec.ts index e3c5a6d24..3233b1b19 100644 --- a/packages/utils/test/keychain.spec.ts +++ b/packages/utils/test/keychain.spec.ts @@ -1,4 +1,6 @@ +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' @@ -9,11 +11,12 @@ 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 = [ - 'RSA', - 'Ed25519' +const SUPPORTED_KEYS: Array<'RSA' | 'Ed25519'> = [ + 'Ed25519', + 'RSA' ] describe('keychain', () => { @@ -72,8 +75,7 @@ describe('keychain', () => { password, hash: 'SHA-256', salt: 'salt-salt-salt-salt', - iterations: 1000, - keyLength: 14 + iterations: 1000 }) expect(ok).to.exist() }) @@ -343,7 +345,6 @@ describe('keychain', () => { /* spell-checker:disable-next-line */ salt: '3Nd/Ya4ENB3bcByNKptb4IR', iterations: 10000, - keyLength: 64, hash: 'SHA-512' } @@ -473,4 +474,64 @@ describe('keychain', () => { .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) + /* + if (type === 'Ed25519') { + // truncate key because libp2p appends the public key to the private key + expect(new Uint8Array(heliaPrivateKey.raw).subarray(0, 32)).to.equalBytes(libp2pPrivateKey.raw.subarray(0, 32)) + } else if (type === 'RSA') { + expect(new Uint8Array(heliaPrivateKey.raw)).to.equalBytes(libp2pPrivateKey.raw) + } else { + throw new Error(`Uknown crypto type ${type}`) + } +*/ + 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() + }) + }) + }) }) From c7f955e275bdbbff0e5d475fc6a8b6ac08f98873 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 21 May 2026 07:44:56 +0300 Subject: [PATCH 05/10] chore: ipns tests --- packages/interface/src/index.ts | 6 +-- packages/ipns/src/ipns/publisher.ts | 3 +- packages/ipns/src/ipns/republisher.ts | 9 ++-- packages/ipns/src/utils.ts | 54 ++++++++++++++------ packages/ipns/test/fixtures/create-ipns.ts | 35 +++++++++++-- packages/ipns/test/fixtures/crypto-loader.ts | 2 +- packages/ipns/test/publish.spec.ts | 38 +++++++------- packages/ipns/test/republish.spec.ts | 50 +++++++++--------- packages/ipns/test/resolve.spec.ts | 8 +-- packages/ipns/test/routing/pubsub.spec.ts | 3 +- packages/utils/src/crypto/ed25519.ts | 16 ++++-- packages/utils/src/crypto/rsa.ts | 1 + packages/utils/test/keychain.spec.ts | 16 ++---- 13 files changed, 149 insertions(+), 92 deletions(-) diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index a91cf0fa7..7b1939013 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -76,8 +76,7 @@ export function isPublicKey (obj?: any): obj is PublicKey { return false } - return typeof obj.type === 'string' && typeof obj.code === 'number' && obj.raw instanceof Uint8Array && - typeof obj.toMultihash === 'function' && obj.verify === 'function' + return typeof obj.type === 'string' && typeof obj.code === 'number' && typeof obj.verify === 'function' } export interface PrivateKey { @@ -113,8 +112,7 @@ export function isPrivateKey (obj?: any): obj is PrivateKey { return false } - return typeof obj.type === 'string' && typeof obj.code === 'number' && obj.raw instanceof Uint8Array && - isPublicKey(obj.publicKey) && obj.sign === 'function' + return typeof obj.type === 'string' && typeof obj.code === 'number' && typeof obj.sign === 'function' && isPublicKey(obj.publicKey) } export interface CipherOptions { diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index f3f83531c..a8114b892 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -52,8 +52,7 @@ export class IPNSPublisher { // 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) { diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index c8e236249..da4374bb1 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -82,13 +82,16 @@ 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 @@ -101,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 @@ -130,7 +133,7 @@ export class IPNSRepublisher { } } - 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/utils.ts b/packages/ipns/src/utils.ts index 8d9964a1a..eb1712aae 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -2,7 +2,6 @@ import { isPublicKey } from '@helia/interface' import { InvalidParametersError } from '@libp2p/interface' import * as cborg from 'cborg' import { Key } from 'interface-datastore' -import { digest } from 'multiformats' import { base36 } from 'multiformats/bases/base36' import { base58btc } from 'multiformats/bases/base58' import { CID } from 'multiformats/cid' @@ -18,6 +17,7 @@ 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 @@ -301,7 +301,7 @@ export function normalizeByteValue (value: Uint8Array): string { export function normalizeValue (value?: PublicKey | CID | MultihashDigest | string): string { if (value != null) { if (isPublicKey(value)) { - return `/ipns/${value.toCID().toString(base36)}` + return `/ipns/${value.toCID().toV1().toString(base36)}` } const cid = asCID(value) @@ -310,7 +310,7 @@ export function normalizeValue (value?: PublicKey | CID | MultihashDigest | stri if (cid != null) { // PeerID encoded as a CID if (cid.code === LIBP2P_KEY_CODEC) { - return `/ipns/${cid.toString(base36)}` + return `/ipns/${cid.toV1().toString(base36)}` } return `/ipfs/${cid.toV1().toString()}` @@ -323,6 +323,13 @@ export function normalizeValue (value?: PublicKey | CID | MultihashDigest | stri // 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 } @@ -335,16 +342,16 @@ 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 (value?: PublicKey | CID | MultihashDigest | string): { digest: MultihashDigest, path: string } { - if (value != null) { - if (isPublicKey(value)) { +export function normalizeKey (key?: PublicKey | CID | MultihashDigest | string): { digest: MultihashDigest, path: string } { + if (key != null) { + if (isPublicKey(key)) { return { - digest: value.toMultihash(), + digest: key.toMultihash(), path: '/' } } - const cid = asCID(value) + const cid = asCID(key) // if we have a CID, turn it into an ipfs path if (cid != null) { @@ -359,22 +366,37 @@ export function normalizeKey (value?: PublicKey | CID | Multihash } } - if (isMultihashDigest(value)) { + if (isMultihashDigest(key)) { return { - digest: value, + digest: key, path: '/' } } - value = value.toString() + key = key.toString() - if (value.startsWith('/ipns/')) { - const parts = value.split('/') - const codec = parts[1].startsWith('1') ? base58btc : base36 + 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: digest.decode(codec.decode(value[1])), - path: `/${parts.slice(2).join('/')}` + digest, + path: `/${rest.join('/')}` } } } diff --git a/packages/ipns/test/fixtures/create-ipns.ts b/packages/ipns/test/fixtures/create-ipns.ts index 290760ba5..ebba1aba6 100644 --- a/packages/ipns/test/fixtures/create-ipns.ts +++ b/packages/ipns/test/fixtures/create-ipns.ts @@ -1,11 +1,12 @@ -import { TypedEventEmitter } from '@libp2p/interface' +import { ed25519Crypto } from '@helia/utils' +import { NotFoundError, TypedEventEmitter } from '@libp2p/interface' import { defaultLogger } from '@libp2p/logger' import { MemoryDatastore } from 'datastore-core' -import Sinon from 'sinon' 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, Keychain } from '@helia/interface' +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' @@ -31,8 +32,32 @@ export async function createIPNS (): Promise { const logger = defaultLogger() const events = new TypedEventEmitter() - const getCryptoKey = Sinon.stub() - const keychain = stubInterface() + + 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, diff --git a/packages/ipns/test/fixtures/crypto-loader.ts b/packages/ipns/test/fixtures/crypto-loader.ts index 5c1766719..7755b2e1d 100644 --- a/packages/ipns/test/fixtures/crypto-loader.ts +++ b/packages/ipns/test/fixtures/crypto-loader.ts @@ -7,7 +7,7 @@ export const getCryptoKey: CryptoKeyLoader = async (code: number | string, optio return rsaCrypto() } - if (code === 1 || code === 'Ed15519') { + if (code === 1 || code === 'Ed25519') { return ed25519Crypto() } diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index 5125daf29..77f77fb87 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -95,18 +95,18 @@ 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(uint8ArrayToString(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(uint8ArrayToString(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)) @@ -119,18 +119,18 @@ describe('publish', () => { 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' - 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)) @@ -143,18 +143,18 @@ describe('publish', () => { 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' - 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 last(name.resolve(recursiveRecord.publicKey)) @@ -167,18 +167,18 @@ describe('publish', () => { 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)) @@ -194,13 +194,13 @@ 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(record.publicKey)) + const result = await last(name.resolve(published.publicKey)) if (result == null) { throw new Error('No results found') diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 9687bd49d..5b20e8f91 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -25,6 +25,9 @@ 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 @@ -57,7 +60,7 @@ describe('republish', () => { const key = await result.keychain.createKey('test-key', 'Ed25519') // Create a test record and store it in the real datastore - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000) + 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 using the localStore @@ -65,7 +68,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -80,7 +83,7 @@ describe('republish', () => { it('should call all routers for republish', async () => { // Create a test record and store it in the real datastore const key = await result.keychain.createKey('test-key', 'Ed25519') - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000) + 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 using the localStore @@ -88,15 +91,16 @@ describe('republish', () => { 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 @@ -108,7 +112,7 @@ describe('republish', () => { it('should republish records with valid metadata', async () => { const key = await result.keychain.createKey('test-key', 'Ed25519') - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000) + 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 @@ -116,7 +120,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -137,7 +141,7 @@ describe('republish', () => { describe('record processing', () => { it('should skip records without metadata', async () => { const key = await result.keychain.createKey('test-key', 'Ed25519') - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000) + 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) @@ -159,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 } }) @@ -172,7 +176,7 @@ describe('republish', () => { it('should increment sequence numbers correctly', async () => { const key = await result.keychain.createKey('test-key', 'Ed25519') - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 5n, 24 * 60 * 60 * 1000) // Start with sequence 5 + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 5n, SHORTENED_VALIDITY) // Start with sequence 5 const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) // Store the record in the real datastore @@ -180,7 +184,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -199,7 +203,7 @@ describe('republish', () => { it('should use existing TTL from records', async () => { 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, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000, { ttlNs: customTtl }) + const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY, { ttlNs: customTtl }) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) // Store the record in the real datastore @@ -207,7 +211,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -226,7 +230,7 @@ describe('republish', () => { it('should use default TTL when not present', async () => { const key = await result.keychain.createKey('test-key', 'Ed25519') - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000) + 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 @@ -234,7 +238,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -283,7 +287,7 @@ describe('republish', () => { describe('error handling', () => { it('should skip republishing records with missing key', async () => { const key = await result.keychain.createKey('test-key', 'Ed25519') - const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, 24 * 60 * 60 * 1000) + 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) @@ -291,7 +295,7 @@ describe('republish', () => { await store.put(routingKey, marshalIPNSRecord(record), { metadata: { keyName: 'missing-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -367,7 +371,7 @@ describe('republish', () => { await store.put(routingKey, new Uint8Array([255, 255, 255]), { metadata: { keyName: 'test-key', - lifetime: 24 * 60 * 60 * 1000 + lifetime: SHORTENED_VALIDITY } }) @@ -382,7 +386,7 @@ describe('republish', () => { it('should continue republishing other records when one record fails', async () => { 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, 24 * 60 * 60 * 1000) + const record2 = await createIPNSRecord(key2, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey1 = multihashToIPNSRoutingKey(key1.publicKey.toMultihash()) const routingKey2 = multihashToIPNSRoutingKey(key2.publicKey.toMultihash()) @@ -392,13 +396,13 @@ describe('republish', () => { 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 88605e753..9b556e408 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -274,7 +274,7 @@ describe('resolve', () => { await datastore.put(dhtKey, dhtRecord.serialize()) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), getCryptoKey)) + 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() @@ -294,7 +294,7 @@ describe('resolve', () => { await datastore.put(dhtKey, dhtRecord.serialize()) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), getCryptoKey)) + 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() @@ -320,7 +320,7 @@ describe('resolve', () => { customRouting.get.withArgs(customRoutingKey).resolves(marshalIPNSRecord(ipnsRecordFromRouting)) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), getCryptoKey)) + 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() @@ -346,7 +346,7 @@ describe('resolve', () => { customRouting.get.withArgs(customRoutingKey).resolves(marshalIPNSRecord(ipnsRecordFromRouting)) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), getCryptoKey)) + 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 8018a4eec..dfc3f0e75 100644 --- a/packages/ipns/test/routing/pubsub.spec.ts +++ b/packages/ipns/test/routing/pubsub.spec.ts @@ -8,6 +8,7 @@ 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' @@ -147,7 +148,7 @@ describe('pubsub routing', () => { const result = await store.get(routingKey) 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/src/crypto/ed25519.ts b/packages/utils/src/crypto/ed25519.ts index ef107666a..2cb1929de 100644 --- a/packages/utils/src/crypto/ed25519.ts +++ b/packages/utils/src/crypto/ed25519.ts @@ -1,4 +1,4 @@ -import { InvalidPrivateKeyError } from '@libp2p/interface' +import { InvalidParametersError, InvalidPrivateKeyError } from '@libp2p/interface' import { CID } from 'multiformats' import { base64 } from 'multiformats/bases/base64' import { identity } from 'multiformats/hashes/identity' @@ -13,6 +13,7 @@ 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' @@ -20,11 +21,20 @@ class Ed25519PublicKey implements PublicKey { 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 { - return identity.digest(new Uint8Array(this.raw)) + const buf = PrivateKeyMessage.encode({ + Type: this.code, + Data: new Uint8Array(this.raw.slice()) + }) + + return identity.digest(buf) } toCID (): CID { @@ -89,7 +99,7 @@ class Ed25519Crypto implements CryptoKeyImplementation { } async publicKeyFromArray (key: ArrayBuffer | Uint8Array, options?: AbortOptions): Promise { - const publicKey = new Ed25519PublicKey(key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).buffer) + const publicKey = new Ed25519PublicKey(key instanceof ArrayBuffer ? key : uint8ArrayWithArrayBuffer(key).slice().buffer) options?.signal?.throwIfAborted() return publicKey diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts index 3006a492a..bc68d0e30 100644 --- a/packages/utils/src/crypto/rsa.ts +++ b/packages/utils/src/crypto/rsa.ts @@ -40,6 +40,7 @@ class RSAPublicKey implements PublicKey { alg: 'RS256', kty: 'RSA', n: uint8ArrayToString(new Uint8Array(this.raw), 'base64url'), + /* spell-checker:disable-next-line */ e: 'AQAB' }, { name: 'RSASSA-PKCS1-v1_5', diff --git a/packages/utils/test/keychain.spec.ts b/packages/utils/test/keychain.spec.ts index 3233b1b19..ce617fa5c 100644 --- a/packages/utils/test/keychain.spec.ts +++ b/packages/utils/test/keychain.spec.ts @@ -9,7 +9,7 @@ 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 { isPrivateKey, isPublicKey, 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' @@ -427,6 +427,9 @@ describe('keychain', () => { 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 () => { @@ -500,16 +503,7 @@ describe('keychain', () => { const libp2pPrivateKey = await generateKeyPair(type) await libp2pKeychain.importKey(keyName, libp2pPrivateKey) const heliaPrivateKey = await keychain.exportKey(keyName) - /* - if (type === 'Ed25519') { - // truncate key because libp2p appends the public key to the private key - expect(new Uint8Array(heliaPrivateKey.raw).subarray(0, 32)).to.equalBytes(libp2pPrivateKey.raw.subarray(0, 32)) - } else if (type === 'RSA') { - expect(new Uint8Array(heliaPrivateKey.raw)).to.equalBytes(libp2pPrivateKey.raw) - } else { - throw new Error(`Uknown crypto type ${type}`) - } -*/ + const message = Uint8Array.from([0, 1, 2, 3, 4]) const heliaSig = await heliaPrivateKey.sign(message) From 3f53351bc68860ff00e576c761f82f3ddf805f48 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 21 May 2026 07:49:11 +0300 Subject: [PATCH 06/10] chore: linting --- packages/utils/test/keychain.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/utils/test/keychain.spec.ts b/packages/utils/test/keychain.spec.ts index ce617fa5c..76c19d6ab 100644 --- a/packages/utils/test/keychain.spec.ts +++ b/packages/utils/test/keychain.spec.ts @@ -1,3 +1,4 @@ +import { isPrivateKey, isPublicKey } from '@helia/interface' import { generateKeyPair } from '@libp2p/crypto/keys' import { start } from '@libp2p/interface' import { keychain as libp2pKeychainFactory } from '@libp2p/keychain' @@ -9,7 +10,7 @@ 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 { isPrivateKey, isPublicKey, type PrivateKey } from '@helia/interface' +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' From 7fcb4620ea0ac1f0e92e5cb5400b064823531f80 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Thu, 21 May 2026 08:46:48 +0300 Subject: [PATCH 07/10] chore: firefox --- package.json | 5 +--- packages/interface/src/index.ts | 14 +++++++---- packages/utils/src/crypto/ed25519.ts | 31 +++++++++++++++--------- packages/utils/src/crypto/rsa.ts | 36 +++++++++++++++++++++------- packages/utils/src/keychain.ts | 31 ++++++++++++++---------- 5 files changed, 76 insertions(+), 41 deletions(-) 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/src/index.ts b/packages/interface/src/index.ts index 7b1939013..854de7c83 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -115,15 +115,21 @@ export function isPrivateKey (obj?: any): obj is PrivateKey { return typeof obj.type === 'string' && typeof obj.code === 'number' && typeof obj.sign === 'function' && isPublicKey(obj.publicKey) } -export interface CipherOptions { +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): Promise> + encrypt(data: Uint8Array, options?: AbortOptions): Promise decrypt(salt: Uint8Array, iv: Uint8Array, cipherText: Uint8Array, options?: CipherOptions): Promise> } @@ -153,12 +159,12 @@ export interface CryptoKeyImplementation { /** * Convert a private key into a string suitable for storing in a datastore */ - serialize (key: PrivateKey, cipher: Cipher): Promise + serialize (key: PrivateKey, cipher: Cipher, options?: AbortOptions): Promise /** * Convert a string from a datastore into a private key */ - deserialize (pem: string, cipher: Cipher): Promise + deserialize (pem: string, cipher: Cipher, options?: AbortOptions): Promise } /** diff --git a/packages/utils/src/crypto/ed25519.ts b/packages/utils/src/crypto/ed25519.ts index 2cb1929de..f6262ec21 100644 --- a/packages/utils/src/crypto/ed25519.ts +++ b/packages/utils/src/crypto/ed25519.ts @@ -2,7 +2,6 @@ import { InvalidParametersError, InvalidPrivateKeyError } from '@libp2p/interfac import { CID } from 'multiformats' import { base64 } from 'multiformats/bases/base64' import { identity } from 'multiformats/hashes/identity' -import { concat as uin8ArrayConcat } from 'uint8arrays/concat' import { concat as uint8ArrayConcat } from 'uint8arrays/concat' import { fromString as uint8arrayFromString } from 'uint8arrays/from-string' import { toString as uint8arrayToString } from 'uint8arrays/to-string' @@ -105,7 +104,7 @@ class Ed25519Crypto implements CryptoKeyImplementation { return publicKey } - async serialize (key: PrivateKey, cipher: Cipher): Promise { + async serialize (key: PrivateKey, cipher: Cipher, options?: AbortOptions): Promise { const buf = PrivateKeyMessage.encode({ Type: key.code, Data: uint8ArrayConcat([ @@ -114,12 +113,16 @@ class Ed25519Crypto implements CryptoKeyImplementation { ], 64) }) - const cipherText = await cipher.encrypt(buf) + const result = await cipher.encrypt(buf, options) - return base64.encode(cipherText) + 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): Promise { + 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) @@ -132,8 +135,8 @@ class Ed25519Crypto implements CryptoKeyImplementation { throw new InvalidPrivateKeyError('Protobuf message did not contain private key') } - const raw = pb.Data.slice().buffer - return new Ed25519PrivateKey(raw, await derivePublicKey(raw)) + const raw = pb.Data.slice(0, 32).buffer + return new Ed25519PrivateKey(raw, await derivePublicKey(raw, options)) } } @@ -162,15 +165,21 @@ async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promi } else { const privateKey = truncateKey(raw) const pkcs8 = convertRawX25519KeyToPKCS(privateKey) - const key = await crypto.subtle.importKey('pkcs8', pkcs8, 'Ed25519', true, ['sign']) - options?.signal?.throwIfAborted() + const key = await crypto.subtle.importKey('pkcs8', pkcs8, { + name: 'Ed25519' + }, true, ['sign']) const exported = await crypto.subtle.exportKey('jwk', key) - options?.signal?.throwIfAborted() + + 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) } @@ -179,7 +188,7 @@ const PKCS8_HEADER = Uint8Array.from([ ]) function convertRawX25519KeyToPKCS (privateKey: ArrayBuffer): Uint8Array { - return uin8ArrayConcat([ + return uint8ArrayConcat([ PKCS8_HEADER, Uint8Array.from([privateKey.byteLength]), new Uint8Array(privateKey) diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts index bc68d0e30..c80f5e4e7 100644 --- a/packages/utils/src/crypto/rsa.ts +++ b/packages/utils/src/crypto/rsa.ts @@ -2,6 +2,7 @@ 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' @@ -78,6 +79,7 @@ class RSAPrivateKey implements PrivateKey { const sig = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, key, uint8ArrayWithArrayBuffer(message)) + options?.signal?.throwIfAborted() return new Uint8Array(sig, 0, sig.byteLength) @@ -101,16 +103,21 @@ class RSACrypto implements CryptoKeyImplementation { 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, await sha256.digest(new Uint8Array(raw))) + return new RSAPublicKey(raw, digest) } - async serialize (key: PrivateKey, cipher: Cipher): Promise { + async serialize (key: PrivateKey, cipher: Cipher, options?: AbortOptions): Promise { const pkcs8 = await crypto.subtle.importKey('pkcs8', key.raw, { name: 'RSASSA-PKCS1-v1_5', hash: { @@ -125,18 +132,22 @@ class RSACrypto implements CryptoKeyImplementation { Data: pkcs1 }) - const cipherText = await cipher.encrypt(buf) + const result = await cipher.encrypt(buf, options) - return base64.encode(cipherText) + 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): Promise { + 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) + const plainText = await cipher.decrypt(salt, iv, cypherText, options) const pb = PrivateKeyMessage.decode(plainText) if (pb.Type !== 0) { @@ -162,8 +173,11 @@ class RSACrypto implements CryptoKeyImplementation { }, 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, await sha256.digest(new Uint8Array(publicKey)))) + return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, digest)) } pem = pem.replaceAll('-----BEGIN ENCRYPTED PRIVATE KEY-----', '') @@ -184,7 +198,8 @@ class RSACrypto implements CryptoKeyImplementation { iterations, keyLength: keyLength * 8, hash: 'SHA-512', - algorithm: 'AES-CBC' + algorithm: 'AES-CBC', + signal: options?.signal }) const keyWrapper = decodeDer(plainText) @@ -205,8 +220,11 @@ class RSACrypto implements CryptoKeyImplementation { }, 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, await sha256.digest(new Uint8Array(publicKey)))) + return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, digest)) } } diff --git a/packages/utils/src/keychain.ts b/packages/utils/src/keychain.ts index b3fbdde5a..b479aecd9 100644 --- a/packages/utils/src/keychain.ts +++ b/packages/utils/src/keychain.ts @@ -4,13 +4,12 @@ import { base58btc } from 'multiformats/bases/base58' import { base64 } from 'multiformats/bases/base64' import { sha256 } from 'multiformats/hashes/sha2' import sanitize from 'sanitize-filename' -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 } 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 } from '@helia/interface' +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' @@ -141,12 +140,15 @@ function dsInfoName (name: string): Key { return new Key(infoPrefix + name) } -export async function keyId (key: PrivateKey): Promise { +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) } @@ -260,14 +262,13 @@ export class Keychain implements KeychainInterface { } private async _importKey (name: string, privateKey: PrivateKey, cipher: Cipher, batch: Batch, options?: AbortOptions): Promise { - const cryptoImpl = await this.components.getCryptoKey(privateKey.code) - const pem = await cryptoImpl.serialize(privateKey, cipher) - options?.signal?.throwIfAborted() + 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) + id: await keyId(privateKey, options) } batch.put(dsName(name), uint8ArrayFromString(pem)) @@ -301,7 +302,7 @@ export class Keychain implements KeychainInterface { 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 plainText = await cipher.decrypt(salt, iv, cipherText, options) const pb = PrivateKeyMessage.decode(plainText) if (pb.Type != null) { @@ -315,7 +316,7 @@ export class Keychain implements KeychainInterface { } try { - const key = await cryptoImpl.deserialize(pem, cipher) + const key = await cryptoImpl.deserialize(pem, cipher, options) options?.signal?.throwIfAborted() return key @@ -535,7 +536,7 @@ function createAESCipher (password: string, salt: Uint8Array, keych /** * Encrypt data using the derived encryption key */ - async function encrypt (data: Uint8Array): Promise> { + async function encrypt (data: Uint8Array, opts?: AbortOptions): Promise { if (keychainDek == null) { keychainDek = await createKeychainDek() } @@ -548,11 +549,13 @@ function createAESCipher (password: string, salt: Uint8Array, keych iv }, cryptoKey, data) - return uint8ArrayConcat([ + opts?.signal?.throwIfAborted() + + return { salt, iv, - new Uint8Array(ciphertext) - ], salt.byteLength + iv.byteLength + ciphertext.byteLength) + cipherText: new Uint8Array(ciphertext) + } } /** @@ -575,6 +578,8 @@ function createAESCipher (password: string, salt: Uint8Array, keych iv: withArrayBuffer(iv) }, cryptoKey, withArrayBuffer(cipherText)) + opts?.signal?.throwIfAborted() + return new Uint8Array(plaintext) } From 9b572b4977ffd2116027d83433326ae3835dcee7 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 22 May 2026 14:49:25 +0100 Subject: [PATCH 08/10] chore: tests --- package.json | 8 +- packages/helia/package.json | 3 +- .../src/utils/libp2p-defaults.browser.ts | 10 +- packages/helia/src/utils/libp2p-defaults.ts | 19 +- packages/helia/src/utils/libp2p.ts | 1 - packages/http/package.json | 2 +- packages/http/src/utils/libp2p.ts | 1 - packages/interface/src/index.ts | 30 +-- packages/interface/src/keychain.ts | 22 +- packages/interop/.aegir.js | 2 +- packages/interop/package.json | 2 - .../src/fixtures/create-helia.browser.ts | 6 +- packages/interop/src/fixtures/create-helia.ts | 6 +- packages/interop/src/helia-blockstore.spec.ts | 5 +- .../interop/src/helia-progress-events.spec.ts | 5 +- packages/interop/src/ipns-http.spec.ts | 1 - packages/interop/src/ipns-pubsub.spec.ts | 9 +- packages/interop/src/ipns.spec.ts | 42 +-- packages/ipns/src/index.ts | 3 +- packages/ipns/src/ipns.ts | 24 +- packages/ipns/src/ipns/publisher.ts | 20 +- packages/ipns/src/ipns/republisher.ts | 7 +- packages/ipns/src/ipns/resolver.ts | 12 +- packages/ipns/src/pb/keys.proto | 36 --- packages/ipns/src/pb/keys.ts | 241 ------------------ packages/ipns/src/records.ts | 13 +- packages/ipns/src/routing/pubsub.ts | 14 +- packages/ipns/src/utils.ts | 46 +--- packages/ipns/test/fixtures/create-ipns.ts | 37 +-- packages/ipns/test/publish.spec.ts | 13 +- packages/ipns/test/republish.spec.ts | 35 ++- packages/ipns/test/resolve.spec.ts | 21 +- packages/ipns/test/routing/pubsub.spec.ts | 12 +- packages/routers/package.json | 4 +- .../routers/src/delegated-http-routing.ts | 15 +- .../test/delegated-http-routing.spec.ts | 29 ++- packages/utils/src/crypto/ed25519.ts | 54 ++-- packages/utils/src/crypto/rsa.ts | 221 ++++++++++------ packages/utils/src/keychain.ts | 25 +- packages/utils/test/keychain.spec.ts | 79 ++++-- 40 files changed, 474 insertions(+), 661 deletions(-) delete mode 100644 packages/ipns/src/pb/keys.proto delete mode 100644 packages/ipns/src/pb/keys.ts diff --git a/package.json b/package.json index abd9dec2f..a49c5149a 100644 --- a/package.json +++ b/package.json @@ -45,5 +45,11 @@ "type": "module", "workspaces": [ "packages/*" - ] + ], + "overrides": { + "multiformats": "^14.0.0", + "uint8arrays": "^6.0.0", + "interface-datastore": "^10.0.1", + "uint8-varint": "^3.0.0" + } } diff --git a/packages/helia/package.json b/packages/helia/package.json index 1656543f8..f70b48ac2 100644 --- a/packages/helia/package.json +++ b/packages/helia/package.json @@ -52,7 +52,7 @@ "@chainsafe/libp2p-noise": "^17.0.0", "@chainsafe/libp2p-yamux": "^8.0.1", "@helia/block-brokers": "^5.2.4", - "@helia/delegated-routing-v1-http-api-client": "^6.0.1", + "@helia/delegated-routing-v1-http-api-client": "^7.0.1", "@helia/interface": "^6.2.1", "@helia/routers": "^5.1.1", "@helia/utils": "^2.5.2", @@ -79,7 +79,6 @@ "blockstore-core": "^7.0.1", "datastore-core": "^12.0.1", "interface-datastore": "^10.0.1", - "ipns": "^11.0.0", "libp2p": "^3.2.0", "multiformats": "^14.0.0" }, diff --git a/packages/helia/src/utils/libp2p-defaults.browser.ts b/packages/helia/src/utils/libp2p-defaults.browser.ts index 6ee90650f..69efc9eb1 100644 --- a/packages/helia/src/utils/libp2p-defaults.browser.ts +++ b/packages/helia/src/utils/libp2p-defaults.browser.ts @@ -14,8 +14,6 @@ import { mplex } from '@libp2p/mplex' import { ping } from '@libp2p/ping' import { webRTC, webRTCDirect } from '@libp2p/webrtc' import { webSockets } from '@libp2p/websockets' -import { ipnsSelector } from 'ipns/selector' -import { ipnsValidator } from 'ipns/validator' import { userAgent } from 'libp2p/user-agent' import { name, version } from '../version.ts' import { bootstrapConfig } from './bootstrappers.ts' @@ -74,13 +72,7 @@ export function libp2pDefaults (options: Libp2pDefaultsOptions = {}): Libp2pOpti dcutr: dcutr(), delegatedRouting: delegatedRoutingV1HttpApiClient(delegatedHTTPRoutingDefaults()), dht: kadDHT({ - clientMode: true, - validators: { - ipns: ipnsValidator - }, - selectors: { - ipns: ipnsSelector - } + clientMode: true }), identify: identify(), identifyPush: identifyPush(), diff --git a/packages/helia/src/utils/libp2p-defaults.ts b/packages/helia/src/utils/libp2p-defaults.ts index 5e6d7e059..386c8164e 100644 --- a/packages/helia/src/utils/libp2p-defaults.ts +++ b/packages/helia/src/utils/libp2p-defaults.ts @@ -1,6 +1,6 @@ import { noise } from '@chainsafe/libp2p-noise' import { yamux } from '@chainsafe/libp2p-yamux' -import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' +import { delegatedRoutingV1HttpApiClientContentRouting, delegatedRoutingV1HttpApiClientPeerRouting } from '@helia/delegated-routing-v1-http-api-client' import { delegatedHTTPRoutingDefaults } from '@helia/routers' import { autoTLS } from '@ipshipyard/libp2p-auto-tls' import { autoNAT } from '@libp2p/autonat' @@ -19,8 +19,6 @@ import { tls } from '@libp2p/tls' import { uPnPNAT } from '@libp2p/upnp-nat' import { webRTC, webRTCDirect } from '@libp2p/webrtc' import { webSockets } from '@libp2p/websockets' -import { ipnsSelector } from 'ipns/selector' -import { ipnsValidator } from 'ipns/validator' import { userAgent } from 'libp2p/user-agent' import { name, version } from '../version.ts' import { bootstrapConfig } from './bootstrappers.ts' @@ -38,7 +36,8 @@ export interface DefaultLibp2pServices extends Record { autoNAT: unknown autoTLS: AutoTLS dcutr: unknown - delegatedRouting: unknown + delegatedContentRouting: unknown + delegatedPeerRouting: unknown dht: KadDHT identify: Identify keychain: Keychain @@ -116,15 +115,9 @@ export function libp2pDefaults (options: Libp2pDefaultsOptions = {}): Libp2pOpti autoNAT: autoNAT(), autoTLS: autoTLS(), dcutr: dcutr(), - delegatedRouting: delegatedRoutingV1HttpApiClient(delegatedHTTPRoutingDefaults()), - dht: kadDHT({ - validators: { - ipns: ipnsValidator - }, - selectors: { - ipns: ipnsSelector - } - }), + delegatedPeerRouting: delegatedRoutingV1HttpApiClientPeerRouting(delegatedHTTPRoutingDefaults()), + delegatedContentRouting: delegatedRoutingV1HttpApiClientContentRouting(delegatedHTTPRoutingDefaults()), + dht: kadDHT(), identify: identify(), identifyPush: identifyPush(), keychain: keychain(options.keychain), diff --git a/packages/helia/src/utils/libp2p.ts b/packages/helia/src/utils/libp2p.ts index 1d0a05082..200f2a46d 100644 --- a/packages/helia/src/utils/libp2p.ts +++ b/packages/helia/src/utils/libp2p.ts @@ -26,7 +26,6 @@ export async function createLibp2p > (options: // if no peer id was passed, try to load it from the keychain if (libp2pOptions.privateKey == null && options.datastore != null) { - // @ts-expect-error libp2p needs dep updates libp2pOptions.privateKey = await loadOrCreateSelfKey(options.datastore, options.keychain) } diff --git a/packages/http/package.json b/packages/http/package.json index d966bd6bb..cea1aaee9 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "@helia/block-brokers": "^5.2.4", - "@helia/delegated-routing-v1-http-api-client": "^6.0.1", + "@helia/delegated-routing-v1-http-api-client": "^7.0.1", "@helia/interface": "^6.2.1", "@helia/routers": "^5.1.1", "@helia/utils": "^2.5.2", diff --git a/packages/http/src/utils/libp2p.ts b/packages/http/src/utils/libp2p.ts index b2a0198ca..08921e053 100644 --- a/packages/http/src/utils/libp2p.ts +++ b/packages/http/src/utils/libp2p.ts @@ -26,7 +26,6 @@ export async function createLibp2p > (options: // if no peer id was passed, try to load it from the keychain if (libp2pOptions.privateKey == null && options.datastore != null) { - // @ts-expect-error libp2p needs dep updates libp2pOptions.privateKey = await loadOrCreateSelfKey(options.datastore, options.keychain) } diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 854de7c83..d58b61347 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -42,18 +42,13 @@ export interface PublicKey { /** * The type of the crypto implementation, e.g. `Ed15519` */ - type: string + readonly 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 + readonly code: number /** * Return a MultihashDigest that represents this key @@ -63,7 +58,12 @@ export interface PublicKey { /** * Return the libp2p-key CID that represents this key */ - toCID (): CID + toCID (): CID + + /** + * Return this key encoded as a protobuf PublicKey message + */ + toProtobuf (): Uint8Array /** * Verify the passed message against it's signature @@ -83,23 +83,23 @@ export interface PrivateKey { /** * The type of the crypto implementation, e.g. `Ed15519` */ - type: string + readonly type: string /** * The code that is used as the `Type` field in the protobuf representation of * the public/private keys */ - code: number + readonly code: number /** - * The raw private key + * The public key that corresponds to this private key */ - raw: ArrayBuffer + readonly publicKey: PublicKey /** - * The public key that corresponds to this private key + * Return this key encoded as a protobuf PrivateKey message */ - publicKey: PublicKey + toProtobuf (): Uint8Array /** * Sign the passed message and return a signature @@ -154,7 +154,7 @@ export interface CryptoKeyImplementation { * 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 + publicKeyFromProtobuf(buf: Uint8Array, options?: AbortOptions): PublicKey | Promise /** * Convert a private key into a string suitable for storing in a datastore diff --git a/packages/interface/src/keychain.ts b/packages/interface/src/keychain.ts index 10e93c073..1236e4863 100644 --- a/packages/interface/src/keychain.ts +++ b/packages/interface/src/keychain.ts @@ -1,4 +1,4 @@ -import type { PrivateKey } from './index.ts' +import type { PrivateKey, PublicKey } from './index.ts' import type { AbortOptions } from 'abort-error' export interface KeyInfo { @@ -18,12 +18,22 @@ export interface KeyInfo { type?: 'Ed25519' | 'RSA' | string } +export interface GenerateKeyOptions extends AbortOptions, Record { + /** + * The type of key to generate + * + * @default 'Ed25519' + */ + 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. + * cryptography implementation must be configured for the key type (defaults + * to Ed25519). */ - createKey (name: string, type: 'Ed25519' | 'RSA' | string, options?: AbortOptions & Record): Promise + generateKey (name: string, options?: AbortOptions & Record): Promise /** * Import a new private key. @@ -104,4 +114,10 @@ export interface Keychain { * ``` */ rotateKeychainPass(password: string, options?: AbortOptions): Promise + + /** + * Attempts to load a public key from a serialized protobuf message conforming + * to the `PublicKey` message. + */ + loadPublicKeyFromProtobuf (buf: Uint8Array, options?: AbortOptions): Promise } diff --git a/packages/interop/.aegir.js b/packages/interop/.aegir.js index 538c53d71..851185ce7 100644 --- a/packages/interop/.aegir.js +++ b/packages/interop/.aegir.js @@ -6,7 +6,7 @@ import { path } from 'kubo' /** @type {import('aegir').PartialOptions} */ export default { test: { - files: './dist/src/*.spec.js', + files: './src/*.spec.ts', before: async (options) => { if (options.runner !== 'node') { const ipfsdPort = await getPort() diff --git a/packages/interop/package.json b/packages/interop/package.json index e336f55ec..f1586afdb 100644 --- a/packages/interop/package.json +++ b/packages/interop/package.json @@ -68,7 +68,6 @@ "@ipld/car": "^5.4.3", "@ipld/dag-cbor": "^10.0.0", "@ipld/dag-pb": "^4.1.5", - "@libp2p/crypto": "^5.1.15", "@libp2p/floodsub": "^11.0.16", "@libp2p/interface": "^3.2.0", "@libp2p/kad-dht": "^16.2.0", @@ -82,7 +81,6 @@ "helia": "^6.1.4", "ipfs-unixfs-importer": "^17.0.0", "ipfsd-ctl": "^17.0.0", - "ipns": "^11.0.0", "it-all": "^3.0.11", "it-drain": "^3.0.12", "it-last": "^3.0.11", diff --git a/packages/interop/src/fixtures/create-helia.browser.ts b/packages/interop/src/fixtures/create-helia.browser.ts index ac86bf525..25e3c109a 100644 --- a/packages/interop/src/fixtures/create-helia.browser.ts +++ b/packages/interop/src/fixtures/create-helia.browser.ts @@ -46,8 +46,10 @@ export async function createHeliaNode (libp2pOptions?: Libp2pOptions): Promise>({ blockBrokers: [ diff --git a/packages/interop/src/fixtures/create-helia.ts b/packages/interop/src/fixtures/create-helia.ts index 60f9319da..ed33d014d 100644 --- a/packages/interop/src/fixtures/create-helia.ts +++ b/packages/interop/src/fixtures/create-helia.ts @@ -35,8 +35,10 @@ export async function createHeliaNode (libp2pOptions?: Libp2pOptions): Promise { }) it('should be able to send a block', async () => { - const input = randomBytes(10) + const input = crypto.getRandomValues(new Uint8Array(10)) const digest = await sha256.digest(input) const cid = CID.createV1(raw.code, digest) await helia.blockstore.put(cid, input) @@ -45,7 +44,7 @@ describe('helia - blockstore', () => { }) it('should be able to receive a block', async () => { - const input = randomBytes(10) + const input = crypto.getRandomValues(new Uint8Array(10)) const { cid } = await kubo.api.add({ content: input }, { cidVersion: 1, rawLeaves: true diff --git a/packages/interop/src/helia-progress-events.spec.ts b/packages/interop/src/helia-progress-events.spec.ts index 76870ddb0..27be3dbb0 100644 --- a/packages/interop/src/helia-progress-events.spec.ts +++ b/packages/interop/src/helia-progress-events.spec.ts @@ -1,4 +1,3 @@ -import { randomBytes } from '@libp2p/crypto' import { contentRoutingSymbol } from '@libp2p/interface' import { peerIdFromString } from '@libp2p/peer-id' import { CODE_P2P, multiaddr } from '@multiformats/multiaddr' @@ -62,7 +61,7 @@ describe('helia - progress events', () => { }) it('should yield routing events', async () => { - const input = randomBytes(10) + const input = crypto.getRandomValues(new Uint8Array(10)) const { cid } = await kubo.api.add({ content: input }, { cidVersion: 1, rawLeaves: true @@ -82,7 +81,7 @@ describe('helia - progress events', () => { }) it('should yield block broker events', async () => { - const input = randomBytes(10) + const input = crypto.getRandomValues(new Uint8Array(10)) const { cid } = await kubo.api.add({ content: input }, { cidVersion: 1, rawLeaves: true diff --git a/packages/interop/src/ipns-http.spec.ts b/packages/interop/src/ipns-http.spec.ts index c7480cade..720bfeacd 100644 --- a/packages/interop/src/ipns-http.spec.ts +++ b/packages/interop/src/ipns-http.spec.ts @@ -64,7 +64,6 @@ describe('@helia/ipns - http', () => { }) const key = peerIdFromCID(CID.parse(res.name)) - // @ts-expect-error @libp2p/peer-id needs new multiformats const result = await last(name.resolve(key.toMultihash())) if (result == null) { diff --git a/packages/interop/src/ipns-pubsub.spec.ts b/packages/interop/src/ipns-pubsub.spec.ts index 9242f8c77..95aa853fd 100644 --- a/packages/interop/src/ipns-pubsub.spec.ts +++ b/packages/interop/src/ipns-pubsub.spec.ts @@ -2,7 +2,6 @@ import { ipns } from '@helia/ipns' import { pubsub } from '@helia/ipns/routing' -import { generateKeyPair } from '@libp2p/crypto/keys' import { floodsub } from '@libp2p/floodsub' import { peerIdFromCID } from '@libp2p/peer-id' import { expect } from 'aegir/chai' @@ -70,13 +69,12 @@ keyTypes.filter(keyType => keyType !== 'RSA').forEach(keyType => { const digest = await sha256.digest(input) const cid = CID.createV1(raw.code, digest) - const privateKey = await generateKeyPair('Ed25519') const keyName = 'my-ipns-key' - await helia.libp2p.services.keychain.importKey(keyName, privateKey) + const privateKey = await helia.keychain.generateKey(keyName) // first call to pubsub resolver will fail but we should trigger // subscribing pubsub for updates - await expect(last(kubo.api.name.resolve(privateKey.publicKey.toString(), { + await expect(last(kubo.api.name.resolve(`${privateKey.publicKey}`, { timeout: 100 }))).to.eventually.be.undefined() @@ -137,7 +135,7 @@ keyTypes.filter(keyType => keyType !== 'RSA').forEach(keyType => { } // first call to pubsub resolver should fail but we should now be subscribed for updates - await expect(name.resolve(peerCid.multihash)).to.eventually.be.rejected() + await expect(last(name.resolve(peerCid.multihash))).to.eventually.be.rejected() // actual pubsub subscription name const subscriptionName = `/record/${uint8ArrayToString(uint8ArrayConcat([ @@ -173,7 +171,6 @@ keyTypes.filter(keyType => keyType !== 'RSA').forEach(keyType => { // we should get an update eventually await waitFor(async () => { try { - // @ts-expect-error @libp2p/peer-id needs dep updates resolveResult = await last(name.resolve(peerId.toMultihash())) return true diff --git a/packages/interop/src/ipns.spec.ts b/packages/interop/src/ipns.spec.ts index d188dd48e..cdddebb9d 100644 --- a/packages/interop/src/ipns.spec.ts +++ b/packages/interop/src/ipns.spec.ts @@ -1,12 +1,13 @@ import { ipns } from '@helia/ipns' -import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys' import { peerIdFromString } from '@libp2p/peer-id' import { expect } from 'aegir/chai' -import { multihashToIPNSRoutingKey } from 'ipns' import last from 'it-last' +import { base36 } from 'multiformats/bases/base36' import { CID } from 'multiformats/cid' import * as raw from 'multiformats/codecs/raw' import { sha256 } from 'multiformats/hashes/sha2' +import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { isElectronMain } from 'wherearewe' import { connect } from './fixtures/connect.ts' @@ -20,6 +21,14 @@ import type { IPNS } from '@helia/ipns' import type { Libp2p } from '@libp2p/interface' import type { DefaultLibp2pServices, Helia } from 'helia' import type { KuboNode } from 'ipfsd-ctl' +import type { MultihashDigest } from 'multiformats/cid' + +function multihashToIPNSRoutingKey (digest: MultihashDigest): Uint8Array { + return uint8ArrayConcat([ + uint8ArrayFromString('/ipns/'), + digest.bytes + ]) +} keyTypes.forEach(type => { describe(`@helia/ipns - default routing with ${type} keys`, () => { @@ -30,7 +39,7 @@ keyTypes.forEach(type => { // the CID we are going to publish let value: CID - // the public key we will use to publish the value + // the key we will use to publish the value let key: PrivateKey /** @@ -46,16 +55,16 @@ keyTypes.forEach(type => { helia = await createHeliaNode() kubo = await createKuboNode() - // find a PeerId that is KAD-closer to the resolver than the publisher when used as an IPNS key + // find a key that is KAD-closer to the resolver than the publisher when + // used as an IPNS key while (true) { - if (type === 'Ed25519') { - key = await helia.keychain.createKey('test-key', 'Ed25519') - } else { - key = await helia.keychain.createKey('test-key', 'RSA') - } + await helia.keychain.removeKey('test-key') + key = await helia.keychain.generateKey('test-key', { + type + }) + await helia.keychain.removeKey('test-key') - // @ts-expect-error @libp2p/crypto needs dep updates - const routingKey = multihashToIPNSRoutingKey(key.publicKey?.toMultihash()) + const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const [closest] = await sortClosestPeers(routingKey, [ helia.libp2p.peerId, @@ -120,12 +129,11 @@ keyTypes.forEach(type => { } }) - it(`should publish on helia and resolve on kubo using a ${type} key`, async () => { + it('should publish on helia and resolve on kubo', async () => { await createNodes('kubo') - const privateKey = await generateKeyPair('Ed25519') const keyName = 'my-ipns-key' - await helia.libp2p.services.keychain.importKey(keyName, privateKey) + const privateKey = await helia.keychain.generateKey(keyName) await name.publish(keyName, value) const resolved = await last(kubo.api.name.resolve(privateKey.publicKey.toString())) @@ -156,8 +164,7 @@ keyTypes.forEach(type => { // ensure the key is in the kubo keychain so we can use it to publish the IPNS record const body = new FormData() - // @ts-expect-error @libp2p/crypto needs dep updates - body.append('key', new Blob([privateKeyToProtobuf(key)])) + body.append('key', new Blob([key.toProtobuf()])) // can't use the kubo-rpc-api for this call yet const config = kubo.api.getEndpointConfig() @@ -168,6 +175,9 @@ keyTypes.forEach(type => { expect(response).to.have.property('status', 200) + const json = await response.json() + expect(json.Id).to.equal(key.publicKey.toCID().toString(base36), 'did not import key correctly') + const oneHourNS = BigInt(60 * 60 * 1e+9) await kubo.api.name.publish(cid, { diff --git a/packages/ipns/src/index.ts b/packages/ipns/src/index.ts index d8b2aedf8..1ed6db73f 100644 --- a/packages/ipns/src/index.ts +++ b/packages/ipns/src/index.ts @@ -151,7 +151,7 @@ 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, CryptoKeyLoader, Keychain, PublicKey } from '@helia/interface' +import type { Routing, HeliaEvents, Keychain, PublicKey } from '@helia/interface' import type { ComponentLogger, TypedEventEmitter } from '@libp2p/interface' import type { AbortOptions } from 'abort-error' import type { Datastore } from 'interface-datastore' @@ -328,7 +328,6 @@ export interface IPNSComponents { logger: ComponentLogger keychain: Keychain events: TypedEventEmitter // Helia event bus - getCryptoKey: CryptoKeyLoader } export interface IPNSOptions { diff --git a/packages/ipns/src/ipns.ts b/packages/ipns/src/ipns.ts index 6a371f353..b5508d3de 100644 --- a/packages/ipns/src/ipns.ts +++ b/packages/ipns/src/ipns.ts @@ -61,29 +61,20 @@ export class IPNS implements IPNSInterface, Startable { if (this.started) { this.republisher.start() } - } - - start (): void { - if (this.started) { - return - } - - 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 + // @ 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))) + const records = await Promise.all(values.map(buf => unmarshalIPNSRecord(key, buf, this.components.keychain))) return ipnsSelector(key, records) } service.validators.ipns = async (key: Uint8Array, value: Uint8Array): Promise => { - const record = await unmarshalIPNSRecord(key, value, this.components.getCryptoKey) + const record = await unmarshalIPNSRecord(key, value, this.components.keychain) await ipnsValidator(record) } } @@ -92,6 +83,15 @@ export class IPNS implements IPNSInterface, Startable { } } + start (): void { + if (this.started) { + return + } + + this.started = true + this.republisher.start() + } + stop (): void { if (!this.started) { return diff --git a/packages/ipns/src/ipns/publisher.ts b/packages/ipns/src/ipns/publisher.ts index a8114b892..ed79a4507 100644 --- a/packages/ipns/src/ipns/publisher.ts +++ b/packages/ipns/src/ipns/publisher.ts @@ -1,4 +1,4 @@ -import { base58btc } from 'multiformats/bases/base58' +import { base36 } from 'multiformats/bases/base36' import { CustomProgressEvent } from 'progress-events' import { DEFAULT_LIFETIME_MS, DEFAULT_TTL_NS } from '../constants.ts' import { createIPNSRecord } from '../records.ts' @@ -6,7 +6,7 @@ import { marshalIPNSRecord, multihashToIPNSRoutingKey, unmarshalIPNSRecord } fro import type { PublishResult, PublishOptions } from '../index.ts' import type { LocalStore } from '../local-store.ts' import type { IPNSRouting } from '../routing/index.ts' -import type { CryptoKeyLoader, Keychain, PrivateKey } from '@helia/interface' +import type { Keychain, PrivateKey } from '@helia/interface' import type { AbortOptions, ComponentLogger } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' @@ -14,7 +14,6 @@ export interface IPNSPublisherComponents { datastore: Datastore logger: ComponentLogger keychain: Keychain - getCryptoKey: CryptoKeyLoader } export interface IPNSPublisherInit { @@ -26,13 +25,11 @@ 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.keychain this.localStore = init.localStore this.routers = init.routers - this.getCryptoKey = components.getCryptoKey } async publish (keyName: string, value: Uint8Array, options: PublishOptions = {}): Promise { @@ -45,7 +42,7 @@ export class IPNSPublisher { 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 = await unmarshalIPNSRecord(routingKey, record, this.getCryptoKey, options) + const existingRecord = await unmarshalIPNSRecord(routingKey, record, this.keychain, options) sequenceNumber = existingRecord.sequence + 1n } @@ -66,9 +63,12 @@ export class IPNSPublisher { } return { - record, - name: `/ipns/${base58btc.encode(digest.bytes)}`, - publicKey: record.publicKey + record: { + ...record, + publicKey: key.publicKey + }, + name: `/ipns/${key.publicKey.toCID().toString(base36)}`, + publicKey: key.publicKey } } catch (err: any) { options.onProgress?.(new CustomProgressEvent('ipns:publish:error', err)) @@ -86,7 +86,7 @@ export class IPNSPublisher { } catch (err: any) { if (err.name === 'NotFoundError') { // create a new key - return this.keychain.createKey(keyName, 'Ed25519', options) + return this.keychain.generateKey(keyName, options) } else { throw err } diff --git a/packages/ipns/src/ipns/republisher.ts b/packages/ipns/src/ipns/republisher.ts index da4374bb1..e5d95e922 100644 --- a/packages/ipns/src/ipns/republisher.ts +++ b/packages/ipns/src/ipns/republisher.ts @@ -6,14 +6,13 @@ 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 { CryptoKeyLoader, Keychain, PrivateKey } from '@helia/interface' +import type { Keychain, PrivateKey } from '@helia/interface' import type { AbortOptions, ComponentLogger, Logger } from '@libp2p/interface' import type { RepeatingTask } from '@libp2p/utils' export interface IPNSRepublisherComponents { logger: ComponentLogger keychain: Keychain - getCryptoKey: CryptoKeyLoader } export interface IPNSRepublisherInit { @@ -29,7 +28,6 @@ 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 @@ -37,7 +35,6 @@ export class IPNSRepublisher { this.log = components.logger.forComponent('helia:ipns') this.localStore = init.localStore this.keychain = components.keychain - this.getCryptoKey = components.getCryptoKey this.republishConcurrency = init.republishConcurrency || DEFAULT_REPUBLISH_CONCURRENCY this.started = false this.routers = init.routers ?? [] @@ -96,7 +93,7 @@ export class IPNSRepublisher { } let ipnsRecord: IPNSRecord try { - ipnsRecord = await unmarshalIPNSRecord(routingKey, record, this.getCryptoKey, options) + ipnsRecord = await unmarshalIPNSRecord(routingKey, record, this.keychain, options) } catch (err: any) { this.log.error('error unmarshaling record - %e', err) continue diff --git a/packages/ipns/src/ipns/resolver.ts b/packages/ipns/src/ipns/resolver.ts index d9c5adaf5..d451f57c1 100644 --- a/packages/ipns/src/ipns/resolver.ts +++ b/packages/ipns/src/ipns/resolver.ts @@ -7,7 +7,7 @@ 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, CryptoKeyLoader } from '@helia/interface' +import type { Routing, Keychain } from '@helia/interface' import type { ComponentLogger, Logger } from '@libp2p/interface' import type { Datastore } from 'interface-datastore' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -16,7 +16,7 @@ export interface IPNSResolverComponents { datastore: Datastore routing: Routing logger: ComponentLogger - getCryptoKey: CryptoKeyLoader + keychain: Keychain } export interface IPNResolverInit { @@ -28,13 +28,13 @@ export class IPNSResolver { public readonly routers: IPNSRouting[] private readonly localStore: LocalStore private readonly log: Logger - private getCryptoKey: CryptoKeyLoader + private keychain: Keychain constructor (components: IPNSResolverComponents, init: IPNResolverInit) { this.log = components.logger.forComponent('helia:ipns') this.localStore = init.localStore this.routers = init.routers - this.getCryptoKey = components.getCryptoKey + this.keychain = components.keychain } async * resolve (key: MultihashDigest, options: ResolveOptions = {}): AsyncGenerator { @@ -74,7 +74,7 @@ export class IPNSResolver { this.log('record retrieved from cache') // unmarshal the record - const ipnsRecord = await unmarshalIPNSRecord(routingKey, marshaledIPNSRecord, this.getCryptoKey, options) + const ipnsRecord = await unmarshalIPNSRecord(routingKey, marshaledIPNSRecord, this.keychain, options) // validate the record await ipnsValidator(ipnsRecord, options) @@ -144,7 +144,7 @@ export class IPNSResolver { // 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) + const record = await unmarshalIPNSRecord(routingKey, marshaledIPNSRecord, this.keychain, options) await ipnsValidator(record, options) diff --git a/packages/ipns/src/pb/keys.proto b/packages/ipns/src/pb/keys.proto deleted file mode 100644 index db5c1fd5c..000000000 --- a/packages/ipns/src/pb/keys.proto +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 635a8a8d2..000000000 --- a/packages/ipns/src/pb/keys.ts +++ /dev/null @@ -1,241 +0,0 @@ -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 index d66f033ce..900ad00c3 100644 --- a/packages/ipns/src/records.ts +++ b/packages/ipns/src/records.ts @@ -211,11 +211,7 @@ const _create = async (privateKey: PrivateKey, value: Uint8Array, seq: number | 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 - + const publicKey = shouldEmbedPublicKey(privateKey.publicKey) ? privateKey.publicKey : undefined let record: any if (options.v1Compatible === true) { @@ -268,3 +264,10 @@ const signLegacyV1 = async (privateKey: PrivateKey, value: Uint8Array, validityT throw new SignatureCreationError('Record signature creation failed') } } + +/** + * Returns true if the public key multihash is not an identity hash + */ +function shouldEmbedPublicKey (key: PublicKey): boolean { + return key.toMultihash().code !== 0 +} diff --git a/packages/ipns/src/routing/pubsub.ts b/packages/ipns/src/routing/pubsub.ts index 5465abd46..2d283f2cb 100644 --- a/packages/ipns/src/routing/pubsub.ts +++ b/packages/ipns/src/routing/pubsub.ts @@ -17,7 +17,7 @@ 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 { Keychain } 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' @@ -63,7 +63,7 @@ export interface PubSub extends TypedEventTarget { export interface PubsubRoutingComponents { datastore: Datastore logger: ComponentLogger - getCryptoKey: CryptoKeyLoader + keychain: Keychain metrics?: Metrics libp2p: Pick, 'peerId' | 'register' | 'unregister' | 'services'> } @@ -107,7 +107,7 @@ export class PubSubRouting implements IPNSRouting, Startable { private readonly fetchPeers: PeerSet private shutdownController: AbortController private fetchTopologyId?: string - private getCryptoKey: CryptoKeyLoader + private keychain: Keychain constructor (components: PubsubRoutingComponents, init: PubsubRoutingInit = {}) { this.subscriptions = new Set() @@ -118,7 +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 + this.keychain = components.keychain // default libp2p-fetch timeout is 10 seconds - we should have an existing // connection to the peer so this can be shortened @@ -239,13 +239,13 @@ export class PubSubRouting implements IPNSRouting, Startable { } async #handleRecord (topic: string, routingKey: Uint8Array, marshalledRecord: Uint8Array, publish: boolean, options?: AbortOptions): Promise { - const record = await unmarshalIPNSRecord(routingKey, marshalledRecord, this.getCryptoKey, options) + const record = await unmarshalIPNSRecord(routingKey, marshalledRecord, this.keychain, options) await ipnsValidator(record, options) this.shutdownController.signal.throwIfAborted() if (await this.localStore.has(routingKey)) { const { record: marshaledCurrentRecord } = await this.localStore.get(routingKey, options) - const currentRecord = await unmarshalIPNSRecord(routingKey, marshaledCurrentRecord, this.getCryptoKey, options) + const currentRecord = await unmarshalIPNSRecord(routingKey, marshaledCurrentRecord, this.keychain, options) if (uint8ArrayEquals(marshaledCurrentRecord, record.bytes)) { log.trace('found identical record for %m', routingKey) @@ -370,7 +370,7 @@ export class PubSubRouting implements IPNSRouting, Startable { */ cancel (key: PublicKey | MultihashDigest<0x00 | 0x12>): void { const digest = isPublicKey(key) ? key.toMultihash() : key - // @ts-expect-error @libp2p/crypto needs new multiformats + // @ ts-expect-error @libp2p/crypto needs new multiformats const routingKey = multihashToIPNSRoutingKey(digest) const topic = keyToTopic(routingKey) diff --git a/packages/ipns/src/utils.ts b/packages/ipns/src/utils.ts index eb1712aae..7a73c8647 100644 --- a/packages/ipns/src/utils.ts +++ b/packages/ipns/src/utils.ts @@ -13,9 +13,8 @@ import { toString as uint8ArrayToString } from 'uint8arrays/to-string' import { DHT_EXPIRY_MS, REPUBLISH_THRESHOLD } from './constants.ts' 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 { PublicKey, Keychain } from '@helia/interface' import type { AbortOptions } from '@libp2p/interface' import type { MultibaseDecoder } from 'multiformats/cid' import type { MultihashDigest } from 'multiformats/hashes/interface' @@ -126,20 +125,20 @@ export function marshalIPNSRecord (obj: IPNSRecord | IPNSRecordV2): Uint8Array { validity: uint8ArrayFromString(obj.validity), sequence: obj.sequence, ttl: obj.ttl, - publicKey: obj.publicKey?.raw != null ? new Uint8Array(obj.publicKey.raw) : undefined, + publicKey: obj.publicKey?.toProtobuf(), signatureV2: obj.signatureV2, data: obj.data }) } else { return IpnsEntry.encode({ - publicKey: obj.publicKey?.raw != null ? new Uint8Array(obj.publicKey.raw) : undefined, + publicKey: obj.publicKey?.toProtobuf(), signatureV2: obj.signatureV2, data: obj.data }) } } -export async function unmarshalIPNSRecord (routingKey: Uint8Array, marshalledRecord: Uint8Array, getCryptoKey: CryptoKeyLoader, options?: AbortOptions): Promise { +export async function unmarshalIPNSRecord (routingKey: Uint8Array, marshalledRecord: Uint8Array, keychain: Keychain, options?: AbortOptions): Promise { if (marshalledRecord.byteLength > MAX_RECORD_SIZE) { throw new RecordTooLargeError('The record is too large') } @@ -160,22 +159,15 @@ export async function unmarshalIPNSRecord (routingKey: Uint8Array, marshalledRec // 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) + publicKey = await keychain.loadPublicKeyFromProtobuf(routingMultihash.digest, options) } // 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 && message.publicKey != null) { + publicKey = await keychain.loadPublicKeyFromProtobuf(message.publicKey, options) } if (publicKey == null) { @@ -269,30 +261,6 @@ export function parseCborData (buf: Uint8Array): IPNSRecordData { 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}`, diff --git a/packages/ipns/test/fixtures/create-ipns.ts b/packages/ipns/test/fixtures/create-ipns.ts index ebba1aba6..61b86654a 100644 --- a/packages/ipns/test/fixtures/create-ipns.ts +++ b/packages/ipns/test/fixtures/create-ipns.ts @@ -1,12 +1,12 @@ -import { ed25519Crypto } from '@helia/utils' -import { NotFoundError, TypedEventEmitter } from '@libp2p/interface' +import { Keychain as KeychainClass } from '@helia/utils' +import { 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, Keychain, PrivateKey } from '@helia/interface' +import type { HeliaEvents, Routing, Keychain } 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 - keychain: StubbedInstance + keychain: Keychain datastore: Datastore, log: Logger events: TypedEventEmitter @@ -33,30 +33,10 @@ export async function createIPNS (): Promise { const logger = defaultLogger() 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 keychain = new KeychainClass({ + logger, + datastore, + getCryptoKey }) const name = new IPNS({ @@ -64,7 +44,6 @@ export async function createIPNS (): Promise { routing: heliaRouting, logger, events, - getCryptoKey, keychain }, { routers: [customRouting] diff --git a/packages/ipns/test/publish.spec.ts b/packages/ipns/test/publish.spec.ts index 77f77fb87..9825e688a 100644 --- a/packages/ipns/test/publish.spec.ts +++ b/packages/ipns/test/publish.spec.ts @@ -248,7 +248,14 @@ describe('publish', () => { const progressEvents: any[] = [] - const putStub = Sinon.stub(result.datastore, 'get').rejects(new Error('Storage error')) + const originalGet = result.datastore.get.bind(result.datastore) + const getStub = Sinon.stub(result.datastore, 'get').callsFake(async (key, options) => { + if (key.toString().startsWith('/dht/record')) { + throw new Error('Storage error') + } + + return originalGet(key, options) + }) const hasStub = Sinon.stub(result.datastore, 'has').resolves(false) const keyName = 'test-key-progress-error' @@ -257,8 +264,8 @@ describe('publish', () => { onProgress: (evt) => progressEvents.push(evt) })).to.be.rejectedWith('Storage error') - expect(hasStub.called).to.be.true() - expect(putStub.called).to.be.true() + expect(hasStub.called).to.be.true('has stub was not called') + expect(getStub.called).to.be.true('get stub was not called') // Check if error progress event was emitted by localStore const errorEvent = progressEvents.find(evt => evt.type === 'ipns:routing:datastore:error') diff --git a/packages/ipns/test/republish.spec.ts b/packages/ipns/test/republish.spec.ts index 5b20e8f91..5324f46dc 100644 --- a/packages/ipns/test/republish.spec.ts +++ b/packages/ipns/test/republish.spec.ts @@ -6,7 +6,6 @@ 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' @@ -57,7 +56,7 @@ 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') + const key = await result.keychain.generateKey('test-key') // Create a test record and store it in the real datastore const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) @@ -82,7 +81,7 @@ describe('republish', () => { it('should call all routers for republish', async () => { // Create a test record and store it in the real datastore - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -111,7 +110,7 @@ describe('republish', () => { }) it('should republish records with valid metadata', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -133,14 +132,14 @@ describe('republish', () => { const callArgs = putStubCustom.firstCall.args expect(callArgs[0]).to.deep.equal(routingKey) - const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) expect(republishedRecord.sequence).to.equal(2n) // Incremented from 1n }) }) describe('record processing', () => { it('should skip records without metadata', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -175,7 +174,7 @@ describe('republish', () => { }) it('should increment sequence numbers correctly', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 5n, SHORTENED_VALIDITY) // Start with sequence 5 const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -194,14 +193,14 @@ describe('republish', () => { expect(putStubCustom.called).to.be.true() const callArgs = putStubCustom.firstCall.args - const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) expect(republishedRecord.sequence).to.equal(6n) // Incremented from 5n }) }) describe('TTL and lifetime', () => { it('should use existing TTL from records', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const customTtl = BigInt(10 * 60 * 1000) * 1_000_000n // 10 minutes in nanoseconds const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY, { ttlNs: customTtl }) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -223,13 +222,13 @@ describe('republish', () => { const callArgs = putStubCustom.firstCall.args expect(callArgs[0]).to.deep.equal(routingKey) - const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) 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 result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -247,12 +246,12 @@ describe('republish', () => { expect(putStubCustom.called).to.be.true() const callArgs = putStubCustom.firstCall.args - const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) expect(republishedRecord.ttl).to.equal(5n * 60n * 1000n * 1_000_000n) // Default TTL }) it('should use metadata lifetime', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const customLifetime = 5 * 1000 // 5 seconds const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, customLifetime) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -274,7 +273,7 @@ describe('republish', () => { expect(putStubCustom.called).to.be.true() const callArgs = putStubCustom.firstCall.args - const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], getCryptoKey) + const republishedRecord = await unmarshalIPNSRecord(routingKey, callArgs[1], result.keychain) // Check that the validity is set to the custom lifetime const actualValidity = new Date(republishedRecord.validity) @@ -286,7 +285,7 @@ describe('republish', () => { describe('error handling', () => { it('should skip republishing records with missing key', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const record = await createIPNSRecord(key, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) @@ -362,7 +361,7 @@ describe('republish', () => { }) it('should handle corrupt record data during republish iteration', async () => { - const key = await result.keychain.createKey('test-key', 'Ed25519') + const key = await result.keychain.generateKey('test-key') const routingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const store = localStore(result.datastore, result.log) @@ -384,8 +383,8 @@ describe('republish', () => { }) it('should continue republishing other records when one record fails', async () => { - const key1 = await result.keychain.createKey('test-key-1', 'Ed25519') - const key2 = await result.keychain.createKey('test-key-2', 'Ed25519') + const key1 = await result.keychain.generateKey('test-key-1') + const key2 = await result.keychain.generateKey('test-key-2') const record2 = await createIPNSRecord(key2, uint8ArrayFromString(`/ipfs/${testCid.toV1()}`), 1n, SHORTENED_VALIDITY) const routingKey1 = multihashToIPNSRoutingKey(key1.publicKey.toMultihash()) const routingKey2 = multihashToIPNSRoutingKey(key2.publicKey.toMultihash()) diff --git a/packages/ipns/test/resolve.spec.ts b/packages/ipns/test/resolve.spec.ts index 9b556e408..4eb54b08e 100644 --- a/packages/ipns/test/resolve.spec.ts +++ b/packages/ipns/test/resolve.spec.ts @@ -10,7 +10,6 @@ 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' @@ -194,7 +193,7 @@ describe('resolve', () => { }) it('should cache a record', async function () { - const key = await keychain.createKey('test-key', 'Ed25519') + const key = await keychain.generateKey('test-key') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) @@ -217,7 +216,7 @@ describe('resolve', () => { }) it('should cache the most recent record', async function () { - const key = await keychain.createKey('test-key', 'Ed25519') + const key = await keychain.generateKey('test-key') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) @@ -261,7 +260,7 @@ describe('resolve', () => { }) it('should not search the routing for updated IPNS records when a locally cached copy is within the TTL', async () => { - const key = await keychain.createKey('test-key', 'Ed25519') + const key = await keychain.generateKey('test-key') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) @@ -274,14 +273,14 @@ describe('resolve', () => { await datastore.put(dhtKey, dhtRecord.serialize()) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), getCryptoKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), keychain)) // 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 keychain.createKey('test-key', 'Ed25519') + const key = await keychain.generateKey('test-key') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) @@ -294,14 +293,14 @@ describe('resolve', () => { await datastore.put(dhtKey, dhtRecord.serialize()) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), getCryptoKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecord), keychain)) // 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 keychain.createKey('test-key', 'Ed25519') + const key = await keychain.generateKey('test-key') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) @@ -320,14 +319,14 @@ describe('resolve', () => { customRouting.get.withArgs(customRoutingKey).resolves(marshalIPNSRecord(ipnsRecordFromRouting)) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), getCryptoKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), keychain)) // 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 keychain.createKey('test-key', 'Ed25519') + const key = await keychain.generateKey('test-key') const customRoutingKey = multihashToIPNSRoutingKey(key.publicKey.toMultihash()) const dhtKey = new Key('/dht/record/' + uint8ArrayToString(customRoutingKey, 'base32'), false) @@ -346,7 +345,7 @@ describe('resolve', () => { customRouting.get.withArgs(customRoutingKey).resolves(marshalIPNSRecord(ipnsRecordFromRouting)) const result = await last(name.resolve(key.publicKey)) - expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), getCryptoKey)) + expect(result).to.have.deep.property('record', await unmarshalIPNSRecord(customRoutingKey, marshalIPNSRecord(ipnsRecordFromRouting), keychain)) // 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 dfc3f0e75..d761cb77f 100644 --- a/packages/ipns/test/routing/pubsub.spec.ts +++ b/packages/ipns/test/routing/pubsub.spec.ts @@ -59,20 +59,20 @@ describe('pubsub routing', () => { } }) - pubsubRouter = new PubSubRouting({ + keychain = new Keychain({ datastore, logger, - libp2p, getCryptoKey }) - keychain = new Keychain({ + pubsubRouter = new PubSubRouting({ datastore, logger, - getCryptoKey + libp2p, + keychain }) - privateKey = await keychain.createKey('test-key', 'Ed25519') + privateKey = await keychain.generateKey('test-key') routingKey = multihashToIPNSRoutingKey(privateKey.publicKey.toMultihash()) topic = `/record/${toString(routingKey, 'base64url')}` record = await createIPNSRecord(privateKey, uint8ArrayFromString('/test'), 1n, DEFAULT_LIFETIME_MS) @@ -146,7 +146,7 @@ describe('pubsub routing', () => { await delay(100) const result = await store.get(routingKey) - const updatedRecord = await unmarshalIPNSRecord(routingKey, result.record, getCryptoKey) + const updatedRecord = await unmarshalIPNSRecord(routingKey, result.record, keychain) expect(updatedRecord.sequence).to.equal(2n) expect(uint8ArrayToString(updatedRecord.value)).to.equal('/test2') }) diff --git a/packages/routers/package.json b/packages/routers/package.json index e4e1792d7..ffb6d578c 100644 --- a/packages/routers/package.json +++ b/packages/routers/package.json @@ -48,19 +48,17 @@ "test:electron-main": "aegir test -t electron-main" }, "dependencies": { - "@helia/delegated-routing-v1-http-api-client": "^6.0.1", + "@helia/delegated-routing-v1-http-api-client": "^7.0.1", "@helia/interface": "^6.2.1", "@libp2p/interface": "^3.2.0", "@libp2p/peer-id": "^6.0.6", "@multiformats/uri-to-multiaddr": "^10.0.0", - "ipns": "^11.0.0", "it-first": "^3.0.11", "it-map": "^3.1.5", "multiformats": "^14.0.0", "uint8arrays": "^6.1.1" }, "devDependencies": { - "@libp2p/crypto": "^5.1.15", "@libp2p/logger": "^6.2.4", "aegir": "^48.0.4", "it-all": "^3.0.11", diff --git a/packages/routers/src/delegated-http-routing.ts b/packages/routers/src/delegated-http-routing.ts index 067b394f4..49b47afe1 100644 --- a/packages/routers/src/delegated-http-routing.ts +++ b/packages/routers/src/delegated-http-routing.ts @@ -1,9 +1,9 @@ import { delegatedRoutingV1HttpApiClient } from '@helia/delegated-routing-v1-http-api-client' import { NotFoundError } from '@libp2p/interface' -import { marshalIPNSRecord, multihashFromIPNSRoutingKey, unmarshalIPNSRecord } from 'ipns' import first from 'it-first' import map from 'it-map' import { CID } from 'multiformats/cid' +import * as Digest from 'multiformats/hashes/digest' import { equals as uint8ArrayEquals } from 'uint8arrays/equals' import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { delegatedHTTPRoutingDefaults } from './utils/delegated-http-routing-defaults.ts' @@ -50,11 +50,10 @@ class DelegatedHTTPRouter implements Routing { return } - const digest = multihashFromIPNSRoutingKey(key) + const digest = Digest.decode(key.slice(IPNS_PREFIX.length)) const cid = CID.createV1(0x72, digest) - const record = unmarshalIPNSRecord(value) - await this.client.putIPNS(cid, record, options) + await this.client.putIPNS(cid, value, options) } async get (key: Uint8Array, options?: RoutingOptions): Promise { @@ -62,13 +61,11 @@ class DelegatedHTTPRouter implements Routing { throw new NotFoundError('Not found') } - const digest = multihashFromIPNSRoutingKey(key) + const digest = Digest.decode(key.slice(IPNS_PREFIX.length)) const cid = CID.createV1(0x72, digest) try { - const record = await this.client.getIPNS(cid, options) - - return marshalIPNSRecord(record) + return await this.client.getIPNS(cid, options) } catch (err: any) { // BadResponseError is thrown when the response had no body, which means // the record couldn't be found @@ -81,7 +78,7 @@ class DelegatedHTTPRouter implements Routing { } async findPeer (peerId: PeerId, options?: RoutingOptions): Promise { - const peer = await first(this.client.getPeers(peerId, options)) + const peer = await first(this.client.getPeers(peerId.toCID(), options)) if (peer != null) { return { diff --git a/packages/routers/test/delegated-http-routing.spec.ts b/packages/routers/test/delegated-http-routing.spec.ts index 0da1c49ce..f35a56d52 100644 --- a/packages/routers/test/delegated-http-routing.spec.ts +++ b/packages/routers/test/delegated-http-routing.spec.ts @@ -1,11 +1,11 @@ -import { generateKeyPair } from '@libp2p/crypto/keys' import { defaultLogger } from '@libp2p/logger' import { peerIdFromString } from '@libp2p/peer-id' import { expect } from 'aegir/chai' -import { multihashToIPNSRoutingKey, createIPNSRecord, marshalIPNSRecord } from 'ipns' import drain from 'it-drain' -import { CID } from 'multiformats' +import { CID } from 'multiformats/cid' import { stubInterface } from 'sinon-ts' +import { concat as uint8ArrayConcat } from 'uint8arrays/concat' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' import { delegatedHTTPRouting } from '../src/index.ts' import type { DelegatedRoutingV1HttpApiClient, PeerRecord } from '@helia/delegated-routing-v1-http-api-client' import type { Routing } from '@helia/interface' @@ -52,11 +52,11 @@ describe('delegated-http-routing', () => { }) it('should put a IPNS record value', async () => { - const privateKey = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats - const key = multihashToIPNSRoutingKey(privateKey.publicKey.toMultihash()) - const record = await createIPNSRecord(privateKey, '/hello world', 0, 100) - const value = marshalIPNSRecord(record) + const key = uint8ArrayConcat([ + uint8ArrayFromString('/ipns/'), + CID.parse('k51qzi5uqu5dm0ntxloxmkd7w0snw9b13x9tveslq3r8v0i10z2inerg32k8mx').multihash.bytes + ]) + const value = Uint8Array.from([5, 6, 7, 8, 9]) const options = {} await router.put(key, value, options) @@ -75,13 +75,14 @@ describe('delegated-http-routing', () => { }) it('should get a IPNS record value', async () => { - const privateKey = await generateKeyPair('Ed25519') - // @ts-expect-error @libp2p/crypto needs new multiformats - const key = multihashToIPNSRoutingKey(privateKey.publicKey.toMultihash()) - const record = await createIPNSRecord(privateKey, '/hello world', 0, 100) + const key = uint8ArrayConcat([ + uint8ArrayFromString('/ipns/'), + CID.parse('k51qzi5uqu5dm0ntxloxmkd7w0snw9b13x9tveslq3r8v0i10z2inerg32k8mx').multihash.bytes + ]) + const value = Uint8Array.from([5, 6, 7, 8, 9]) const options = {} - client.getIPNS.resolves(record) + client.getIPNS.resolves(value) await router.get(key, options) @@ -115,7 +116,7 @@ describe('delegated-http-routing', () => { await router.findPeer(peerId, options) - expect(client.getPeers.calledWith(peerId, options)).to.be.true() + expect(client.getPeers.called).to.be.true() }) it.skip('should get closest peers', async () => { diff --git a/packages/utils/src/crypto/ed25519.ts b/packages/utils/src/crypto/ed25519.ts index f6262ec21..9677b25c5 100644 --- a/packages/utils/src/crypto/ed25519.ts +++ b/packages/utils/src/crypto/ed25519.ts @@ -1,12 +1,13 @@ import { InvalidParametersError, InvalidPrivateKeyError } from '@libp2p/interface' import { CID } from 'multiformats' +import { base58btc } from 'multiformats/bases/base58' 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 { PrivateKeyMessage, PublicKeyMessage } from '../keychain/keys.ts' import type { Cipher, CryptoKeyImplementation, PrivateKey, PublicKey } from '@helia/interface' import type { AbortOptions } from 'abort-error' import type { MultihashDigest } from 'multiformats' @@ -27,19 +28,25 @@ class Ed25519PublicKey implements PublicKey { this.raw = raw } - toMultihash (): MultihashDigest { - const buf = PrivateKeyMessage.encode({ - Type: this.code, - Data: new Uint8Array(this.raw.slice()) - }) - - return identity.digest(buf) + toMultihash (): MultihashDigest<0x00> { + return identity.digest(this.toProtobuf()) } - toCID (): CID { + toCID (): CID { return CID.createV1(0x72, this.toMultihash()) } + toString (): string { + return base58btc.encode(this.toMultihash().bytes).substring(1) + } + + toProtobuf (): Uint8Array { + return PublicKeyMessage.encode({ + Type: this.code, + Data: new Uint8Array(this.raw.slice()) + }) + } + 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)) @@ -53,9 +60,9 @@ class Ed25519PrivateKey implements PrivateKey { public type = 'Ed25519' public code = 1 public raw: ArrayBuffer - public publicKey: PublicKey + public publicKey: Ed25519PublicKey - constructor (raw: ArrayBuffer, publicKey: PublicKey) { + constructor (raw: ArrayBuffer, publicKey: Ed25519PublicKey) { if (raw.byteLength < PRIVATE_KEY_LENGTH) { throw new InvalidPrivateKeyError(`Incorrect key length, got ${raw.byteLength} expected ${PRIVATE_KEY_LENGTH}`) } @@ -64,6 +71,16 @@ class Ed25519PrivateKey implements PrivateKey { this.publicKey = publicKey } + toProtobuf (): Uint8Array { + return PrivateKeyMessage.encode({ + Type: this.code, + Data: uint8ArrayConcat([ + new Uint8Array(this.raw.slice()), + new Uint8Array(this.publicKey.raw.slice()) + ], 64) + }) + } + async sign (message: Uint8Array, options?: AbortOptions): Promise> { const key = await crypto.subtle.importKey('jwk', { crv: 'Ed25519', @@ -97,22 +114,15 @@ class Ed25519Crypto implements CryptoKeyImplementation { 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) + async publicKeyFromProtobuf (key: Uint8Array, options?: AbortOptions): Promise { + const publicKey = new Ed25519PublicKey(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 buf = key.toProtobuf() const result = await cipher.encrypt(buf, options) return base64.encode(uint8ArrayConcat([ @@ -156,7 +166,7 @@ function truncateKey (input: ArrayBuffer): ArrayBuffer { return key } -async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promise { +async function derivePublicKey (raw: ArrayBuffer, options?: AbortOptions): Promise { let publicKey: ArrayBuffer // if the public key is appended to the private key, just return that diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts index c80f5e4e7..54cd7b381 100644 --- a/packages/utils/src/crypto/rsa.ts +++ b/packages/utils/src/crypto/rsa.ts @@ -1,14 +1,14 @@ import { InvalidParametersError } from '@libp2p/interface' import { CID } from 'multiformats' +import { base36 } from 'multiformats/bases/base36' 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 { PrivateKeyMessage, PublicKeyMessage } from '../keychain/keys.ts' +import { decodeDer, encodeBitString, 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' @@ -18,32 +18,40 @@ export const MAX_RSA_KEY_SIZE = 8192 class RSAPublicKey implements PublicKey { public type = 'RSA' public code = 0 - public raw: ArrayBuffer - private digest: MultihashDigest + public _raw?: Uint8Array + private digest: MultihashDigest<0x012> + private jwk: JsonWebKey - constructor (raw: ArrayBuffer, digest: MultihashDigest) { - this.raw = raw + constructor (jwk: JsonWebKey, digest: MultihashDigest<0x012>) { + if (rsaKeySize(jwk) > MAX_RSA_KEY_SIZE) { + throw new InvalidParametersError('Key size is too large') + } + + this.jwk = jwk this.digest = digest } - toMultihash (): MultihashDigest { + toMultihash (): MultihashDigest<0x012> { return this.digest } - toCID (): CID { + toCID (): CID { return CID.createV1(0x72, this.toMultihash()) } + toString (): string { + return this.toCID().toString(base36) + } + + toProtobuf (): Uint8Array { + return PublicKeyMessage.encode({ + Type: this.code, + Data: jwkToPkix(this.jwk) + }) + } + 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' - }, { + const key = await crypto.subtle.importKey('jwk', this.jwk, { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' @@ -61,16 +69,27 @@ class RSAPublicKey implements PublicKey { class RSAPrivateKey implements PrivateKey { public type = 'RSA' public code = 0 - public raw: ArrayBuffer public publicKey: PublicKey + private readonly jwk: JsonWebKey - constructor (pkcs8: ArrayBuffer, publicKey: PublicKey) { - this.raw = pkcs8 + constructor (jwk: JsonWebKey, publicKey: PublicKey) { + if (rsaKeySize(jwk) > MAX_RSA_KEY_SIZE) { + throw new InvalidParametersError('Key size is too large') + } + + this.jwk = jwk this.publicKey = publicKey } + toProtobuf (): Uint8Array { + return PrivateKeyMessage.encode({ + Type: this.code, + Data: jwkToPkcs1(this.jwk) + }) + } + async sign (message: Uint8Array, options?: AbortOptions): Promise> { - const key = await crypto.subtle.importKey('pkcs8', this.raw, { + const key = await crypto.subtle.importKey('jwk', this.jwk, { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' @@ -86,52 +105,49 @@ class RSAPrivateKey implements PrivateKey { } } +export interface CreateRSAPrivateKeyOptions extends AbortOptions, Record { + /** + * The key size + * + * @default 2048 + */ + bits?: number +} + class RSACrypto implements CryptoKeyImplementation { public type = 'RSA' public code = 0 - async createPrivateKey (options?: AbortOptions & Record): Promise { - const privateKey = await crypto.subtle.generateKey({ + async createPrivateKey (options?: CreateRSAPrivateKeyOptions): Promise { + const keypair = await crypto.subtle.generateKey({ name: 'RSASSA-PKCS1-v1_5', - modulusLength: 2048, + modulusLength: options?.bits ?? 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') + + const jwkPrivateKey = await crypto.subtle.exportKey('jwk', keypair.privateKey) + const jwkPublicKey = await crypto.subtle.exportKey('jwk', keypair.publicKey) + const digest = await publicKeyId(jwkPublicKey) options?.signal?.throwIfAborted() - return new RSAPrivateKey(rawPrivateKey, new RSAPublicKey(publicKey.buffer, await sha256.digest(new Uint8Array(publicKey)))) + return new RSAPrivateKey(jwkPrivateKey, new RSAPublicKey(jwkPublicKey, digest)) } - 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)) + async publicKeyFromProtobuf (data: Uint8Array, options?: AbortOptions): Promise { + const jwk = pkixMessageToJwk(data) + const digest = await publicKeyId(jwk) options?.signal?.throwIfAborted() - return new RSAPublicKey(raw, digest) + return new RSAPublicKey(jwk, 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 buf = key.toProtobuf() const result = await cipher.encrypt(buf, options) return base64.encode(uint8ArrayConcat([ @@ -159,25 +175,13 @@ class RSACrypto implements CryptoKeyImplementation { } 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)) + const privateKeyJwk = pkcs1MessageToJwk(pkcs1Decoded) + const publicKeyJwk = privateJWKToPublicJWK(privateKeyJwk) + const digest = await publicKeyId(publicKeyJwk) options?.signal?.throwIfAborted() - return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, digest)) + return new RSAPrivateKey(privateKeyJwk, new RSAPublicKey(publicKeyJwk, digest)) } pem = pem.replaceAll('-----BEGIN ENCRYPTED PRIVATE KEY-----', '') @@ -188,6 +192,8 @@ class RSACrypto implements CryptoKeyImplementation { const decoded = base64.decode(`m${pem}`) const der = decodeDer(decoded) + // this looks fragile but DER is a canonical format so we are safe to have + // deep property chains like this 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]) @@ -206,25 +212,14 @@ class RSACrypto implements CryptoKeyImplementation { 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 privateKeyJwk = pkcs1MessageToJwk(pkcs1Decoded) - 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)) + const publicKeyJwk = privateJWKToPublicJWK(privateKeyJwk) + const digest = await publicKeyId(publicKeyJwk) options?.signal?.throwIfAborted() - return new RSAPrivateKey(pkcs8, new RSAPublicKey(publicKey.buffer, digest)) + return new RSAPrivateKey(privateKeyJwk, new RSAPublicKey(publicKeyJwk, digest)) } } @@ -249,9 +244,14 @@ function toNumber (buf: Uint8Array): number { */ function pkcs1MessageToJwk (message: Uint8Array[]): JsonWebKey { return { + alg: 'RS256', kty: 'RSA', n: uint8ArrayToString(message[1], 'base64url'), e: uint8ArrayToString(message[2], 'base64url'), + ext: true, + key_ops: [ + 'sign' + ], d: uint8ArrayToString(message[3], 'base64url'), p: uint8ArrayToString(message[4], 'base64url'), q: uint8ArrayToString(message[5], 'base64url'), @@ -282,12 +282,77 @@ function jwkToPkcs1 (jwk: JsonWebKey): Uint8Array { ]).subarray() } +const RSA_ALGORITHM_IDENTIFIER = Uint8Array.from([ + 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 +]) + +function jwkToPkix (jwk: JsonWebKey): Uint8Array { + if (jwk.n == null || jwk.e == null) { + throw new InvalidParametersError('JWK public key was missing components') + } + + const subjectPublicKeyInfo = encodeSequence([ + RSA_ALGORITHM_IDENTIFIER, + encodeBitString( + encodeSequence([ + encodeInteger(uint8ArrayFromString(jwk.n, 'base64url')), + encodeInteger(uint8ArrayFromString(jwk.e, 'base64url')) + ]) + ) + ]) + + return subjectPublicKeyInfo.subarray() +} + +function pkixMessageToJwk (message: Uint8Array): JsonWebKey { + const cert = decodeDer(message) + + if (cert.length < 2 || cert[0]?.[0] !== '1.2.840.113549.1.1.1') { + throw new Error('PKIX certificate was invalid') + } + + const keys = decodeDer(cert[1]) + + return { + kty: 'RSA', + n: uint8ArrayToString( + keys[0], + 'base64url' + ), + e: uint8ArrayToString( + keys[1], + 'base64url' + ) + } +} + +function privateJWKToPublicJWK (jwk: JsonWebKey): JsonWebKey { + return { + key_ops: ['verify'], + ext: true, + alg: 'RS256', + kty: 'RSA', + n: jwk.n, + e: 'AQAB' + } +} + 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 } + +async function publicKeyId (jwk: JsonWebKey): Promise> { + const data = PublicKeyMessage.encode({ + Type: 0, + Data: jwkToPkix(jwk) + }) + + return sha256.digest(data) +} diff --git a/packages/utils/src/keychain.ts b/packages/utils/src/keychain.ts index b479aecd9..b159c0e99 100644 --- a/packages/utils/src/keychain.ts +++ b/packages/utils/src/keychain.ts @@ -8,8 +8,8 @@ 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 { PrivateKeyMessage, PublicKeyMessage } from './keychain/keys.ts' +import type { Keychain as KeychainInterface, KeyInfo, PrivateKey, CryptoKeyLoader, CryptoKeyImplementation, Cipher, CipherOptions, EncryptionResult, GenerateKeyOptions, PublicKey } from '@helia/interface' import type { ComponentLogger, Logger } from '@libp2p/interface' import type { AbortOptions } from 'abort-error' import type { Datastore } from 'interface-datastore' @@ -141,10 +141,7 @@ function dsInfoName (name: string): Key { } export async function keyId (key: PrivateKey, options?: AbortOptions): Promise { - const pb = PrivateKeyMessage.encode({ - Type: key.code, - Data: new Uint8Array(key.raw) - }) + const pb = key.toProtobuf() const hash = await sha256.digest(pb) options?.signal?.throwIfAborted() @@ -232,8 +229,8 @@ export class Keychain implements KeychainInterface { '@libp2p/keychain' ] - async createKey (name: string, type: 'Ed25519' | 'RSA' | string, options?: AbortOptions & Record): Promise { - const crypto = await this.components.getCryptoKey(type, options) + async generateKey (name: string, options?: GenerateKeyOptions): Promise { + const crypto = await this.components.getCryptoKey(options?.type ?? 'Ed25519', options) const key = await crypto.createPrivateKey(options) return this.importKey(name, key, options) @@ -425,6 +422,18 @@ export class Keychain implements KeychainInterface { this.log('keychain reconstructed') } + + async loadPublicKeyFromProtobuf (buf: Uint8Array, options?: AbortOptions): Promise { + const pb = PublicKeyMessage.decode(buf) + + if (pb.Type == null || pb.Data == null) { + throw new InvalidParametersError('Protobuf was missing Type and/or Data') + } + + const crypto = await this.components.getCryptoKey(pb.Type, options) + + return crypto.publicKeyFromProtobuf(pb.Data) + } } /** diff --git a/packages/utils/test/keychain.spec.ts b/packages/utils/test/keychain.spec.ts index 76c19d6ab..e5acd4851 100644 --- a/packages/utils/test/keychain.spec.ts +++ b/packages/utils/test/keychain.spec.ts @@ -6,9 +6,10 @@ import { defaultLogger } from '@libp2p/logger' import { expect } from 'aegir/chai' import { MemoryDatastore } from 'datastore-core/memory' import all from 'it-all' +import { PublicKeyMessage } from '../src/keychain/keys.ts' import { Keychain as KeychainClass } from '../src/keychain.ts' import { getCryptoKey } from './fixtures/crypto-loader.ts' -import type { Keychain } from '../src/index.js' +import type { Keychain } from '../src/index.ts' import type { KeychainInit } from '../src/keychain.ts' import type { PrivateKey } from '@helia/interface' import type { ComponentLogger } from '@libp2p/interface' @@ -168,7 +169,7 @@ describe('keychain', () => { await start(keychain) const keyName = 'my-key' - const privateKey = await keychain.createKey(keyName, 'Ed25519') + const privateKey = await keychain.generateKey(keyName) await expect(keychain.importKey(keyName, privateKey)).to.eventually.be.rejected .with.property('name', 'InvalidParametersError') @@ -186,7 +187,9 @@ describe('keychain', () => { }) await start(keychain) - privateKey = await keychain.createKey(rsaKeyName, 'RSA') + privateKey = await keychain.generateKey(rsaKeyName, { + type: 'RSA' + }) }) it('finds all existing keys', async () => { @@ -199,7 +202,7 @@ describe('keychain', () => { it('exports a key by name', async () => { const key = await keychain.exportKey(rsaKeyName) expect(key).to.exist() - expect(key).to.deep.equal(privateKey) + expect(key.toProtobuf()).to.equalBytes(privateKey.toProtobuf()) }) it('returns the key\'s name', async () => { @@ -224,7 +227,9 @@ describe('keychain', () => { }) await start(keychain) - privateKey = await keychain.createKey(rsaKeyName, 'RSA') + privateKey = await keychain.generateKey(rsaKeyName, { + type: 'RSA' + }) }) it('requires the key name', async () => { @@ -238,7 +243,7 @@ describe('keychain', () => { expect(imported).to.deep.equal(privateKey) const exported = await keychain.exportKey('imported-key') - expect(exported).to.deep.equal(privateKey) + expect(exported.toProtobuf()).to.equalBytes(privateKey.toProtobuf()) }) it('requires the key', async () => { @@ -265,7 +270,9 @@ describe('keychain', () => { }) await start(keychain) - privateKey = await keychain.createKey(rsaKeyName, 'RSA') + privateKey = await keychain.generateKey(rsaKeyName, { + type: 'RSA' + }) }) it('requires an existing key name', async () => { @@ -292,7 +299,7 @@ describe('keychain', () => { it('removes the existing key name', async () => { await keychain.renameKey(rsaKeyName, renamedRsaKeyName) const exported = await keychain.exportKey(renamedRsaKeyName) - expect(exported).to.deep.equal(privateKey) + expect(exported.toProtobuf()).to.equalBytes(privateKey.toProtobuf()) // Try to find the changed key await expect(keychain.exportKey(rsaKeyName)).to.eventually.be.rejected() @@ -332,6 +339,20 @@ describe('keychain', () => { await expect(keychain.exportKey(rsaKeyName)).to.eventually.be.rejected .with.property('name', 'NotFoundError') }) + + it('can read a public key from a protobuf', async () => { + const key = await keychain.generateKey('my-key', { + type: 'Ed25519' + }) + + const pb = key.publicKey.toMultihash().digest + const read = await keychain.loadPublicKeyFromProtobuf(pb) + + const message = Uint8Array.from([0, 1, 2, 3, 4]) + const sig = await key.sign(message) + + await expect(read.verify(message, sig)).to.eventually.be.true() + }) }) describe('rotate keychain passphrase', () => { @@ -373,7 +394,7 @@ describe('keychain', () => { it('can rotate keychain passphrase', async () => { const newPassword = 'newInsecurePassphrase' const keyName = 'test-key' - const key = await keychain.createKey(keyName, 'Ed25519') + const key = await keychain.generateKey(keyName) await keychain.rotateKeychainPass(newPassword) @@ -422,12 +443,13 @@ describe('keychain', () => { const keyName = 'my custom key' it(`can create a ${type} key`, async () => { - const privateKey = await keychain.createKey(keyName, type) + const privateKey = await keychain.generateKey(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() @@ -450,12 +472,36 @@ describe('keychain', () => { it('can sign and verify', async () => { const keyName = 'my-key' - const privateKey = await keychain.createKey(keyName, type) + const privateKey = await keychain.generateKey(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() }) + + it('can round-trip public key to protobuf', async () => { + const keyName = 'my-key' + const privateKey = await keychain.generateKey(keyName, { + type + }) + + const message = Uint8Array.from([0, 1, 2, 3, 4]) + const sig = await privateKey.sign(message) + + const pb = privateKey.publicKey.toProtobuf() + const pbMessage = PublicKeyMessage.decode(pb) + + if (pbMessage.Type == null || pbMessage.Data == null) { + throw new Error('PublicKeyMessage message was missing Type and/or Data') + } + + const crypto = await getCryptoKey(pbMessage.Type) + const publicKey = await crypto.publicKeyFromProtobuf(pbMessage.Data) + + await expect(publicKey.verify(message, sig)).to.eventually.be.true() + }) }) }) @@ -474,7 +520,9 @@ describe('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 + await expect(keychain.generateKey(keyName, { + type: 'ECDSA' + })).to.eventually.be.rejected .with.property('name', 'UnsupportedCryptographyImplementationError') }) }) @@ -492,7 +540,6 @@ describe('keychain', () => { await start(keychain) libp2pKeychain = libp2pKeychainFactory()({ - // @ts-expect-error libp2p needs new interface-datastore datastore, logger }) @@ -516,7 +563,9 @@ describe('keychain', () => { it(`should write ${type} libp2p keychain keys`, async () => { const keyName = 'my-key' - const heliaPrivateKey = await keychain.createKey(keyName, type) + const heliaPrivateKey = await keychain.generateKey(keyName, { + type + }) const libp2pPrivateKey = await libp2pKeychain.exportKey(keyName) const message = Uint8Array.from([0, 1, 2, 3, 4]) From bdba807e2bcb3d43fb7001d8e6cdf9ad474dda8a Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 22 May 2026 14:59:35 +0100 Subject: [PATCH 09/10] chore: spelling --- packages/utils/src/crypto/rsa.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/utils/src/crypto/rsa.ts b/packages/utils/src/crypto/rsa.ts index 54cd7b381..8844a273c 100644 --- a/packages/utils/src/crypto/rsa.ts +++ b/packages/utils/src/crypto/rsa.ts @@ -333,6 +333,7 @@ function privateJWKToPublicJWK (jwk: JsonWebKey): JsonWebKey { alg: 'RS256', kty: 'RSA', n: jwk.n, + // cspell:ignore AQAB e: 'AQAB' } } From bd6bffbecc1e2c1a6198f28d0fb6760117eb1069 Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 22 May 2026 15:26:04 +0100 Subject: [PATCH 10/10] chore: fix chrome --- packages/interop/package.json | 4 ++-- packages/interop/src/fixtures/create-helia.browser.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/interop/package.json b/packages/interop/package.json index f1586afdb..a6988bffe 100644 --- a/packages/interop/package.json +++ b/packages/interop/package.json @@ -94,8 +94,8 @@ "wherearewe": "^2.0.1" }, "browser": { - "./dist/src/fixtures/create-helia.js": "./dist/src/fixtures/create-helia.browser.js", - "./dist/src/fixtures/create-kubo.js": "./dist/src/fixtures/create-kubo.browser.js", + "./src/fixtures/create-helia.ts": "./src/fixtures/create-helia.browser.ts", + "./src/fixtures/create-kubo.ts": "./src/fixtures/create-kubo.browser.ts", "./dist/src/bin.js": "./dist/src/index.js", "kubo": false }, diff --git a/packages/interop/src/fixtures/create-helia.browser.ts b/packages/interop/src/fixtures/create-helia.browser.ts index 25e3c109a..9829dec51 100644 --- a/packages/interop/src/fixtures/create-helia.browser.ts +++ b/packages/interop/src/fixtures/create-helia.browser.ts @@ -29,9 +29,6 @@ export async function createHeliaNode (libp2pOptions?: Libp2pOptions): Promise