220 lines
7.2 KiB
Python
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": "encrypt",
|
|
"params": [pt_b64, {"algorithm": "otp", "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": "nostr_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())
|