Skip to content
Merged
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
313 changes: 195 additions & 118 deletions docs/API.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions docs/PolicyServer.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,22 @@ Called whenever a new decrypt command is received by Ocean Node
"policyServer": {}
}
```

## Passthrough and initialization authentication

`POST /api/services/PolicyServerPassthrough` lets a caller send an arbitrary payload
straight to the PolicyServer without authentication. Ocean Node adds the resolved `ddo`
and its own `nodeAddress`, but it does not verify or inject caller identity. Every field in
the passthrough payload, including `action` and `consumerAddress`, must therefore be treated
as untrusted input.

`POST /api/services/initializePSVerification` remains authenticated. The caller supplies
either an `Authorization` header or a `nonce` + `signature` pair together with
`consumerAddress`. Its signed message is
`consumerAddress + nonce + "PolicyServerInitialize"`, and its credentials are forwarded
inside the `policyServer` object.

> **A caller controls `action`.** A passthrough payload can claim `"action": "download"` or
> `"action": "startCompute"` and look much like the ones Ocean Node itself sends for those
> commands. Neither the action nor caller identity is verified — do not grant a
> passthrough request the same authority as a node-initiated one.
2 changes: 2 additions & 0 deletions src/@types/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ export interface PolicyServerInitializeCommand extends Command {
serviceId?: string
consumerAddress?: string
policyServer?: any
nonce?: string
signature?: string
}

export interface CreateAuthTokenCommand extends Command {
Expand Down
9 changes: 8 additions & 1 deletion src/components/core/handler/coreHandlersRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
} from './ddoHandler.js'
import { DownloadHandler } from './downloadHandler.js'
import { FileInfoHandler } from './fileInfoHandler.js'
import { PolicyServerPassthroughHandler } from './policyServer.js'
import {
PolicyServerPassthroughHandler,
PolicyServerInitializeHandler
} from './policyServer.js'
import { EncryptHandler, EncryptFileHandler } from './encryptHandler.js'
import { FeesHandler } from './feesHandler.js'
import { BaseHandler, CommandHandler } from './handler.js'
Expand Down Expand Up @@ -129,6 +132,10 @@ export class CoreHandlersRegistry {
PROTOCOL_COMMANDS.POLICY_SERVER_PASSTHROUGH,
new PolicyServerPassthroughHandler(node)
)
this.registerCoreHandler(
PROTOCOL_COMMANDS.POLICY_SERVER_INITIALIZE,
new PolicyServerInitializeHandler(node)
)

this.registerCoreHandler(PROTOCOL_COMMANDS.VALIDATE_DDO, new ValidateDDOHandler(node))
this.registerCoreHandler(
Expand Down
42 changes: 37 additions & 5 deletions src/components/core/handler/policyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
PolicyServerInitializeCommand
} from '../../../@types/commands.js'
import { Readable } from 'stream'
import { isAddress } from 'ethers'
import { CommandHandler } from './handler.js'
import {
ValidateParams,
Expand All @@ -20,8 +21,16 @@ export class PolicyServerPassthroughHandler extends CommandHandler {
return buildInvalidRequestMessage(
'Invalid Request: missing policyServerPassthrough field!'
)
const validation = validateCommandParameters(command, []) // all optional? weird
return validation
// we inject fields into this object below, so it has to be a keyed object. arrays are
// objects too, and would be forwarded as {"0":..,"1":..} with no action
if (
typeof command.policyServerPassthrough !== 'object' ||
Array.isArray(command.policyServerPassthrough)
)
return buildInvalidRequestMessage(
'Invalid Request: "policyServerPassthrough" must be an object!'
)
return validateCommandParameters(command, [])
}

async handle(task: PolicyServerPassthroughCommand): Promise<P2PCommandResponse> {
Expand Down Expand Up @@ -71,7 +80,12 @@ export class PolicyServerInitializeHandler extends CommandHandler {
'documentId',
'serviceId',
'consumerAddress'
]) // all optional? weird
])
if (validation.valid && !isAddress(command.consumerAddress)) {
return buildInvalidRequestMessage(
'Parameter : "consumerAddress" is not a valid web3 address'
)
}
return validation
}

Expand All @@ -80,6 +94,17 @@ export class PolicyServerInitializeHandler extends CommandHandler {
if (this.shouldDenyTaskHandling(validationResponse)) {
return validationResponse
}
// same auth contract as startCompute: an authorization token, or nonce + signature
const authValidationResponse = await this.validateTokenOrSignature(
task.authorization,
task.consumerAddress,
task.nonce,
task.signature,
task.command
)
if (authValidationResponse.status.httpStatus !== 200) {
return authValidationResponse
}
// resolve DDO first
try {
const database = await this.getOceanNode().getDatabase()
Expand All @@ -98,12 +123,19 @@ export class PolicyServerInitializeHandler extends CommandHandler {
}
// policyServer check
const policyServer = new PolicyServer()
// forward the address this node actually verified, plus the caller credentials,
// so the policy server can run its own additional checks
const policyStatus = await policyServer.initializePSVerification(
task.documentId,
ddo,
task.serviceId,
task.consumerAddress,
task.policyServer
authValidationResponse.consumerAddress,
{
...task.policyServer,
authorization: task.authorization,
nonce: task.nonce,
signature: task.signature
}
)
if (!policyStatus.success) {
return {
Expand Down
5 changes: 4 additions & 1 deletion src/components/httpRoutes/policyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,14 @@ PolicyServerPassthroughRoute.post(
)
try {
const response = await new PolicyServerInitializeHandler(req.oceanNode).handle({
command: PROTOCOL_COMMANDS.POLICY_SERVER_PASSTHROUGH,
command: PROTOCOL_COMMANDS.POLICY_SERVER_INITIALIZE,
documentId: req.body.documentId,
serviceId: req.body.serviceId,
consumerAddress: req.body.consumerAddress,
policyServer: req.body.policyServer,
nonce: req.body.nonce,
signature: req.body.signature,
authorization: req.headers?.authorization,
caller: req.caller
})
if (response.stream) {
Expand Down
73 changes: 73 additions & 0 deletions src/components/httpRoutes/validateCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PROTOCOL_COMMANDS, SUPPORTED_PROTOCOL_COMMANDS } from '../../utils/cons
import { P2PCommandResponse } from '../../@types/OceanNode.js'
import { Command } from '../../@types/commands.js'
import { CORE_LOGGER } from '../../utils/logging/common.js'
import { isDefined } from '../../utils/util.js'
import { ReadableString } from '../P2P/handlers.js'

export type ValidateParams = {
Expand All @@ -10,6 +11,69 @@ export type ValidateParams = {
status?: number
}

// credentials present on (almost) any command. these must never reach the logs, on any
// command, since they are what authorizes the request in the first place
const SENSITIVE_COMMAND_FIELDS = [
'authorization', // auth token (JWT), usually taken from the Authorization header
'signature', // consumer signature authorizing this command
'aes_encrypted_key', // download: encrypted key material
'encryptedDockerRegistryAuth' // compute: encrypted docker registry credentials
]

// "token" is an ERC20 address on most commands (escrow, compute payment) and only a
// credential on the auth-token commands, so it is redacted per-command instead
const SENSITIVE_TOKEN_COMMANDS: string[] = [
PROTOCOL_COMMANDS.INVALIDATE_AUTH_TOKEN,
PROTOCOL_COMMANDS.VALIDATE_AUTH_TOKEN
]

const REDACTED = '[REDACTED]'

/**
* Returns a copy of the payload with every credential field redacted, at any depth.
*
* This must not mutate its input: the clone the caller hands us can be a *shallow* copy
* (the fallback path below), so nested objects are still shared with the real command and
* the handlers still need the actual credentials. So we rebuild containers instead of
* writing into them.
*
* Only plain objects and arrays are walked - Buffers, typed arrays, Dates, streams and the
* like are passed through untouched.
*/
function redactSensitiveFields(
value: any,
redactToken: boolean,
seen: WeakSet<object>
): any {
if (value === null || typeof value !== 'object') {
return value
}
if (seen.has(value)) {
return '[CIRCULAR]'
}
if (Array.isArray(value)) {
seen.add(value)
const copy = value.map((item) => redactSensitiveFields(item, redactToken, seen))
seen.delete(value)
return copy
}
const proto = Object.getPrototypeOf(value)
if (proto !== Object.prototype && proto !== null) {
return value
}
seen.add(value)
const copy: any = {}
for (const [key, item] of Object.entries(value)) {
if (SENSITIVE_COMMAND_FIELDS.includes(key) || (redactToken && key === 'token')) {
copy[key] = isDefined(item) ? REDACTED : item
} else {
copy[key] = redactSensitiveFields(item, redactToken, seen)
}
}
seen.delete(value)
return copy
}

// add others when we add suppor

// request level validation, just check if we have a "command" field and its a supported one
Expand Down Expand Up @@ -56,6 +120,15 @@ export function validateCommandParameters(
logCommandData.rawData = []
}

// never log the caller's credentials, whatever the command is. credentials also show up
// nested (the free-form "policyServer" / "policyServerPassthrough" blobs), so this walks
// the whole payload
logCommandData = redactSensitiveFields(
logCommandData,
SENSITIVE_TOKEN_COMMANDS.includes(commandStr),
new WeakSet()
)

CORE_LOGGER.info(
`Checking received command data for Command "${commandStr}": ${JSON.stringify(
logCommandData,
Expand Down
Loading
Loading