titan-iac/scripts/styx_kioskification.sh

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."