Skip to content

Commit

Permalink
feat(cu): implement hash chain validation on messages received from SU
Browse files Browse the repository at this point in the history
  • Loading branch information
TillaTheHun0 committed Jan 16, 2025
1 parent 1921867 commit a7dac85
Show file tree
Hide file tree
Showing 2 changed files with 194 additions and 5 deletions.
59 changes: 56 additions & 3 deletions servers/cu/src/effects/ao-su.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,57 @@
/* eslint-disable camelcase */
import { Transform, compose as composeStreams } from 'node:stream'
import { createHash } from 'node:crypto'

import { of } from 'hyper-async'
import { always, applySpec, filter, has, ifElse, isNil, isNotNil, juxt, last, mergeAll, path, pathOr, pipe, prop } from 'ramda'
import DataLoader from 'dataloader'

import { backoff, mapForwardedBy, mapFrom, addressFrom, parseTags, strFromFetchError } from '../domain/utils.js'

export const base64UrlToBytes = (b64Url) =>
Buffer.from(b64Url, 'base64url')

const okRes = (res) => {
if (res.ok) return res
throw res
}

const resToJson = (res) => res.json()

const hashChain = (prevHashChain, prevAssignmentId) => {
const hash = createHash('sha256')

/**
* For the very first message, there is no previous id,
* so it is not included in the hashed bytes, to produce the very first
* hash chain
*/
if (prevAssignmentId) hash.update(base64UrlToBytes(prevAssignmentId))
/**
* Always include the previous hash chain
*/
hash.update(base64UrlToBytes(prevHashChain))

return hash.digest('base64url')
}

export const isHashChainValid = (scheduled) => {
const { prevAssignment, message } = scheduled
const actual = message['Hash-Chain']

/**
* This will match in cases where the SU returns no prevAssignment
* which is to say the feature isn't live on the SU.
*
* AND will match validating the first assignment, which needs
* no validation, besides needing to check its hashChain is present
*/
if (!prevAssignment?.id || !prevAssignment?.hashChain) return !!actual

const expected = hashChain(prevAssignment.hashChain, prevAssignment.id)
return expected === actual
}

/**
* See new shape in https://github.com/permaweb/ao/issues/563#issuecomment-2020597581
*/
Expand Down Expand Up @@ -79,6 +118,10 @@ export const mapNode = pipe(
),
// both
applySpec({
prevAssignment: applySpec({
hashChain: pathOr(null, ['previous_assignment', 'hash_chain']),
id: pathOr(null, ['previous_assignment', 'id'])
}),
isAssignment: pipe(
path(['message', 'id']),
isNil
Expand All @@ -94,6 +137,7 @@ export const mapNode = pipe(
name: `${fBoth.isAssignment ? 'Assigned' : 'Scheduled'} Message ${fBoth.isAssignment ? fAssignment.message.Message : fMessage.Id} ${fAssignment.message.Timestamp}:${fAssignment.message.Nonce}`,
exclude: fAssignment.Exclude,
isAssignment: fBoth.isAssignment,
prevAssignment: fBoth.prevAssignment,
message: mergeAll([
fMessage,
fAssignment.message,
Expand Down Expand Up @@ -233,15 +277,15 @@ export const loadMessagesWith = ({ fetch, logger: _logger, pageSize }) => {
}
}

function mapAoMessage ({ processId, processOwner, processTags, moduleId, moduleOwner, moduleTags }) {
function mapAoMessage ({ processId, processOwner, processTags, moduleId, moduleOwner, moduleTags, logger }) {
const AoGlobal = {
Process: { Id: processId, Owner: processOwner, Tags: processTags },
Module: { Id: moduleId, Owner: moduleOwner, Tags: moduleTags }
}

return async function * (edges) {
for await (const edge of edges) {
yield pipe(
const scheduled = pipe(
prop('node'),
/**
* Map to the expected shape
Expand All @@ -252,6 +296,15 @@ export const loadMessagesWith = ({ fetch, logger: _logger, pageSize }) => {
return scheduled
}
)(edge)

if (!isHashChainValid(scheduled)) {
logger('HashChain invalid on message "%s" scheduled on process "%s"', scheduled.message.Id, processId)
const err = new Error(`HashChain invalid on message ${scheduled.message.Id}`)
err.status = 422
throw err
}

yield scheduled
}
}
}
Expand All @@ -264,7 +317,7 @@ export const loadMessagesWith = ({ fetch, logger: _logger, pageSize }) => {
* compose will convert the AsyncIterable into a readable Duplex
*/
fetchAllPages({ suUrl, processId, from, to })(),
Transform.from(mapAoMessage({ processId, processOwner, processTags, moduleId, moduleOwner, moduleTags }))
Transform.from(mapAoMessage({ processId, processOwner, processTags, moduleId, moduleOwner, moduleTags, logger }))
)
})
.toPromise()
Expand Down
140 changes: 138 additions & 2 deletions servers/cu/src/effects/ao-su.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* eslint-disable no-throw-literal */
import { describe, test } from 'node:test'
import assert from 'node:assert'
import { createHash } from 'node:crypto'

import { loadMessageMetaSchema, loadProcessSchema, loadTimestampSchema } from '../domain/dal.js'
import { messageSchema } from '../domain/model.js'
import { createTestLogger } from '../domain/logger.js'
import { loadMessageMetaWith, loadProcessWith, loadTimestampWith, mapNode } from './ao-su.js'
import { isHashChainValid, loadMessageMetaWith, loadProcessWith, loadTimestampWith, mapNode } from './ao-su.js'

const withoutAoGlobal = messageSchema.omit({ AoGlobal: true })
const logger = createTestLogger({ name: 'ao-cu:ao-su' })
Expand All @@ -20,6 +21,7 @@ describe('ao-su', () => {
ordinate: '23',
name: `Scheduled Message ${messageId} ${now}:23`,
exclude: undefined,
prevAssignment: { id: null, hashChain: null },
message: {
Id: messageId,
Signature: 'sig-123',
Expand Down Expand Up @@ -80,7 +82,7 @@ describe('ao-su', () => {
assert.equal(res.isAssignment, false)
})

describe('should map an assigned tx', () => {
describe('should map an assignment tx', () => {
const res = mapNode({
message: null,
assignment: {
Expand Down Expand Up @@ -132,6 +134,140 @@ describe('ao-su', () => {
})
})
})

describe('should map the previous assignment data', () => {
const arg = {
message: null,
assignment: {
owner: {
address: 'su-123',
key: 'su-123'
},
tags: [
{ name: 'Epoch', value: '0' },
{ name: 'Nonce', value: '23' },
{ name: 'Process', value: 'process-123' },
{ name: 'Block-Height', value: '000000000123' },
{ name: 'Timestamp', value: `${now}` },
{ name: 'Hash-Chain', value: 'hash-123' },
{ name: 'Message', value: assignedMessageId }
],
data: 'su-data-123'
}
}

test('should map prevAssignment fields', () => {
const res = mapNode({
...arg,
previous_assignment: {
id: 'prev-assignment-id',
hash_chain: 'prev-hashchain'
}
})

assert.deepStrictEqual(
res.prevAssignment,
{ id: 'prev-assignment-id', hashChain: 'prev-hashchain' }
)
})

test('should set prevAssignment fields to null', () => {
const res = mapNode({
...arg,
no_previous_assigment: true
})

assert.deepStrictEqual(
res.prevAssignment,
{ id: null, hashChain: null }
)
})
})
})

describe('isHashChainValid', () => {
const now = new Date().getTime()
const messageId = 'message-123'
const arg = {
cron: undefined,
ordinate: '23',
name: `Scheduled Message ${messageId} ${now}:23`,
exclude: undefined,
message: {
Id: messageId,
Signature: 'sig-123',
Data: 'data-123',
Owner: 'owner-123',
Target: 'process-123',
Anchor: '00000000123',
From: 'owner-123',
'Forwarded-By': undefined,
Tags: [{ name: 'Foo', value: 'Bar' }],
Epoch: 0,
Nonce: 23,
Timestamp: now,
'Block-Height': 123,
'Hash-Chain': 'hash-123',
Cron: false
},
block: {
height: 123,
timestamp: now
}
}

test('should return whether the hashChain exists if there is no previous assignment', () => {
// feature not rolled out on SU
assert(isHashChainValid(arg))
// first assignment ergo has no prev assignment
assert(isHashChainValid({
...arg,
prevAssignment: {
hashChain: null,
id: 'foo'
}
}))
assert(isHashChainValid({
...arg,
prevAssignment: {
hashChain: 'foo',
id: null
}
}))
})

test('should calculate and compare the hashChain based on the previous assignment', () => {
const prevAssignmentId = Buffer.from('assignment-123', 'utf8').toString('base64url')
const prevHashChain = Buffer.from('hashchain-123', 'utf8').toString('base64url')

const expected = createHash('sha256')
.update(Buffer.from(prevAssignmentId, 'base64url'))
.update(Buffer.from(prevHashChain, 'base64url'))
.digest('base64url')

const arg = {
// ...
prevAssignment: { id: prevAssignmentId, hashChain: prevHashChain },
message: {
// ....
'Hash-Chain': expected
}
}

assert(isHashChainValid(arg))

const invalid = {
...arg,
message: {
'Hash-Chain': createHash('sha256')
.update(Buffer.from('something else', 'base64url'))
.update(Buffer.from(prevHashChain, 'base64url'))
.digest('base64url')
}
}

assert(!isHashChainValid(invalid))
})
})

describe('loadMessageMetaWith', () => {
Expand Down

0 comments on commit a7dac85

Please sign in to comment.