From c02dc0b7b8e24222762e692d977d5d06420089ad Mon Sep 17 00:00:00 2001 From: achingbrain Date: Fri, 15 May 2026 15:45:04 +0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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) }