v0.0.48 - Added OTP one-time pad encryption (otp_encrypt/otp_decrypt verbs), HTTP listener mode (--listen http:HOST:PORT), interactive OTP pad auto-scan on USB drives, raised SERVER_MAX_MSG_SIZE to 16MB, updated README with curl examples and current API documentation

This commit is contained in:
Laan Tungir
2026-07-19 11:07:07 -04:00
parent a7c6de2dcd
commit 05c055503d
41 changed files with 4225 additions and 114 deletions

View File

@@ -8,7 +8,7 @@ import time
from coincurve import PrivateKey
HOST = "npub15uqyclnr3er7r8uhka7f0ae2yt4gkjat8gxdan04q0e6xrnwmtjswcyla3.fips"
PORT = 8080
PORT = 11111
# Demo caller key (32 bytes). Replace with your stable caller key in real use.
PRIVKEY = bytes(range(1, 33))

View File

@@ -4,14 +4,14 @@
* 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
* nostr_signer qube listening on tcp:[::]:11111, and this client runs in
* a different qube connecting to the signer's FIPS address.
*
* Usage:
* ./get_pubkey_tcp <host> <port>
* ./get_pubkey_tcp npub1xxx...fips 8080
* ./get_pubkey_tcp npub1xxx...fips 11111
*
* If no arguments are given, defaults to localhost:8080.
* If no arguments are given, defaults to localhost:11111.
*
* Output: for each index, prints:
* index 0: hex=<64 hex chars> npub=npub1...
@@ -139,7 +139,7 @@ cleanup:
int main(int argc, char **argv) {
const char *host = "127.0.0.1";
int port = 8080;
int port = 11111;
char hex0[65], npub0[128];
char hex1[65], npub1[128];
int failures = 0;

View File

@@ -5,14 +5,14 @@
* 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
* 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 8080
* node n_signer_qube_example.js fd56:d7c3:f605:719d:15b:18a0:fb06:982f 11111
*
* If no arguments are given, defaults to localhost:8080.
* If no arguments are given, defaults to localhost:11111.
*
* Protocol:
* - 4-byte big-endian length prefix + JSON payload (TCP framing)
@@ -214,7 +214,7 @@ function hexToNpub(pubkeyHex) {
async function main() {
const host = process.argv[2] || "127.0.0.1";
const port = parseInt(process.argv[3] || "8080", 10);
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");

219
examples/otp_nostr_30078.py Normal file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
otp_nostr_30078.py — example: encrypt data with OTP, wrap in a Nostr kind 30078
event, sign it with n_signer, and print the signed event for publishing.
Workflow:
1. Call n_signer's `otp_encrypt` verb to encrypt plaintext with the bound OTP pad.
2. Build a Nostr kind 30078 (replaceable parameterized) event with the ASCII-armored
ciphertext as the `content` field.
3. Call n_signer's `sign_event` verb to sign the event with the secp256k1 key.
4. Print the signed event JSON, ready to publish to Nostr relays.
This is a demo — it does not actually publish to a relay. To publish, send the
signed event to your preferred Nostr relay using a library like nostr-tools,
nostril, or nak.
Usage:
python3 examples/otp_nostr_30078.py "Your secret message here"
Requirements:
- n_signer running with --otp-pad-dir / --otp-pad bound, and a secp256k1
role (e.g. "main") available for sign_event.
- This script connects to n_signer via stdio (one process per request).
See plans/otp_nostr_integration.md for the full design.
"""
import base64
import hashlib
import json
import os
import struct
import subprocess
import sys
import time
NSIGNER = "./build/nsigner"
PAD_DIR = "/media/user/Music/pads"
PAD_SPEC = "333e9902db839d9d"
MNEMONIC_FILE = ".test_mnemonic"
MNEMONIC_TMP = ".test_mnemonic_otp_30078.tmp"
def send_framed(proc, obj):
payload = json.dumps(obj).encode()
proc.stdin.write(struct.pack(">I", len(payload)))
proc.stdin.write(payload)
proc.stdin.flush()
def recv_framed(proc):
"""Read a framed response, skipping any banner text on stdout."""
buf = b""
while True:
b = proc.stdout.read(1)
if not b:
return None
buf = (buf + b)[-4:]
if len(buf) < 4:
continue
(length,) = struct.unpack(">I", buf)
if 1 <= length <= 1024 * 1024:
peek = proc.stdout.read(1)
if peek == b"{":
body = peek + proc.stdout.read(length - 1)
return json.loads(body.decode())
else:
buf = (buf + peek)[-4:]
def run_one_request(req_obj):
"""Run nsigner in stdio mode for a single framed request/response."""
shell_cmd = (
f"exec 3<{MNEMONIC_TMP}; "
f"exec {NSIGNER} --listen stdio --mnemonic-fd 3 "
f"--otp-pad-dir {PAD_DIR} --otp-pad {PAD_SPEC} "
f"--otp-allow-blkback --allow-all"
)
proc = subprocess.Popen(
["bash", "-c", shell_cmd],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
import time as _time
_time.sleep(1.0)
if proc.poll() is not None:
err = proc.stderr.read().decode()
print(f"ERROR: nsigner exited early (code {proc.returncode})")
print(f"stderr: {err}")
return None
send_framed(proc, req_obj)
resp = recv_framed(proc)
proc.stdin.close()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return resp
def compute_event_id(event):
"""Compute the Nostr event ID (SHA-256 of the canonical serialized event)."""
# Nostr event serialization: [0, pubkey, created_at, kind, tags, content]
serialized = json.dumps([
0,
event["pubkey"],
event["created_at"],
event["kind"],
event["tags"],
event["content"],
], separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(serialized.encode()).hexdigest()
def main():
plaintext = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else "Secret OTP message"
print(f"Plaintext: {plaintext}")
# Prepare the mnemonic temp file.
with open(MNEMONIC_FILE) as f:
mnemonic = f.read().strip()
with open(MNEMONIC_TMP, "w") as f:
f.write(mnemonic + "\n")
try:
# Step 1: Get the public key for the "main" role
print("\n=== Step 1: get_public_key ===")
resp = run_one_request({
"id": "1",
"method": "get_public_key",
"params": [{"role": "main"}],
})
if resp is None or "result" not in resp:
print("ERROR: get_public_key failed")
print(f"Response: {resp}")
return 1
# The result is a plain hex string for secp256k1 backward compat.
pubkey_hex = resp["result"].strip('"')
print(f"Public key: {pubkey_hex}")
# Step 2: Encrypt the plaintext with OTP
print("\n=== Step 2: otp_encrypt ===")
pt_b64 = base64.b64encode(plaintext.encode()).decode()
resp = run_one_request({
"id": "2",
"method": "otp_encrypt",
"params": [pt_b64, {"encoding": "ascii"}],
})
if resp is None or "result" not in resp:
print("ERROR: otp_encrypt failed")
print(f"Response: {resp}")
return 1
enc_result = json.loads(resp["result"])
ciphertext = enc_result["ciphertext"]
pad_chksum = enc_result["pad_chksum"]
pad_offset = enc_result["pad_offset_after"]
print(f"Pad checksum: {pad_chksum}")
print(f"Pad offset after encrypt: {pad_offset}")
print(f"Ciphertext (first 60 chars): {ciphertext[:60]}...")
# Step 3: Build the Nostr kind 30078 event
print("\n=== Step 3: Build kind 30078 event ===")
# Use a unique d-tag based on the pad checksum and offset.
d_tag = f"otp-{pad_chksum[:16]}-{pad_offset}"
event = {
"pubkey": pubkey_hex,
"created_at": int(time.time()),
"kind": 30078,
"tags": [
["d", d_tag],
["otp-pad", pad_chksum[:16]],
["otp-version", "v0.0.2-otp"],
["otp-encoding", "ascii"],
],
"content": ciphertext,
}
# Compute the event ID.
event_id = compute_event_id(event)
event["id"] = event_id
print(f"Event ID: {event_id}")
print(f"d-tag: {d_tag}")
# Step 4: Sign the event with n_signer
print("\n=== Step 4: sign_event ===")
# sign_event expects the event JSON as the first param (without id/sig).
# The signer computes the id and signature internally.
event_for_signing = {
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"kind": event["kind"],
"tags": event["tags"],
"content": event["content"],
}
resp = run_one_request({
"id": "3",
"method": "sign_event",
"params": [json.dumps(event_for_signing), {"role": "main"}],
})
if resp is None or "result" not in resp:
print("ERROR: sign_event failed")
print(f"Response: {resp}")
return 1
sig = resp["result"].strip('"')
event["sig"] = sig
print(f"Signature: {sig[:60]}...")
# Step 5: Print the signed event
print("\n=== Signed Nostr event (ready to publish) ===")
print(json.dumps(event, indent=2))
print(f"\nTo publish: send this event to a Nostr relay.")
print(f"To decrypt: call otp_decrypt with the content field.")
return 0
finally:
try:
os.unlink(MNEMONIC_TMP)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())