From a87db6a163b9504051a97db0ffe0deba81ec0f05 Mon Sep 17 00:00:00 2001 From: Tyler Hall Date: Thu, 16 Jan 2025 10:55:03 -0500 Subject: [PATCH] feat(cu): implement hash chain validation on messages received from SU #1112 --- servers/cu/src/effects/ao-su.js | 59 ++++++++++- servers/cu/src/effects/ao-su.test.js | 140 ++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 5 deletions(-) diff --git a/servers/cu/src/effects/ao-su.js b/servers/cu/src/effects/ao-su.js index 0555fb218..73860dbe2 100644 --- a/servers/cu/src/effects/ao-su.js +++ b/servers/cu/src/effects/ao-su.js @@ -1,11 +1,16 @@ /* 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' +const base64UrlToBytes = (b64Url) => + Buffer.from(b64Url, 'base64url') + const okRes = (res) => { if (res.ok) return res throw res @@ -13,6 +18,40 @@ const okRes = (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 */ @@ -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 @@ -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, @@ -233,7 +277,7 @@ 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 } @@ -241,7 +285,7 @@ export const loadMessagesWith = ({ fetch, logger: _logger, pageSize }) => { return async function * (edges) { for await (const edge of edges) { - yield pipe( + const scheduled = pipe( prop('node'), /** * Map to the expected shape @@ -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 } } } @@ -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() diff --git a/servers/cu/src/effects/ao-su.test.js b/servers/cu/src/effects/ao-su.test.js index ce8b44b42..9265a74f7 100644 --- a/servers/cu/src/effects/ao-su.test.js +++ b/servers/cu/src/effects/ao-su.test.js @@ -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' }) @@ -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', @@ -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: { @@ -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', () => {