#!/usr/bin/env bash set -euo pipefail # 0) Create dedicated user if it doesn't exist if ! id -u styx >/dev/null 2>&1; then sudo useradd -m -s /bin/bash styx echo "Created user 'styx'" fi # 1) App directory sudo mkdir -p /opt/styx-kiosk/keys sudo chown -R styx:styx /opt/styx-kiosk # 2) Drop the kiosk app (written below) into place sudo tee /opt/styx-kiosk/kiosk.py >/dev/null <<'PY' #!/usr/bin/env python3 import base64, json, os, subprocess, threading, tempfile from datetime import datetime import tkinter as tk from tkinter import ttk, messagebox APP_TITLE = "STYX Airgap Signer" CAMERA_DEV = os.environ.get("ZBAR_DEV", "/dev/video0") KEY_PATH = os.environ.get("STYX_KEY", "/vault/keys/signer_ed25519.pem") # in the LUKS vault ALGO = os.environ.get("STYX_ALGO", "ed25519") # or 'secp256r1' QR_TMP = "/tmp/styx_signed.png" def zbar_scan_oneshot(): # --raw -> data only; --nodisplay -> no preview window; --oneshot -> exit after first code # (zbarcam supports --oneshot; prints one code and exits). :contentReference[oaicite:2]{index=2} cmd = ["zbarcam", "--raw", "--nodisplay", "--oneshot", CAMERA_DEV] try: out = subprocess.check_output(cmd, text=True, timeout=30) out = out.strip() return out if out else None except Exception as e: return None def openssl_pub_der_b64(key_path): der = subprocess.check_output(["openssl","pkey","-in",key_path,"-pubout","-outform","DER"]) return base64.b64encode(der).decode() def sign_bytes(msg: bytes, key_path: str, algo: str) -> bytes: with tempfile.NamedTemporaryFile(delete=False) as f: f.write(msg) msg_path = f.name try: if algo.lower() == "ed25519": # Ed25519 expects raw message; OpenSSL handles hashing internally. sig = subprocess.check_output( ["openssl","pkeyutl","-sign","-inkey",key_path,"-rawin","-in",msg_path] ) return sig elif algo.lower() in ("secp256r1","prime256v1","p256"): # ECDSA over P-256; hash with SHA-256; OpenSSL returns DER-encoded (r,s) sig = subprocess.check_output( ["openssl","dgst","-sha256","-sign",key_path,msg_path] ) return sig else: raise RuntimeError(f"Unsupported algo: {algo}") finally: try: os.unlink(msg_path) except: pass def make_signed_envelope(scanned_text: str, key_path: str, algo: str) -> dict: # Accept either raw string or JSON with 'tx_bytes' (base64) or 'message' try: obj = json.loads(scanned_text) if "tx_bytes" in obj: msg = base64.b64decode(obj["tx_bytes"]) elif "message" in obj: msg = obj["message"].encode() else: # If it's JSON but doesn't carry known fields, sign canonical JSON bytes msg = json.dumps(obj, sort_keys=True, separators=(",",":")).encode() request_id = obj.get("request_id") except Exception: # Non-JSON → treat the scanned text as the message to sign msg = scanned_text.encode() request_id = None sig = sign_bytes(msg, key_path, algo) env = { "algo": algo.lower(), "signature_b64": base64.b64encode(sig).decode(), "pubkey_spki_der_b64": openssl_pub_der_b64(key_path), "payload_sha256_b64": base64.b64encode(subprocess.check_output(["openssl","dgst","-sha256","-binary"], input=msg)).decode(), "quote_raw": scanned_text, "request_id": request_id, "device": os.uname().nodename, "ts_utc": datetime.utcnow().isoformat(timespec="seconds") + "Z", } return env def qrencode_to_file(text: str, path: str): # Use qrencode CLI to render a PNG we can display. subprocess.run(["qrencode","-l","M","-s","16","-t","PNG","-o",path], input=text.encode(), check=True) class App(tk.Tk): def __init__(self): super().__init__() self.title(APP_TITLE) self.attributes("-fullscreen", True) self.configure(background="black") self.bind("", lambda e: self.quit()) # for maintenance only s = ttk.Style(self) s.configure("Big.TButton", font=("DejaVu Sans", 48), padding=24) s.configure("Big.TLabel", font=("DejaVu Sans", 32), foreground="white", background="black") self.container = tk.Frame(self, bg="black") self.container.pack(expand=True, fill="both") self.status = ttk.Label(self.container, text="Ready", style="Big.TLabel") self.status.pack(pady=20) self.scan_btn = ttk.Button(self.container, text="SCAN", style="Big.TButton", command=self.start_scan) self.scan_btn.pack(pady=20) self.image_label = tk.Label(self.container, bg="black") self.image_label.pack(pady=10) self.new_btn = ttk.Button(self.container, text="NEW SCAN", style="Big.TButton", command=self.reset) self.new_btn.pack_forget() self.note = ttk.Label(self.container, text="", style="Big.TLabel") self.note.pack(pady=10) if not os.path.exists(KEY_PATH): self.status.config(text=f"Key not found at {KEY_PATH}\nInsert/unlock vault to proceed.") def reset(self): self.image_label.configure(image="") self.image_label.image = None self.new_btn.pack_forget() self.note.config(text="") self.status.config(text="Ready") self.scan_btn.config(state="normal") def start_scan(self): if not os.path.exists(KEY_PATH): messagebox.showerror("Key missing", f"Signing key not found at:\n{KEY_PATH}\nUnlock your vault.") return self.status.config(text="Scanning…") self.scan_btn.config(state="disabled") threading.Thread(target=self._do_scan_and_sign, daemon=True).start() def _do_scan_and_sign(self): scanned = zbar_scan_oneshot() if not scanned: self.after(0, self._scan_failed) return try: envelope = make_signed_envelope(scanned, KEY_PATH, ALGO) payload = json.dumps(envelope, separators=(",",":")) qrencode_to_file(payload, QR_TMP) self.after(0, self._show_qr, envelope) except Exception as e: self.after(0, lambda: self._error(str(e))) def _scan_failed(self): self.status.config(text="No QR detected. Try again.") self.scan_btn.config(state="normal") def _show_qr(self, envelope): # Display the PNG produced by qrencode try: img = tk.PhotoImage(file=QR_TMP) self.image_label.configure(image=img) self.image_label.image = img except Exception as e: self.status.config(text=f"QR render failed: {e}") self.scan_btn.config(state="normal") return self.status.config(text="Signed. Show this QR to your online box.") self.note.config(text=f"Algo: {envelope['algo']} Host: {envelope['device']}") self.new_btn.pack(pady=20) if __name__ == "__main__": App().mainloop() PY sudo chmod +x /opt/styx-kiosk/kiosk.py sudo chown -R styx:styx /opt/styx-kiosk # 3) Minimal X session: openbox + kiosk; no mouse pointer sudo -u styx tee /home/styx/.xinitrc >/dev/null <<'XRC' xset -dpms xset s off xset s noblank # If 'unclutter' is installed, uncomment the next line to hide cursor: # unclutter -idle 0 -root & openbox-session & /opt/styx-kiosk/kiosk.py XRC sudo chown styx:styx /home/styx/.xinitrc sudo chmod 0755 /home/styx/.xinitrc # 4) Autologin the 'styx' user on tty1, auto-start X sudo mkdir -p /etc/systemd/system/getty@tty1.service.d sudo tee /etc/systemd/system/getty@tty1.service.d/override.conf >/dev/null <<'OVR' [Service] ExecStart= ExecStart=-/sbin/agetty --autologin styx --noclear %I $TERM Type=idle OVR sudo -u styx tee -a /home/styx/.bash_profile >/dev/null <<'BRC' # Start X on the first tty automatically, headless if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then exec startx -- -nocursor fi BRC sudo systemctl daemon-reload sudo systemctl enable getty@tty1.service echo "Done. Reboot to try the kiosk."