From 1b5af2fd3352f85293c8bd3226ffb5132f3be19f Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 11 Jul 2026 15:01:48 -0400 Subject: [PATCH] v0.0.44 - Add JavaScript and Python demo programs (client/demo_javascript.js, client/demo_python.py) demonstrating get_public_key, sign_event, and nip44 encrypt/decrypt via qrexec --- client/demo_javascript.js | 225 +++++++++++++++++++++++++++++++++ client/demo_python.py | 256 ++++++++++++++++++++++++++++++++++++++ src/main.c | 4 +- 3 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 client/demo_javascript.js create mode 100644 client/demo_python.py diff --git a/client/demo_javascript.js b/client/demo_javascript.js new file mode 100644 index 0000000..647f4e4 --- /dev/null +++ b/client/demo_javascript.js @@ -0,0 +1,225 @@ +#!/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 [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(); diff --git a/client/demo_python.py b/client/demo_python.py new file mode 100644 index 0000000..4a010d9 --- /dev/null +++ b/client/demo_python.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +demo_python.py — comprehensive Python 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 + - Python 3 with no external dependencies (uses only stdlib) + +Usage: + python3 client/demo_python.py [nostr_index] + python3 client/demo_python.py nostr_signer 1 + +If no nostr_index is given, defaults to 0. +""" + +import json +import struct +import subprocess +import sys +import time + + +# --- Bech32 encoder (NIP-19 npub conversion, no external dependencies) --- + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def bech32_polymod(values): + generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + chk = 1 + for v in values: + b = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ v + for i in range(5): + chk ^= generator[i] if ((b >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_create_checksum(hrp, data): + values = bech32_hrp_expand(hrp) + data + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp, data): + combined = data + bech32_create_checksum(hrp, data) + return hrp + "1" + "".join([CHARSET[d] for d in combined]) + + +def convertbits(data, frombits, tobits, pad=True): + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad and bits: + ret.append((acc << (tobits - bits)) & maxv) + return ret + + +def hex_to_npub(pubkey_hex): + """Convert a 32-byte hex pubkey to bech32 npub format (NIP-19).""" + pubkey_bytes = bytes.fromhex(pubkey_hex) + data = convertbits(pubkey_bytes, 8, 5) + return bech32_encode("npub", data) + + +# --- n_signer qrexec client --- + +def call_nsigner(target_qube, request): + """ + 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). + """ + payload = json.dumps(request, separators=(",", ":")).encode("utf-8") + frame = struct.pack(">I", len(payload)) + payload + + proc = subprocess.Popen( + ["qrexec-client-vm", target_qube, "qubes.NsignerRpc"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + out, err = proc.communicate(frame) + + if proc.returncode != 0: + raise RuntimeError( + f"qrexec-client-vm exited with code {proc.returncode}: " + f"{err.decode('utf-8', 'replace').strip()}" + ) + + if len(out) < 4: + raise RuntimeError("short response (missing frame header)") + + length = struct.unpack(">I", out[:4])[0] + body = out[4 : 4 + length] + if len(body) != length: + raise RuntimeError( + f"short response payload: expected {length}, got {len(body)}" + ) + + return json.loads(body.decode("utf-8")) + + +# --- Demos --- + +def demo_get_public_key(target_qube, nostr_index): + """Demo 1: Get a public key by nostr_index.""" + print(f"\n=== Demo 1: get_public_key (nostr_index={nostr_index}) ===") + + response = call_nsigner( + target_qube, + {"id": "1", "method": "get_public_key", "params": [{"nostr_index": nostr_index}]}, + ) + + if "error" in response: + raise RuntimeError(f"get_public_key failed: {json.dumps(response['error'])}") + + pubkey_hex = response["result"] + npub = hex_to_npub(pubkey_hex) + print(f" pubkey hex: {pubkey_hex}") + print(f" npub: {npub}") + return pubkey_hex + + +def demo_sign_event(target_qube, nostr_index, pubkey_hex): + """Demo 2: Sign a Nostr event (kind 1 text note).""" + print("\n=== Demo 2: sign_event (kind 1 text note) ===") + + unsigned_event = { + "kind": 1, + "content": "Hello from n_signer Python demo!", + "created_at": int(time.time()), + "tags": [], + "pubkey": pubkey_hex, + } + + print(" Unsigned event:") + print(f" {json.dumps(unsigned_event, separators=(',', ':'))}") + + response = call_nsigner( + target_qube, + { + "id": "2", + "method": "sign_event", + "params": [json.dumps(unsigned_event, separators=(",", ":")), {"nostr_index": nostr_index}], + }, + ) + + if "error" in response: + raise RuntimeError(f"sign_event failed: {json.dumps(response['error'])}") + + signed_event = json.loads(response["result"]) + print(" Signed event:") + print(f" {json.dumps(signed_event, separators=(',', ':'))}") + print(f" event id: {signed_event['id']}") + print(f" signature: {signed_event['sig']}") + return signed_event + + +def demo_nip44(target_qube, nostr_index, pubkey_hex): + """Demo 3: NIP-44 encrypt and decrypt.""" + plaintext = "Secret message from n_signer Python demo!" + + print("\n=== Demo 3: nip44_encrypt / nip44_decrypt ===") + print(f' plaintext: "{plaintext}"') + print(f" peer pubkey: {pubkey_hex} (self)") + + # Encrypt + enc_response = call_nsigner( + target_qube, + { + "id": "3", + "method": "nip44_encrypt", + "params": [pubkey_hex, plaintext, {"nostr_index": nostr_index}], + }, + ) + + if "error" in enc_response: + raise RuntimeError(f"nip44_encrypt failed: {json.dumps(enc_response['error'])}") + + ciphertext = enc_response["result"] + print(f" ciphertext: {ciphertext}") + + # Decrypt + dec_response = call_nsigner( + target_qube, + { + "id": "4", + "method": "nip44_decrypt", + "params": [pubkey_hex, ciphertext, {"nostr_index": nostr_index}], + }, + ) + + if "error" in dec_response: + raise RuntimeError(f"nip44_decrypt failed: {json.dumps(dec_response['error'])}") + + decrypted = dec_response["result"] + print(f' decrypted: "{decrypted}"') + + if plaintext == decrypted: + print(" ✓ Round-trip verified: plaintext matches decrypted") + else: + raise RuntimeError("Round-trip FAILED: plaintext does not match decrypted") + + +# --- Main --- + +def main(): + target_qube = sys.argv[1] if len(sys.argv) > 1 else "nostr_signer" + nostr_index = int(sys.argv[2]) if len(sys.argv) > 2 else 0 + + print("=== n_signer Python Demo ===") + print(f"Target qube: {target_qube}") + print(f"Service: qubes.NsignerRpc") + print(f"nostr_index: {nostr_index}") + print("\nConnecting to n_signer via qrexec...") + + try: + pubkey_hex = demo_get_public_key(target_qube, nostr_index) + demo_sign_event(target_qube, nostr_index, pubkey_hex) + demo_nip44(target_qube, nostr_index, pubkey_hex) + + print("\n=== Summary ===") + print("All demos completed successfully.") + except Exception as e: + print("\n=== Summary ===") + print(f"Demo failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/main.c b/src/main.c index be8d7da..6ecdc0c 100644 --- a/src/main.c +++ b/src/main.c @@ -503,8 +503,8 @@ int socket_name_random(char *out, size_t out_len); /* Version information (auto-updated by build/version tooling) */ #define NSIGNER_VERSION_MAJOR 0 #define NSIGNER_VERSION_MINOR 0 -#define NSIGNER_VERSION_PATCH 43 -#define NSIGNER_VERSION "v0.0.43" +#define NSIGNER_VERSION_PATCH 44 +#define NSIGNER_VERSION "v0.0.44" /* NSIGNER_HEADERLESS_DECLS_END */