mirror of
https://github.com/benjamin-wilson/public-pool.git
synced 2026-09-29 17:15:03 -07:00
Add SV2 DATUM payout hardening
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
DATUM_INITIAL_HEADER_KEY,
|
||||
DATUM_EXTRANONCE_SIZE,
|
||||
DatumFrameReader,
|
||||
DatumMiningCommand,
|
||||
DatumProtocolCommand,
|
||||
DatumShareResponseStatus,
|
||||
datumHeaderXorFeedback,
|
||||
decodeDatumHeader,
|
||||
deserializeDatumCoinbaserFetch,
|
||||
deserializeDatumJobValidationResponse,
|
||||
deserializeDatumMiningCommand,
|
||||
deserializeDatumPowSubmit,
|
||||
encodeDatumFrame,
|
||||
encodeDatumHeader,
|
||||
serializeDatumJobValidationFullTransactionBlobRequest,
|
||||
serializeDatumJobValidationFullTransactionsRequest,
|
||||
serializeDatumJobValidationShortTxIdsRequest,
|
||||
serializeDatumCoinbaserFetchResponse,
|
||||
serializeDatumMiningCommand,
|
||||
serializeDatumShareResponse,
|
||||
} from './datum-codec';
|
||||
|
||||
describe('datum-codec', () => {
|
||||
it('round-trips DATUM packed headers with XOR key', () => {
|
||||
const encoded = encodeDatumHeader({
|
||||
cmdLen: 1234,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
}, DATUM_INITIAL_HEADER_KEY);
|
||||
|
||||
expect(decodeDatumHeader(encoded, DATUM_INITIAL_HEADER_KEY)).toEqual({
|
||||
cmdLen: 1234,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches the DATUM header feedback function for stable inputs', () => {
|
||||
expect(datumHeaderXorFeedback(0).toString(16)).toBe('74a55cf6');
|
||||
expect(datumHeaderXorFeedback(0xdc871829).toString(16)).toBe('88e1697d');
|
||||
});
|
||||
|
||||
it('splits framed DATUM payloads', () => {
|
||||
const first = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: 0,
|
||||
reserved: 0,
|
||||
isSigned: false,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
},
|
||||
payload: serializeDatumMiningCommand(DatumMiningCommand.TEMPLATE_REFRESH),
|
||||
});
|
||||
const second = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: 0,
|
||||
reserved: 0,
|
||||
isSigned: false,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd: DatumProtocolCommand.MINING,
|
||||
},
|
||||
payload: serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER, Buffer.alloc(8)),
|
||||
}, datumHeaderXorFeedback(DATUM_INITIAL_HEADER_KEY));
|
||||
|
||||
const frames = new DatumFrameReader().feed(Buffer.concat([first, second]));
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(deserializeDatumMiningCommand(frames[0].payload).command).toBe(DatumMiningCommand.TEMPLATE_REFRESH);
|
||||
expect(deserializeDatumMiningCommand(frames[1].payload).command).toBe(DatumMiningCommand.FETCH_COINBASER);
|
||||
});
|
||||
|
||||
it('serializes coinbaser fetch responses', () => {
|
||||
const response = serializeDatumCoinbaserFetchResponse(50n, [
|
||||
{ value: 50n, scriptPubKey: Buffer.from('6a', 'hex') },
|
||||
]);
|
||||
const mining = deserializeDatumMiningCommand(response);
|
||||
expect(mining.command).toBe(DatumMiningCommand.FETCH_COINBASER_RESPONSE);
|
||||
expect(mining.body.readBigUInt64LE(0)).toBe(50n);
|
||||
expect(mining.body.readUInt32LE(8)).toBe(11);
|
||||
});
|
||||
|
||||
it('deserializes coinbaser fetch requests', () => {
|
||||
const payload = Buffer.alloc(8);
|
||||
payload.writeBigUInt64LE(123n, 0);
|
||||
expect(deserializeDatumCoinbaserFetch(payload)).toEqual({ rewardValue: 123n });
|
||||
});
|
||||
|
||||
it('deserializes fixed DATUM POW submissions', () => {
|
||||
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
|
||||
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
|
||||
const payload = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
|
||||
payload[0] = 7;
|
||||
payload[1] = 1;
|
||||
payload[2] = 0x01;
|
||||
payload[3] = 4;
|
||||
payload.writeUInt32LE(100, 4);
|
||||
payload.writeUInt32LE(200, 8);
|
||||
payload.writeUInt32LE(0x20000000, 12);
|
||||
payload[16] = DATUM_EXTRANONCE_SIZE;
|
||||
extranonce.copy(payload, 17);
|
||||
const parsed = deserializeDatumPowSubmit(Buffer.concat([
|
||||
payload,
|
||||
username,
|
||||
Buffer.alloc(4),
|
||||
Buffer.from([0xfe]),
|
||||
]));
|
||||
|
||||
expect(parsed.jobId).toBe(7);
|
||||
expect(parsed.coinbaseId).toBe(1);
|
||||
expect(parsed.isBlock).toBe(true);
|
||||
expect(parsed.extranonce).toEqual(extranonce);
|
||||
expect(parsed.username).toBe('bc1ptest.worker');
|
||||
});
|
||||
|
||||
it('deserializes cached DATUM POW context sections', () => {
|
||||
const username = Buffer.from('bc1ptest.worker\0', 'utf8');
|
||||
const extranonce = Buffer.from('000102030405060708090a0b', 'hex');
|
||||
const base = Buffer.alloc(17 + DATUM_EXTRANONCE_SIZE);
|
||||
base[0] = 3;
|
||||
base[1] = 2;
|
||||
base[2] = 0;
|
||||
base[3] = 0x12;
|
||||
base.writeUInt32LE(100, 4);
|
||||
base.writeUInt32LE(200, 8);
|
||||
base.writeUInt32LE(0x20000000, 12);
|
||||
base[16] = DATUM_EXTRANONCE_SIZE;
|
||||
extranonce.copy(base, 17);
|
||||
|
||||
const templateSection = Buffer.concat([
|
||||
Buffer.from([0x01]),
|
||||
Buffer.alloc(32, 0xaa),
|
||||
Buffer.from([0x09, 0x00]),
|
||||
Buffer.from('ffff7f20', 'hex'),
|
||||
Buffer.from([7]),
|
||||
Buffer.from('64000000', 'hex'),
|
||||
Buffer.from('0100000000000000', 'hex'),
|
||||
Buffer.from('02000000', 'hex'),
|
||||
Buffer.from('03000000', 'hex'),
|
||||
Buffer.from('04000000', 'hex'),
|
||||
Buffer.from('05000000', 'hex'),
|
||||
Buffer.from([1]),
|
||||
Buffer.alloc(32, 0xbb),
|
||||
]);
|
||||
const coinbaseSection = Buffer.concat([
|
||||
Buffer.from([0x02, 2]),
|
||||
Buffer.from([0x02, 0x00]),
|
||||
Buffer.from([0x03, 0x00]),
|
||||
Buffer.from('abcd', 'hex'),
|
||||
Buffer.from('010203', 'hex'),
|
||||
]);
|
||||
|
||||
const parsed = deserializeDatumPowSubmit(Buffer.concat([
|
||||
base,
|
||||
username,
|
||||
Buffer.alloc(4),
|
||||
templateSection,
|
||||
coinbaseSection,
|
||||
Buffer.from([0xfe]),
|
||||
]));
|
||||
|
||||
expect(parsed.jobId).toBe(3);
|
||||
expect(parsed.coinbaseId).toBe(2);
|
||||
expect(parsed.targetByte).toBe(0x12);
|
||||
expect(parsed.prevBlockHash?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
|
||||
expect(parsed.targetByteIndex).toBe(9);
|
||||
expect(parsed.nBits?.toString('hex')).toBe('ffff7f20');
|
||||
expect(parsed.height).toBe(100);
|
||||
expect(parsed.merkleBranches?.[0].equals(Buffer.alloc(32, 0xbb))).toBe(true);
|
||||
expect(parsed.coinbasePairs.get(2)?.coinb1.toString('hex')).toBe('abcd');
|
||||
expect(parsed.coinbasePairs.get(2)?.coinb2.toString('hex')).toBe('010203');
|
||||
});
|
||||
|
||||
it('rejects DATUM POW submissions with non-standard extranonce sizes', () => {
|
||||
const payload = Buffer.alloc(18);
|
||||
payload[16] = 1;
|
||||
payload[17] = 0xaa;
|
||||
|
||||
expect(() => deserializeDatumPowSubmit(payload)).toThrow('Unsupported DATUM extranonce size 1');
|
||||
});
|
||||
|
||||
it('serializes share responses', () => {
|
||||
const response = serializeDatumShareResponse({
|
||||
status: DatumShareResponseStatus.ACCEPTED,
|
||||
reasonCode: 0,
|
||||
nonce: 123,
|
||||
targetByte: 4,
|
||||
jobId: 7,
|
||||
});
|
||||
expect(response.toString('hex')).toBe('8f5000007b0000000407');
|
||||
});
|
||||
|
||||
it('serializes DATUM job validation requests', () => {
|
||||
expect(serializeDatumJobValidationShortTxIdsRequest(7).toString('hex')).toBe('501007');
|
||||
expect(serializeDatumJobValidationFullTransactionBlobRequest(7).toString('hex')).toBe('501207');
|
||||
expect(serializeDatumJobValidationFullTransactionsRequest(7, [1, 258]).toString('hex')).toBe('501107020001000201');
|
||||
});
|
||||
|
||||
it('deserializes DATUM short transaction ID validation responses', () => {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from([0x90, 7, 0x01]),
|
||||
Buffer.from('0200', 'hex'),
|
||||
Buffer.from('010203040506', 'hex'),
|
||||
Buffer.from('111213141516', 'hex'),
|
||||
Buffer.alloc(32, 0xaa),
|
||||
Buffer.from([0xfe, 0x00, 0x00]),
|
||||
]);
|
||||
|
||||
const parsed = deserializeDatumJobValidationResponse(payload);
|
||||
expect(parsed.kind).toBe('short-txids');
|
||||
expect(parsed.jobId).toBe(7);
|
||||
expect(parsed.status).toBe(1);
|
||||
expect(parsed.transactionCount).toBe(2);
|
||||
if (parsed.kind !== 'short-txids') {
|
||||
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
|
||||
}
|
||||
expect(parsed.shortTxIds.map(id => id.toString('hex'))).toEqual(['010203040506', '111213141516']);
|
||||
expect(parsed.crosscheck?.equals(Buffer.alloc(32, 0xaa))).toBe(true);
|
||||
});
|
||||
|
||||
it('deserializes DATUM full transaction blob validation responses', () => {
|
||||
const tx = Buffer.from(
|
||||
'0100000001'
|
||||
+ '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
+ 'ffffffff00ffffffff'
|
||||
+ '01000000000000000000'
|
||||
+ '00000000',
|
||||
'hex',
|
||||
);
|
||||
const txSize = Buffer.alloc(3);
|
||||
txSize.writeUIntLE(tx.length, 0, 3);
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from([0x92, 9, 0x01]),
|
||||
Buffer.from('0100', 'hex'),
|
||||
txSize,
|
||||
tx,
|
||||
Buffer.from([0xfe]),
|
||||
]);
|
||||
|
||||
const parsed = deserializeDatumJobValidationResponse(payload);
|
||||
expect(parsed.kind).toBe('full-transaction-blob');
|
||||
expect(parsed.jobId).toBe(9);
|
||||
expect(parsed.status).toBe(1);
|
||||
expect(parsed.transactionCount).toBe(1);
|
||||
if (parsed.kind !== 'full-transaction-blob') {
|
||||
throw new Error(`Unexpected DATUM validation response kind ${parsed.kind}`);
|
||||
}
|
||||
expect(parsed.transactions[0]).toEqual(tx);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import { BufferReader, BufferWriter } from '../sv2/sv2-binary-codec';
|
||||
|
||||
export const DATUM_PROTOCOL_VERSION = 'v0.4.1-beta';
|
||||
export const DATUM_INITIAL_HEADER_KEY = 0xdc871829;
|
||||
export const DATUM_MAX_PAYLOAD_SIZE = 4194304;
|
||||
export const DATUM_MAX_JOBS = 8;
|
||||
export const DATUM_EXTRANONCE_SIZE = 12;
|
||||
|
||||
export enum DatumProtocolCommand {
|
||||
PING = 0x01,
|
||||
HANDSHAKE_INIT = 0x01,
|
||||
HANDSHAKE_RESPONSE = 0x02,
|
||||
MINING = 0x05,
|
||||
}
|
||||
|
||||
export enum DatumMiningCommand {
|
||||
FETCH_COINBASER = 0x10,
|
||||
FETCH_COINBASER_RESPONSE = 0x11,
|
||||
SUBMIT_POW = 0x27,
|
||||
JOB_VALIDATION = 0x50,
|
||||
SHARE_RESPONSE = 0x8f,
|
||||
CLIENT_CONFIGURE = 0x99,
|
||||
TEMPLATE_REFRESH = 0xf9,
|
||||
}
|
||||
|
||||
export enum DatumJobValidationCommand {
|
||||
REQUEST_SHORT_TX_IDS = 0x10,
|
||||
REQUEST_FULL_TRANSACTIONS = 0x11,
|
||||
REQUEST_FULL_TRANSACTION_BLOB = 0x12,
|
||||
SHORT_TX_IDS_RESPONSE = 0x90,
|
||||
FULL_TRANSACTIONS_RESPONSE = 0x91,
|
||||
FULL_TRANSACTION_BLOB_RESPONSE = 0x92,
|
||||
}
|
||||
|
||||
export enum DatumJobValidationStatus {
|
||||
SUCCESS = 0x01,
|
||||
}
|
||||
|
||||
export enum DatumShareResponseStatus {
|
||||
ACCEPTED = 0x50,
|
||||
ACCEPTED_TENTATIVE = 0x55,
|
||||
REJECTED = 0x66,
|
||||
}
|
||||
|
||||
export enum DatumRejectReason {
|
||||
BAD_JOB_ID = 10,
|
||||
BAD_COINBASE_ID = 11,
|
||||
BAD_EXTRANONCE_SIZE = 12,
|
||||
BAD_TARGET = 13,
|
||||
BAD_USERNAME = 14,
|
||||
BAD_COINBASER_ID = 15,
|
||||
BAD_MERKLE_COUNT = 16,
|
||||
BAD_COINBASE_TOO_LARGE = 17,
|
||||
COINBASE_MISSING = 18,
|
||||
TARGET_MISMATCH = 19,
|
||||
HIGH_HASH = 21,
|
||||
BAD_NTIME = 23,
|
||||
BAD_VERSION = 24,
|
||||
STALE_BLOCK = 25,
|
||||
DUPLICATE_WORK = 29,
|
||||
OTHER = 30,
|
||||
}
|
||||
|
||||
export interface DatumHeader {
|
||||
cmdLen: number;
|
||||
reserved: number;
|
||||
isSigned: boolean;
|
||||
isEncryptedPubkey: boolean;
|
||||
isEncryptedChannel: boolean;
|
||||
protoCmd: number;
|
||||
}
|
||||
|
||||
export interface DatumFrame {
|
||||
header: DatumHeader;
|
||||
payload: Buffer;
|
||||
}
|
||||
|
||||
export interface DatumCoinbaserFetch {
|
||||
rewardValue: bigint;
|
||||
}
|
||||
|
||||
export interface DatumPayoutOutput {
|
||||
value: bigint;
|
||||
scriptPubKey: Buffer;
|
||||
}
|
||||
|
||||
export interface DatumPowSubmit {
|
||||
jobId: number;
|
||||
coinbaseId: number;
|
||||
isBlock: boolean;
|
||||
subsidyOnly: boolean;
|
||||
quickDiff: boolean;
|
||||
targetByte: number;
|
||||
ntime: number;
|
||||
nonce: number;
|
||||
version: number;
|
||||
extranonce: Buffer;
|
||||
username: string;
|
||||
reserved: Buffer;
|
||||
prevBlockHash?: Buffer;
|
||||
targetByteIndex?: number;
|
||||
nBits?: Buffer;
|
||||
coinbaserId?: number;
|
||||
height?: number;
|
||||
coinbaseValue?: bigint;
|
||||
transactionCount?: number;
|
||||
totalWeight?: number;
|
||||
totalSize?: number;
|
||||
totalSigops?: number;
|
||||
merkleBranches?: Buffer[];
|
||||
coinbasePairs: Map<number, { coinb1: Buffer; coinb2: Buffer }>;
|
||||
subsidyOnlyCoinbase?: { coinb1: Buffer; coinb2: Buffer };
|
||||
}
|
||||
|
||||
export interface DatumShortTxIdsResponse {
|
||||
kind: 'short-txids';
|
||||
jobId: number;
|
||||
status: number;
|
||||
transactionCount: number;
|
||||
shortTxIds: Buffer[];
|
||||
crosscheck: Buffer | null;
|
||||
}
|
||||
|
||||
export interface DatumFullTransactionsResponse {
|
||||
kind: 'full-transactions';
|
||||
jobId: number;
|
||||
status: number;
|
||||
transactionCount: number;
|
||||
transactions: Buffer[];
|
||||
}
|
||||
|
||||
export interface DatumFullTransactionBlobResponse {
|
||||
kind: 'full-transaction-blob';
|
||||
jobId: number;
|
||||
status: number;
|
||||
transactionCount: number;
|
||||
transactions: Buffer[];
|
||||
}
|
||||
|
||||
export type DatumJobValidationResponse =
|
||||
| DatumShortTxIdsResponse
|
||||
| DatumFullTransactionsResponse
|
||||
| DatumFullTransactionBlobResponse;
|
||||
|
||||
export function encodeDatumHeader(header: DatumHeader, xorKey = 0): Buffer {
|
||||
if (header.cmdLen < 0 || header.cmdLen > DATUM_MAX_PAYLOAD_SIZE - 1) {
|
||||
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
|
||||
}
|
||||
|
||||
let raw = header.cmdLen & 0x3fffff;
|
||||
raw |= (header.reserved & 0x03) << 22;
|
||||
raw |= (header.isSigned ? 1 : 0) << 24;
|
||||
raw |= (header.isEncryptedPubkey ? 1 : 0) << 25;
|
||||
raw |= (header.isEncryptedChannel ? 1 : 0) << 26;
|
||||
raw |= (header.protoCmd & 0x1f) << 27;
|
||||
|
||||
const out = Buffer.alloc(4);
|
||||
out.writeUInt32LE((raw ^ xorKey) >>> 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decodeDatumHeader(input: Buffer, xorKey = 0): DatumHeader {
|
||||
if (input.length < 4) {
|
||||
throw new RangeError('DATUM header requires 4 bytes');
|
||||
}
|
||||
const raw = (input.readUInt32LE(0) ^ xorKey) >>> 0;
|
||||
return {
|
||||
cmdLen: raw & 0x3fffff,
|
||||
reserved: (raw >>> 22) & 0x03,
|
||||
isSigned: ((raw >>> 24) & 0x01) !== 0,
|
||||
isEncryptedPubkey: ((raw >>> 25) & 0x01) !== 0,
|
||||
isEncryptedChannel: ((raw >>> 26) & 0x01) !== 0,
|
||||
protoCmd: (raw >>> 27) & 0x1f,
|
||||
};
|
||||
}
|
||||
|
||||
export function datumHeaderXorFeedback(input: number): number {
|
||||
let h = 0xb10cfeed >>> 0;
|
||||
let k = input >>> 0;
|
||||
k = Math.imul(k, 0xcc9e2d51) >>> 0;
|
||||
k = (((k << 15) >>> 0) | (k >>> 17)) >>> 0;
|
||||
k = Math.imul(k, 0x1b873593) >>> 0;
|
||||
h = (h ^ k) >>> 0;
|
||||
h = (((h << 13) >>> 0) | (h >>> 19)) >>> 0;
|
||||
h = (Math.imul(h, 5) + 0xe6546b64) >>> 0;
|
||||
h = (h ^ 4) >>> 0;
|
||||
h = (h ^ (h >>> 16)) >>> 0;
|
||||
h = Math.imul(h, 0x85ebca6b) >>> 0;
|
||||
h = (h ^ (h >>> 13)) >>> 0;
|
||||
h = Math.imul(h, 0xc2b2ae35) >>> 0;
|
||||
h = (h ^ (h >>> 16)) >>> 0;
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export class DatumFrameReader {
|
||||
private buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(private headerXorKey = DATUM_INITIAL_HEADER_KEY) {}
|
||||
|
||||
public setHeaderXorKey(headerXorKey: number): void {
|
||||
this.headerXorKey = headerXorKey >>> 0;
|
||||
}
|
||||
|
||||
public feed(data: Buffer): DatumFrame[] {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
const frames: DatumFrame[] = [];
|
||||
|
||||
while (this.buffer.length >= 4) {
|
||||
const header = decodeDatumHeader(this.buffer.subarray(0, 4), this.headerXorKey);
|
||||
if (header.cmdLen > DATUM_MAX_PAYLOAD_SIZE) {
|
||||
throw new RangeError(`DATUM payload length ${header.cmdLen} exceeds protocol limit`);
|
||||
}
|
||||
if (this.buffer.length < 4 + header.cmdLen) {
|
||||
break;
|
||||
}
|
||||
|
||||
frames.push({
|
||||
header,
|
||||
payload: Buffer.from(this.buffer.subarray(4, 4 + header.cmdLen)),
|
||||
});
|
||||
this.buffer = this.buffer.subarray(4 + header.cmdLen);
|
||||
if (header.isEncryptedChannel) {
|
||||
this.headerXorKey = datumHeaderXorFeedback(this.headerXorKey);
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeDatumFrame(frame: DatumFrame, xorKey = DATUM_INITIAL_HEADER_KEY): Buffer {
|
||||
return Buffer.concat([
|
||||
encodeDatumHeader({ ...frame.header, cmdLen: frame.payload.length }, xorKey),
|
||||
frame.payload,
|
||||
]);
|
||||
}
|
||||
|
||||
export function deserializeDatumMiningCommand(payload: Buffer): { command: DatumMiningCommand; body: Buffer } {
|
||||
if (payload.length < 1) {
|
||||
throw new RangeError('DATUM mining command requires a sub-command byte');
|
||||
}
|
||||
return {
|
||||
command: payload[0],
|
||||
body: Buffer.from(payload.subarray(1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeDatumMiningCommand(command: DatumMiningCommand, body = Buffer.alloc(0)): Buffer {
|
||||
return Buffer.concat([Buffer.from([command]), body]);
|
||||
}
|
||||
|
||||
export function serializeDatumJobValidationShortTxIdsRequest(jobId: number): Buffer {
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
|
||||
DatumJobValidationCommand.REQUEST_SHORT_TX_IDS,
|
||||
jobId & 0xff,
|
||||
]));
|
||||
}
|
||||
|
||||
export function serializeDatumJobValidationFullTransactionBlobRequest(jobId: number): Buffer {
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from([
|
||||
DatumJobValidationCommand.REQUEST_FULL_TRANSACTION_BLOB,
|
||||
jobId & 0xff,
|
||||
]));
|
||||
}
|
||||
|
||||
export function serializeDatumJobValidationFullTransactionsRequest(jobId: number, transactionIndexes: number[]): Buffer {
|
||||
if (transactionIndexes.length > 0xffff) {
|
||||
throw new RangeError('DATUM requested transaction index count must fit in u16');
|
||||
}
|
||||
const w = new BufferWriter();
|
||||
w.writeU8(DatumJobValidationCommand.REQUEST_FULL_TRANSACTIONS);
|
||||
w.writeU8(jobId);
|
||||
w.writeU16(transactionIndexes.length);
|
||||
for (const index of transactionIndexes) {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 0xffff) {
|
||||
throw new RangeError(`Invalid DATUM transaction index ${index}`);
|
||||
}
|
||||
w.writeU16(index);
|
||||
}
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.JOB_VALIDATION, Buffer.from(w.toBuffer()));
|
||||
}
|
||||
|
||||
export function deserializeDatumJobValidationResponse(payload: Buffer): DatumJobValidationResponse {
|
||||
const reader = new BufferReader(payload);
|
||||
const responseCommand = reader.readU8();
|
||||
const jobId = reader.readU8();
|
||||
const status = reader.readU8();
|
||||
|
||||
if (responseCommand === DatumJobValidationCommand.SHORT_TX_IDS_RESPONSE) {
|
||||
if (status !== DatumJobValidationStatus.SUCCESS) {
|
||||
return {
|
||||
kind: 'short-txids',
|
||||
jobId,
|
||||
status,
|
||||
transactionCount: 0,
|
||||
shortTxIds: [],
|
||||
crosscheck: null,
|
||||
};
|
||||
}
|
||||
const transactionCount = reader.readU16();
|
||||
const shortTxIds: Buffer[] = [];
|
||||
for (let i = 0; i < transactionCount; i++) {
|
||||
shortTxIds.push(reader.readBytes(6));
|
||||
}
|
||||
const crosscheck = reader.remaining >= 32 ? reader.readBytes(32) : null;
|
||||
consumeDatumTerminatorAndPadding(reader);
|
||||
return {
|
||||
kind: 'short-txids',
|
||||
jobId,
|
||||
status,
|
||||
transactionCount,
|
||||
shortTxIds,
|
||||
crosscheck,
|
||||
};
|
||||
}
|
||||
|
||||
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTIONS_RESPONSE) {
|
||||
return deserializeDatumTransactionListResponse('full-transactions', reader, jobId, status);
|
||||
}
|
||||
|
||||
if (responseCommand === DatumJobValidationCommand.FULL_TRANSACTION_BLOB_RESPONSE) {
|
||||
return deserializeDatumTransactionListResponse('full-transaction-blob', reader, jobId, status);
|
||||
}
|
||||
|
||||
throw new RangeError(`Unsupported DATUM job validation response 0x${responseCommand.toString(16)}`);
|
||||
}
|
||||
|
||||
export function deserializeDatumCoinbaserFetch(payload: Buffer): DatumCoinbaserFetch {
|
||||
if (payload.length < 8) {
|
||||
throw new RangeError('DATUM coinbaser fetch requires reward value');
|
||||
}
|
||||
return { rewardValue: payload.readBigUInt64LE(0) };
|
||||
}
|
||||
|
||||
export function serializeDatumCoinbaserFetchResponse(rewardValue: bigint, outputs: DatumPayoutOutput[]): Buffer {
|
||||
const w = new BufferWriter();
|
||||
w.writeU64(rewardValue);
|
||||
const outputsBuffer = serializeDatumPayoutOutputs(outputs);
|
||||
w.writeU32(outputsBuffer.length);
|
||||
w.writeBytes(outputsBuffer);
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.FETCH_COINBASER_RESPONSE, Buffer.from(w.toBuffer()));
|
||||
}
|
||||
|
||||
export function serializeDatumPayoutOutputs(outputs: DatumPayoutOutput[]): Buffer {
|
||||
if (outputs.length > 255) {
|
||||
throw new RangeError('DATUM payout output count must fit in one byte');
|
||||
}
|
||||
const w = new BufferWriter();
|
||||
w.writeU8(outputs.length);
|
||||
for (const output of outputs) {
|
||||
if (output.scriptPubKey.length > 255) {
|
||||
throw new RangeError('DATUM scriptPubKey length must fit in one byte');
|
||||
}
|
||||
w.writeU64(output.value);
|
||||
w.writeU8(output.scriptPubKey.length);
|
||||
w.writeBytes(output.scriptPubKey);
|
||||
}
|
||||
return w.toBuffer();
|
||||
}
|
||||
|
||||
export function deserializeDatumPowSubmit(payload: Buffer): DatumPowSubmit {
|
||||
const reader = new BufferReader(payload);
|
||||
const jobId = reader.readU8();
|
||||
const coinbaseId = reader.readU8();
|
||||
const flags = reader.readU8();
|
||||
const targetByte = reader.readU8();
|
||||
const ntime = reader.readU32();
|
||||
const nonce = reader.readU32();
|
||||
const version = reader.readU32();
|
||||
const extranonceSize = reader.readU8();
|
||||
if (extranonceSize !== DATUM_EXTRANONCE_SIZE) {
|
||||
throw new RangeError(`Unsupported DATUM extranonce size ${extranonceSize}`);
|
||||
}
|
||||
const extranonce = reader.readBytes(extranonceSize);
|
||||
const username = readNullTerminatedString(reader);
|
||||
const reserved = reader.readBytes(Math.min(4, reader.remaining));
|
||||
const coinbasePairs = new Map<number, { coinb1: Buffer; coinb2: Buffer }>();
|
||||
const result: DatumPowSubmit = {
|
||||
jobId,
|
||||
coinbaseId,
|
||||
isBlock: (flags & 0x01) !== 0,
|
||||
subsidyOnly: (flags & 0x02) !== 0,
|
||||
quickDiff: (flags & 0x04) !== 0,
|
||||
targetByte,
|
||||
ntime,
|
||||
nonce,
|
||||
version,
|
||||
extranonce,
|
||||
username,
|
||||
reserved,
|
||||
coinbasePairs,
|
||||
};
|
||||
|
||||
while (reader.remaining > 0) {
|
||||
const section = reader.readU8();
|
||||
if (section === 0xfe) {
|
||||
break;
|
||||
}
|
||||
if (section === 0x01) {
|
||||
result.prevBlockHash = reader.readBytes(32);
|
||||
result.targetByteIndex = reader.readU16();
|
||||
result.nBits = reader.readBytes(4);
|
||||
result.coinbaserId = reader.readU8();
|
||||
result.height = reader.readU32();
|
||||
result.coinbaseValue = reader.readU64();
|
||||
result.transactionCount = reader.readU32();
|
||||
result.totalWeight = reader.readU32();
|
||||
result.totalSize = reader.readU32();
|
||||
result.totalSigops = reader.readU32();
|
||||
const merkleBranchCount = reader.readU8();
|
||||
result.merkleBranches = [];
|
||||
for (let i = 0; i < merkleBranchCount; i++) {
|
||||
result.merkleBranches.push(reader.readBytes(32));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (section === 0x02) {
|
||||
const coinbaseType = reader.readU8();
|
||||
const coinb1Len = reader.readU16();
|
||||
const coinb2Len = reader.readU16();
|
||||
const coinbase = {
|
||||
coinb1: reader.readBytes(coinb1Len),
|
||||
coinb2: reader.readBytes(coinb2Len),
|
||||
};
|
||||
if (coinbaseType === 255 || coinbaseId === 255) {
|
||||
result.subsidyOnlyCoinbase = coinbase;
|
||||
} else {
|
||||
coinbasePairs.set(coinbaseType, coinbase);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw new RangeError(`Unsupported DATUM POW section 0x${section.toString(16)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function serializeDatumShareResponse(input: {
|
||||
status: DatumShareResponseStatus;
|
||||
reasonCode: number;
|
||||
nonce: number;
|
||||
targetByte: number;
|
||||
jobId: number;
|
||||
}): Buffer {
|
||||
const w = new BufferWriter();
|
||||
w.writeU8(input.status);
|
||||
w.writeU16(input.reasonCode);
|
||||
w.writeU32(input.nonce);
|
||||
w.writeU8(input.targetByte);
|
||||
w.writeU8(input.jobId);
|
||||
return serializeDatumMiningCommand(DatumMiningCommand.SHARE_RESPONSE, Buffer.from(w.toBuffer()));
|
||||
}
|
||||
|
||||
function deserializeDatumTransactionListResponse(
|
||||
kind: 'full-transactions' | 'full-transaction-blob',
|
||||
reader: BufferReader,
|
||||
jobId: number,
|
||||
status: number,
|
||||
): DatumFullTransactionsResponse | DatumFullTransactionBlobResponse {
|
||||
if (status !== DatumJobValidationStatus.SUCCESS) {
|
||||
return {
|
||||
kind,
|
||||
jobId,
|
||||
status,
|
||||
transactionCount: 0,
|
||||
transactions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const transactionCount = reader.readU16();
|
||||
const transactions: Buffer[] = [];
|
||||
for (let i = 0; i < transactionCount; i++) {
|
||||
const transactionSize = reader.readU24();
|
||||
transactions.push(reader.readBytes(transactionSize));
|
||||
}
|
||||
consumeDatumTerminatorAndPadding(reader);
|
||||
return {
|
||||
kind,
|
||||
jobId,
|
||||
status,
|
||||
transactionCount,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeDatumTerminatorAndPadding(reader: BufferReader): void {
|
||||
if (reader.remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
const terminator = reader.readU8();
|
||||
if (terminator !== 0xfe) {
|
||||
throw new RangeError(`DATUM job validation response missing terminator; got 0x${terminator.toString(16)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readNullTerminatedString(reader: BufferReader): string {
|
||||
const bytes: number[] = [];
|
||||
while (reader.remaining > 0) {
|
||||
const byte = reader.readU8();
|
||||
if (byte === 0) {
|
||||
break;
|
||||
}
|
||||
bytes.push(byte);
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf8');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as sodium from 'libsodium-wrappers-sumo';
|
||||
import {
|
||||
DATUM_INITIAL_HEADER_KEY,
|
||||
DatumProtocolCommand,
|
||||
datumHeaderXorFeedback,
|
||||
decodeDatumHeader,
|
||||
encodeDatumFrame,
|
||||
} from './datum-codec';
|
||||
import { DatumCryptoSession } from './datum-crypto';
|
||||
|
||||
describe('DatumCryptoSession', () => {
|
||||
it('derives stable DATUM public keys from a configured seed', async () => {
|
||||
await sodium.ready;
|
||||
const seed = Buffer.from('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 'hex');
|
||||
|
||||
const first = DatumCryptoSession.generateKeyPairFromSeed(seed);
|
||||
const second = DatumCryptoSession.generateKeyPairFromSeed(seed);
|
||||
|
||||
expect(Buffer.concat([first.edPublicKey, first.xPublicKey]).toString('hex'))
|
||||
.toBe(Buffer.concat([second.edPublicKey, second.xPublicKey]).toString('hex'));
|
||||
expect(first.edSecretKey).toEqual(second.edSecretKey);
|
||||
});
|
||||
|
||||
it('opens signed sealed handshakes and builds encrypted responses', async () => {
|
||||
await sodium.ready;
|
||||
const serverIdentity = DatumCryptoSession.generateKeyPair();
|
||||
const serverSessionKeys = DatumCryptoSession.generateKeyPair();
|
||||
const clientIdentity = DatumCryptoSession.generateKeyPair();
|
||||
const clientSessionKeys = DatumCryptoSession.generateKeyPair();
|
||||
const server = await DatumCryptoSession.create(serverIdentity, serverSessionKeys);
|
||||
const nextHeaderKey = 0x12345678;
|
||||
|
||||
const signedBody = Buffer.concat([
|
||||
clientIdentity.edPublicKey,
|
||||
clientIdentity.xPublicKey,
|
||||
clientSessionKeys.edPublicKey,
|
||||
clientSessionKeys.xPublicKey,
|
||||
Buffer.from('datum-test/0\0', 'utf8'),
|
||||
Buffer.from([0xfe]),
|
||||
uint32(nextHeaderKey),
|
||||
Buffer.from([1, 2, 3]),
|
||||
]);
|
||||
const signature = Buffer.from(sodium.crypto_sign_detached(signedBody, clientIdentity.edSecretKey));
|
||||
const encrypted = Buffer.from(sodium.crypto_box_seal(
|
||||
Buffer.concat([signedBody, signature]),
|
||||
serverIdentity.xPublicKey,
|
||||
));
|
||||
const frame = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: encrypted.length,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: true,
|
||||
isEncryptedChannel: false,
|
||||
protoCmd: DatumProtocolCommand.HANDSHAKE_INIT,
|
||||
},
|
||||
payload: encrypted,
|
||||
}, DATUM_INITIAL_HEADER_KEY);
|
||||
const header = decodeDatumHeader(frame.subarray(0, 4), DATUM_INITIAL_HEADER_KEY);
|
||||
|
||||
const hello = server.openHandshake(frame.subarray(4, 4 + header.cmdLen));
|
||||
expect(hello.userAgent).toBe('datum-test/0');
|
||||
expect(hello.nextHeaderKey).toBe(nextHeaderKey);
|
||||
expect(hello.sessionXPublicKey).toEqual(clientSessionKeys.xPublicKey);
|
||||
|
||||
const response = server.buildHandshakeResponse(hello, 'motd');
|
||||
const responseHeader = decodeDatumHeader(response.subarray(0, 4), datumHeaderXorFeedback((~nextHeaderKey) >>> 0));
|
||||
expect(responseHeader.protoCmd).toBe(DatumProtocolCommand.HANDSHAKE_RESPONSE);
|
||||
expect(responseHeader.isSigned).toBe(true);
|
||||
expect(responseHeader.isEncryptedPubkey).toBe(true);
|
||||
const opened = Buffer.from(sodium.crypto_box_seal_open(
|
||||
response.subarray(4),
|
||||
clientSessionKeys.xPublicKey,
|
||||
clientSessionKeys.xSecretKey,
|
||||
));
|
||||
expect(opened.subarray(0, 32)).toEqual(clientIdentity.edPublicKey);
|
||||
expect(opened.subarray(128, 160)).toEqual(serverSessionKeys.edPublicKey);
|
||||
|
||||
const ping = server.encryptChannelFrame(DatumProtocolCommand.PING, Buffer.alloc(0));
|
||||
const pingHeader = decodeDatumHeader(
|
||||
ping.subarray(0, 4),
|
||||
datumHeaderXorFeedback(datumHeaderXorFeedback((~nextHeaderKey) >>> 0)),
|
||||
);
|
||||
expect(pingHeader.protoCmd).toBe(DatumProtocolCommand.PING);
|
||||
expect(pingHeader.isEncryptedChannel).toBe(true);
|
||||
expect(pingHeader.cmdLen).toBe(sodium.crypto_box_MACBYTES);
|
||||
});
|
||||
});
|
||||
|
||||
function uint32(value: number): Buffer {
|
||||
const result = Buffer.alloc(4);
|
||||
result.writeUInt32LE(value >>> 0, 0);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as sodium from 'libsodium-wrappers-sumo';
|
||||
import {
|
||||
DATUM_INITIAL_HEADER_KEY,
|
||||
DatumFrame,
|
||||
DatumProtocolCommand,
|
||||
decodeDatumHeader,
|
||||
encodeDatumFrame,
|
||||
datumHeaderXorFeedback,
|
||||
} from './datum-codec';
|
||||
|
||||
export interface DatumKeyPair {
|
||||
edPublicKey: Buffer;
|
||||
edSecretKey: Buffer;
|
||||
xPublicKey: Buffer;
|
||||
xSecretKey: Buffer;
|
||||
}
|
||||
|
||||
export interface DatumHello {
|
||||
identityEdPublicKey: Buffer;
|
||||
identityXPublicKey: Buffer;
|
||||
sessionEdPublicKey: Buffer;
|
||||
sessionXPublicKey: Buffer;
|
||||
userAgent: string;
|
||||
nextHeaderKey: number;
|
||||
}
|
||||
|
||||
export class DatumCryptoSession {
|
||||
private initialized = false;
|
||||
private sharedSecret: Uint8Array | null = null;
|
||||
private receiveNonce: Buffer | null = null;
|
||||
private sendNonce: Buffer | null = null;
|
||||
private receiveHeaderKey = DATUM_INITIAL_HEADER_KEY;
|
||||
private sendHeaderKey = DATUM_INITIAL_HEADER_KEY;
|
||||
private clientIdentityXPublicKey: Buffer | null = null;
|
||||
|
||||
private constructor(
|
||||
private readonly identityKeys: DatumKeyPair,
|
||||
private readonly sessionKeys: DatumKeyPair,
|
||||
) {}
|
||||
|
||||
public static async create(identityKeys?: DatumKeyPair, sessionKeys?: DatumKeyPair): Promise<DatumCryptoSession> {
|
||||
await sodium.ready;
|
||||
return new DatumCryptoSession(
|
||||
identityKeys ?? DatumCryptoSession.generateKeyPair(),
|
||||
sessionKeys ?? DatumCryptoSession.generateKeyPair(),
|
||||
);
|
||||
}
|
||||
|
||||
public get publicKeyHex(): string {
|
||||
return Buffer.concat([this.identityKeys.edPublicKey, this.identityKeys.xPublicKey]).toString('hex');
|
||||
}
|
||||
|
||||
public get currentReceiveHeaderKey(): number {
|
||||
return this.receiveHeaderKey;
|
||||
}
|
||||
|
||||
public get currentSendHeaderKey(): number {
|
||||
return this.sendHeaderKey;
|
||||
}
|
||||
|
||||
public openHandshake(framePayload: Buffer): DatumHello {
|
||||
const opened = Buffer.from(sodium.crypto_box_seal_open(
|
||||
framePayload,
|
||||
this.identityKeys.xPublicKey,
|
||||
this.identityKeys.xSecretKey,
|
||||
));
|
||||
if (opened.length < 32 * 4 + 1 + 4 + sodium.crypto_sign_BYTES) {
|
||||
throw new Error('DATUM handshake payload is too short');
|
||||
}
|
||||
|
||||
const signature = opened.subarray(opened.length - sodium.crypto_sign_BYTES);
|
||||
const signedPayload = opened.subarray(0, opened.length - sodium.crypto_sign_BYTES);
|
||||
const clientIdentityEdPublicKey = signedPayload.subarray(0, 32);
|
||||
if (!sodium.crypto_sign_verify_detached(signature, signedPayload, clientIdentityEdPublicKey)) {
|
||||
throw new Error('DATUM handshake signature verification failed');
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const hello: DatumHello = {
|
||||
identityEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
identityXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
sessionEdPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
sessionXPublicKey: Buffer.from(signedPayload.subarray(offset, offset += 32)),
|
||||
userAgent: '',
|
||||
nextHeaderKey: 0,
|
||||
};
|
||||
|
||||
const userAgentStart = offset;
|
||||
while (offset < signedPayload.length && signedPayload[offset] !== 0) {
|
||||
offset++;
|
||||
}
|
||||
hello.userAgent = signedPayload.subarray(userAgentStart, offset).toString('utf8');
|
||||
offset++;
|
||||
|
||||
if (signedPayload[offset] !== 0xfe) {
|
||||
throw new Error('DATUM handshake missing key delimiter');
|
||||
}
|
||||
offset++;
|
||||
hello.nextHeaderKey = signedPayload.readUInt32LE(offset);
|
||||
|
||||
this.clientIdentityXPublicKey = hello.identityXPublicKey;
|
||||
this.sharedSecret = sodium.crypto_box_beforenm(hello.sessionXPublicKey, this.sessionKeys.xSecretKey);
|
||||
this.receiveHeaderKey = datumHeaderXorFeedback(hello.nextHeaderKey);
|
||||
this.sendHeaderKey = datumHeaderXorFeedback((~hello.nextHeaderKey) >>> 0);
|
||||
this.initializeNonces(hello.nextHeaderKey, hello.sessionEdPublicKey);
|
||||
this.initialized = true;
|
||||
|
||||
return hello;
|
||||
}
|
||||
|
||||
public buildHandshakeResponse(hello: DatumHello, motd: string): Buffer {
|
||||
if (this.clientIdentityXPublicKey == null) {
|
||||
throw new Error('Cannot build DATUM handshake response before opening client hello');
|
||||
}
|
||||
|
||||
const body = Buffer.concat([
|
||||
hello.identityEdPublicKey,
|
||||
hello.identityXPublicKey,
|
||||
hello.sessionEdPublicKey,
|
||||
hello.sessionXPublicKey,
|
||||
this.sessionKeys.edPublicKey,
|
||||
this.sessionKeys.xPublicKey,
|
||||
Buffer.from(`${motd}\0`, 'utf8'),
|
||||
]);
|
||||
const signature = Buffer.from(sodium.crypto_sign_detached(body, this.identityKeys.edSecretKey));
|
||||
const encrypted = Buffer.from(sodium.crypto_box_seal(
|
||||
Buffer.concat([body, signature]),
|
||||
hello.sessionXPublicKey,
|
||||
));
|
||||
|
||||
const response = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: encrypted.length,
|
||||
reserved: 0,
|
||||
isSigned: true,
|
||||
isEncryptedPubkey: true,
|
||||
isEncryptedChannel: false,
|
||||
protoCmd: DatumProtocolCommand.HANDSHAKE_RESPONSE,
|
||||
},
|
||||
payload: encrypted,
|
||||
}, this.sendHeaderKey);
|
||||
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
|
||||
return response;
|
||||
}
|
||||
|
||||
public decryptChannelPayload(payload: Buffer): Buffer {
|
||||
if (!this.initialized || this.sharedSecret == null || this.receiveNonce == null) {
|
||||
throw new Error('DATUM channel is not initialized');
|
||||
}
|
||||
const opened = sodium.crypto_box_open_easy_afternm(payload, this.receiveNonce, this.sharedSecret);
|
||||
this.incrementNonce(this.receiveNonce);
|
||||
this.receiveHeaderKey = datumHeaderXorFeedback(this.receiveHeaderKey);
|
||||
return Buffer.from(opened);
|
||||
}
|
||||
|
||||
public encryptChannelFrame(protoCmd: DatumProtocolCommand, payload: Buffer, signed = false): Buffer {
|
||||
if (!this.initialized || this.sharedSecret == null || this.sendNonce == null) {
|
||||
throw new Error('DATUM channel is not initialized');
|
||||
}
|
||||
const signedPayload = signed
|
||||
? Buffer.concat([payload, Buffer.from(sodium.crypto_sign_detached(payload, this.sessionKeys.edSecretKey))])
|
||||
: payload;
|
||||
const encrypted = Buffer.from(sodium.crypto_box_easy_afternm(signedPayload, this.sendNonce, this.sharedSecret));
|
||||
const frame = encodeDatumFrame({
|
||||
header: {
|
||||
cmdLen: encrypted.length,
|
||||
reserved: 0,
|
||||
isSigned: signed,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: true,
|
||||
protoCmd,
|
||||
},
|
||||
payload: encrypted,
|
||||
}, this.sendHeaderKey);
|
||||
this.incrementNonce(this.sendNonce);
|
||||
this.sendHeaderKey = datumHeaderXorFeedback(this.sendHeaderKey);
|
||||
return frame;
|
||||
}
|
||||
|
||||
public decodeHeader(headerBytes: Buffer): ReturnType<typeof decodeDatumHeader> {
|
||||
return decodeDatumHeader(headerBytes, this.receiveHeaderKey);
|
||||
}
|
||||
|
||||
private initializeNonces(nextHeaderKey: number, clientSessionEdPublicKey: Buffer): void {
|
||||
const receiver = Buffer.alloc(sodium.crypto_box_NONCEBYTES);
|
||||
let key = ((nextHeaderKey - 42) ^ clientSessionEdPublicKey.readUInt32LE(7)) >>> 0;
|
||||
for (let i = 0; i < receiver.length; i += 4) {
|
||||
receiver.writeUInt32LE(datumHeaderXorFeedback((key - 42) >>> 0), i);
|
||||
key = (~receiver.readUInt32LE(i)) >>> 0;
|
||||
}
|
||||
|
||||
const sender = Buffer.alloc(receiver.length);
|
||||
for (let i = 0; i < receiver.length; i += 4) {
|
||||
sender.writeUInt32LE((receiver.readUInt32LE(i) ^ 0x57575757) >>> 0, i);
|
||||
}
|
||||
|
||||
this.sendNonce = receiver;
|
||||
this.receiveNonce = sender;
|
||||
}
|
||||
|
||||
private incrementNonce(nonce: Buffer): void {
|
||||
for (let offset = 0; offset < nonce.length; offset += 4) {
|
||||
const next = (nonce.readUInt32LE(offset) + 1) >>> 0;
|
||||
nonce.writeUInt32LE(next, offset);
|
||||
if (next !== 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static generateKeyPair(): DatumKeyPair {
|
||||
const seed = crypto.randomBytes(32);
|
||||
return DatumCryptoSession.generateKeyPairFromSeed(seed);
|
||||
}
|
||||
|
||||
public static generateKeyPairFromSeed(seed: Buffer): DatumKeyPair {
|
||||
if (seed.length !== 32) {
|
||||
throw new RangeError(`DATUM key seed must be 32 bytes, got ${seed.length}`);
|
||||
}
|
||||
const ed = sodium.crypto_sign_seed_keypair(seed);
|
||||
const xPublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(ed.publicKey);
|
||||
const xSecretKey = sodium.crypto_sign_ed25519_sk_to_curve25519(ed.privateKey);
|
||||
return {
|
||||
edPublicKey: Buffer.from(ed.publicKey),
|
||||
edSecretKey: Buffer.from(ed.privateKey),
|
||||
xPublicKey: Buffer.from(xPublicKey),
|
||||
xSecretKey: Buffer.from(xSecretKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDatumPlainFrame(protoCmd: DatumProtocolCommand, payload: Buffer): DatumFrame {
|
||||
return {
|
||||
header: {
|
||||
cmdLen: payload.length,
|
||||
reserved: 0,
|
||||
isSigned: false,
|
||||
isEncryptedPubkey: false,
|
||||
isEncryptedChannel: false,
|
||||
protoCmd,
|
||||
},
|
||||
payload,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user