v0.0.28 - Finalize Feather TinyUSB migration and remove cardputer artifacts
This commit is contained in:
BIN
examples/__pycache__/cardputer_sign_event.cpython-313.pyc
Normal file
BIN
examples/__pycache__/cardputer_sign_event.cpython-313.pyc
Normal file
Binary file not shown.
BIN
examples/__pycache__/feather_get_public_key.cpython-313.pyc
Normal file
BIN
examples/__pycache__/feather_get_public_key.cpython-313.pyc
Normal file
Binary file not shown.
136
examples/feather_get_public_key.py
Normal file
136
examples/feather_get_public_key.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
from coincurve import PrivateKey
|
||||
|
||||
|
||||
def read_exact(ser: serial.Serial, n: int, timeout_s: float = 8.0) -> bytes:
|
||||
end = time.time() + timeout_s
|
||||
out = bytearray()
|
||||
while len(out) < n and time.time() < end:
|
||||
chunk = ser.read(n - len(out))
|
||||
if chunk:
|
||||
out.extend(chunk)
|
||||
continue
|
||||
time.sleep(0.005)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def recv_frame(ser: serial.Serial, timeout_s: float = 10.0) -> bytes:
|
||||
end = time.time() + timeout_s
|
||||
window = bytearray()
|
||||
|
||||
while time.time() < end:
|
||||
b = ser.read(1)
|
||||
if not b:
|
||||
time.sleep(0.005)
|
||||
continue
|
||||
window.extend(b)
|
||||
if len(window) > 4:
|
||||
window.pop(0)
|
||||
if len(window) < 4:
|
||||
continue
|
||||
(n,) = struct.unpack(">I", bytes(window))
|
||||
# Keep this cap small to avoid getting stuck on false headers in stream noise.
|
||||
if n == 0 or n > 64 * 1024:
|
||||
continue
|
||||
body = read_exact(ser, n, timeout_s=max(0.2, end - time.time()))
|
||||
if len(body) != n:
|
||||
continue
|
||||
if body[:1] not in (b"{", b"["):
|
||||
continue
|
||||
try:
|
||||
json.loads(body.decode("utf-8", errors="strict"))
|
||||
except Exception:
|
||||
continue
|
||||
return body
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
def build_auth_envelope(method: str, params, caller_priv: bytes, label: str = "py-cli") -> dict:
|
||||
body_json = json.dumps(params, separators=(",", ":")).encode("utf-8")
|
||||
body_hash = hashlib.sha256(body_json).hexdigest()
|
||||
|
||||
sk = PrivateKey(caller_priv)
|
||||
caller_pub_x = sk.public_key.format(compressed=True)[1:].hex()
|
||||
|
||||
created_at = int(time.time())
|
||||
tags = [
|
||||
["nsigner_rpc", "1"],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", body_hash],
|
||||
]
|
||||
serialized = json.dumps(
|
||||
[0, caller_pub_x, created_at, 27235, tags, label],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
event_id = hashlib.sha256(serialized).hexdigest()
|
||||
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
return {
|
||||
"id": event_id,
|
||||
"pubkey": caller_pub_x,
|
||||
"created_at": created_at,
|
||||
"kind": 27235,
|
||||
"tags": tags,
|
||||
"content": label,
|
||||
"sig": sig,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
|
||||
|
||||
caller_priv = bytes(range(1, 33))
|
||||
params = []
|
||||
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "get_public_key",
|
||||
"params": params,
|
||||
"auth": build_auth_envelope("get_public_key", params, caller_priv),
|
||||
}
|
||||
body = json.dumps(req, separators=(",", ":")).encode("utf-8")
|
||||
frame = struct.pack(">I", len(body)) + body
|
||||
|
||||
ser = serial.Serial()
|
||||
ser.port = port
|
||||
ser.baudrate = 115200
|
||||
ser.timeout = 0.2
|
||||
ser.rtscts = False
|
||||
ser.dsrdtr = False
|
||||
# Set modem-control lines before open to avoid auto-reset pulses.
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
|
||||
with ser:
|
||||
# Let the board settle if OS still caused a reconnect.
|
||||
time.sleep(3.0)
|
||||
|
||||
for attempt in range(1, 4):
|
||||
ser.reset_input_buffer()
|
||||
ser.write(frame)
|
||||
ser.flush()
|
||||
|
||||
resp_body = recv_frame(ser, timeout_s=10.0)
|
||||
if resp_body:
|
||||
print(resp_body.decode("utf-8", errors="replace"))
|
||||
return 0
|
||||
|
||||
print(f"No framed response received (attempt {attempt}/3)")
|
||||
time.sleep(0.5)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
199
examples/feather_sign_event.py
Normal file
199
examples/feather_sign_event.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
from coincurve import PrivateKey, PublicKeyXOnly
|
||||
|
||||
|
||||
def read_exact(ser: serial.Serial, n: int, timeout_s: float = 8.0) -> bytes:
|
||||
end = time.time() + timeout_s
|
||||
out = bytearray()
|
||||
while len(out) < n and time.time() < end:
|
||||
chunk = ser.read(n - len(out))
|
||||
if chunk:
|
||||
out.extend(chunk)
|
||||
continue
|
||||
time.sleep(0.005)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def recv_frame(ser: serial.Serial, timeout_s: float = 10.0) -> bytes:
|
||||
end = time.time() + timeout_s
|
||||
window = bytearray()
|
||||
|
||||
while time.time() < end:
|
||||
b = ser.read(1)
|
||||
if not b:
|
||||
time.sleep(0.005)
|
||||
continue
|
||||
|
||||
window.extend(b)
|
||||
if len(window) > 4:
|
||||
window.pop(0)
|
||||
|
||||
if len(window) < 4:
|
||||
continue
|
||||
|
||||
(n,) = struct.unpack(">I", bytes(window))
|
||||
if n == 0 or n > 1_000_000:
|
||||
continue
|
||||
|
||||
body = read_exact(ser, n, timeout_s=max(0.2, end - time.time()))
|
||||
if len(body) != n:
|
||||
return b""
|
||||
if body[:1] not in (b"{", b"["):
|
||||
continue
|
||||
|
||||
try:
|
||||
json.loads(body.decode("utf-8", errors="strict"))
|
||||
except Exception:
|
||||
# Probably matched a false header inside stream noise; keep scanning.
|
||||
continue
|
||||
|
||||
return body
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
def nostr_serialize(pubkey: str, created_at: int, kind: int, tags: list, content: str) -> bytes:
|
||||
arr = [0, pubkey, created_at, kind, tags, content]
|
||||
return json.dumps(arr, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def event_id_hex(event_obj: dict) -> str:
|
||||
ser = nostr_serialize(
|
||||
event_obj["pubkey"],
|
||||
int(event_obj["created_at"]),
|
||||
int(event_obj["kind"]),
|
||||
event_obj["tags"],
|
||||
event_obj["content"],
|
||||
)
|
||||
return hashlib.sha256(ser).hexdigest()
|
||||
|
||||
|
||||
def verify_event(event_obj: dict) -> bool:
|
||||
ev_id = event_id_hex(event_obj)
|
||||
if ev_id != event_obj.get("id"):
|
||||
return False
|
||||
|
||||
pk = PublicKeyXOnly(bytes.fromhex(event_obj["pubkey"]))
|
||||
sig = bytes.fromhex(event_obj["sig"])
|
||||
return pk.verify(sig, bytes.fromhex(ev_id))
|
||||
|
||||
|
||||
def build_auth_envelope(method: str, params, caller_priv: bytes, label: str = "py-cli") -> dict:
|
||||
body_json = json.dumps(params, separators=(",", ":")).encode("utf-8")
|
||||
body_hash = hashlib.sha256(body_json).hexdigest()
|
||||
|
||||
sk = PrivateKey(caller_priv)
|
||||
caller_pub_x = sk.public_key.format(compressed=True)[1:].hex()
|
||||
created_at = int(time.time())
|
||||
tags = [
|
||||
["nsigner_rpc", "1"],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", body_hash],
|
||||
]
|
||||
serialized = json.dumps(
|
||||
[0, caller_pub_x, created_at, 27235, tags, label],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
event_id = hashlib.sha256(serialized).hexdigest()
|
||||
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
return {
|
||||
"id": event_id,
|
||||
"pubkey": caller_pub_x,
|
||||
"created_at": created_at,
|
||||
"kind": 27235,
|
||||
"tags": tags,
|
||||
"content": label,
|
||||
"sig": sig,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = [a for a in sys.argv[1:]]
|
||||
no_auth = False
|
||||
if "--no-auth" in args:
|
||||
no_auth = True
|
||||
args.remove("--no-auth")
|
||||
use_alt_caller = "--alt-caller" in args
|
||||
if use_alt_caller:
|
||||
args.remove("--alt-caller")
|
||||
port = args[0] if args else "/dev/ttyACM0"
|
||||
|
||||
caller_priv = bytes(range(1, 33)) if not use_alt_caller else bytes(range(33, 65))
|
||||
|
||||
unsigned_event = {
|
||||
"kind": 1,
|
||||
"created_at": int(time.time()),
|
||||
"tags": [],
|
||||
"content": "hello from feather phase4",
|
||||
}
|
||||
|
||||
params = [unsigned_event]
|
||||
req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "2",
|
||||
"method": "sign_event",
|
||||
"params": params,
|
||||
}
|
||||
if not no_auth:
|
||||
req["auth"] = build_auth_envelope("sign_event", params, caller_priv)
|
||||
|
||||
body = json.dumps(req, separators=(",", ":")).encode("utf-8")
|
||||
frame = struct.pack(">I", len(body)) + body
|
||||
|
||||
ser = serial.Serial()
|
||||
ser.port = port
|
||||
ser.baudrate = 115200
|
||||
ser.timeout = 0.2
|
||||
ser.rtscts = False
|
||||
ser.dsrdtr = False
|
||||
# Set modem-control lines before open to avoid auto-reset pulses.
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
|
||||
with ser:
|
||||
# Let board fully settle after USB open/reconnect.
|
||||
time.sleep(3.0)
|
||||
ser.reset_input_buffer()
|
||||
ser.write(frame)
|
||||
ser.flush()
|
||||
|
||||
resp_raw = recv_frame(ser, timeout_s=45.0)
|
||||
if not resp_raw:
|
||||
print("No framed response received")
|
||||
return 1
|
||||
|
||||
try:
|
||||
resp = json.loads(resp_raw.decode("utf-8", errors="strict"))
|
||||
except Exception:
|
||||
print("Invalid JSON response bytes:", resp_raw)
|
||||
return 1
|
||||
if "error" in resp:
|
||||
print("RPC error:", json.dumps(resp["error"], separators=(",", ":")))
|
||||
return 1
|
||||
|
||||
result = resp.get("result")
|
||||
if not isinstance(result, dict):
|
||||
print("Invalid RPC result")
|
||||
return 1
|
||||
|
||||
ok = verify_event(result)
|
||||
print(json.dumps(result, separators=(",", ":")))
|
||||
if not ok:
|
||||
print("signature invalid")
|
||||
return 1
|
||||
|
||||
print("✓ signature valid")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
162
examples/feather_webusb_demo.html
Normal file
162
examples/feather_webusb_demo.html
Normal file
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>n_signer Feather WebUSB Demo</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 16px; max-width: 840px; }
|
||||
button { margin-right: 8px; margin-bottom: 8px; }
|
||||
pre { background: #111; color: #ddd; padding: 12px; border-radius: 6px; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>n_signer Feather WebUSB Demo</h1>
|
||||
<p>Connect to the ESP32-S3 WebUSB interface, send authenticated <code>get_public_key</code>, and display the result.</p>
|
||||
|
||||
<button id="connectBtn">Connect</button>
|
||||
<button id="pubkeyBtn" disabled>Get Public Key</button>
|
||||
|
||||
<pre id="log"></pre>
|
||||
|
||||
<script type="module">
|
||||
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
|
||||
|
||||
const logEl = document.getElementById("log");
|
||||
const connectBtn = document.getElementById("connectBtn");
|
||||
const pubkeyBtn = document.getElementById("pubkeyBtn");
|
||||
|
||||
let dev = null;
|
||||
let iface = null;
|
||||
const EP_OUT = 1;
|
||||
const EP_IN = 1;
|
||||
|
||||
function log(...args) {
|
||||
logEl.textContent += args.join(" ") + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
async function sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest("SHA-256", dataBytes);
|
||||
return hex(new Uint8Array(h));
|
||||
}
|
||||
|
||||
function be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
async function buildAuth(method, params) {
|
||||
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
|
||||
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params);
|
||||
const bodyHash = await sha256Hex(utf8(paramsJson));
|
||||
const tags = [
|
||||
["nsigner_rpc", "1"],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", bodyHash],
|
||||
];
|
||||
const content = "webusb-demo";
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await sha256Hex(utf8(ser));
|
||||
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
|
||||
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
|
||||
}
|
||||
|
||||
async function sendRpc(reqObj) {
|
||||
const body = utf8(JSON.stringify(reqObj));
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
await dev.transferOut(EP_OUT, frame);
|
||||
|
||||
const deadline = Date.now() + 10000;
|
||||
let ring = new Uint8Array(0);
|
||||
while (Date.now() < deadline) {
|
||||
const r = await dev.transferIn(EP_IN, 512);
|
||||
if (!r.data || r.data.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
const chunk = new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength);
|
||||
const next = new Uint8Array(ring.length + chunk.length);
|
||||
next.set(ring, 0);
|
||||
next.set(chunk, ring.length);
|
||||
ring = next;
|
||||
|
||||
while (ring.length >= 4) {
|
||||
const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3];
|
||||
if (n <= 0 || n > 1_000_000) {
|
||||
ring = ring.slice(1);
|
||||
continue;
|
||||
}
|
||||
if (ring.length < 4 + n) {
|
||||
break;
|
||||
}
|
||||
const payload = ring.slice(4, 4 + n);
|
||||
ring = ring.slice(4 + n);
|
||||
const txt = new TextDecoder().decode(payload);
|
||||
return JSON.parse(txt);
|
||||
}
|
||||
}
|
||||
throw new Error("Timed out waiting for framed response");
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
dev = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] });
|
||||
await dev.open();
|
||||
if (dev.configuration === null) {
|
||||
await dev.selectConfiguration(1);
|
||||
}
|
||||
|
||||
const intf = dev.configuration.interfaces.find(i =>
|
||||
i.alternates.some(a => a.interfaceClass === 0xff)
|
||||
);
|
||||
if (!intf) throw new Error("No vendor WebUSB interface found");
|
||||
iface = intf.interfaceNumber;
|
||||
await dev.claimInterface(iface);
|
||||
const alt = intf.alternates.find(a => a.interfaceClass === 0xff);
|
||||
await dev.selectAlternateInterface(iface, alt.alternateSetting);
|
||||
|
||||
await dev.controlTransferOut({
|
||||
requestType: "class",
|
||||
recipient: "interface",
|
||||
request: 0x22,
|
||||
value: 1,
|
||||
index: iface,
|
||||
});
|
||||
|
||||
pubkeyBtn.disabled = false;
|
||||
log("Connected. Interface", String(iface));
|
||||
}
|
||||
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
log("Connect failed:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
pubkeyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const params = [];
|
||||
const auth = await buildAuth("get_public_key", params);
|
||||
const req = { jsonrpc: "2.0", id: "web-1", method: "get_public_key", params, auth };
|
||||
const resp = await sendRpc(req);
|
||||
log("Response:", JSON.stringify(resp));
|
||||
} catch (e) {
|
||||
log("RPC failed:", String(e));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
64
examples/get_pubkey_fips.py
Normal file
64
examples/get_pubkey_fips.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from coincurve import PrivateKey
|
||||
|
||||
HOST = "npub15uqyclnr3er7r8uhka7f0ae2yt4gkjat8gxdan04q0e6xrnwmtjswcyla3.fips"
|
||||
PORT = 8080
|
||||
|
||||
# Demo caller key (32 bytes). Replace with your stable caller key in real use.
|
||||
PRIVKEY = bytes(range(1, 33))
|
||||
|
||||
params = []
|
||||
body_hash = hashlib.sha256(json.dumps(params, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
sk = PrivateKey(PRIVKEY)
|
||||
pubkey_x = sk.public_key.format(compressed=False)[1:33].hex()
|
||||
created_at = int(time.time())
|
||||
|
||||
tags = [
|
||||
["nsigner_rpc", "1"],
|
||||
["nsigner_method", "get_public_key"],
|
||||
["nsigner_body_hash", body_hash],
|
||||
]
|
||||
|
||||
content = "py-min"
|
||||
serialized_event = json.dumps(
|
||||
[0, pubkey_x, created_at, 27235, tags, content], separators=(",", ":"), ensure_ascii=False
|
||||
).encode()
|
||||
event_id = hashlib.sha256(serialized_event).hexdigest()
|
||||
sig = sk.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32).hex()
|
||||
|
||||
request = {
|
||||
"id": "1",
|
||||
"method": "get_public_key",
|
||||
"params": params,
|
||||
"auth": {
|
||||
"id": event_id,
|
||||
"pubkey": pubkey_x,
|
||||
"created_at": created_at,
|
||||
"kind": 27235,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": sig,
|
||||
},
|
||||
}
|
||||
|
||||
payload = json.dumps(request, separators=(",", ":")).encode()
|
||||
frame = struct.pack(">I", len(payload)) + payload
|
||||
|
||||
with socket.create_connection((HOST, PORT), timeout=10) as s:
|
||||
s.sendall(frame)
|
||||
hdr = b""
|
||||
while len(hdr) < 4:
|
||||
hdr += s.recv(4 - len(hdr))
|
||||
ln = struct.unpack(">I", hdr)[0]
|
||||
body = b""
|
||||
while len(body) < ln:
|
||||
body += s.recv(ln - len(body))
|
||||
|
||||
print(json.loads(body.decode())["result"])
|
||||
Reference in New Issue
Block a user