Files
n_signer/examples/otp_nostr_30078.py

220 lines
7.2 KiB
Python

#!/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())