diff --git a/packages/cli/src/__tests__/daemon-config.test.ts b/packages/cli/src/__tests__/daemon-config.test.ts index 073ee0c18a..aacc7403dc 100644 --- a/packages/cli/src/__tests__/daemon-config.test.ts +++ b/packages/cli/src/__tests__/daemon-config.test.ts @@ -3,6 +3,8 @@ import { writeFile } from 'node:fs/promises' import { DaemonConfig } from '../daemon-config.js' import { homedir } from 'node:os' +const mockNodeConfig = {'did-seed': 'inplace://scheme#seed'} + describe('reading from file', () => { let folder: tmp.DirectoryResult let configFilepath: URL @@ -16,10 +18,21 @@ describe('reading from file', () => { }) test('read config from file', async () => { - const config = {} + const config = {node: mockNodeConfig} await writeFile(configFilepath, JSON.stringify(config)) await expect(DaemonConfig.fromFile(configFilepath)).resolves.toBeInstanceOf(DaemonConfig) }) + test('error if missing node.did-seed', async () => { + const config = {} + await writeFile(configFilepath, JSON.stringify(config)) + await expect(DaemonConfig.fromFile(configFilepath)).rejects.toThrow('Daemon config is missing node.did-seed') + }) + test('set did-seed from file', async () => { + const config = {node: mockNodeConfig} + await writeFile(configFilepath, JSON.stringify(config)) + const daemonConfig = await DaemonConfig.fromFile(configFilepath) + expect(daemonConfig.node.sensitive_didSeed()).toEqual(mockNodeConfig['did-seed']) + }) test('expand relative path', async () => { const config = { logger: { @@ -28,6 +41,7 @@ describe('reading from file', () => { 'state-store': { 'local-directory': './statestore/', }, + node: mockNodeConfig } await writeFile(configFilepath, JSON.stringify(config)) const read = await DaemonConfig.fromFile(configFilepath) @@ -44,6 +58,7 @@ describe('reading from file', () => { 'state-store': { 'local-directory': '~/statestore/', }, + node: mockNodeConfig } await writeFile(configFilepath, JSON.stringify(config)) const read = await DaemonConfig.fromFile(configFilepath) @@ -59,6 +74,7 @@ describe('reading from file', () => { 'state-store': { 'local-directory': '~+/statestore/', }, + node: mockNodeConfig } await writeFile(configFilepath, JSON.stringify(config)) const read = await DaemonConfig.fromFile(configFilepath) @@ -73,6 +89,7 @@ describe('reading from file', () => { 'state-store': { 'local-directory': '/var/ceramic/statestore/', }, + node: mockNodeConfig } await writeFile(configFilepath, JSON.stringify(config)) const read = await DaemonConfig.fromFile(configFilepath) @@ -80,3 +97,23 @@ describe('reading from file', () => { expect(read.stateStore.localDirectory).toEqual('/var/ceramic/statestore/') }) }) + +describe('stringify', () => { + test('excludes node.did-seed from string representation', async () => { + // includes everything if not DaemonConfig type + const config = {node: mockNodeConfig} + const configString = JSON.stringify(config) + expect(configString.includes('node')).toBeTruthy() + expect(configString.includes('did-seed')).toBeTruthy() + expect(configString.includes(mockNodeConfig['did-seed'])).toBeTruthy() + + // expcludes sensitive field from DaemonConfig type + const daemonConfig = DaemonConfig.fromObject(config) + const daemonConfigString = JSON.stringify(daemonConfig) + expect(daemonConfigString.includes('node')).toBeTruthy() + expect(daemonConfigString.includes('did-seed')).toBeFalsy() + expect(daemonConfigString.includes(mockNodeConfig['did-seed'])).toBeFalsy() + // keeps did-seed in object + expect(daemonConfig.node.sensitive_didSeed()).toEqual(mockNodeConfig['did-seed']) + }) +}) diff --git a/packages/cli/src/ceramic-cli-utils.ts b/packages/cli/src/ceramic-cli-utils.ts index 9bb9c50074..43732117d0 100644 --- a/packages/cli/src/ceramic-cli-utils.ts +++ b/packages/cli/src/ceramic-cli-utils.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises' import { Ed25519Provider } from 'key-did-provider-ed25519' import { CeramicClient } from '@ceramicnetwork/http-client' -import { CeramicApi, LogLevel, Networks, StreamUtils } from '@ceramicnetwork/common' +import { AnchorServiceAuthMethods, CeramicApi, LogLevel, Networks, StreamUtils } from '@ceramicnetwork/common' import { StreamID, CommitID } from '@ceramicnetwork/streamid' import { CeramicDaemon } from './ceramic-daemon.js' @@ -20,6 +20,7 @@ import { Resolver } from 'did-resolver' import { DID } from 'dids' import { handleHeapdumpSignal } from './daemon/handle-heapdump-signal.js' import { handleSigintSignal } from './daemon/handle-sigint-signal.js' +import { generateSeedUrl } from './daemon/did-utils.js' const HOMEDIR = new URL(`file://${os.homedir()}/`) const CWD = new URL(`file://${process.cwd()}/`) @@ -30,25 +31,40 @@ const DEFAULT_CLI_CONFIG_FILENAME = new URL('client.config.json', DEFAULT_CONFIG const LEGACY_CLI_CONFIG_FILENAME = new URL('config.json', DEFAULT_CONFIG_PATH) // todo(1615): Remove this backwards compatibility support const DEFAULT_INDEXING_DB_FILENAME = new URL('./indexing.sqlite', DEFAULT_CONFIG_PATH) -const DEFAULT_DAEMON_CONFIG = DaemonConfig.fromObject({ - anchor: {}, - 'http-api': { 'cors-allowed-origins': [new RegExp('.*')], 'admin-dids': [] }, - ipfs: { mode: IpfsMode.BUNDLED }, - logger: { 'log-level': LogLevel.important, 'log-to-files': false }, - metrics: { - 'metrics-exporter-enabled': false, - }, - network: { name: Networks.TESTNET_CLAY }, - node: {}, - 'state-store': { - mode: StateStoreMode.FS, - 'local-directory': DEFAULT_STATE_STORE_DIRECTORY.pathname, - }, - indexing: { - db: `sqlite://${DEFAULT_INDEXING_DB_FILENAME.pathname}`, - 'allow-queries-before-historical-sync': true, - }, -}) +/** + * Generates a valid Daemon config. + * + * Most values are set to hardcoded defaults. + * The `node.did-seed` is randomly generated. + * @returns Daemon config with default values + */ +const generateDefaultDaemonConfig = () => { + const didSeed = generateSeedUrl() + + return DaemonConfig.fromObject({ + anchor: { + auth: AnchorServiceAuthMethods.DID + }, + 'http-api': { 'cors-allowed-origins': [new RegExp('.*')], 'admin-dids': [] }, + ipfs: { mode: IpfsMode.BUNDLED }, + logger: { 'log-level': LogLevel.important, 'log-to-files': false }, + metrics: { + 'metrics-exporter-enabled': false, + }, + network: { name: Networks.TESTNET_CLAY }, + node: { + 'did-seed': didSeed + }, + 'state-store': { + mode: StateStoreMode.FS, + 'local-directory': DEFAULT_STATE_STORE_DIRECTORY.pathname, + }, + indexing: { + db: `sqlite://${DEFAULT_INDEXING_DB_FILENAME.pathname}`, + 'allow-queries-before-historical-sync': true, + }, + }) +} /** * CLI configuration @@ -121,6 +137,9 @@ export class CeramicCliUtils { config.metrics.metricsExporterEnabled = process.env.CERAMIC_METRICS_EXPORTER_ENABLED == 'true' if (process.env.COLLECTOR_HOSTNAME) config.metrics.collectorHost = process.env.COLLECTOR_HOSTNAME + if (process.env.CERAMIC_NODE_DID_SEED) { + config.node.didSeed = new URL(process.env.CERAMIC_NODE_DID_SEED) + } { // CLI flags override values from environment variables and config file @@ -584,14 +603,17 @@ export class CeramicCliUtils { /** * Load configuration file for the Ceramic Daemon. + * + * If no file is present a new one will be generated with configured defaults. * @private */ static async _loadDaemonConfig(filepath: URL): Promise { try { await fs.access(filepath) } catch (err) { - await this._saveConfig(DEFAULT_DAEMON_CONFIG, filepath) - return DEFAULT_DAEMON_CONFIG + const defaultDaemonConfig = generateDefaultDaemonConfig() + await this._saveConfig(defaultDaemonConfig, filepath) + return defaultDaemonConfig } return DaemonConfig.fromFile(filepath) } diff --git a/packages/cli/src/ceramic-daemon.ts b/packages/cli/src/ceramic-daemon.ts index ba81deed9d..34dd7530a1 100644 --- a/packages/cli/src/ceramic-daemon.ts +++ b/packages/cli/src/ceramic-daemon.ts @@ -31,6 +31,7 @@ import { DaemonConfig, StateStoreMode } from './daemon-config.js' import type { ResolverRegistry } from 'did-resolver' import { ErrorHandlingRouter } from './error-handling-router.js' import { collectionQuery, countQuery } from './daemon/collection-queries.js' +import { makeNodeDIDProvider, parseSeedUrl } from './daemon/did-utils.js' import { StatusCodes } from 'http-status-codes' import crypto from 'crypto' // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -81,6 +82,7 @@ export function makeCeramicConfig(opts: DaemonConfig): CeramicConfig { loggerProvider, gateway: opts.node.gateway || false, anchorServiceUrl: opts.anchor.anchorServiceUrl, + anchorServiceAuthMethod: opts.anchor.authMethod, ethereumRpcUrl: opts.anchor.ethereumRpcUrl, ipfsPinningEndpoints: opts.ipfs.pinningEndpoints, networkName: opts.network.name, @@ -297,7 +299,8 @@ export class CeramicDaemon { const s3Store = new S3Store(opts.stateStore?.s3Bucket, params.networkOptions.name) await ceramic.repository.injectStateStore(s3Store) } - const did = new DID({ resolver: makeResolvers(ceramic, ceramicConfig, opts) }) + const provider = makeNodeDIDProvider(parseSeedUrl(opts.node.sensitive_didSeed())) + const did = new DID({ provider, resolver: makeResolvers(ceramic, ceramicConfig, opts) }) ceramic.did = did await ceramic._init(true) diff --git a/packages/cli/src/daemon-config.ts b/packages/cli/src/daemon-config.ts index 70c5743ceb..a26a8b0586 100644 --- a/packages/cli/src/daemon-config.ts +++ b/packages/cli/src/daemon-config.ts @@ -194,6 +194,13 @@ export class DaemonAnchorConfig { @jsonMember(String, { name: 'anchor-service-url' }) anchorServiceUrl?: string + /** + * Controls the authentication method Ceramic uses to make requests to the Ceramic Anchor Service. + * When specifying in a config file, use the name 'auth-method'. + */ + @jsonMember(String, { name: 'auth-method' }) + authMethod: string + /** * Ethereum RPC URL that can be used to create or query ethereum transactions. * When specifying in a config file, use the name 'ethereum-rpc-url'. @@ -260,6 +267,30 @@ export class DaemonDidResolversConfig { @jsonObject @toJson export class DaemonCeramicNodeConfig { + + private _didSeed: URL; + + /** + * Disallows public access to did-seed because it is a sensitive field. + */ + @jsonMember(String, { name: 'did-seed' }) + public get didSeed(): any { + return undefined; + } + + /** + * Setter for seed used to sign requests to CAS. + * A seed is randomly generated if a config file is not found. + * When specifying in a config file, use the name 'did-seed'. + */ + public set didSeed(value: URL) { + this._didSeed = value + } + + public sensitive_didSeed(): URL { + return this._didSeed + } + /** * Whether to run the Ceramic node in read-only gateway mode. */ @@ -412,6 +443,9 @@ export class DaemonConfig { static async fromFile(filepath: URL): Promise { const content = await readFile(filepath, { encoding: 'utf8' }) const config = DaemonConfig.fromString(content) + // Whenever we load from a file the did-seed needs to be present even if not using an anchor auth method + if (!config.node) throw Error('Daemon config is missing node.did-seed') + if (!config.node.sensitive_didSeed()) throw Error('Daemon config is missing node.did-seed') expandPaths(config, filepath) return config } diff --git a/packages/cli/src/daemon/did-utils.ts b/packages/cli/src/daemon/did-utils.ts new file mode 100644 index 0000000000..882bba2bcc --- /dev/null +++ b/packages/cli/src/daemon/did-utils.ts @@ -0,0 +1,49 @@ +import { Ed25519Provider } from "key-did-provider-ed25519" +import * as u8a from 'uint8arrays' +import { DID } from 'dids' +import * as KeyDidResolver from 'key-did-resolver' +import { Resolver } from 'did-resolver' +import { randomBytes } from '@stablelib/random' + +export function makeNodeDID(seed: Uint8Array): DID { + const provider = makeNodeDIDProvider(seed) + const keyDidResolver = KeyDidResolver.getResolver() + const resolver = new Resolver({...keyDidResolver}) + return new DID({ provider, resolver }) +} + +export function makeNodeDIDProvider(seed: Uint8Array): Ed25519Provider { + return new Ed25519Provider(seed) +} + +export function generateSeed(): string { + return u8a.toString(randomBytes(32), 'base16') +} + +export function generateSeedUrl(): URL { + const seed = generateSeed() + const url = `inplace:ed25519#${seed}` + return new URL(url) +} + +/** + * Parses DID seed url + * + * Examples: + * When the seed is in the url itself it must be formatted as `inplace:#`. + * A URL should look like `new URL('inplace:ed25519#abc123')` + * + * @param seedUrl Url for seed + * @returns base16 uint8 array + */ +export function parseSeedUrl(seedUrl: URL): Uint8Array { + let seed: string + if (seedUrl.protocol == 'inplace') { + seed = seedUrl.hash.slice(1) + } + return parseSeed(seed) +} + +export function parseSeed(seed: string) { + return u8a.fromString(seed, 'base16') +} diff --git a/packages/common/src/anchor-service.ts b/packages/common/src/anchor-service.ts index 1bc6833350..81251aed75 100644 --- a/packages/common/src/anchor-service.ts +++ b/packages/common/src/anchor-service.ts @@ -2,8 +2,13 @@ import type { CID } from 'multiformats/cid' import type { Observable } from 'rxjs' import type { AnchorProof, AnchorStatus } from './stream.js' import type { CeramicApi } from './ceramic-api.js' +import type { FetchJson } from './utils/http-utils.js' import type { StreamID } from '@ceramicnetwork/streamid' +export enum AnchorServiceAuthMethods { + DID = 'did', +} + export interface AnchorServicePending { readonly status: AnchorStatus.PENDING readonly streamId: StreamID @@ -85,6 +90,36 @@ export interface AnchorService { getSupportedChains(): Promise> } +export interface AuthenticatedAnchorService extends AnchorService { + /** + * Set Anchor Service Auth instance + * + * @param auth - Anchor service authentication instance + */ + auth: AnchorServiceAuth +} + +export interface AnchorServiceAuth { + /** + * Performs whatever initialization work is required by the specific auth implementation + */ + init(): Promise + + /** + * Set Ceramic API instance + * + * @param ceramic - Ceramic API used for various purposes + */ + ceramic: CeramicApi + + /** + * + * @param url - Anchor service url as URL or string + * @param {FetchOpts} opts - Optional options for the request + */ + sendAuthenticatedRequest: FetchJson +} + /** * Describes behavior for validation anchor commit inclusion on chain */ diff --git a/packages/common/src/utils/http-utils.ts b/packages/common/src/utils/http-utils.ts index 4a59fc3977..2508aa3400 100644 --- a/packages/common/src/utils/http-utils.ts +++ b/packages/common/src/utils/http-utils.ts @@ -2,7 +2,7 @@ import fetch from 'cross-fetch' import { mergeAbortSignals, TimedAbortSignal, abortable } from './abort-signal-utils.js' const DEFAULT_FETCH_TIMEOUT = 60 * 1000 * 3 // 3 minutes -interface FetchOpts { +export interface FetchOpts { body?: any method?: string headers?: any @@ -10,6 +10,13 @@ interface FetchOpts { signal?: AbortSignal } +export type FetchJson = (url: URL | string, opts?: FetchOpts) => Promise + +export enum HttpMethods { + GET = 'GET', + POST = 'POST' +} + export async function fetchJson(url: URL | string, opts: FetchOpts = {}): Promise { if (opts.body) { Object.assign(opts, { diff --git a/packages/core/src/__tests__/create-did-anchor-service-auth.ts b/packages/core/src/__tests__/create-did-anchor-service-auth.ts new file mode 100644 index 0000000000..dc48d9e2ce --- /dev/null +++ b/packages/core/src/__tests__/create-did-anchor-service-auth.ts @@ -0,0 +1,13 @@ +import { CeramicApi, DiagnosticsLogger, LoggerProvider } from '@ceramicnetwork/common' +import { DIDAnchorServiceAuth } from '../anchor/auth/did-anchor-service-auth.js' + +export function createDidAnchorServiceAuth( + anchorServiceUrl: string, + ceramic: CeramicApi, + logger?: any +): { auth: DIDAnchorServiceAuth, logger: DiagnosticsLogger } { + logger = logger ?? new LoggerProvider().getDiagnosticsLogger() + const auth = new DIDAnchorServiceAuth(anchorServiceUrl, logger) + auth.ceramic = ceramic + return { auth, logger } +} diff --git a/packages/core/src/anchor/auth/__tests__/did-anchor-service-auth.test.ts b/packages/core/src/anchor/auth/__tests__/did-anchor-service-auth.test.ts new file mode 100644 index 0000000000..0dc93a87cc --- /dev/null +++ b/packages/core/src/anchor/auth/__tests__/did-anchor-service-auth.test.ts @@ -0,0 +1,128 @@ +import { jest } from '@jest/globals' + +const mockedUrls = { + OFFLINE: 'http://offline.test.ts', + ONLINE: 'https://online.test.ts', + nonceOffline: (did) => `http://offline.test.ts/api/v0/auth/did/${did}/nonce`, + nonceOnline: (did) => `https://online.test.ts/api/v0/auth/did/${did}/nonce` +} + +const mockedCalls = { + NONCE_OFFLINE: { response: { error: 'failed' } }, + NONCE_ONLINE: { response: { nonce: 5 } }, +} + +jest.unstable_mockModule('cross-fetch', () => { + const fetchFunc = jest.fn(async (url: string, opts: any = {}) => ({ + ok: true, + json: async () => { + if (url.startsWith(mockedUrls.OFFLINE)) { + return mockedCalls.NONCE_OFFLINE.response + } else { + return mockedCalls.NONCE_ONLINE.response + } + }, + })) + return { + default: fetchFunc, + } +}) + +let ipfs: any +let ceramic: any + +beforeAll(async () => { + await setup() +}) + +afterAll(async () => { + await ceramic.close() + await ipfs.stop() +}) + +const setup = async (): Promise => { + const { createIPFS } = await import('@ceramicnetwork/ipfs-daemon') + const { createCeramic } = await import('../../../__tests__/create-ceramic.js') + ipfs = await createIPFS() + ceramic = await createCeramic(ipfs, { streamCacheLimit: 1, anchorOnRequest: false }) +} + +const setupAuth = async (url): Promise => { + const { createDidAnchorServiceAuth } = await import('../../../__tests__/create-did-anchor-service-auth.js') + return createDidAnchorServiceAuth(url, ceramic) +} + +describe('init', () => { + jest.setTimeout(40000) + + test('initializes nonce to 0 if not retrieved by CAS', async () => { + const { auth } = await setupAuth(mockedUrls.OFFLINE) + await auth.init() + expect(auth.nonce).toEqual(0) + }) + test('initializes nonce retrieved by CAS', async () => { + const { auth } = await setupAuth(mockedUrls.ONLINE) + await auth.init() + expect(auth.nonce).toEqual(5) + }) +}) + +describe('lookupLastNonce', () => { + jest.setTimeout(40000) + test('creates a signed payload without nonce for the `authorization` header', async () => { + const { auth } = await setupAuth(mockedUrls.ONLINE) + await auth.init() + + const signRequestSpy = jest.spyOn(auth, 'signRequest') + const getSignRequestResult = (): Promise => signRequestSpy.mock.results[0].value; + + const jws = await ceramic.did.createJWS({ + url: mockedUrls.nonceOnline(ceramic.did.id) + }) + const authorization = `Basic ${jws.signatures[0].protected}.${jws.payload}.${jws.signatures[0].signature}` + + await auth.lookupLastNonce() + + expect(signRequestSpy).toBeCalledWith(mockedUrls.nonceOnline(ceramic.did.id)) + expect(await getSignRequestResult()).toEqual({jws, authorization}) + }) +}) + +describe('sendAuthenticatedRequest', () => { + jest.setTimeout(40000) + test('sends request with signed payload in `authorization` header', async () => { + const { auth } = await setupAuth(mockedUrls.ONLINE) + await auth.init() + + const signRequestSpy = jest.spyOn(auth, 'signRequest') + const getSignRequestResult = (): Promise => signRequestSpy.mock.results[0].value; + + const jws = await ceramic.did.createJWS({ + url: mockedUrls.nonceOnline(ceramic.did.id), + nonce: 6 + }) + const authorization = `Basic ${jws.signatures[0].protected}.${jws.payload}.${jws.signatures[0].signature}` + + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(ceramic.did.id)) + + expect(await getSignRequestResult()).toEqual({jws, authorization}) + }) + test('increments nonce on success', async () => { + const { auth } = await setupAuth(mockedUrls.ONLINE) + await auth.init() + expect(auth.nonce).toEqual(5) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(ceramic.did.id)) + expect(auth.nonce).toEqual(6) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(ceramic.did.id)) + expect(auth.nonce).toEqual(7) + }) + test('increments nonce on failure', async () => { + const { auth } = await setupAuth(mockedUrls.ONLINE) + await auth.init() + expect(auth.nonce).toEqual(5) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOffline(ceramic.did.id)) + expect(auth.nonce).toEqual(6) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(ceramic.did.id)) + expect(auth.nonce).toEqual(7) + }) +}) diff --git a/packages/core/src/anchor/auth/did-anchor-service-auth.ts b/packages/core/src/anchor/auth/did-anchor-service-auth.ts new file mode 100644 index 0000000000..2a0b2d13ec --- /dev/null +++ b/packages/core/src/anchor/auth/did-anchor-service-auth.ts @@ -0,0 +1,105 @@ +import { + AnchorServiceAuth, + CeramicApi, + DiagnosticsLogger, + FetchOpts, + HttpMethods, + fetchJson +} from "@ceramicnetwork/common" +import { DagJWS } from "dids" + +export class DIDAnchorServiceAuth implements AnchorServiceAuth { + private _ceramic: CeramicApi + private _nonce: number + private readonly _anchorServiceUrl: string + private readonly _logger: DiagnosticsLogger + + constructor( + anchorServiceUrl: string, + logger: DiagnosticsLogger, + ) { + this._anchorServiceUrl = anchorServiceUrl + this._logger = logger + } + + /** + * Set Ceramic API instance + * + * @param ceramic - Ceramic API used for various purposes + */ + set ceramic(ceramic: CeramicApi) { + this._ceramic = ceramic + } + + get nonce(): number { + return this._nonce + } + + init = async (): Promise => { + this._nonce = await this.lookupLastNonce() ?? 0 + } + + lookupLastNonce = async (): Promise => { + if (!this._ceramic) { + throw new Error('Missing Ceramic instance required by this auth method') + } + let nonce + const { authorization } = await this.signRequest(this._getNonceEndpoint()) + const data = await this._sendRequest(this._getNonceEndpoint(), { + method: HttpMethods.POST, + headers: { authorization }, + body: {did: this._ceramic.did.id} + }) + if (data) { + if (data.nonce) { + nonce = Number(data.nonce) + if (!isNaN(nonce)) { + return nonce + } + } + } + this._logger.debug('Could not get nonce from anchor service') + return + } + + sendAuthenticatedRequest = async (url: URL | string, opts?: FetchOpts): Promise => { + if (!this._ceramic) { + throw new Error('Missing Ceramic instance required by this auth method') + } + this._updateNonce() + const { authorization } = await this.signRequest(url, opts?.body, this._nonce) + let headers: any = { authorization } + opts?.headers && (headers = {...opts.headers, headers }) + return await this._sendRequest(url, { ...opts, headers}) + } + + signRequest = async (url: URL | string, body?: any, nonce?: number): Promise<{jws: DagJWS, authorization: string}> => { + let payload: any = { url } + body && (payload = { ...payload, body}) + nonce && (payload = { ...payload, nonce}) + const jws = await this._ceramic.did.createJWS(payload) + const authorization = `Basic ${jws.signatures[0].protected}.${jws.payload}.${jws.signatures[0].signature}` + return { jws, authorization } + } + + /** + * Increments nonce in memory. + */ + private _updateNonce = async (): Promise => { + this._nonce = this._nonce + 1 + } + + private _sendRequest = async (url: URL| string, opts?: FetchOpts): Promise => { + const data = await fetchJson(url, opts) + if (data.error) { + this._logger.err(data.error) + return data + } + return data + } + + private _getNonceEndpoint = (): string => { + return this._anchorServiceUrl + `/api/v0/auth/did/${this._ceramic.did.id}/nonce` + } + +} diff --git a/packages/core/src/anchor/ethereum/__tests__/ethereum-did-auth-anchor-service.retry-failure.test.ts b/packages/core/src/anchor/ethereum/__tests__/ethereum-did-auth-anchor-service.retry-failure.test.ts new file mode 100644 index 0000000000..28c59fee08 --- /dev/null +++ b/packages/core/src/anchor/ethereum/__tests__/ethereum-did-auth-anchor-service.retry-failure.test.ts @@ -0,0 +1,78 @@ +import { jest } from '@jest/globals' +import { CID } from 'multiformats/cid' +import { StreamID } from '@ceramicnetwork/streamid' +import { whenSubscriptionDone } from '../../../__tests__/when-subscription-done.util.js' + +const FAKE_CID = CID.parse('bafybeig6xv5nwphfmvcnektpnojts33jqcuam7bmye2pb54adnrtccjlsu') +const FAKE_STREAM_ID = StreamID.fromString( + 'kjzl6cwe1jw147dvq16zluojmraqvwdmbh61dx9e0c59i344lcrsgqfohexp60s' +) + +const MAX_FAILED_ATTEMPTS = 2 +let attemptNum = 0 + +const casProcessingResponse = { + status: 'PROCESSING', + message: `CAS is finally available; nonce: ${Math.random()}`, +} + +jest.unstable_mockModule('cross-fetch', () => { + const fetchFunc = jest.fn(async (url: string, opts: any = {}) => ({ + ok: true, + json: async () => { + attemptNum += 1 + if (attemptNum <= MAX_FAILED_ATTEMPTS + 1) { + throw new Error(`Cas is unavailable`) + } + return casProcessingResponse + }, + })) + return { + default: fetchFunc, + } +}) + +jest.setTimeout(20000) + +let ipfs: any +let ceramic: any + +afterAll(async () => { + ceramic && await ceramic.close() + ipfs && await ipfs.stop() +}) + +test('re-request an anchor till get a response', async () => { + const common = await import('@ceramicnetwork/common') + const eas = await import('../ethereum-anchor-service.js') + const { createIPFS } = await import('@ceramicnetwork/ipfs-daemon') + const { createCeramic } = await import('../../../__tests__/create-ceramic.js') + const { createDidAnchorServiceAuth } = await import('../../../__tests__/create-did-anchor-service-auth.js') + const loggerProvider = new common.LoggerProvider() + const diagnosticsLogger = loggerProvider.getDiagnosticsLogger() + const errSpy = jest.spyOn(diagnosticsLogger, 'err') + const url = 'http://example.com' + + ipfs = await createIPFS() + ceramic = await createCeramic(ipfs, { streamCacheLimit: 1, anchorOnRequest: true }) + const { auth } = createDidAnchorServiceAuth(url, ceramic, diagnosticsLogger) + const anchorService = new eas.AuthenticatedEthereumAnchorService( + auth, + url, + diagnosticsLogger, + 100 + ) + + let lastResponse: any + const subscription = anchorService + .requestAnchor(FAKE_STREAM_ID, FAKE_CID) + .subscribe((response) => { + if (response.status === common.AnchorStatus.PROCESSING) { + lastResponse = response + subscription.unsubscribe() + } + }) + await whenSubscriptionDone(subscription) + expect(lastResponse.message).toEqual(casProcessingResponse.message) + expect(errSpy).toBeCalledTimes(3) +}) diff --git a/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts b/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts index 1333a0446d..59d43ec8ba 100644 --- a/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts +++ b/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts @@ -5,9 +5,12 @@ import { CeramicApi, AnchorServiceResponse, AnchorService, + AnchorServiceAuth, AnchorStatus, + AuthenticatedAnchorService, DiagnosticsLogger, fetchJson, + FetchJson, } from '@ceramicnetwork/common' import { StreamID } from '@ceramicnetwork/streamid' import { Observable, interval, from, concat, of, defer } from 'rxjs' @@ -37,16 +40,19 @@ export class EthereumAnchorService implements AnchorService { * Retry a request to CAS every +pollInterval+ milliseconds. */ private readonly pollInterval: number + private readonly sendRequest: FetchJson constructor( readonly anchorServiceUrl: string, logger: DiagnosticsLogger, - pollInterval: number = DEFAULT_POLL_INTERVAL + pollInterval: number = DEFAULT_POLL_INTERVAL, + sendRequest: FetchJson = fetchJson, ) { this.requestsApiEndpoint = this.anchorServiceUrl + '/api/v0/requests' this.chainIdApiEndpoint = this.anchorServiceUrl + '/api/v0/service-info/supported_chains' this._logger = logger this.pollInterval = pollInterval + this.sendRequest = sendRequest } /** @@ -64,7 +70,7 @@ export class EthereumAnchorService implements AnchorService { async init(): Promise { // Get the chainIds supported by our anchor service - const response = await fetchJson(this.chainIdApiEndpoint) + const response = await this.sendRequest(this.chainIdApiEndpoint) if (response.supportedChains.length > 1) { throw new Error( "Anchor service returned multiple supported chains, which isn't supported by js-ceramic yet" @@ -82,7 +88,7 @@ export class EthereumAnchorService implements AnchorService { const cidStreamPair: CidAndStream = { cid: tip, streamId } return concat( this._announcePending(cidStreamPair), - this._makeRequest(cidStreamPair), + this._makeAnchorRequest(cidStreamPair), this.pollForAnchorResponse(streamId, tip) ).pipe( catchError((error) => @@ -118,10 +124,10 @@ export class EthereumAnchorService implements AnchorService { * @param cidStreamPair - mapping * @private */ - private _makeRequest(cidStreamPair: CidAndStream): Observable { + private _makeAnchorRequest(cidStreamPair: CidAndStream): Observable { return defer(() => from( - fetchJson(this.requestsApiEndpoint, { + this.sendRequest(this.requestsApiEndpoint, { method: 'POST', body: { streamId: cidStreamPair.streamId.toString(), @@ -167,7 +173,7 @@ export class EthereumAnchorService implements AnchorService { if (now > maxTime) { throw new Error('Exceeded max anchor polling time limit') } else { - const response = await fetchJson(requestUrl) + const response = await this.sendRequest(requestUrl) return this.parseResponse(cidStream, response) } }) @@ -226,3 +232,34 @@ export class EthereumAnchorService implements AnchorService { } } } + +/** + * Ethereum anchor service that authenticates requests + */ +export class AuthenticatedEthereumAnchorService extends EthereumAnchorService implements AuthenticatedAnchorService { + readonly auth: AnchorServiceAuth + + constructor( + auth: AnchorServiceAuth, + readonly anchorServiceUrl: string, + logger: DiagnosticsLogger, + pollInterval: number = DEFAULT_POLL_INTERVAL, + ) { + super(anchorServiceUrl, logger, pollInterval, auth.sendAuthenticatedRequest) + this.auth = auth + } + + /** + * Set Ceramic API instance + * + * @param ceramic - Ceramic API used for various purposes + */ + set ceramic(ceramic: CeramicApi) { + this.auth.ceramic = ceramic + } + + async init(): Promise { + await this.auth.init() + await super.init() + } +} diff --git a/packages/core/src/ceramic.ts b/packages/core/src/ceramic.ts index 909f34f51a..a4f603ff42 100644 --- a/packages/core/src/ceramic.ts +++ b/packages/core/src/ceramic.ts @@ -10,6 +10,7 @@ import { StreamUtils, LoadOpts, AnchorService, + AnchorServiceAuthMethods, CeramicApi, CeramicCommit, IpfsApi, @@ -31,7 +32,8 @@ import { DID } from 'dids' import { PinStoreFactory } from './store/pin-store-factory.js' import { PathTrie, TrieNode, promiseTimeout } from './utils.js' -import { EthereumAnchorService } from './anchor/ethereum/ethereum-anchor-service.js' +import { DIDAnchorServiceAuth } from './anchor/auth/did-anchor-service-auth.js' +import { AuthenticatedEthereumAnchorService, EthereumAnchorService } from './anchor/ethereum/ethereum-anchor-service.js' import { InMemoryAnchorService } from './anchor/memory/in-memory-anchor-service.js' import { randomUint32 } from '@stablelib/random' @@ -65,6 +67,10 @@ const DEFAULT_ANCHOR_SERVICE_URLS = { [Networks.LOCAL]: 'http://localhost:8081', } +const DEFAULT_ANCHOR_SERVICE_AUTH_METHODS = { + [AnchorServiceAuthMethods.DID]: DIDAnchorServiceAuth, +} + const DEFAULT_LOCAL_ETHEREUM_RPC = 'http://localhost:7545' // default Ganache port const SUPPORTED_CHAINS_BY_NETWORK = { @@ -94,6 +100,7 @@ const ERROR_LOADING_STREAM = 'error_loading_stream' export interface CeramicConfig { ethereumRpcUrl?: string anchorServiceUrl?: string + anchorServiceAuthMethod?: string stateStoreDirectory?: string ipfsPinningEndpoints?: string[] @@ -396,11 +403,20 @@ export class Ceramic implements CeramicApi { const networkOptions = Ceramic._generateNetworkOptions(config) let anchorService = null + let anchorServiceAuth = null if (!config.gateway) { const anchorServiceUrl = config.anchorServiceUrl?.replace(TRAILING_SLASH, '') || DEFAULT_ANCHOR_SERVICE_URLS[networkOptions.name] + if (config.anchorServiceAuthMethod) { + const method = DEFAULT_ANCHOR_SERVICE_AUTH_METHODS[config.anchorServiceAuthMethod] + if (!method) { + throw new Error(`Invalid auth method for anchor service: ${config.anchorServiceAuthMethod}`) + } + anchorServiceAuth = new method(anchorServiceUrl, logger) + } + if ( (networkOptions.name == Networks.MAINNET || networkOptions.name == Networks.ELP) && anchorServiceUrl !== 'https://cas-internal.3boxlabs.com' && @@ -408,10 +424,14 @@ export class Ceramic implements CeramicApi { ) { throw new Error('Cannot use custom anchor service on Ceramic mainnet') } - anchorService = - networkOptions.name != Networks.INMEMORY - ? new EthereumAnchorService(anchorServiceUrl, logger) - : new InMemoryAnchorService(config as any) + + if (networkOptions.name != Networks.INMEMORY) { + anchorService = anchorServiceAuth + ? new AuthenticatedEthereumAnchorService(anchorServiceAuth, anchorServiceUrl, logger) + : new EthereumAnchorService(anchorServiceUrl, logger) + } else { + anchorService = new InMemoryAnchorService(config as any) + } } let ethereumRpcUrl = config.ethereumRpcUrl diff --git a/packages/core/src/indexing/__tests__/build-indexing.test.ts b/packages/core/src/indexing/__tests__/build-indexing.test.ts index bcc2c7ef51..784a4cd4cb 100644 --- a/packages/core/src/indexing/__tests__/build-indexing.test.ts +++ b/packages/core/src/indexing/__tests__/build-indexing.test.ts @@ -1,4 +1,5 @@ import tmp from 'tmp-promise' +import { jest } from '@jest/globals' import { buildIndexing, UnsupportedDatabaseProtocolError } from '../build-indexing.js' import { PostgresIndexApi } from '../postgres/postgres-index-api.js' import { SqliteIndexApi } from '../sqlite/sqlite-index-api.js' @@ -9,6 +10,8 @@ const diagnosticsLogger = new LoggerProvider().getDiagnosticsLogger() // @ts-ignore default import const getDatabase = pgTest.default +jest.setTimeout(20000) + describe('sqlite', () => { let databaseFolder: tmp.DirectoryResult diff --git a/packages/core/src/state-management/state-manager.ts b/packages/core/src/state-management/state-manager.ts index 738aeec437..cca06bb95c 100644 --- a/packages/core/src/state-management/state-manager.ts +++ b/packages/core/src/state-management/state-manager.ts @@ -18,7 +18,7 @@ import { } from '@ceramicnetwork/common' import { RunningState } from './running-state.js' import type { CID } from 'multiformats/cid' -import { catchError, concatMap, takeUntil, tap } from 'rxjs/operators' +import { catchError, concatMap, takeUntil } from 'rxjs/operators' import { empty, Observable, Subject, Subscription, timer, lastValueFrom, merge, of } from 'rxjs' import { SnapshotState } from './snapshot-state.js' import { CommitID, StreamID } from '@ceramicnetwork/streamid'