258 lines
7.4 KiB
JavaScript
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:[::]:8080, 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 8080
|
|
*
|
|
* If no arguments are given, defaults to localhost:8080.
|
|
*
|
|
* 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] || "8080", 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);
|
|
});
|