Skip to content

Commit e25a483

Browse files
authored
Check wallet signature (#947)
* check wallet signature * lint fix * configurable ddo validate env * add tests
1 parent f2e3034 commit e25a483

9 files changed

Lines changed: 176 additions & 34 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export MAX_REQ_PER_MINUTE=
3737
export MAX_CHECKSUM_LENGTH=
3838
export LOG_LEVEL=
3939
export HTTP_API_PORT=
40+
export VALIDATE_UNSIGNED_DDO=
4041

4142
## p2p
4243

docs/env.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Environmental variables are also tracked in `ENVIRONMENT_VARIABLES` within `src/
3232
- `IS_BOOTSTRAP`: Is this node to be used as bootstrap node or not. Default is `false`.
3333
- `AUTHORIZED_PUBLISHERS`: Authorized list of publishers. If present, Node will only index assets published by the accounts in the list. Example: `"[\"0x967da4048cD07aB37855c090aAF366e4ce1b9F48\",\"0x388C818CA8B9251b393131C08a736A67ccB19297\"]"`
3434
- `AUTHORIZED_PUBLISHERS_LIST`: AccessList contract addresses (per chain). If present, Node will only index assets published by the accounts present on the given access lists. Example: `"{ \"8996\": [\"0x967da4048cD07aB37855c090aAF366e4ce1b9F48\",\"0x388C818CA8B9251b393131C08a736A67ccB19297\"] }"`
35+
- `VALIDATE_UNSIGNED_DDO`: If set to `false`, the node will not validate unsigned DDOs and will request a signed message with the publisher address, nonce and signature. Default is `true`. Example: `false`
3536

3637
## Payments
3738

src/@types/OceanNode.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export interface OceanNodeConfig {
112112
denyList?: DenyList
113113
unsafeURLs?: string[]
114114
isBootstrap?: boolean
115+
validateUnsignedDDO?: boolean
115116
}
116117

117118
export interface P2PStatusResponse {

src/@types/commands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export interface FindDDOCommand extends DDOCommand {
7979
// https://github.com/oceanprotocol/ocean-node/issues/47
8080
export interface ValidateDDOCommand extends Command {
8181
ddo: DDO
82+
publisherAddress?: string
83+
nonce?: string
84+
signature?: string
8285
}
8386

8487
export interface StatusCommand extends Command {

src/components/core/handler/ddoHandler.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -396,17 +396,9 @@ export class DecryptDdoHandler extends CommandHandler {
396396
['bytes'],
397397
[ethers.hexlify(ethers.toUtf8Bytes(message))]
398398
)
399-
const addressFromHashSignature = ethers.verifyMessage(messageHash, task.signature)
400-
const messageHashBytes = ethers.toBeArray(messageHash)
401-
const addressFromBytesSignature = ethers.verifyMessage(
402-
messageHashBytes,
403-
task.signature
404-
)
399+
const addressFromSignature = ethers.verifyMessage(messageHash, task.signature)
405400

406-
if (
407-
addressFromHashSignature?.toLowerCase() !== decrypterAddress?.toLowerCase() &&
408-
addressFromBytesSignature?.toLowerCase() !== decrypterAddress?.toLowerCase()
409-
) {
401+
if (addressFromSignature?.toLowerCase() !== decrypterAddress?.toLowerCase()) {
410402
throw new Error('address does not match')
411403
}
412404
} catch (error) {
@@ -809,6 +801,7 @@ export class ValidateDDOHandler extends CommandHandler {
809801
}
810802

811803
async handle(task: ValidateDDOCommand): Promise<P2PCommandResponse> {
804+
const configuration = await getConfiguration()
812805
const validationResponse = await this.verifyParamsAndRateLimits(task)
813806
if (this.shouldDenyTaskHandling(validationResponse)) {
814807
return validationResponse
@@ -817,6 +810,35 @@ export class ValidateDDOHandler extends CommandHandler {
817810
const ddoInstance = DDOManager.getDDOClass(task.ddo)
818811
const validation = await ddoInstance.validate()
819812

813+
const { ddo, publisherAddress, nonce, signature: signatureFromRequest } = task
814+
if (configuration.validateUnsignedDDO === false) {
815+
if (!publisherAddress || !nonce || !signatureFromRequest) {
816+
return {
817+
stream: null,
818+
status: {
819+
httpStatus: 400,
820+
error:
821+
'A signature is required to validate a DDO, please provide a signed message with the publisher address, nonce and signature'
822+
}
823+
}
824+
}
825+
}
826+
827+
if (publisherAddress && nonce && signatureFromRequest) {
828+
const isValid = validateDdoSignedByPublisher(
829+
ddo,
830+
nonce,
831+
signatureFromRequest,
832+
publisherAddress
833+
)
834+
if (!isValid) {
835+
return {
836+
stream: null,
837+
status: { httpStatus: 400, error: 'Invalid signature' }
838+
}
839+
}
840+
}
841+
820842
if (validation[0] === false) {
821843
CORE_LOGGER.logMessageWithEmoji(
822844
`Validation failed with error: ${validation[1]}`,
@@ -849,6 +871,27 @@ export class ValidateDDOHandler extends CommandHandler {
849871
}
850872
}
851873

874+
export function validateDdoSignedByPublisher(
875+
ddo: DDO,
876+
nonce: string,
877+
signature: string,
878+
publisherAddress: string
879+
): boolean {
880+
try {
881+
const message = ddo.id + nonce
882+
const messageHash = ethers.solidityPackedKeccak256(
883+
['bytes'],
884+
[ethers.hexlify(ethers.toUtf8Bytes(message))]
885+
)
886+
const messageHashBytes = ethers.toBeArray(messageHash)
887+
const recoveredAddress = ethers.verifyMessage(messageHashBytes, signature)
888+
return recoveredAddress === publisherAddress
889+
} catch (error) {
890+
CORE_LOGGER.logMessage(`Error: ${error}`, true)
891+
return false
892+
}
893+
}
894+
852895
export function validateDDOIdentifier(identifier: string): ValidateParams {
853896
const valid = identifier && identifier.length > 0 && identifier.startsWith('did:op')
854897
if (!valid) {

src/components/httpRoutes/aquarius.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { QueryCommand } from '../../@types/commands.js'
1010
import { DatabaseFactory } from '../database/DatabaseFactory.js'
1111
import { SearchQuery } from '../../@types/DDO/SearchQuery.js'
1212
import { getConfiguration } from '../../utils/index.js'
13-
import { DDO } from '@oceanprotocol/ddo-js'
1413

1514
export const aquariusRoutes = express.Router()
1615

@@ -134,23 +133,32 @@ aquariusRoutes.get(`${AQUARIUS_API_BASE_PATH}/state/ddo`, async (req, res) => {
134133
})
135134

136135
aquariusRoutes.post(`${AQUARIUS_API_BASE_PATH}/assets/ddo/validate`, async (req, res) => {
136+
const node = req.oceanNode
137137
try {
138-
if (!req.body || req.body === undefined) {
138+
if (!req.body) {
139139
res.status(400).send('Missing DDO object')
140140
return
141141
}
142-
const ddo = JSON.parse(req.body) as DDO
142+
143+
const requestBody = JSON.parse(req.body)
144+
const { publisherAddress, nonce, signature } = requestBody
145+
146+
// This is for backward compatibility with the old way of sending the DDO
147+
const ddo = requestBody.ddo || JSON.parse(req.body)
143148

144149
if (!ddo.version) {
145150
res.status(400).send('Missing DDO version')
146151
return
147152
}
148153

149-
const node = req.oceanNode
150154
const result = await new ValidateDDOHandler(node).handle({
151155
ddo,
156+
publisherAddress,
157+
nonce,
158+
signature,
152159
command: PROTOCOL_COMMANDS.VALIDATE_DDO
153160
})
161+
154162
if (result.stream) {
155163
const validationResult = JSON.parse(await streamToString(result.stream as Readable))
156164
res.json(validationResult)

src/test/unit/indexer/validation.test.ts

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,44 @@
11
import { DDOExample, ddov5, ddov7, ddoValidationSignature } from '../../data/ddo.js'
22
import { getValidationSignature } from '../../../components/core/utils/validateDdoHandler.js'
33
import { ENVIRONMENT_VARIABLES } from '../../../utils/index.js'
4-
54
import { expect } from 'chai'
65
import {
76
setupEnvironment,
87
tearDownEnvironment,
98
buildEnvOverrideConfig,
10-
OverrideEnvConfig
9+
OverrideEnvConfig,
10+
getMockSupportedNetworks
1111
} from '../../utils/utils.js'
12-
import { DDOManager } from '@oceanprotocol/ddo-js'
12+
import { DDOManager, DDO } from '@oceanprotocol/ddo-js'
13+
import { ValidateDDOHandler } from '../../../components/core/handler/ddoHandler.js'
14+
import { OceanNode } from '../../../OceanNode.js'
15+
import { PROTOCOL_COMMANDS } from '../../../utils/constants.js'
16+
import { ethers } from 'ethers'
17+
import { RPCS } from '../../../@types/blockchain.js'
1318

14-
describe('Schema validation tests', async () => {
19+
describe('Schema validation tests', () => {
1520
let envOverrides: OverrideEnvConfig[]
16-
envOverrides = buildEnvOverrideConfig(
17-
[
18-
ENVIRONMENT_VARIABLES.PRIVATE_KEY,
19-
ENVIRONMENT_VARIABLES.IPFS_GATEWAY,
20-
ENVIRONMENT_VARIABLES.ARWEAVE_GATEWAY,
21-
ENVIRONMENT_VARIABLES.RPCS
22-
],
23-
[
24-
'0xc594c6e5def4bab63ac29eed19a134c130388f74f019bc74b8f4389df2837a58',
25-
'https://ipfs.io/',
26-
'https://arweave.net/',
27-
'{ "1": "https://rpc.eth.gateway.fm", "137": "https://polygon.meowrpc.com" }'
28-
]
29-
)
30-
envOverrides = await setupEnvironment(null, envOverrides)
21+
const mockSupportedNetworks: RPCS = getMockSupportedNetworks()
22+
23+
before(async () => {
24+
envOverrides = buildEnvOverrideConfig(
25+
[
26+
ENVIRONMENT_VARIABLES.VALIDATE_UNSIGNED_DDO,
27+
ENVIRONMENT_VARIABLES.PRIVATE_KEY,
28+
ENVIRONMENT_VARIABLES.IPFS_GATEWAY,
29+
ENVIRONMENT_VARIABLES.ARWEAVE_GATEWAY,
30+
ENVIRONMENT_VARIABLES.RPCS
31+
],
32+
[
33+
'false',
34+
'0xc594c6e5def4bab63ac29eed19a134c130388f74f019bc74b8f4389df2837a58',
35+
'https://ipfs.io/',
36+
'https://arweave.net/',
37+
JSON.stringify(mockSupportedNetworks)
38+
]
39+
)
40+
envOverrides = await setupEnvironment(null, envOverrides)
41+
})
3142

3243
after(() => {
3344
// Restore original local setup / env variables after test
@@ -40,6 +51,7 @@ describe('Schema validation tests', async () => {
4051
expect(validationResult[0]).to.eql(true)
4152
expect(validationResult[1]).to.eql({})
4253
})
54+
4355
it('should not pass due to invalid metadata.created on version 4.1.0', async () => {
4456
const copy = JSON.parse(JSON.stringify(DDOExample))
4557
copy['@context'] = ['https://w3id.org/did/v1']
@@ -49,12 +61,14 @@ describe('Schema validation tests', async () => {
4961
expect(validationResult[0]).to.eql(false)
5062
})
5163
// TO DO after fixing regex for created & updated: it('should not pass due to invalid ISO timestamp on version 4.1.0', async () => {
64+
5265
it('4.5.0 should pass the validation without service', async () => {
5366
const ddoInstance = DDOManager.getDDOClass(ddov5)
5467
const validationResult = await ddoInstance.validate()
5568
expect(validationResult[0]).to.eql(true)
5669
expect(validationResult[1]).to.eql({})
5770
})
71+
5872
it('should pass the validation and return signature', async () => {
5973
const ddoInstance = DDOManager.getDDOClass(ddoValidationSignature)
6074
const validationResult = await ddoInstance.validate()
@@ -88,4 +102,69 @@ describe('Schema validation tests', async () => {
88102
expect(validationResult[0]).to.eql(true)
89103
expect(validationResult[1]).to.eql({})
90104
})
105+
106+
it('should fail validation when signature is missing', async () => {
107+
const node = OceanNode.getInstance()
108+
const handler = new ValidateDDOHandler(node)
109+
const ddoInstance = DDOManager.getDDOClass(DDOExample)
110+
const task = {
111+
ddo: ddoInstance.getDDOData() as DDO,
112+
publisherAddress: '0x8F292046bb73595A978F4e7A131b4EBd03A15e8a',
113+
nonce: '123456',
114+
command: PROTOCOL_COMMANDS.VALIDATE_DDO
115+
}
116+
117+
const result = await handler.handle(task)
118+
expect(result.status.httpStatus).to.equal(400)
119+
})
120+
121+
it('should fail validation when signature is invalid', async () => {
122+
const node = OceanNode.getInstance()
123+
const handler = new ValidateDDOHandler(node)
124+
const ddoInstance = DDOManager.getDDOClass(DDOExample)
125+
const ddo: DDO = {
126+
...(ddoInstance.getDDOData() as DDO)
127+
}
128+
const task = {
129+
ddo,
130+
publisherAddress: '0x8F292046bb73595A978F4e7A131b4EBd03A15e8a',
131+
nonce: '123456',
132+
signature: '0xInvalidSignature',
133+
command: PROTOCOL_COMMANDS.VALIDATE_DDO
134+
}
135+
136+
const result = await handler.handle(task)
137+
138+
expect(result.status.httpStatus).to.equal(400)
139+
})
140+
141+
it('should pass validation with valid signature', async () => {
142+
const node = OceanNode.getInstance()
143+
const handler = new ValidateDDOHandler(node)
144+
const ddoInstance = DDOManager.getDDOClass(ddoValidationSignature)
145+
const ddo = ddoInstance.getDDOData() as DDO
146+
147+
const privateKey = process.env.PRIVATE_KEY
148+
const wallet = new ethers.Wallet(privateKey)
149+
const nonce = Date.now().toString()
150+
const message = ddo.id + nonce
151+
const messageHash = ethers.solidityPackedKeccak256(
152+
['bytes'],
153+
[ethers.hexlify(ethers.toUtf8Bytes(message))]
154+
)
155+
const messageHashBytes = ethers.getBytes(messageHash)
156+
const signature = await wallet.signMessage(messageHashBytes)
157+
158+
const task = {
159+
ddo,
160+
publisherAddress: await wallet.getAddress(),
161+
nonce,
162+
signature,
163+
command: PROTOCOL_COMMANDS.VALIDATE_DDO
164+
}
165+
166+
const result = await handler.handle(task)
167+
168+
expect(result.status.httpStatus).to.equal(200)
169+
})
91170
})

src/utils/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,8 @@ async function getEnvConfig(isStartup?: boolean): Promise<OceanNodeConfig> {
831831
knownUnsafeURLs
832832
),
833833
isBootstrap: getBoolEnvValue('IS_BOOTSTRAP', false),
834-
claimDurationTimeout: getIntEnvValue(process.env.ESCROW_CLAIM_TIMEOUT, 600)
834+
claimDurationTimeout: getIntEnvValue(process.env.ESCROW_CLAIM_TIMEOUT, 600),
835+
validateUnsignedDDO: getBoolEnvValue('VALIDATE_UNSIGNED_DDO', true)
835836
}
836837

837838
if (!previousConfiguration) {

src/utils/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,11 @@ export const ENVIRONMENT_VARIABLES: Record<any, EnvVariable> = {
439439
name: 'POLICY_SERVER_URL',
440440
value: process.env.POLICY_SERVER_URL,
441441
required: false
442+
},
443+
VALIDATE_UNSIGNED_DDO: {
444+
name: 'VALIDATE_UNSIGNED_DDO',
445+
value: process.env.VALIDATE_UNSIGNED_DDO,
446+
required: false
442447
}
443448
}
444449
export const CONNECTION_HISTORY_DELETE_THRESHOLD = 300

0 commit comments

Comments
 (0)