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
This commit is contained in:
225
client/demo_javascript.js
Normal file
225
client/demo_javascript.js
Normal file
@@ -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 <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();
|
||||
256
client/demo_python.py
Normal file
256
client/demo_python.py
Normal file
@@ -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 <target_qube> [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()
|
||||
Reference in New Issue
Block a user