@tech-bureau/mijin-catapult-tools
Version:
This tool is for easy operation of mijin Catapult(v.2)
185 lines (164 loc) • 7.28 kB
text/typescript
/*
* Copyright 2022 Fernando Boucquez
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as noble from '@noble/ed25519'
import { existsSync, lstatSync, readdirSync, readFileSync } from 'fs'
import { join } from 'path'
import { Convert, Crypto } from '@tech-bureau/symbol-sdk'
import * as nacl from 'tweetnacl'
export interface KeyPair {
privateKey: Uint8Array
publicKey: Uint8Array
}
export interface CryptoImplementation {
name: string
createKeyPairFromPrivateKey: (privateKey: Uint8Array) => Promise<KeyPair>
sign: (keyPair: KeyPair, data: Uint8Array) => Promise<Uint8Array>
}
export interface VotingKeyAccount {
readonly startEpoch: number
readonly endEpoch: number
readonly publicKey: string
}
export type VotingKeyFile = VotingKeyAccount & { filename: string }
export class VotingUtils {
public static nobleImplementation: CryptoImplementation = {
name: 'Noble',
createKeyPairFromPrivateKey: async (privateKey: Uint8Array): Promise<KeyPair> => {
const publicKey = await noble.getPublicKey(privateKey)
return { privateKey, publicKey: publicKey }
},
sign: async (keyPair: KeyPair, data: Uint8Array): Promise<Uint8Array> => {
return await noble.sign(data, keyPair.privateKey)
},
}
public static tweetNaClImplementation: CryptoImplementation = {
name: 'TweetNaCl',
createKeyPairFromPrivateKey: async (privateKey: Uint8Array): Promise<KeyPair> => {
const { publicKey } = nacl.sign.keyPair.fromSeed(privateKey)
return { privateKey, publicKey }
},
sign: async (keyPair: KeyPair, data: Uint8Array): Promise<Uint8Array> => {
const secretKey = new Uint8Array(64)
secretKey.set(keyPair.privateKey)
secretKey.set(keyPair.publicKey, 32)
return nacl.sign.detached(data, secretKey)
},
}
public static implementations = [VotingUtils.nobleImplementation, VotingUtils.tweetNaClImplementation]
constructor(private readonly implementation: CryptoImplementation = VotingUtils.nobleImplementation) {}
public insert(result: Uint8Array, value: Uint8Array, index: number): number {
result.set(value, index)
return index + value.length
}
public async createVotingFile(
secret: string,
votingKeyStartEpoch: number,
votingKeyEndEpoch: number,
unitTestPrivateKeys: Uint8Array[] | undefined = undefined
): Promise<Uint8Array> {
const items = votingKeyEndEpoch - votingKeyStartEpoch + 1
const headerSize = 64 + 16
const itemSize = 32 + 64
const totalSize = headerSize + items * itemSize
const rootPrivateKey = await this.implementation.createKeyPairFromPrivateKey(Convert.hexToUint8(secret))
const result = new Uint8Array(totalSize)
//start-epoch (8b),
let index = 0
index = this.insert(result, Convert.numberToUint8Array(votingKeyStartEpoch, 8), index)
//end-epoch (8b),
index = this.insert(result, Convert.numberToUint8Array(votingKeyEndEpoch, 8), index)
// could it have other values????
//last key identifier (8b) - for fresh file this is 0xFFFF'FFFF'FFFF'FFFF (a.k.a. Invalid_Id)
index = this.insert(result, Convert.hexToUint8('FFFFFFFFFFFFFFFF'), index)
//last wipe key identifier (8b) - again, for fresh file this is 0xFFFF'FFFF'FFFF'FFFF (Invalid_Id)
index = this.insert(result, Convert.hexToUint8('FFFFFFFFFFFFFFFF'), index)
// root public key (32b) - this is root public key that is getting announced via vote link tx
index = this.insert(result, rootPrivateKey.publicKey, index)
// start-epoch (8b), \ those two are exactly same one, as top level, reason is this was earlier a tree,
index = this.insert(result, Convert.numberToUint8Array(votingKeyStartEpoch, 8), index)
//end-epoch (8b), / and each level holds this separately, so we left it as is
index = this.insert(result, Convert.numberToUint8Array(votingKeyEndEpoch, 8), index)
/// what follows are bound keys, there are (end - start + 1) of them.
// each key is:
for (let i = 0; i < items; i++) {
// random PRIVATE key (32b)
const randomPrivateKey = unitTestPrivateKeys ? unitTestPrivateKeys[i] : Crypto.randomBytes(32)
if (randomPrivateKey.length != 32) {
throw new Error(`Invalid private key size ${randomPrivateKey.length}!`)
}
const randomKeyPar = await this.implementation.createKeyPairFromPrivateKey(randomPrivateKey)
index = this.insert(result, randomPrivateKey, index)
// signature (64b)
// now the signature is usual signature done using ROOT private key on a following data:
// (public key (32b), identifier (8b))
//
// identifier is simply epoch, but, most importantly keys are written in REVERSE order.
//
// i.e. say your start-epoch = 2, end-epoch = 42
const identifier = Convert.numberToUint8Array(votingKeyEndEpoch - i, 8)
const signature = await this.implementation.sign(
rootPrivateKey,
Uint8Array.from([...randomKeyPar.publicKey, ...identifier])
)
index = this.insert(result, signature, index)
}
//
// root private key is discarded after file is created.
// header:
// 2, 42, ff.., ff..., (root pub), 2, 42
// keys:
// (priv key 42, sig 42), (priv key 41, sig 31), ..., (priv key 2, sig 2)
//
// every priv key should be cryptographically random,
return result
}
public readVotingFile(file: Uint8Array): VotingKeyAccount {
//start-epoch (8b),
const votingKeyStartEpoch = Convert.uintArray8ToNumber(file.slice(0, 8))
//end-epoch (8b),
const votingKeyEndEpoch = Convert.uintArray8ToNumber(file.slice(8, 16))
const votingPublicKey = Convert.uint8ToHex(file.slice(32, 64))
const items = votingKeyEndEpoch - votingKeyStartEpoch + 1
const headerSize = 64 + 16
const itemSize = 32 + 64
const totalSize = headerSize + items * itemSize
if (file.length != totalSize) {
throw new Error(`Unexpected voting key file. Expected ${totalSize} but got ${file.length}`)
}
return {
publicKey: votingPublicKey,
startEpoch: votingKeyStartEpoch,
endEpoch: votingKeyEndEpoch,
}
}
public loadVotingFiles(folder: string): VotingKeyFile[] {
if (!existsSync(folder)) {
return []
}
return readdirSync(folder)
.map((filename: string) => {
const currentPath = join(folder, filename)
if (lstatSync(currentPath).isFile() && filename.startsWith('private_key_tree') && filename.endsWith('.dat')) {
return { ...this.readVotingFile(readFileSync(currentPath)), filename }
} else {
return undefined
}
})
.filter((i) => i)
.map((i) => i as VotingKeyFile)
.sort((a, b) => a.startEpoch - b.startEpoch)
}
}