219 lines
8.0 KiB
Bash
219 lines
8.0 KiB
Bash
#!/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("<Escape>", 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."
|