Files
n_signer/examples/n_signer_qube_example_fips.js

258 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
/**
* n_signer_qube_example.js — connect to a running n_signer over TCP (FIPS mesh)
* and call get_public_key for nostr_index 0 and 1, printing both hex pubkey
* and bech32 npub for each.
*
* This is a cross-qube test client for Qubes OS: the signer runs in the
* nostr_signer qube listening on tcp:[::]:11111, and this client runs in
* a different qube connecting to the signer's FIPS address.
*
* Usage:
* node n_signer_qube_example.js [host] [port]
* node n_signer_qube_example.js fd56:d7c3:f605:719d:15b:18a0:fb06:982f 11111
*
* If no arguments are given, defaults to localhost:11111.
*
* Protocol:
* - 4-byte big-endian length prefix + JSON payload (TCP framing)
* - JSON-RPC: {"id":"...","method":"get_public_key","params":[{"nostr_index":N}],"auth":{...}}
* - Auth envelope: kind 27235 Nostr event with nsigner_rpc, nsigner_method,
* nsigner_body_hash tags. body_hash = sha256(json.dumps(params))
* - Response: {"id":"...","result":"<pubkey hex>"} or {"id":"...","error":{...}}
*/
const net = require("net");
const crypto = require("crypto");
const secp = require("@noble/secp256k1");
// @noble/secp256k1 v3 requires us to provide sync sha256/hmacSha256
secp.hashes.sha256 = (msg) => new Uint8Array(crypto.createHash("sha256").update(msg).digest());
secp.hashes.hmacSha256 = (key, msg) =>
new Uint8Array(crypto.createHmac("sha256", key).update(msg).digest());
const { schnorr } = secp;
/** Convert a Uint8Array to a hex string. */
function bytesToHex(bytes) {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
/** Convert a hex string to a Uint8Array. */
function hexToBytes(hex) {
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < arr.length; i++) {
arr[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return arr;
}
// Demo caller key (32 bytes). Replace with your stable caller key in real use.
const DEMO_PRIVKEY = new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1));
/**
* Compute the body hash for a params array.
* This must match n_signer's hashing: sha256 of canonical JSON (no spaces).
*/
function computeBodyHash(params) {
const canonical = JSON.stringify(params);
return crypto.createHash("sha256").update(canonical).digest("hex");
}
/**
* Build a kind-27235 auth envelope for the given method and params.
* Returns the auth object to include in the JSON-RPC request.
*/
function buildAuthEnvelope(privkey, method, params, requestId) {
const bodyHash = computeBodyHash(params);
const pubkeyHex = bytesToHex(schnorr.getPublicKey(privkey));
const createdAt = Math.floor(Date.now() / 1000);
// nsigner_rpc tag value MUST match the JSON-RPC request "id" field
const tags = [
["nsigner_rpc", requestId],
["nsigner_method", method],
["nsigner_body_hash", bodyHash],
];
const content = "js-example";
// Serialize the event for signing: [0, pubkey, created_at, kind, tags, content]
const serialized = JSON.stringify([
0,
pubkeyHex,
createdAt,
27235,
tags,
content,
]);
const eventId = crypto.createHash("sha256").update(serialized).digest("hex");
const sig = bytesToHex(schnorr.sign(hexToBytes(eventId), privkey, new Uint8Array(32)));
return {
id: eventId,
pubkey: pubkeyHex,
created_at: createdAt,
kind: 27235,
tags: tags,
content: content,
sig: sig,
};
}
/**
* Send a framed JSON-RPC request over a TCP socket and receive the response.
* Framing: 4-byte big-endian length prefix + JSON payload.
*/
function sendRequest(socket, request) {
return new Promise((resolve, reject) => {
const payload = Buffer.from(JSON.stringify(request), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32BE(payload.length, 0);
socket.write(Buffer.concat([header, payload]));
let headerBuf = Buffer.alloc(0);
let bodyBuf = Buffer.alloc(0);
let bodyLen = 0;
let state = "header";
const onData = (chunk) => {
if (state === "header") {
headerBuf = Buffer.concat([headerBuf, chunk]);
if (headerBuf.length >= 4) {
bodyLen = headerBuf.readUInt32BE(0);
const remaining = headerBuf.subarray(4);
headerBuf = Buffer.alloc(0);
state = "body";
if (remaining.length > 0) {
bodyBuf = Buffer.concat([bodyBuf, remaining]);
}
if (bodyBuf.length >= bodyLen) {
finish();
}
}
} else if (state === "body") {
bodyBuf = Buffer.concat([bodyBuf, chunk]);
if (bodyBuf.length >= bodyLen) {
finish();
}
}
};
function finish() {
socket.off("data", onData);
socket.off("error", onError);
const body = bodyBuf.subarray(0, bodyLen).toString("utf8");
try {
resolve(JSON.parse(body));
} catch (e) {
reject(new Error(`Failed to parse response: ${e.message}`));
}
}
function onError(err) {
socket.off("data", onData);
reject(err);
}
socket.on("data", onData);
socket.on("error", onError);
});
}
/**
* Query get_public_key for a given nostr_index.
* Opens a fresh TCP connection for each request (n_signer handles one request
* per connection).
*/
async function getPublicKey(host, port, nostrIndex, privkey) {
const params = [{ nostr_index: nostrIndex }];
const requestId = String(nostrIndex);
const auth = buildAuthEnvelope(privkey, "get_public_key", params, requestId);
const request = {
id: requestId,
method: "get_public_key",
params: params,
auth: auth,
};
return new Promise((resolve, reject) => {
const socket = new net.Socket();
socket.setTimeout(15000);
socket.connect(port, host, async () => {
try {
const response = await sendRequest(socket, request);
socket.destroy();
resolve(response);
} catch (e) {
socket.destroy();
reject(e);
}
});
socket.on("timeout", () => {
socket.destroy();
reject(new Error("Connection timed out"));
});
socket.on("error", (err) => {
reject(err);
});
});
}
/**
* Convert a 32-byte hex pubkey to bech32 npub format (NIP-19).
*/
function hexToNpub(pubkeyHex) {
const { nip19 } = require("nostr-tools");
return nip19.npubEncode(pubkeyHex);
}
async function main() {
const host = process.argv[2] || "127.0.0.1";
const port = parseInt(process.argv[3] || "11111", 10);
console.log(`Connecting to n_signer at ${host}:${port}`);
console.log("Querying get_public_key for nostr_index 0 and 1...\n");
let failures = 0;
for (const index of [0, 1]) {
// Small delay between requests to avoid auth nonce collision
// (the auth envelope uses created_at as part of the nonce)
if (index > 0) await new Promise((r) => setTimeout(r, 1100));
try {
const response = await getPublicKey(host, port, index, DEMO_PRIVKEY);
if (response.error) {
console.log(`index ${index}: ERROR: ${JSON.stringify(response.error)}`);
failures++;
continue;
}
const pubkeyHex = response.result;
const npub = hexToNpub(pubkeyHex);
console.log(`index ${index}: hex=${pubkeyHex} npub=${npub}`);
} catch (e) {
console.log(`index ${index}: FAILED - ${e.message}`);
failures++;
}
}
if (failures > 0) {
console.log(`\n${failures} query(s) failed`);
process.exit(1);
}
console.log("\nAll queries succeeded");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});