From 2e0831be9d773179397d30101749d420b7ad0027 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Mon, 21 Nov 2022 15:19:51 -0500 Subject: [PATCH 01/16] feat: create anchor service auth interface --- packages/common/src/anchor-service.ts | 15 +++++++++++++++ packages/common/src/utils/http-utils.ts | 2 ++ 2 files changed, 17 insertions(+) diff --git a/packages/common/src/anchor-service.ts b/packages/common/src/anchor-service.ts index 1bc6833350..03747b7a66 100644 --- a/packages/common/src/anchor-service.ts +++ b/packages/common/src/anchor-service.ts @@ -2,6 +2,7 @@ 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 interface AnchorServicePending { @@ -85,6 +86,20 @@ export interface AnchorService { getSupportedChains(): Promise> } +export interface AnchorServiceAuth { + /** + * Performs whatever initialization work is required by the specific auth implementation + */ + init(): Promise + + /** + * + * @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..f904c1ddd0 100644 --- a/packages/common/src/utils/http-utils.ts +++ b/packages/common/src/utils/http-utils.ts @@ -10,6 +10,8 @@ interface FetchOpts { signal?: AbortSignal } +export type FetchJson = (url: URL | string, opts?: FetchOpts) => Promise + export async function fetchJson(url: URL | string, opts: FetchOpts = {}): Promise { if (opts.body) { Object.assign(opts, { From be51e155eb7628015ff0e9a5c80235174456cc6e Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Mon, 21 Nov 2022 15:26:41 -0500 Subject: [PATCH 02/16] feat: implement did anchor service auth --- .../auth-did/did-anchor-service-auth.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/core/src/anchor/auth-did/did-anchor-service-auth.ts diff --git a/packages/core/src/anchor/auth-did/did-anchor-service-auth.ts b/packages/core/src/anchor/auth-did/did-anchor-service-auth.ts new file mode 100644 index 0000000000..22dd59de84 --- /dev/null +++ b/packages/core/src/anchor/auth-did/did-anchor-service-auth.ts @@ -0,0 +1,50 @@ +import { + AnchorServiceAuth, + CeramicApi, + DiagnosticsLogger, + FetchOpts, + fetchJson +} from "@ceramicnetwork/common" + +export class DIDAnchorServiceAuth implements AnchorServiceAuth { + nonce: string + ceramic: CeramicApi + private readonly didApiEndpoint: string + private readonly nonceApiEndpoint: string + private readonly _logger: DiagnosticsLogger + + constructor( + ceramic: CeramicApi, + readonly anchorServiceUrl: string, + logger: DiagnosticsLogger, + ) { + this.ceramic = ceramic + this.didApiEndpoint = this.anchorServiceUrl + '/api/v0/auth/did' + this.nonceApiEndpoint = this.anchorServiceUrl + '/api/v0/auth/nonce' + this._logger = logger + } + + async init(): Promise { + // TODO: error handling here for nonce + this.nonce = await this.lookupNonce() + } + + async lookupNonce(): Promise { + return await this.sendAuthenticatedRequest(this.nonceApiEndpoint) + } + + async sendAuthenticatedRequest(url: URL | string, opts?: FetchOpts): Promise { + const jws = this.ceramic.did.createJWS({}) + const headers = {...opts.headers, 'authorization': `Basic ${jws}`} + return await this._sendRequest(url, { ...opts, headers}) + } + + private async _sendRequest(url: URL| string, opts?: FetchOpts): Promise { + return await fetchJson(url, opts) + } +} + +/** + * when implementing an anchor service you can add an auth service to it + * the auth service will handle all your requests by signing and sending them + */ From b24a9ce93ffdf24ac36019530cebad5baa12604e Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:36:50 -0500 Subject: [PATCH 03/16] feat: add did utils --- packages/cli/src/daemon/did-utils.ts | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/cli/src/daemon/did-utils.ts 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') +} From 5f1b7df49f7df979d8c9fbcca1156488e349deae Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:40:03 -0500 Subject: [PATCH 04/16] feat: generate did for daemon config --- packages/cli/src/ceramic-cli-utils.ts | 69 ++++++++++++++++++--------- packages/cli/src/daemon-config.ts | 23 +++++++++ packages/common/src/anchor-service.ts | 4 ++ packages/core/src/ceramic.ts | 6 +++ 4 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/ceramic-cli-utils.ts b/packages/cli/src/ceramic-cli-utils.ts index 9bb9c50074..816e35d7b2 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 { generateSeed, generateSeedUrl, makeNodeDID, parseSeed, parseSeedUrl } from './daemon/did-utils.js' const HOMEDIR = new URL(`file://${os.homedir()}/`) const CWD = new URL(`file://${process.cwd()}/`) @@ -30,25 +31,42 @@ 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() + const did = makeNodeDID(parseSeedUrl(didSeed)).id + + 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': did, + '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 +139,10 @@ 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) + config.node.did = makeNodeDID(parseSeedUrl(config.node.didSeed)).id + } { // CLI flags override values from environment variables and config file @@ -584,14 +606,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/daemon-config.ts b/packages/cli/src/daemon-config.ts index 70c5743ceb..53e1365cbd 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,20 @@ export class DaemonDidResolversConfig { @jsonObject @toJson export class DaemonCeramicNodeConfig { + /** + * DID used to sign requests to CAS. This is automatically derived by the `did-seed`. + */ + @jsonMember(String) + did: string + + /** + * Seed used to sign requests to CAS. + * This is randomly generated if a config file is not found. + * When specifying in a config file, use the name 'did-seed'. + */ + @jsonMember(String, { name: 'did-seed' }) + didSeed: URL + /** * Whether to run the Ceramic node in read-only gateway mode. */ @@ -412,6 +433,8 @@ 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.didSeed) throw Error('Daemon config is missing node.did-seed') expandPaths(config, filepath) return config } diff --git a/packages/common/src/anchor-service.ts b/packages/common/src/anchor-service.ts index 03747b7a66..cb32440510 100644 --- a/packages/common/src/anchor-service.ts +++ b/packages/common/src/anchor-service.ts @@ -5,6 +5,10 @@ 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 diff --git a/packages/core/src/ceramic.ts b/packages/core/src/ceramic.ts index 909f34f51a..e37b8f23d2 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, @@ -65,6 +66,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 +99,7 @@ const ERROR_LOADING_STREAM = 'error_loading_stream' export interface CeramicConfig { ethereumRpcUrl?: string anchorServiceUrl?: string + anchorServiceAuthMethod?: string stateStoreDirectory?: string ipfsPinningEndpoints?: string[] From 543b801d5ef5f748be12065d7a64e5307b793d47 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:41:06 -0500 Subject: [PATCH 05/16] feat: add provider to ceramic daemon DID --- packages/cli/src/ceramic-daemon.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ceramic-daemon.ts b/packages/cli/src/ceramic-daemon.ts index ba81deed9d..295ba99bc7 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, parseSeed, 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.didSeed)) + const did = new DID({ provider, resolver: makeResolvers(ceramic, ceramicConfig, opts) }) ceramic.did = did await ceramic._init(true) From 5c811daa379224e5c5b84903c90f2e52d3ba0350 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:42:53 -0500 Subject: [PATCH 06/16] feat: accept request handler for eth anchor service --- packages/common/src/anchor-service.ts | 16 ++++++ packages/common/src/utils/http-utils.ts | 7 ++- .../auth-did/did-anchor-service-auth.ts | 50 ------------------- .../ethereum/ethereum-anchor-service.ts | 18 ++++--- 4 files changed, 34 insertions(+), 57 deletions(-) delete mode 100644 packages/core/src/anchor/auth-did/did-anchor-service-auth.ts diff --git a/packages/common/src/anchor-service.ts b/packages/common/src/anchor-service.ts index cb32440510..81251aed75 100644 --- a/packages/common/src/anchor-service.ts +++ b/packages/common/src/anchor-service.ts @@ -90,12 +90,28 @@ 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 diff --git a/packages/common/src/utils/http-utils.ts b/packages/common/src/utils/http-utils.ts index f904c1ddd0..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 @@ -12,6 +12,11 @@ interface FetchOpts { 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/anchor/auth-did/did-anchor-service-auth.ts b/packages/core/src/anchor/auth-did/did-anchor-service-auth.ts deleted file mode 100644 index 22dd59de84..0000000000 --- a/packages/core/src/anchor/auth-did/did-anchor-service-auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - AnchorServiceAuth, - CeramicApi, - DiagnosticsLogger, - FetchOpts, - fetchJson -} from "@ceramicnetwork/common" - -export class DIDAnchorServiceAuth implements AnchorServiceAuth { - nonce: string - ceramic: CeramicApi - private readonly didApiEndpoint: string - private readonly nonceApiEndpoint: string - private readonly _logger: DiagnosticsLogger - - constructor( - ceramic: CeramicApi, - readonly anchorServiceUrl: string, - logger: DiagnosticsLogger, - ) { - this.ceramic = ceramic - this.didApiEndpoint = this.anchorServiceUrl + '/api/v0/auth/did' - this.nonceApiEndpoint = this.anchorServiceUrl + '/api/v0/auth/nonce' - this._logger = logger - } - - async init(): Promise { - // TODO: error handling here for nonce - this.nonce = await this.lookupNonce() - } - - async lookupNonce(): Promise { - return await this.sendAuthenticatedRequest(this.nonceApiEndpoint) - } - - async sendAuthenticatedRequest(url: URL | string, opts?: FetchOpts): Promise { - const jws = this.ceramic.did.createJWS({}) - const headers = {...opts.headers, 'authorization': `Basic ${jws}`} - return await this._sendRequest(url, { ...opts, headers}) - } - - private async _sendRequest(url: URL| string, opts?: FetchOpts): Promise { - return await fetchJson(url, opts) - } -} - -/** - * when implementing an anchor service you can add an auth service to it - * the auth service will handle all your requests by signing and sending them - */ diff --git a/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts b/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts index 1333a0446d..e6efce95ca 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) } }) From 220b306bab731009092391edc10fba74c1a18829 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:43:44 -0500 Subject: [PATCH 07/16] feat: use did auth for cas by default --- .../create-did-anchor-service-auth.ts | 13 ++ .../__tests__/did-anchor-service-auth.test.ts | 128 ++++++++++++++++++ .../anchor/auth/did-anchor-service-auth.ts | 101 ++++++++++++++ ...-auth-anchor-service.retry-failure.test.ts | 79 +++++++++++ .../ethereum/ethereum-anchor-service.ts | 31 +++++ packages/core/src/ceramic.ts | 24 +++- 6 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/__tests__/create-did-anchor-service-auth.ts create mode 100644 packages/core/src/anchor/auth/__tests__/did-anchor-service-auth.test.ts create mode 100644 packages/core/src/anchor/auth/did-anchor-service-auth.ts create mode 100644 packages/core/src/anchor/ethereum/__tests__/ethereum-did-auth-anchor-service.retry-failure.test.ts 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..126c8c0bcf --- /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 + } else { + return mockedCalls.NONCE_ONLINE + } + }, + })) + 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(20000) + + 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(20000) + 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(20000) + 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(auth.ceramic.did.id)) + expect(auth.nonce).toEqual(6) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(auth.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(auth.ceramic.did.id)) + expect(auth.nonce).toEqual(6) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(auth.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..566fb033c5 --- /dev/null +++ b/packages/core/src/anchor/auth/did-anchor-service-auth.ts @@ -0,0 +1,101 @@ +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 nonceApiEndpoint: string + private readonly _logger: DiagnosticsLogger + + constructor( + readonly anchorServiceUrl: string, + logger: DiagnosticsLogger, + ) { + this.nonceApiEndpoint = this.anchorServiceUrl + '/api/v0/auth/nonce' + 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.nonceApiEndpoint) + let headers: any = { authorization } + const data = await this._sendRequest(this.nonceApiEndpoint, { + method: HttpMethods.POST, + headers, + body: {did: this._ceramic.did.id} + }) + if (data) { + if (data.nonce) { + nonce = Number(data.nonce) + if (nonce != NaN) { + 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}) + } + + /** + * Increments nonce in memory. + */ + private _updateNonce = async (): Promise => { + this._nonce = this._nonce + 1 + } + + 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 } + } + + 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 + } +} 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..394d0aa3df --- /dev/null +++ b/packages/core/src/anchor/ethereum/__tests__/ethereum-did-auth-anchor-service.retry-failure.test.ts @@ -0,0 +1,79 @@ +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() + let errSpy = jest.spyOn(diagnosticsLogger, 'err') + let anchorService + const url = 'http://example.com' + + ipfs = await createIPFS() + ceramic = await createCeramic(ipfs, { streamCacheLimit: 1, anchorOnRequest: true }) + const { auth } = createDidAnchorServiceAuth(url, ceramic, diagnosticsLogger) + 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 e6efce95ca..727bb111e1 100644 --- a/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts +++ b/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts @@ -232,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 e37b8f23d2..a4f603ff42 100644 --- a/packages/core/src/ceramic.ts +++ b/packages/core/src/ceramic.ts @@ -32,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' @@ -402,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' && @@ -414,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 From 45cd6edd544ceec9dbe90824e40df2330622cefb Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:48:21 -0500 Subject: [PATCH 08/16] fmt --- ...-auth-anchor-service.retry-failure.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) 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 index 394d0aa3df..31c83ca1e6 100644 --- 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 @@ -51,18 +51,17 @@ test('re-request an anchor till get a response', async () => { const loggerProvider = new common.LoggerProvider() const diagnosticsLogger = loggerProvider.getDiagnosticsLogger() let errSpy = jest.spyOn(diagnosticsLogger, 'err') - let anchorService const url = 'http://example.com' - ipfs = await createIPFS() - ceramic = await createCeramic(ipfs, { streamCacheLimit: 1, anchorOnRequest: true }) - const { auth } = createDidAnchorServiceAuth(url, ceramic, diagnosticsLogger) - anchorService = new eas.AuthenticatedEthereumAnchorService( - auth, - url, - diagnosticsLogger, - 100 - ) + 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 From 08d2bf3f3243a481da2d13e0fce1c17db501940e Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 14:48:49 -0500 Subject: [PATCH 09/16] lint --- .../ethereum-did-auth-anchor-service.retry-failure.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 31c83ca1e6..28c59fee08 100644 --- 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 @@ -50,7 +50,7 @@ test('re-request an anchor till get a response', async () => { const { createDidAnchorServiceAuth } = await import('../../../__tests__/create-did-anchor-service-auth.js') const loggerProvider = new common.LoggerProvider() const diagnosticsLogger = loggerProvider.getDiagnosticsLogger() - let errSpy = jest.spyOn(diagnosticsLogger, 'err') + const errSpy = jest.spyOn(diagnosticsLogger, 'err') const url = 'http://example.com' ipfs = await createIPFS() From 6451c316bc450cfadc0dbbd9f0bdb12f1027f9ab Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 15:14:38 -0500 Subject: [PATCH 10/16] lint --- packages/core/src/anchor/auth/did-anchor-service-auth.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/src/anchor/auth/did-anchor-service-auth.ts b/packages/core/src/anchor/auth/did-anchor-service-auth.ts index 566fb033c5..3a4c89b7a6 100644 --- a/packages/core/src/anchor/auth/did-anchor-service-auth.ts +++ b/packages/core/src/anchor/auth/did-anchor-service-auth.ts @@ -45,16 +45,15 @@ export class DIDAnchorServiceAuth implements AnchorServiceAuth { } let nonce const { authorization } = await this.signRequest(this.nonceApiEndpoint) - let headers: any = { authorization } const data = await this._sendRequest(this.nonceApiEndpoint, { method: HttpMethods.POST, - headers, + headers: { authorization }, body: {did: this._ceramic.did.id} }) if (data) { if (data.nonce) { nonce = Number(data.nonce) - if (nonce != NaN) { + if (!isNaN(nonce)) { return nonce } } From d7d32c0d0d04297ed47565ac11096a5d9485a6a5 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 16:02:26 -0500 Subject: [PATCH 11/16] fix: add did to cas url --- .../__tests__/did-anchor-service-auth.test.ts | 16 +++++----- .../anchor/auth/did-anchor-service-auth.ts | 29 +++++++++++-------- 2 files changed, 25 insertions(+), 20 deletions(-) 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 index 126c8c0bcf..2cfad03179 100644 --- 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 @@ -8,8 +8,8 @@ const mockedUrls = { } const mockedCalls = { - 'NONCE_OFFLINE': { response: { error: 'failed' } }, - 'NONCE_ONLINE': { response: { nonce: 5 } }, + NONCE_OFFLINE: { response: { error: 'failed' } }, + NONCE_ONLINE: { response: { nonce: 5 } }, } jest.unstable_mockModule('cross-fetch', () => { @@ -17,9 +17,9 @@ jest.unstable_mockModule('cross-fetch', () => { ok: true, json: async () => { if (url.startsWith(mockedUrls.OFFLINE)) { - return mockedCalls.NONCE_OFFLINE + return mockedCalls.NONCE_OFFLINE.response } else { - return mockedCalls.NONCE_ONLINE + return mockedCalls.NONCE_ONLINE.response } }, })) @@ -111,18 +111,18 @@ describe('sendAuthenticatedRequest', () => { const { auth } = await setupAuth(mockedUrls.ONLINE) await auth.init() expect(auth.nonce).toEqual(5) - await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(auth.ceramic.did.id)) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(ceramic.did.id)) expect(auth.nonce).toEqual(6) - await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(auth.ceramic.did.id)) + 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(auth.ceramic.did.id)) + await auth.sendAuthenticatedRequest(mockedUrls.nonceOffline(ceramic.did.id)) expect(auth.nonce).toEqual(6) - await auth.sendAuthenticatedRequest(mockedUrls.nonceOnline(auth.ceramic.did.id)) + 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 index 3a4c89b7a6..2a0b2d13ec 100644 --- a/packages/core/src/anchor/auth/did-anchor-service-auth.ts +++ b/packages/core/src/anchor/auth/did-anchor-service-auth.ts @@ -11,14 +11,14 @@ import { DagJWS } from "dids" export class DIDAnchorServiceAuth implements AnchorServiceAuth { private _ceramic: CeramicApi private _nonce: number - private readonly nonceApiEndpoint: string + private readonly _anchorServiceUrl: string private readonly _logger: DiagnosticsLogger constructor( - readonly anchorServiceUrl: string, + anchorServiceUrl: string, logger: DiagnosticsLogger, ) { - this.nonceApiEndpoint = this.anchorServiceUrl + '/api/v0/auth/nonce' + this._anchorServiceUrl = anchorServiceUrl this._logger = logger } @@ -44,8 +44,8 @@ export class DIDAnchorServiceAuth implements AnchorServiceAuth { throw new Error('Missing Ceramic instance required by this auth method') } let nonce - const { authorization } = await this.signRequest(this.nonceApiEndpoint) - const data = await this._sendRequest(this.nonceApiEndpoint, { + 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} @@ -73,13 +73,6 @@ export class DIDAnchorServiceAuth implements AnchorServiceAuth { return await this._sendRequest(url, { ...opts, headers}) } - /** - * Increments nonce in memory. - */ - private _updateNonce = async (): Promise => { - this._nonce = this._nonce + 1 - } - signRequest = async (url: URL | string, body?: any, nonce?: number): Promise<{jws: DagJWS, authorization: string}> => { let payload: any = { url } body && (payload = { ...payload, body}) @@ -89,6 +82,13 @@ export class DIDAnchorServiceAuth implements AnchorServiceAuth { 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) { @@ -97,4 +97,9 @@ export class DIDAnchorServiceAuth implements AnchorServiceAuth { } return data } + + private _getNonceEndpoint = (): string => { + return this._anchorServiceUrl + `/api/v0/auth/did/${this._ceramic.did.id}/nonce` + } + } From f285bb6e88616139ce919f03ddbf97a6237b7ad3 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Thu, 8 Dec 2022 20:24:32 -0500 Subject: [PATCH 12/16] fix: uncomment auth config --- .../core/src/anchor/ethereum/ethereum-anchor-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts b/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts index 727bb111e1..59d43ec8ba 100644 --- a/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts +++ b/packages/core/src/anchor/ethereum/ethereum-anchor-service.ts @@ -246,7 +246,7 @@ export class AuthenticatedEthereumAnchorService extends EthereumAnchorService im pollInterval: number = DEFAULT_POLL_INTERVAL, ) { super(anchorServiceUrl, logger, pollInterval, auth.sendAuthenticatedRequest) - // this.auth = auth + this.auth = auth } /** @@ -255,11 +255,11 @@ export class AuthenticatedEthereumAnchorService extends EthereumAnchorService im * @param ceramic - Ceramic API used for various purposes */ set ceramic(ceramic: CeramicApi) { - // this.auth.ceramic = ceramic + this.auth.ceramic = ceramic } async init(): Promise { - // await this.auth.init() + await this.auth.init() await super.init() } } From a2954dd2ae919b86bc1f8be904b56e816f13caad Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Mon, 12 Dec 2022 15:14:22 -0500 Subject: [PATCH 13/16] fix: increase timeout for build indexing test --- packages/core/src/indexing/__tests__/build-indexing.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 From 72ace16a4f5d2b5ac232e80ca319fa6924b0c988 Mon Sep 17 00:00:00 2001 From: v-stickykeys <8445610+v-stickykeys@users.noreply.github.com> Date: Mon, 12 Dec 2022 20:03:39 -0500 Subject: [PATCH 14/16] feat: remove didSeed getter and did --- .../cli/src/__tests__/daemon-config.test.ts | 39 ++++++++++++++++++- packages/cli/src/ceramic-cli-utils.ts | 3 -- packages/cli/src/ceramic-daemon.ts | 2 +- packages/cli/src/daemon-config.ts | 27 +++++++++---- 4 files changed, 58 insertions(+), 13 deletions(-) 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 816e35d7b2..d2ac1e6858 100644 --- a/packages/cli/src/ceramic-cli-utils.ts +++ b/packages/cli/src/ceramic-cli-utils.ts @@ -40,7 +40,6 @@ const DEFAULT_INDEXING_DB_FILENAME = new URL('./indexing.sqlite', DEFAULT_CONFIG */ const generateDefaultDaemonConfig = () => { const didSeed = generateSeedUrl() - const did = makeNodeDID(parseSeedUrl(didSeed)).id return DaemonConfig.fromObject({ anchor: { @@ -54,7 +53,6 @@ const generateDefaultDaemonConfig = () => { }, network: { name: Networks.TESTNET_CLAY }, node: { - 'did': did, 'did-seed': didSeed }, 'state-store': { @@ -141,7 +139,6 @@ export class CeramicCliUtils { 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) - config.node.did = makeNodeDID(parseSeedUrl(config.node.didSeed)).id } { diff --git a/packages/cli/src/ceramic-daemon.ts b/packages/cli/src/ceramic-daemon.ts index 295ba99bc7..29b093faf2 100644 --- a/packages/cli/src/ceramic-daemon.ts +++ b/packages/cli/src/ceramic-daemon.ts @@ -299,7 +299,7 @@ export class CeramicDaemon { const s3Store = new S3Store(opts.stateStore?.s3Bucket, params.networkOptions.name) await ceramic.repository.injectStateStore(s3Store) } - const provider = makeNodeDIDProvider(parseSeedUrl(opts.node.didSeed)) + 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 53e1365cbd..bff1634d10 100644 --- a/packages/cli/src/daemon-config.ts +++ b/packages/cli/src/daemon-config.ts @@ -267,19 +267,29 @@ export class DaemonDidResolversConfig { @jsonObject @toJson export class DaemonCeramicNodeConfig { + + private _didSeed: URL; + /** - * DID used to sign requests to CAS. This is automatically derived by the `did-seed`. + * Disallows public access to did-seed because it is a sensitive field. */ - @jsonMember(String) - did: string + @jsonMember(String, { name: 'did-seed' }) + public get didSeed(): any { + return undefined; + } + + public sensitive_didSeed(): URL { + return this._didSeed + } /** - * Seed used to sign requests to CAS. - * This is randomly generated if a config file is not found. + * 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'. */ - @jsonMember(String, { name: 'did-seed' }) - didSeed: URL + public set didSeed(value: URL) { + this._didSeed = value + } /** * Whether to run the Ceramic node in read-only gateway mode. @@ -434,7 +444,8 @@ export class DaemonConfig { 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.didSeed) throw Error('Daemon config is missing node.did-seed') + 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 } From 87648cc4936bb6625b2e39f6418341fb9ccd507e Mon Sep 17 00:00:00 2001 From: Golda Velez Date: Fri, 16 Dec 2022 14:39:14 -0700 Subject: [PATCH 15/16] address linter errors & warnings in the edited files --- packages/cli/src/ceramic-cli-utils.ts | 2 +- packages/cli/src/ceramic-daemon.ts | 2 +- packages/cli/src/daemon-config.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ceramic-cli-utils.ts b/packages/cli/src/ceramic-cli-utils.ts index d2ac1e6858..43732117d0 100644 --- a/packages/cli/src/ceramic-cli-utils.ts +++ b/packages/cli/src/ceramic-cli-utils.ts @@ -20,7 +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 { generateSeed, generateSeedUrl, makeNodeDID, parseSeed, parseSeedUrl } from './daemon/did-utils.js' +import { generateSeedUrl } from './daemon/did-utils.js' const HOMEDIR = new URL(`file://${os.homedir()}/`) const CWD = new URL(`file://${process.cwd()}/`) diff --git a/packages/cli/src/ceramic-daemon.ts b/packages/cli/src/ceramic-daemon.ts index 29b093faf2..34dd7530a1 100644 --- a/packages/cli/src/ceramic-daemon.ts +++ b/packages/cli/src/ceramic-daemon.ts @@ -31,7 +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, parseSeed, parseSeedUrl } from './daemon/did-utils.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 diff --git a/packages/cli/src/daemon-config.ts b/packages/cli/src/daemon-config.ts index bff1634d10..a26a8b0586 100644 --- a/packages/cli/src/daemon-config.ts +++ b/packages/cli/src/daemon-config.ts @@ -278,10 +278,6 @@ export class DaemonCeramicNodeConfig { return undefined; } - public sensitive_didSeed(): URL { - return this._didSeed - } - /** * Setter for seed used to sign requests to CAS. * A seed is randomly generated if a config file is not found. @@ -291,6 +287,10 @@ export class DaemonCeramicNodeConfig { this._didSeed = value } + public sensitive_didSeed(): URL { + return this._didSeed + } + /** * Whether to run the Ceramic node in read-only gateway mode. */ From 5b3599b778123e1ff3e1c854f6ddd2e9389963da Mon Sep 17 00:00:00 2001 From: Golda Velez Date: Fri, 16 Dec 2022 16:09:17 -0700 Subject: [PATCH 16/16] increase timeout to 40 sec - do we want to do this? --- .../anchor/auth/__tests__/did-anchor-service-auth.test.ts | 6 +++--- packages/core/src/state-management/state-manager.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 2cfad03179..0dc93a87cc 100644 --- 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 @@ -53,7 +53,7 @@ const setupAuth = async (url): Promise => { } describe('init', () => { - jest.setTimeout(20000) + jest.setTimeout(40000) test('initializes nonce to 0 if not retrieved by CAS', async () => { const { auth } = await setupAuth(mockedUrls.OFFLINE) @@ -68,7 +68,7 @@ describe('init', () => { }) describe('lookupLastNonce', () => { - jest.setTimeout(20000) + jest.setTimeout(40000) test('creates a signed payload without nonce for the `authorization` header', async () => { const { auth } = await setupAuth(mockedUrls.ONLINE) await auth.init() @@ -89,7 +89,7 @@ describe('lookupLastNonce', () => { }) describe('sendAuthenticatedRequest', () => { - jest.setTimeout(20000) + jest.setTimeout(40000) test('sends request with signed payload in `authorization` header', async () => { const { auth } = await setupAuth(mockedUrls.ONLINE) await auth.init() 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'