226 lines
7.1 KiB
JavaScript
226 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* demo_javascript.js — comprehensive JavaScript demo for connecting to a
|
|
* running n_signer via Qubes qrexec and performing all three core operations:
|
|
*
|
|
* 1. get_public_key — retrieve a Nostr public key by nostr_index
|
|
* 2. sign_event — sign a Nostr event (kind 1 text note)
|
|
* 3. nip44_encrypt — encrypt a message to a peer (and decrypt it back)
|
|
*
|
|
* Uses qrexec-client-vm (Qubes OS inter-qube IPC). No auth envelope needed —
|
|
* identity comes from QREXEC_REMOTE_DOMAIN on the server side.
|
|
*
|
|
* Prerequisites:
|
|
* - n_signer running in the target qube with --bridge-source-trusted
|
|
* - qubes.NsignerRpc service installed in the target qube
|
|
* - dom0 qrexec policy allowing this qube to call the service
|
|
* - nostr-tools and @noble/secp256k1 npm packages installed
|
|
*
|
|
* Install dependencies (from n_signer repo root):
|
|
* npm install nostr-tools @noble/secp256k1
|
|
*
|
|
* Usage:
|
|
* node client/demo_javascript.js <target_qube> [nostr_index]
|
|
* node client/demo_javascript.js nostr_signer 1
|
|
*
|
|
* If no nostr_index is given, defaults to 0.
|
|
*/
|
|
|
|
const { spawn } = require("child_process");
|
|
const crypto = require("crypto");
|
|
const secp = require("@noble/secp256k1");
|
|
const { nip19 } = require("nostr-tools");
|
|
|
|
// @noble/secp256k1 v3 requires 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());
|
|
|
|
/**
|
|
* Call n_signer via qrexec. Sends one framed JSON-RPC request, receives one
|
|
* framed response. Each call spawns a fresh qrexec-client-vm process.
|
|
*
|
|
* Framing: 4-byte big-endian length prefix + JSON payload.
|
|
* No auth envelope needed for qrexec (identity from QREXEC_REMOTE_DOMAIN).
|
|
*/
|
|
function callNsigner(targetQube, request) {
|
|
return new Promise((resolve, reject) => {
|
|
const payload = Buffer.from(JSON.stringify(request), "utf8");
|
|
const header = Buffer.alloc(4);
|
|
header.writeUInt32BE(payload.length, 0);
|
|
const framed = Buffer.concat([header, payload]);
|
|
|
|
const proc = spawn("qrexec-client-vm", [targetQube, "qubes.NsignerRpc"], {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
const stdoutChunks = [];
|
|
const stderrChunks = [];
|
|
|
|
proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
|
|
proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
|
|
proc.on("error", (err) => {
|
|
reject(new Error(`failed to spawn qrexec-client-vm: ${err.message}`));
|
|
});
|
|
|
|
proc.on("close", (code) => {
|
|
if (code !== 0) {
|
|
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
reject(new Error(`qrexec-client-vm exited with code ${code}: ${stderr.trim()}`));
|
|
return;
|
|
}
|
|
const buf = Buffer.concat(stdoutChunks);
|
|
if (buf.length < 4) {
|
|
reject(new Error("short response (missing frame header)"));
|
|
return;
|
|
}
|
|
const len = buf.readUInt32BE(0);
|
|
const body = buf.subarray(4, 4 + len);
|
|
if (body.length !== len) {
|
|
reject(new Error(`short response payload: expected ${len}, got ${body.length}`));
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(body.toString("utf8")));
|
|
} catch (e) {
|
|
reject(new Error(`failed to parse response: ${e.message}`));
|
|
}
|
|
});
|
|
|
|
proc.stdin.write(framed);
|
|
proc.stdin.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Demo 1: Get a public key by nostr_index.
|
|
*/
|
|
async function demoGetPublicKey(targetQube, nostrIndex) {
|
|
console.log(`\n=== Demo 1: get_public_key (nostr_index=${nostrIndex}) ===`);
|
|
|
|
const response = await callNsigner(targetQube, {
|
|
id: "1",
|
|
method: "get_public_key",
|
|
params: [{ nostr_index: nostrIndex }],
|
|
});
|
|
|
|
if (response.error) {
|
|
throw new Error(`get_public_key failed: ${JSON.stringify(response.error)}`);
|
|
}
|
|
|
|
const pubkeyHex = response.result;
|
|
const npub = nip19.npubEncode(pubkeyHex);
|
|
console.log(` pubkey hex: ${pubkeyHex}`);
|
|
console.log(` npub: ${npub}`);
|
|
return pubkeyHex;
|
|
}
|
|
|
|
/**
|
|
* Demo 2: Sign a Nostr event (kind 1 text note).
|
|
*/
|
|
async function demoSignEvent(targetQube, nostrIndex, pubkeyHex) {
|
|
console.log("\n=== Demo 2: sign_event (kind 1 text note) ===");
|
|
|
|
const unsignedEvent = {
|
|
kind: 1,
|
|
content: "Hello from n_signer JavaScript demo!",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [],
|
|
pubkey: pubkeyHex,
|
|
};
|
|
|
|
console.log(" Unsigned event:");
|
|
console.log(` ${JSON.stringify(unsignedEvent)}`);
|
|
|
|
const response = await callNsigner(targetQube, {
|
|
id: "2",
|
|
method: "sign_event",
|
|
params: [JSON.stringify(unsignedEvent), { nostr_index: nostrIndex }],
|
|
});
|
|
|
|
if (response.error) {
|
|
throw new Error(`sign_event failed: ${JSON.stringify(response.error)}`);
|
|
}
|
|
|
|
const signedEvent = JSON.parse(response.result);
|
|
console.log(" Signed event:");
|
|
console.log(` ${JSON.stringify(signedEvent)}`);
|
|
console.log(` event id: ${signedEvent.id}`);
|
|
console.log(` signature: ${signedEvent.sig}`);
|
|
return signedEvent;
|
|
}
|
|
|
|
/**
|
|
* Demo 3: NIP-44 encrypt and decrypt.
|
|
* Encrypts a message to ourselves (using our own pubkey as the peer),
|
|
* then decrypts it to verify round-trip.
|
|
*/
|
|
async function demoNip44(targetQube, nostrIndex, pubkeyHex) {
|
|
const plaintext = "Secret message from n_signer JavaScript demo!";
|
|
|
|
console.log("\n=== Demo 3: nip44_encrypt / nip44_decrypt ===");
|
|
console.log(` plaintext: "${plaintext}"`);
|
|
console.log(` peer pubkey: ${pubkeyHex} (self)`);
|
|
|
|
// Encrypt
|
|
const encResponse = await callNsigner(targetQube, {
|
|
id: "3",
|
|
method: "nip44_encrypt",
|
|
params: [pubkeyHex, plaintext, { nostr_index: nostrIndex }],
|
|
});
|
|
|
|
if (encResponse.error) {
|
|
throw new Error(`nip44_encrypt failed: ${JSON.stringify(encResponse.error)}`);
|
|
}
|
|
|
|
const ciphertext = encResponse.result;
|
|
console.log(` ciphertext: ${ciphertext}`);
|
|
|
|
// Decrypt
|
|
const decResponse = await callNsigner(targetQube, {
|
|
id: "4",
|
|
method: "nip44_decrypt",
|
|
params: [pubkeyHex, ciphertext, { nostr_index: nostrIndex }],
|
|
});
|
|
|
|
if (decResponse.error) {
|
|
throw new Error(`nip44_decrypt failed: ${JSON.stringify(decResponse.error)}`);
|
|
}
|
|
|
|
const decrypted = decResponse.result;
|
|
console.log(` decrypted: "${decrypted}"`);
|
|
|
|
if (plaintext === decrypted) {
|
|
console.log(" ✓ Round-trip verified: plaintext matches decrypted");
|
|
} else {
|
|
throw new Error("Round-trip FAILED: plaintext does not match decrypted");
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const targetQube = process.argv[2] || "nostr_signer";
|
|
const nostrIndex = parseInt(process.argv[3] || "0", 10);
|
|
|
|
console.log("=== n_signer JavaScript Demo ===");
|
|
console.log(`Target qube: ${targetQube}`);
|
|
console.log(`Service: qubes.NsignerRpc`);
|
|
console.log(`nostr_index: ${nostrIndex}`);
|
|
console.log("\nConnecting to n_signer via qrexec...");
|
|
|
|
try {
|
|
const pubkeyHex = await demoGetPublicKey(targetQube, nostrIndex);
|
|
await demoSignEvent(targetQube, nostrIndex, pubkeyHex);
|
|
await demoNip44(targetQube, nostrIndex, pubkeyHex);
|
|
|
|
console.log("\n=== Summary ===");
|
|
console.log("All demos completed successfully.");
|
|
} catch (e) {
|
|
console.error("\n=== Summary ===");
|
|
console.error(`Demo failed: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|