Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion packages/cli/src/__tests__/daemon-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -73,10 +89,31 @@ 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)
expect(read.logger.logDirectory).toEqual('/log-dir/')
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'])
})
})
66 changes: 44 additions & 22 deletions packages/cli/src/ceramic-cli-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()}/`)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<DaemonConfig> {
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)
}
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ceramic-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/daemon-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -412,6 +443,9 @@ export class DaemonConfig {
static async fromFile(filepath: URL): Promise<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) 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
}
Expand Down
49 changes: 49 additions & 0 deletions packages/cli/src/daemon/did-utils.ts
Original file line number Diff line number Diff line change
@@ -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:<scheme>#<seed>`.
* 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')
}
35 changes: 35 additions & 0 deletions packages/common/src/anchor-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,6 +90,36 @@ export interface AnchorService {
getSupportedChains(): Promise<Array<string>>
}

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<void>

/**
* 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
*/
Expand Down
9 changes: 8 additions & 1 deletion packages/common/src/utils/http-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,21 @@ 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
timeout?: number
signal?: AbortSignal
}

export type FetchJson = (url: URL | string, opts?: FetchOpts) => Promise<any>

export enum HttpMethods {
GET = 'GET',
POST = 'POST'
}

export async function fetchJson(url: URL | string, opts: FetchOpts = {}): Promise<any> {
if (opts.body) {
Object.assign(opts, {
Expand Down
Loading