/* * AereCandidacy.java - the signing helper of the Aere Network validator kit (chain 2800). * * It runs on the CANDIDATE's machine, against the lib/ directory of the candidate's OWN build of the * Aere fork (run-node.sh --no-start builds it from the published patch series), with the JDK's * single-file source launcher: * * java -cp '/build/install/besu/lib/*' AereCandidacy.java [options] * * It uses the classes of that build and nothing else: the registry v3 forms (PqRegistryV3, patch 0008), * the Falcon-512 and SLH-DSA-SHA2-128s schemes the node signs with, Besu's secp256k1 and Besu's own * KeyPairUtil (the code path that creates data/key when a node starts for the first time). * * WHAT IT NEVER DOES: print, log, copy or transmit any private key, or any part of one. It reads the * key files, signs, verifies its own signatures before writing anything, and writes PUBLIC data only. * The shell scripts next to it check that again on their own output (validator-candidacy.sh). * * Commands: * params --params prints "chainId bindHeight count index" * node-key --data-path creates /key with Besu's KeyPairUtil if * (and only if) it does not exist * ids --node-key-file --falcon-key --slh-key prints the public identity (JSON) * candidacy --params

--row --falcon-key --slh-key --node-key-file * --enode-host --p2p-port --issued --clock-measured-at * --clock-head --clock-samples --clock-dmin --clock-dmax --out * clock-report --chain-id --node-key-file --enode-host --p2p-port * --clock-measured-at --clock-head --clock-samples --clock-dmin * --clock-dmax --out * * The two signed messages (also written out in validator/README.md, "The candidacy file", so that * anyone can verify a candidacy without this file): * * candidacy = keccak256("AERE-CANDIDACY-1" || u64 chainId || u64 bindHeight || u32 count || u32 index * || u32 len(issued) || issued (ASCII, YYYY-MM-DD) || id (20 bytes) || rowHash (32 bytes)) * clock = keccak256("AERE-CLOCK-1" || u64 chainId || u64 measuredAt || u64 headNumber || u32 samples * || i32 dMin || i32 dMax) * * signed by: candidacy -> the node key (secp256k1, 65 bytes r||s||v), the Falcon-512 key and the * SLH-DSA-SHA2-128s key of the row; clock -> the node key. Integers are big-endian; i32 is two's * complement. rowHash is the registry v3 row hash (AIP-22 section 3.2, PqRegistryV3.rowHash). The node * key is bound through its ADDRESS: the secp256k1 signatures must recover to the row's ecdsa address, * which is also its transport address, and the enode's public key must hash to that same address. * * SPDX-License-Identifier: Apache-2.0 */ import org.hyperledger.besu.consensus.common.bft.FalconSealScheme; import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; import org.hyperledger.besu.consensus.common.bft.SealScheme; import org.hyperledger.besu.consensus.common.bft.SealSchemes; import org.hyperledger.besu.crypto.Hash; import org.hyperledger.besu.crypto.KeyPair; import org.hyperledger.besu.crypto.KeyPairUtil; import org.hyperledger.besu.crypto.SECPPublicKey; import org.hyperledger.besu.crypto.SECPSignature; import org.hyperledger.besu.crypto.SignatureAlgorithm; import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; import org.hyperledger.besu.ethereum.core.Util; import java.io.IOException; import java.io.StringReader; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.HashMap; import java.util.Iterator; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.Set; import java.util.TreeMap; import java.util.regex.Pattern; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.tuweni.bytes.Bytes; import org.apache.tuweni.bytes.Bytes32; import org.bouncycastle.pqc.crypto.falcon.FalconParameters; import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; public class AereCandidacy { static final String CANDIDACY_DOMAIN = "AERE-CANDIDACY-1"; static final String CLOCK_DOMAIN = "AERE-CLOCK-1"; static final String CANDIDACY_FORMAT = "aere-validator-candidacy/1"; static final String CLOCK_FORMAT = "aere-validator-clock/1"; static final String PARAMS_FORMAT = "aere-admission-params/1"; static final ObjectMapper M = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); static final SignatureAlgorithm EC = SignatureAlgorithmFactory.getInstance(); static final Pattern HOST = Pattern.compile("^([A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\\[[0-9A-Fa-f:.]{2,45}\\])$"); static final Pattern DATE = Pattern.compile("^20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"); public static void main(final String[] args) throws IOException { if (args.length == 0) { die(2, "usage: AereCandidacy.java params|node-key|ids|candidacy|clock-report [options] (see the header)"); } final Map o = new HashMap<>(); for (int i = 1; i < args.length; i++) { if (!args[i].startsWith("--") || i + 1 >= args.length) { die(2, "options are --name value pairs; bad argument: " + args[i]); } o.put(args[i].substring(2), args[++i]); } try { switch (args[0]) { case "params" -> { final long[] p = params(o); System.out.println(p[0] + " " + p[1] + " " + p[2] + " " + p[3]); } case "node-key" -> nodeKey(o); case "ids" -> ids(o); case "candidacy" -> candidacy(o); case "clock-report" -> clockReport(o); default -> die(2, "unknown command " + args[0]); } } catch (final IllegalArgumentException | IllegalStateException e) { // messages here never carry key material: every one of them is written below by hand die(1, "REFUSED: " + e.getMessage()); } } // ---- the admission parameters the Foundation sends (public) ---------------------------------- static long[] params(final Map o) throws IOException { final JsonNode p = M.readTree(Files.readString(Path.of(need(o, "params")), StandardCharsets.UTF_8)); if (p == null || !p.isObject()) { throw new IllegalArgumentException("the admission parameters are not a JSON object"); } final Set allowed = Set.of("format", "chainId", "bindHeight", "count", "index", "note"); for (final Iterator it = p.fieldNames(); it.hasNext(); ) { final String f = it.next(); if (!allowed.contains(f)) { throw new IllegalArgumentException("the admission parameters carry an unknown field: " + f); } } if (!PARAMS_FORMAT.equals(p.path("format").asText())) { throw new IllegalArgumentException("the admission parameters are not of format " + PARAMS_FORMAT); } final long chain = integral(p, "chainId"); final long bind = integral(p, "bindHeight"); final long count = integral(p, "count"); final long index = integral(p, "index"); if (chain <= 0 || bind <= 0 || count < 1 || count > 64 || index < 0 || index >= count) { throw new IllegalArgumentException("the admission parameters are out of range (chainId, bindHeight > 0; 1 <= count <= 64; 0 <= index < count)"); } return new long[] {chain, bind, count, index}; } // ---- the node key: Besu's own path, never a second implementation ----------------------------- static void nodeKey(final Map o) { final Path dir = Path.of(need(o, "data-path")); final Path key = dir.resolve("key"); if (Files.exists(key)) { throw new IllegalStateException(key + " already exists; nothing was created (the node key is never replaced)"); } if (!Files.isDirectory(dir)) { throw new IllegalStateException(dir + " is not a directory"); } final KeyPair kp = KeyPairUtil.loadKeyPair(dir); final ObjectNode out = M.createObjectNode(); out.put("created", key.toString()); out.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); out.put("nodePublicKey", kp.getPublicKey().getEncodedBytes().toHexString()); print(out); } // ---- the public identity ------------------------------------------------------------------------ static void ids(final Map o) throws IOException { final KeyPair kp = nodeKeyPair(need(o, "node-key-file")); final Falcon f = falcon(need(o, "falcon-key"), -1); final Slh s = slh(need(o, "slh-key"), -1); if (f.index != s.index) { throw new IllegalArgumentException("the Falcon key file is for index " + f.index + ", the SLH-DSA key file for index " + s.index); } final ObjectNode out = M.createObjectNode(); out.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); out.put("nodePublicKey", kp.getPublicKey().getEncodedBytes().toHexString()); out.put("index", f.index); out.put("id", hx(PqRegistryV3.idOf(f.std))); out.put("falcon512PublicKeySha256", Hash.sha256(Bytes.wrap(f.std)).toHexString()); out.put("slhDsaSha2128sPublicKey", hx(s.pk)); print(out); } // ---- the candidacy -------------------------------------------------------------------------------- static void candidacy(final Map o) throws IOException { final long[] p = params(o); final long chain = p[0], bind = p[1]; final int count = (int) p[2], index = (int) p[3]; final String issued = need(o, "issued"); if (!DATE.matcher(issued).matches()) { throw new IllegalArgumentException("--issued must be a UTC date YYYY-MM-DD"); } final KeyPair kp = nodeKeyPair(need(o, "node-key-file")); final byte[] addr = Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe(); final byte[] nodePub = kp.getPublicKey().getEncodedBytes().toArrayUnsafe(); final Falcon f = falcon(need(o, "falcon-key"), index); final Slh s = slh(need(o, "slh-key"), index); final String enode = enode(nodePub, o); final Path out = Path.of(need(o, "out")); if (Files.exists(out)) { throw new IllegalStateException(out + " exists; a candidacy file is never overwritten"); } // the row, as PqRegistryV3Tool wrote it: it must be THIS node's keys, bound to THESE parameters final JsonNode rf = M.readTree(Files.readString(Path.of(need(o, "row")), StandardCharsets.UTF_8)); if (rf == null || rf.path("index").asInt(-1) != index || !rf.has("row")) { throw new IllegalArgumentException("the row file is not a PqRegistryV3Tool row for index " + index); } final JsonNode row = rf.get("row"); final byte[] id = hex(text(row, "id")); if (!Arrays.equals(id, PqRegistryV3.idOf(f.std))) { throw new IllegalArgumentException("the row's id is not the derivation of this Falcon key (a new id must be)"); } if (!Arrays.equals(hex(text(row.path("keys"), "falcon-512")), f.std) || !Arrays.equals(hex(text(row.path("keys"), "slh-dsa-sha2-128s")), s.pk)) { throw new IllegalArgumentException("the row carries other public keys than the key files given"); } if (row.has("rotation")) { throw new IllegalArgumentException("the row carries a rotation; a new validator's row never does"); } final byte[] transport = hex(text(row.path("transport"), "secp256k1Address")); final byte[] ecAddr = hex(text(row.path("ecdsa"), "addr")); if (!Arrays.equals(transport, addr) || !Arrays.equals(ecAddr, addr)) { throw new IllegalArgumentException("the row's transport and ecdsa addresses must both be the address of the NODE key" + " (the consensus key is the node key; a validator whose node key differs receives no consensus message)"); } final byte[] falconPop = hex(text(row.path("pop"), "falcon-512")); final byte[] slhPop = hex(text(row.path("pop"), "slh-dsa-sha2-128s")); final TreeMap keys = new TreeMap<>(); keys.put(SealSchemes.FALCON_512.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.FALCON_512, f.std, falconPop)); keys.put(SealSchemes.SLH_DSA_128S.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.SLH_DSA_128S, s.pk, slhPop)); final Bytes32 rowHash = PqRegistryV3.rowHash(chain, bind, count, new PqRegistryV3.Row(index, id, keys, transport, Map.of(), null, null)); // the row's own proofs, checked again here before this node signs anything about it if (!SealSchemes.FALCON_512.verifyRaw(f.raw896, PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.FALCON_512.wireId(), f.std).toArrayUnsafe(), falconPop) || !SealSchemes.SLH_DSA_128S.verifyRaw(s.pk, PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.SLH_DSA_128S.wireId(), s.pk).toArrayUnsafe(), slhPop)) { throw new IllegalStateException("a proof of possession in the row does not verify for these parameters"); } final SECPSignature claim = EC.decodeSignature(Bytes.wrap(hex(text(row.path("ecdsa"), "claim")))); final Optional claimer = EC.recoverPublicKeyFromSignature(PqRegistryV3.claimDigest(chain, rowHash), claim); if (claimer.isEmpty() || !Arrays.equals(Util.publicKeyToAddress(claimer.get()).getBytes().toArrayUnsafe(), addr)) { throw new IllegalStateException("the row's ecdsa claim does not recover to the node key's address"); } // the candidacy proof: three signatures over a message bound to the chain, the epoch, the date and the row final Bytes32 msg = candidacyMessage(chain, bind, count, index, issued, id, rowHash); final SECPSignature es = EC.sign(msg, kp); final byte[] fs = ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(f.sk, msg.toArrayUnsafe()) .orElseThrow(() -> new IllegalStateException("the Falcon key did not sign")); final byte[] ss = SealSchemes.SLH_DSA_128S.sign(s.sk, msg.toArrayUnsafe()) .orElseThrow(() -> new IllegalStateException("the SLH-DSA key did not sign")); selfCheck(msg, es, nodePub, f, fs, s, ss); final ObjectNode clock = clock(o, chain, nodePub, kp); final ObjectNode c = M.createObjectNode(); c.put("format", CANDIDACY_FORMAT); c.put("chainId", chain); c.put("bindHeight", bind); c.put("count", count); c.put("index", index); c.put("issued", issued); c.put("enode", enode); c.put("address", hx(addr)); c.put("id", hx(id)); c.set("row", row); c.put("rowHash", rowHash.toHexString()); final ObjectNode pr = c.putObject("proof"); pr.put("domain", CANDIDACY_DOMAIN); pr.put("message", msg.toHexString()); pr.put("secp256k1", es.encodedBytes().toHexString()); pr.put("falcon-512", hx(fs)); pr.put("slh-dsa-sha2-128s", hx(ss)); c.set("clock", clock); Files.writeString(out, M.writeValueAsString(c) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); final ObjectNode sum = M.createObjectNode(); sum.put("written", out.toString()); sum.put("enode", enode); sum.put("address", hx(addr)); sum.put("id", hx(id)); sum.put("rowHash", rowHash.toHexString()); print(sum); } static void clockReport(final Map o) throws IOException { final long chain = Long.parseLong(need(o, "chain-id")); final KeyPair kp = nodeKeyPair(need(o, "node-key-file")); final byte[] nodePub = kp.getPublicKey().getEncodedBytes().toArrayUnsafe(); final Path out = Path.of(need(o, "out")); if (Files.exists(out)) { throw new IllegalStateException(out + " exists; a clock report is never overwritten"); } final ObjectNode c = M.createObjectNode(); c.put("format", CLOCK_FORMAT); c.put("chainId", chain); c.put("enode", enode(nodePub, o)); c.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); c.set("clock", clock(o, chain, nodePub, kp)); Files.writeString(out, M.writeValueAsString(c) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); final ObjectNode sum = M.createObjectNode(); sum.put("written", out.toString()); sum.set("clock", c.get("clock")); print(sum); } // ---- messages ------------------------------------------------------------------------------------- static Bytes32 candidacyMessage(final long chain, final long bind, final int count, final int index, final String issued, final byte[] id, final Bytes32 rowHash) { final byte[] d = CANDIDACY_DOMAIN.getBytes(StandardCharsets.US_ASCII); final byte[] is = issued.getBytes(StandardCharsets.US_ASCII); final ByteBuffer b = ByteBuffer.allocate(d.length + 8 + 8 + 4 + 4 + 4 + is.length + 20 + 32); b.put(d).putLong(chain).putLong(bind).putInt(count).putInt(index).putInt(is.length).put(is).put(id) .put(rowHash.toArrayUnsafe()); return Hash.keccak256(Bytes.wrap(b.array())); } static Bytes32 clockMessage(final long chain, final long measuredAt, final long head, final int samples, final int dMin, final int dMax) { final byte[] d = CLOCK_DOMAIN.getBytes(StandardCharsets.US_ASCII); final ByteBuffer b = ByteBuffer.allocate(d.length + 8 + 8 + 8 + 4 + 4 + 4); b.put(d).putLong(chain).putLong(measuredAt).putLong(head).putInt(samples).putInt(dMin).putInt(dMax); return Hash.keccak256(Bytes.wrap(b.array())); } static ObjectNode clock(final Map o, final long chain, final byte[] nodePub, final KeyPair kp) { final long at = Long.parseLong(need(o, "clock-measured-at")); final long head = Long.parseLong(need(o, "clock-head")); final int samples = Integer.parseInt(need(o, "clock-samples")); final int dMin = Integer.parseInt(need(o, "clock-dmin")); final int dMax = Integer.parseInt(need(o, "clock-dmax")); if (samples < 1 || dMin > dMax || head < 1 || at < 1) { throw new IllegalArgumentException("the clock measurement is malformed"); } final Bytes32 cm = clockMessage(chain, at, head, samples, dMin, dMax); final SECPSignature cs = EC.sign(cm, kp); final Optional back = EC.recoverPublicKeyFromSignature(cm, cs); if (back.isEmpty() || !Arrays.equals(back.get().getEncodedBytes().toArrayUnsafe(), nodePub)) { throw new IllegalStateException("the clock signature does not recover to the node key; nothing written"); } final ObjectNode c = M.createObjectNode(); c.put("domain", CLOCK_DOMAIN); c.put("measuredAt", at); c.put("headNumber", head); c.put("samples", samples); c.put("dMin", dMin); c.put("dMax", dMax); c.put("secp256k1", cs.encodedBytes().toHexString()); return c; } static void selfCheck(final Bytes32 msg, final SECPSignature es, final byte[] nodePub, final Falcon f, final byte[] fs, final Slh s, final byte[] ss) { final Optional back = EC.recoverPublicKeyFromSignature(msg, es); if (back.isEmpty() || !Arrays.equals(back.get().getEncodedBytes().toArrayUnsafe(), nodePub)) { throw new IllegalStateException("the secp256k1 signature does not recover to the node key; nothing written"); } if (!SealSchemes.FALCON_512.verifyRaw(f.raw896, msg.toArrayUnsafe(), fs)) { throw new IllegalStateException("the Falcon-512 signature does not verify; nothing written"); } if (!SealSchemes.SLH_DSA_128S.verifyRaw(s.pk, msg.toArrayUnsafe(), ss)) { throw new IllegalStateException("the SLH-DSA signature does not verify; nothing written"); } } // ---- key files (read, never printed) ----------------------------------------------------------- record Falcon(int index, FalconPrivateKeyParameters sk, byte[] raw896, byte[] std) {} record Slh(int index, SealScheme.PrivateHandle sk, byte[] pk) {} static Falcon falcon(final String file, final int wantIndex) throws IOException { final Properties fp = new Properties(); fp.load(new StringReader(Files.readString(Path.of(file), StandardCharsets.UTF_8))); final int index = Integer.parseInt(prop(fp, "index", "the Falcon key file")); if (wantIndex >= 0 && index != wantIndex) { throw new IllegalArgumentException("the Falcon key file is for index " + index + ", the admission parameters say " + wantIndex); } final byte[] h = hex(prop(fp, "pk", "the Falcon key file")); if (h.length != PqRegistryV3.FALCON_512_PK_LENGTH - 1) { throw new IllegalArgumentException("the Falcon key file's public key is not 896 bytes"); } final FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(FalconParameters.falcon_512, hex(prop(fp, "f", "the Falcon key file")), hex(prop(fp, "g", "the Falcon key file")), hex(prop(fp, "F", "the Falcon key file")), h); final byte[] std = new byte[PqRegistryV3.FALCON_512_PK_LENGTH]; std[0] = PqRegistryV3.FALCON_512_PK_HEADER; System.arraycopy(h, 0, std, 1, h.length); // the private half must match the public half: sign a probe and verify it under the public key final byte[] probe = Hash.keccak256(Bytes.wrap("AERE-KIT-PROBE".getBytes(StandardCharsets.US_ASCII))).toArrayUnsafe(); final byte[] sig = ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(sk, probe) .orElseThrow(() -> new IllegalArgumentException("the Falcon key does not sign")); if (!SealSchemes.FALCON_512.verifyRaw(h, probe, sig)) { throw new IllegalArgumentException("the Falcon key file's private and public halves do not match"); } return new Falcon(index, sk, h, std); } static Slh slh(final String file, final int wantIndex) throws IOException { final Properties sp = new Properties(); sp.load(new StringReader(Files.readString(Path.of(file), StandardCharsets.UTF_8))); final int index = Integer.parseInt(prop(sp, "index", "the SLH-DSA key file")); if (wantIndex >= 0 && index != wantIndex) { throw new IllegalArgumentException("the SLH-DSA key file is for index " + index + ", the admission parameters say " + wantIndex); } if (!"slh-dsa-sha2-128s".equals(sp.getProperty("alg", "").trim())) { throw new IllegalArgumentException("the SLH-DSA key file does not name alg=slh-dsa-sha2-128s"); } final SealScheme.PrivateHandle sk = SealSchemes.SLH_DSA_128S.parsePrivateKey(hex(prop(sp, "sk", "the SLH-DSA key file"))) .orElseThrow(() -> new IllegalArgumentException("the SLH-DSA private key does not parse")); final byte[] pk = hex(prop(sp, "pk", "the SLH-DSA key file")); final byte[] probe = Hash.keccak256(Bytes.wrap("AERE-KIT-PROBE".getBytes(StandardCharsets.US_ASCII))).toArrayUnsafe(); final byte[] sig = SealSchemes.SLH_DSA_128S.sign(sk, probe).orElseThrow(() -> new IllegalArgumentException("the SLH-DSA key does not sign")); if (!SealSchemes.SLH_DSA_128S.verifyRaw(pk, probe, sig)) { throw new IllegalArgumentException("the SLH-DSA key file's private and public halves do not match"); } return new Slh(index, sk, pk); } static KeyPair nodeKeyPair(final String file) throws IOException { final String t = Files.readString(Path.of(file), StandardCharsets.UTF_8).trim(); if (!t.matches("^(0x)?[0-9a-fA-F]{64}$")) { throw new IllegalArgumentException("the node key file does not hold a 32-byte hex key"); } return EC.createKeyPair(EC.createPrivateKey(Bytes32.fromHexString(t))); } static String enode(final byte[] nodePub, final Map o) { final String host = need(o, "enode-host"); final int port = Integer.parseInt(need(o, "p2p-port")); if (!HOST.matcher(host).matches() || port < 1 || port > 65535) { throw new IllegalArgumentException("--enode-host must be a host name or an IP address and --p2p-port a port number"); } return "enode://" + hx(nodePub).substring(2) + "@" + host + ":" + port; } // ---- small helpers ----------------------------------------------------------------------------- static String prop(final Properties p, final String k, final String what) { final String v = p.getProperty(k); if (v == null || v.isBlank()) { throw new IllegalArgumentException(what + " has no '" + k + "' entry"); } return v.trim(); } static long integral(final JsonNode p, final String k) { final JsonNode v = p.get(k); if (v == null || !v.isIntegralNumber()) { throw new IllegalArgumentException("the admission parameters need the integer field " + k); } return v.asLong(); } static String text(final JsonNode n, final String k) { final JsonNode v = n == null ? null : n.get(k); if (v == null || !v.isTextual()) { throw new IllegalArgumentException("the row has no text field " + k); } return v.asText(); } static String need(final Map o, final String k) { final String v = o.get(k); if (v == null || v.isBlank()) { throw new IllegalArgumentException("missing --" + k); } return v; } static byte[] hex(final String s) { return Bytes.fromHexStringLenient(s.trim()).toArrayUnsafe(); } static String hx(final byte[] b) { final StringBuilder sb = new StringBuilder("0x"); for (final byte x : b) { sb.append(String.format(Locale.ROOT, "%02x", x & 0xff)); } return sb.toString(); } static void print(final ObjectNode n) { try { System.out.println(M.writeValueAsString(n)); } catch (final IOException e) { die(2, "cannot write output"); } } static void die(final int code, final String msg) { System.err.println(msg); System.exit(code); } }