Bauanleitung: Pico-TOTP Hardware Token (Split-Knowledge)
Dieses Projekt verwandelt einen Raspberry Pi Pico in einen hochsicheren Hardware-Token für Zwei-Faktor-Authentifizierung (2FA). Um die fehlende Hardware-Verschlüsselung des Pico auszugleichen, wird ein "Split-Knowledge" (Air-Gapped) Ansatz verwendet:
- Der Pico speichert die Konten fest einprogrammiert, aber stark AES-verschlüsselt in seinem Flash-Speicher.
- Ein PC-Hintergrunddienst hält den AES-Schlüssel und sendet ihn samt Uhrzeit beim Einstecken an den Pico.
- Der Pico entschlüsselt die Daten nur im flüchtigen RAM. Wird er abgezogen, ist der Speicher sofort gelöscht – bei Diebstahl ist der Stick komplett wertlos.
Dieses Projekt ist nichts für Anfänger. Man sollte zumindest in Arduino mit dem Raspberry Pico und am PC mit Python etwas grundlegende Erfahrung haben. Rust ist hier nicht unbedingt notwendig, aber ein Nice-To-Have. Wenn man das Projekt nicht nur auf Steckbrett, sondern in einem kleinen Gehäuse aufbauen möchte, sollte man etwas Löterfahrung und Designkenntnisse in Sachen 3D besitzen. Die Python Version sollte direkt unter Windows 11 wie auch Linux funktionieren. Wie es mit der seriellen Schnittstelle unter Rust aussieht, kann ich momentan nicht sagen. Probiert es aus. ;)
0. Das Prinzip
Wer das Prinzip genauer wissen will, dem lege ich die Einführung des Projekts ans Herz. Danach kann jeder entscheiden, ob es für ihn sinnvoll ist.
1. Die Hardware & Verdrahtung
- Mikrocontroller: Raspberry Pi Pico (RP2040)
- Display: 0.96" OLED Display (I2C)
- Taster: Ein handelsüblicher Drucktaster (Push-Button)
- USB-Speicherstick, welcher an Smartphone und PC passt, für Secrets
Verdrahtung:
- OLED:
VCCan3.3V(Pin 36),GNDanGND(Pin 38),SDAanGPIO 4(Pin 6),SCLanGPIO 5(Pin 7). - Taster: Ein Bein an
GPIO 11(Pin 15), das andere Bein anGND(z.B. Pin 13). (Der interne Pull-Up-Widerstand wird per Software aktiviert).
Je nach verwendetem Pico-Board kann man natürlich die Pinbelegungen im Arduino-Sketch anpassen. Allerdings muss man bei dem I2C Bus die Restriktionen beachten.
2. Die Secrets aus dem Smartphone pulen 🦐
Es wird angenommen, Du nutzt den Google Authenticator. Falls Du hingegen einen anderen Authenticator nutzt, schau nach, ob dieser überhaupt einen Export anbietet. Wenn nicht, kommst du nicht umhin, alle Codes neu zu erstellen. So oder so, wir benötigen den Aegis Authenticator. Gibt es im Google Play Store oder bei F-Droid . Diesen Authenticator kann man wärmstens empfehlen, da er nicht nach Hause telefoniert. Er bietet den Import aus vielen anderen Authenticators an. Von Google Authenticator ist es möglich, aber leider nicht trivial. So geht es:
-
Aegis Authenticator auf dem Smartphone installieren
-
Falls kein Dateimanager vorhanden ist, den Dateimanager+ installieren
-
Danach das Smartphone in den Flugmodus versetzen. (Wichtig!)
-
Öffne den Google Authenticator, tippe oben rechts auf das Menü (die drei Punkte oder dein Profilbild) und wähle "Konten exportieren".
-
Wähle die Konten aus, die du für den Pico brauchst. Google zeigt dir nun einen Export-QR-Code an. Ich empfehle 5er Gruppen falls es viele Codes sind. Weil je größer der QR-Code, um so schwerer zu scannen.
-
Mache einen Screenshot von diesem QR-Code (oder fotografiere ihn mit einem anderen Gerät ab). Bei mehreren 5er Gruppen jeweils wiederholen.
-
Öffne nun die neue Aegis App. Gehe auf das Plus-Symbol -> "Aus Bild importieren" (oder scanne ihn) und wähle den Google-Export-QR-Code. Aegis importiert nun alle deine Konten fehlerfrei. Bei 5er Gruppen auch für jede Gruppe einzeln.
-
Überprüfe, ob die Codes von Google und Aegis identisch sind.
-
Gehe in Aegis in die Einstellungen auf "Exportieren". Wähle als Format "Klartext" (Plain text) oder "JSON" und speichere die Datei auf deinem Handy.
-
Schließe den USB-Speicherstick an das Smartphone an und verschiebe die JSON Datei auf den Speicherstick mittels Dateimanager
-
Zum Schluß lösche auf jeden Fall die Screenshots oder Fotos mit QR-Codes vom Smartphone. (Wichtig!)
-
Du solltest nun auf dem USB-Speicherstick eine JSON-Datei haben. Ich empfehle, diese zu öffnen und bei den Einträgen jeweils bei "issuer" nachsehen, ob der Name unter 10 Zeichen ist. Das Display kann nämlich nur bis zu 10 Zeichen darstellen. Also sollte man bei Bedarf die Namen anpassen, damit man sie auf dem Display später erkennen kann.
-
Diesen Speicherstick brauchst du nur für den nächsten Schritt. Danach kann er in den Tresor.
-
Speicherstick vom Smartphone abmelden und abziehen. Abmelden ist wichtig, sonst will das Smartphone neu starten.
-
Wenn Du sicher bist, alle sensiblen Daten vom Smartphone gelöscht zu haben, kannst du den Flugmodus beenden.
3. Die Software-Vorbereitung (Aegis-Export)
Die Basis für die TOTP-Codes liefert ein unverschlüsselter JSON-Export aus der Android-App Aegis. Um diese Daten sicher in C++ Code (secrets.h) zu verwandeln, gibt es folgendes Exporter-Tool. Du kannst wählen, ob du die Python- oder die Rust-Version nutzen möchtest. Beide generieren beim Start auch den für den PC nötigen 16-stelligen pico_aes.key zum Entschlüsseln des Dongles.
Beide Varianten funktionieren gleich. Nach dem Öffnen erscheint ein Dateiauswahlmenu. Mit diesem findet man die Datei auf dem USB-Speicherstick und wählt sie aus. Danach wird secrets.h und pico_aes.key im gleichen Verzeichnis wie das Programm erzeugt und das Programm selbst schließt sich. Der USB-Speicherstick kann nun abgezogen und sicher verwahrt werden. Oder direkt formatiert. pico_aes.key brauchen wir für den PC und secrets.h für die Programmierung des Pico unter Arduino.
Variante A: Der Exporter in Python
Benötigt ein installiertes Python sowie die Pakete: pip install pycryptodome
aegis_exporter.py
import json
import base64
import os
import string
import secrets
import tkinter as tk
from tkinter import filedialog
from Crypto.Cipher import AES
# --- KONFIGURATION ---
KEY_FILE = "pico_aes.key"
HEADER_FILE = "secrets.h"
# Erhöht auf 64 Bytes (4 AES-Blöcke).
# Das reicht für Base32-Secrets mit bis zu 102 Zeichen aus.
BUFFER_SIZE = 64
# ---------------------
def get_or_create_key():
"""Lädt den AES-Key oder generiert einen neuen, falls keiner existiert."""
if os.path.exists(KEY_FILE):
print(f"[+] Lese existierenden Key aus '{KEY_FILE}'...")
with open(KEY_FILE, "r", encoding="utf-8") as f:
key = f.read().strip()
if len(key) != 16:
raise ValueError("Der Key in der Datei muss exakt 16 Zeichen lang sein!")
return key.encode('utf-8')
else:
print(f"[+] Generiere neuen 16-Byte AES-Key und speichere in '{KEY_FILE}'...")
alphabet = string.ascii_letters + string.digits
key = ''.join(secrets.choice(alphabet) for _ in range(16))
with open(KEY_FILE, "w", encoding="utf-8") as f:
f.write(key)
return key.encode('utf-8')
def clean_base32(secret):
"""Entfernt Leerzeichen und fügt Base32-Padding hinzu, falls nötig."""
secret = secret.replace(" ", "").upper()
missing_padding = len(secret) % 8
if missing_padding:
secret += '=' * (8 - missing_padding)
return secret
def pad_bytes(data, size):
"""Füllt die Bytes mit Nullen auf (Zero-Padding für optimale Kompatibilität)."""
if len(data) > size:
raise ValueError(f"Secret ist mit {len(data)} Bytes zu lang für den {size}-Byte Puffer!")
return data + b'\x00' * (size - len(data))
def select_json_file():
"""Öffnet einen Datei-Explorer zur Auswahl des Aegis-Exports."""
root = tk.Tk()
root.withdraw()
print("[*] Bitte wähle die unverschlüsselte Aegis JSON-Exportdatei aus...")
file_path = filedialog.askopenfilename(
title="Aegis JSON Export auswählen",
filetypes=[("JSON Dateien", "*.json"), ("Alle Dateien", "*.*")]
)
return file_path
def main():
print("=== Pico-TOTP Exporter ===")
# 1. Key Management (AES-128)
aes_key = get_or_create_key()
cipher = AES.new(aes_key, AES.MODE_ECB)
# 2. JSON Datei auswählen
json_path = select_json_file()
if not json_path:
print("[-] Keine Datei ausgewählt. Abbruch.")
return
# 3. JSON parsen
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
entries = data.get("db", {}).get("entries", [])
if not entries:
print("[-] Keine Einträge in der JSON gefunden. Ist es ein unverschlüsselter Export?")
return
# 4. Daten verarbeiten und Header aufbauen
header_content = [
"#pragma once",
"#include <Arduino.h>",
"",
"struct Account {",
" char name[16];",
f" uint8_t encrypted_secret[{BUFFER_SIZE}];",
" size_t secret_len;",
f" uint8_t decrypted_secret[{BUFFER_SIZE}];",
"};",
"",
f"const int NUM_ACCOUNTS = {len(entries)};",
"Account accounts[NUM_ACCOUNTS] = {"
]
print(f"[+] Verarbeite {len(entries)} Konten...")
for i, entry in enumerate(entries):
name = entry.get("issuer") or entry.get("name") or "Unbekannt"
name_clean = name[:15].replace('"', '')
base32_secret = entry.get("info", {}).get("secret", "")
raw_secret = base64.b32decode(clean_base32(base32_secret))
secret_len = len(raw_secret)
padded_secret = pad_bytes(raw_secret, BUFFER_SIZE)
encrypted_secret = cipher.encrypt(padded_secret)
hex_array = ", ".join([f"0x{b:02X}" for b in encrypted_secret])
comma = "," if i < len(entries) - 1 else ""
header_content.append(f' {{"{name_clean}", {{{hex_array}}}, {secret_len}, {{0}}}}{comma}')
header_content.append("};")
with open(HEADER_FILE, "w", encoding="utf-8") as f:
f.write("\n".join(header_content))
print(f"[+] Fertig! Die Datei '{HEADER_FILE}' wurde generiert.")
print(f"[!] Der AES-Schlüssel steht in '{KEY_FILE}'.")
if __name__ == "__main__":
main()
Ausführen mit python aegis_exporter.py
Variante B: Der Exporter in Rust (Standalone)
Erstelle ein Projekt mit cargo new aegis_exporter. Cargo.toml (Abhängigkeiten):
[package]
name = "aegis_exporter"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rfd = "0.14"
rand = "0.8"
base32 = "0.4"
aes = "0.8"
src/main.rs:
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use rand::{distributions::Alphanumeric, Rng};
use rfd::FileDialog;
use serde::Deserialize;
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
// --- KONFIGURATION ---
const KEY_FILE: &str = "pico_aes.key";
const HEADER_FILE: &str = "secrets.h";
const BUFFER_SIZE: usize = 64;
// ---------------------
// --- Datenstrukturen für das JSON-Parsing (Serde) ---
#[derive(Deserialize, Debug)]
struct AegisExport {
db: AegisDb,
}
#[derive(Deserialize, Debug)]
struct AegisDb {
entries: Vec<AegisEntry>,
}
#[derive(Deserialize, Debug)]
struct AegisEntry {
issuer: Option<String>,
name: Option<String>,
info: AegisInfo,
}
#[derive(Deserialize, Debug)]
struct AegisInfo {
secret: String,
}
/// Lädt oder generiert den AES-Key
fn get_or_create_key() -> String {
if Path::new(KEY_FILE).exists() {
println!("[+] Lese existierenden Key aus '{}'...", KEY_FILE);
let key = fs::read_to_string(KEY_FILE).expect("Fehler beim Lesen des Keys");
let key = key.trim().to_string();
if key.len() != 16 {
panic!("Der Key in der Datei muss exakt 16 Zeichen lang sein!");
}
key
} else {
println!("[+] Generiere neuen 16-Byte AES-Key und speichere in '{}'...", KEY_FILE);
let key: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
fs::write(KEY_FILE, &key).expect("Fehler beim Speichern des Keys");
key
}
}
/// Säubert den Base32-String (entfernt Leerzeichen, fügt Padding hinzu)
fn clean_base32(secret: &str) -> String {
let mut clean = secret.replace(" ", "").to_uppercase();
let missing_padding = clean.len() % 8;
if missing_padding != 0 {
clean.push_str(&"=".repeat(8 - missing_padding));
}
clean
}
fn main() {
println!("=== Pico-TOTP Exporter (Rust Edition) ===");
// 1. Key Management (AES-128 init)
let aes_key_str = get_or_create_key();
let aes_key = GenericArray::from_slice(aes_key_str.as_bytes());
let cipher = Aes128::new(&aes_key);
// 2. JSON Datei über natives Fenster auswählen
println!("[*] Bitte wähle die unverschlüsselte Aegis JSON-Exportdatei aus...");
let file_path = FileDialog::new()
.add_filter("JSON Dateien", &["json"])
.set_title("Aegis JSON Export auswählen")
.pick_file();
let json_path = match file_path {
Some(path) => path,
None => {
println!("[-] Keine Datei ausgewählt. Abbruch.");
return;
}
};
// 3. JSON parsen
let json_content = fs::read_to_string(&json_path).expect("Fehler beim Lesen der JSON-Datei");
let data: AegisExport = serde_json::from_str(&json_content)
.expect("Fehler beim Parsen der JSON-Datei. Ist es der unverschlüsselte Export?");
let entries = data.db.entries;
if entries.is_empty() {
println!("[-] Keine Einträge in der JSON gefunden.");
return;
}
// 4. Daten verarbeiten und Header aufbauen
let mut header_content = vec![
"#pragma once".to_string(),
"#include <Arduino.h>".to_string(),
"".to_string(),
"struct Account {".to_string(),
" char name[16];".to_string(),
format!(" uint8_t encrypted_secret[{}];", BUFFER_SIZE),
" size_t secret_len;".to_string(),
format!(" uint8_t decrypted_secret[{}];", BUFFER_SIZE),
"};".to_string(),
"".to_string(),
format!("const int NUM_ACCOUNTS = {};", entries.len()),
"Account accounts[NUM_ACCOUNTS] = {".to_string(),
];
println!("[+] Verarbeite {} Konten...", entries.len());
for (i, entry) in entries.iter().enumerate() {
// Name holen und für C++ Array auf 15 Zeichen kürzen
let name_raw = entry.issuer.clone().unwrap_or_else(|| {
entry.name.clone().unwrap_or_else(|| "Unbekannt".to_string())
});
let name_clean: String = name_raw.chars().take(15).filter(|c| *c != '"').collect();
// Base32 dekodieren
let base32_secret = clean_base32(&entry.info.secret);
let raw_secret = base32::decode(base32::Alphabet::RFC4648 { padding: true }, &base32_secret)
.unwrap_or_else(|| panic!("Fehler beim Base32-Decodieren für Account: {}", name_clean));
let secret_len = raw_secret.len();
if secret_len > BUFFER_SIZE {
panic!("Secret für {} ist zu lang ({} Bytes, max ist {})!", name_clean, secret_len, BUFFER_SIZE);
}
// Puffer mit Nullen füllen (Padding) und Secret kopieren
let mut padded_secret = vec![0u8; BUFFER_SIZE];
padded_secret[..secret_len].copy_from_slice(&raw_secret);
// AES-128 ECB Verschlüsselung (Block für Block)
for chunk in padded_secret.chunks_mut(16) {
let block = GenericArray::from_mut_slice(chunk);
cipher.encrypt_block(block);
}
// In C-Array Format umwandeln (0x1A, 0x2B, ...)
let hex_array: Vec<String> = padded_secret.iter().map(|b| format!("0x{:02X}", b)).collect();
let hex_str = hex_array.join(", ");
let comma = if i < entries.len() - 1 { "," } else { "" };
header_content.push(format!(" {{\"{}\", {{{}}}, {}, {{0}}}}{}", name_clean, hex_str, secret_len, comma));
}
header_content.push("};".to_string());
// 5. secrets.h Datei schreiben
let mut file = File::create(HEADER_FILE).expect("Fehler beim Erstellen der secrets.h");
for line in header_content {
writeln!(file, "{}", line).unwrap();
}
println!("[+] Fertig! Die C++ Datei '{}' wurde generiert.", HEADER_FILE);
println!("[!] Der AES-Schlüssel steht in '{}'.", KEY_FILE);
}
Mit cargo build --release erzeugst du eine kleine aegis_exporter.exe
4. Die Pico Firmware (Arduino IDE)
Vorbereitung:
- Installiere den Raspberry Pi Pico/RP2040 Core von Earle F. Philhower über den Boardverwalter.
- Wähle als Board den Raspberry Pi Pico.
- Wichtig: Unter
Werkzeuge -> USB Stackzwingend Adafruit TinyUSB auswählen! - Bibliotheken installieren:
Adafruit SSD1306,Crypto(von Rhys Weatherley) undTOTP library(von Luca Dentella). - Kopiere die generierte
secrets.hin denselben Ordner wie den.inoSketch.
PicoTOTP.ino
#include <Adafruit_TinyUSB.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Crypto.h>
#include <AES.h>
#include <TOTP.h>
#include "secrets.h"
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
#define BUTTON_PIN 11 // Externer Taster an GPIO 11 und GND
// USB HID Konfiguration für Tastatur (Muss vor Serial.begin initialisiert werden!)
uint8_t const desc_hid_report[] = { TUD_HID_REPORT_DESC_KEYBOARD() };
Adafruit_USBD_HID usb_hid(desc_hid_report, sizeof(desc_hid_report), HID_ITF_PROTOCOL_KEYBOARD, 2, false);
enum State { WAITING, DECRYPTING, READY };
State currentState = WAITING;
uint32_t currentUnixTime = 0;
uint32_t lastTimeSync = 0;
char aesKey[17] = {0};
int currentAccountIdx = 0;
bool lastButtonState = false;
uint32_t buttonPressStart = 0;
const uint32_t LONG_PRESS_MS = 600;
void updateDisplay(const char* line1, const char* line2) {
display.clearDisplay();
display.setCursor(0,0);
display.setTextSize(1);
display.println(line1);
display.setCursor(0, 16);
display.setTextSize(2);
display.println(line2);
display.display();
}
void checkSerial() {
if (Serial.available() > 0) {
String input = Serial.readStringUntil('\n');
if (input.startsWith("TIME:") && input.indexOf("|KEY:") > 0) {
int splitIdx = input.indexOf("|KEY:");
currentUnixTime = input.substring(5, splitIdx).toInt();
lastTimeSync = millis();
input.substring(splitIdx + 5).toCharArray(aesKey, 17);
currentState = DECRYPTING;
}
}
}
void decryptSecrets() {
AES128 aes128;
aes128.setKey((uint8_t*)aesKey, 16);
for(int i = 0; i < NUM_ACCOUNTS; i++) {
for(int block = 0; block < 4; block++) {
aes128.decryptBlock(
accounts[i].decrypted_secret + (block * 16),
accounts[i].encrypted_secret + (block * 16)
);
}
}
aes128.clear();
memset(aesKey, 0, sizeof(aesKey)); // RAM sofort säubern!
currentState = READY;
updateDisplay("Entsperrt!", accounts[currentAccountIdx].name);
}
void typeTotpCode(int accountIndex) {
updateDisplay("Tippe Code...", accounts[accountIndex].name);
uint32_t realTime = currentUnixTime + ((millis() - lastTimeSync) / 1000);
TOTP totp(accounts[accountIndex].decrypted_secret, accounts[accountIndex].secret_len);
char* code = totp.getCode(realTime);
usb_hid.keyboardRelease(0);
delay(10);
for(int i = 0; i < 6; i++) {
char c = code[i];
uint8_t keycode = (c == '0') ? HID_KEY_0 : HID_KEY_1 + (c - '1');
uint8_t keys[6] = {keycode, 0, 0, 0, 0, 0};
usb_hid.keyboardReport(0, 0, keys);
delay(20);
usb_hid.keyboardRelease(0);
delay(20);
}
uint8_t enterKey[6] = {HID_KEY_ENTER, 0, 0, 0, 0, 0};
usb_hid.keyboardReport(0, 0, enterKey);
delay(20);
usb_hid.keyboardRelease(0);
delay(500);
updateDisplay("Bereit", accounts[currentAccountIdx].name);
}
void handleButton() {
bool isPressed = (digitalRead(BUTTON_PIN) == LOW);
if (isPressed && !lastButtonState) {
buttonPressStart = millis();
lastButtonState = true;
} else if (!isPressed && lastButtonState) {
uint32_t pressDuration = millis() - buttonPressStart;
lastButtonState = false;
if (pressDuration > LONG_PRESS_MS) {
typeTotpCode(currentAccountIdx);
} else if (pressDuration > 50) {
currentAccountIdx = (currentAccountIdx + 1) % NUM_ACCOUNTS;
updateDisplay("Bereit", accounts[currentAccountIdx].name);
}
}
}
void setup() {
usb_hid.begin(); // MUSS vor Serial initialisiert werden!
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
Wire.setSDA(4);
Wire.setSCL(5);
Wire.begin();
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.setTextColor(SSD1306_WHITE);
updateDisplay("Pico-TOTP", "Warte auf PC...");
}
void loop() {
#ifdef USE_TINYUSB
TinyUSBDevice.task();
#endif
switch (currentState) {
case WAITING: checkSerial(); break;
case DECRYPTING: decryptSecrets(); break;
case READY: handleButton(); break;
}
}
Wichtig! Nach erfolgreicher Kompilierung die secrets.h aus dem Arduino-Verzeichnis aber auch aus dem Verzeichnis wo der aegis_exporter löschen! Ansonsten wäre es ein Sicherheitsrisiko, falls der PC kompromittiert würde.
5. Der PC-Daemon (Der "Schlüsselverwalter")
Dieser Dienst läuft dauerhaft im Hintergrund auf dem PC. Er überwacht die USB-Ports und schickt beim Einstecken des Picos vollautomatisch den AES-Schlüssel (pico_aes.key) sowie die aktuelle Uhrzeit los.
Wichtig: Die pico_aes.key Datei muss im selben Ordner wie der Daemon liegen.
Variante A: Der Daemon in Python
Benötigt: pip install pyserial
pc_daemon.py
import time
import serial
import serial.tools.list_ports
import os
# --- KONFIGURATION ---
KEY_FILE = "pico_aes.key"
USB_VID = 0x239A # Standard Adafruit TinyUSB VID (wird vom Pico genutzt)
# ---------------------
def load_key():
"""Lädt den generierten AES-Schlüssel aus der Datei."""
if not os.path.exists(KEY_FILE):
print(f"[-] Fehler: Schlüsseldatei '{KEY_FILE}' nicht gefunden!")
return None
with open(KEY_FILE, "r", encoding="utf-8") as f:
key = f.read().strip()
if len(key) != 16:
print("[-] Fehler: Der Key muss exakt 16 Zeichen lang sein.")
return None
return key
def find_pico():
"""Sucht nach einem USB-Gerät mit der passenden Vendor ID."""
ports = serial.tools.list_ports.comports()
for port in ports:
if port.vid == USB_VID:
return port.device
return None
def send_auth_data(port_name, aes_key):
"""Sendet die aktuelle Unix-Zeit und den AES-Key an den Pico."""
try:
# Öffne die serielle Verbindung mit 115200 Baud
with serial.Serial(port_name, 115200, timeout=1) as ser:
unix_time = int(time.time())
# Format: TIME:1718391900|KEY:MeinGeheimer1234\n
payload = f"TIME:{unix_time}|KEY:{aes_key}\n"
ser.write(payload.encode('utf-8'))
print(f"[+] Daten gesendet an {port_name} (Zeit: {unix_time})")
except Exception as e:
print(f"[-] Fehler beim Senden: {e}")
def main():
print("=== Pico-TOTP Schlüsselverwalter ===")
aes_key = load_key()
if not aes_key:
return
print("[*] Warte auf Pico...")
pico_connected = False
while True:
port = find_pico()
if port and not pico_connected:
print(f"[+] Pico erkannt an {port}!")
time.sleep(1.5) # Kurz warten, bis der USB-Serial Stack auf dem Pico komplett hochgefahren ist
send_auth_data(port, aes_key)
pico_connected = True
elif not port and pico_connected:
print("[-] Pico wurde entfernt. Warte...")
pico_connected = False
time.sleep(1) # CPU schonen
if __name__ == "__main__":
main()
Variante B: Der Daemon in Rust (Für den produktiven Dauerbetrieb)
Erstelle ein Projekt mit cargo new pico_daemon. Cargo.toml (Abhängigkeiten):
[package]
name = "pico_daemon"
version = "0.1.0"
edition = "2021"
[dependencies]
serialport = "4.3"
src/main.rs:
use serialport::{SerialPortType, UsbPortInfo};
use std::fs;
use std::io::Write;
use std::path::Path;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
// --- KONFIGURATION ---
const KEY_FILE: &str = "pico_aes.key";
const USB_VID: u16 = 0x239A; // Adafruit TinyUSB VID
// ---------------------
/// Lädt den AES-Key aus der Datei und prüft die Länge
fn load_key() -> Option<String> {
if !Path::new(KEY_FILE).exists() {
eprintln!("[-] Fehler: Schlüsseldatei '{}' nicht gefunden!", KEY_FILE);
eprintln!(" Bitte lege die Datei in denselben Ordner wie diese ausführbare Datei.");
return None;
}
match fs::read_to_string(KEY_FILE) {
Ok(content) => {
let key = content.trim().to_string();
if key.len() != 16 {
eprintln!("[-] Fehler: Der Key muss exakt 16 Zeichen lang sein.");
return None;
}
Some(key)
}
Err(e) => {
eprintln!("[-] Fehler beim Lesen der Schlüsseldatei: {}", e);
None
}
}
}
/// Sucht in allen seriellen Ports nach der Adafruit USB Vendor ID
fn find_pico() -> Option<String> {
if let Ok(ports) = serialport::available_ports() {
for port in ports {
// Wir prüfen, ob es ein USB-Port ist und lesen die Vendor ID (VID) aus
if let SerialPortType::UsbPort(UsbPortInfo { vid, .. }) = port.port_type {
if vid == USB_VID {
return Some(port.port_name);
}
}
}
}
None
}
/// Öffnet den seriellen Port und sendet Unix-Zeit und AES-Key
fn send_auth_data(port_name: &str, aes_key: &str) {
// Port mit 115200 Baud öffnen und 1 Sekunde Timeout setzen
match serialport::new(port_name, 115_200)
.timeout(Duration::from_secs(1))
.open()
{
Ok(mut port) => {
// Aktuelle Unix-Zeit generieren
let unix_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Payload im Format "TIME:1718391900|KEY:MeinGeheimer1234\n" bauen
let payload = format!("TIME:{}|KEY:{}\n", unix_time, aes_key);
// Senden
match port.write_all(payload.as_bytes()) {
Ok(_) => println!("[+] Daten gesendet an {} (Zeit: {})", port_name, unix_time),
Err(e) => eprintln!("[-] Fehler beim Senden an den Pico: {}", e),
}
}
Err(e) => eprintln!("[-] Fehler beim Öffnen des Ports {}: {}", port_name, e),
}
}
fn main() {
println!("=== Pico-TOTP Schlüsselverwalter (Rust Edition) ===");
println!("=== Beenden mit Strg-C == Fenster kann minimiert werden ===");
// 1. Key laden
let aes_key = match load_key() {
Some(key) => key,
None => return, // Abbruch, wenn kein Key da ist
};
println!("[*] Warte im Hintergrund auf Pico...");
let mut pico_connected = false;
// 2. Endlosschleife zur Geräteüberwachung
loop {
let port_opt = find_pico();
match port_opt {
// Fall A: Pico wurde gerade neu eingesteckt
Some(port_name) if !pico_connected => {
println!("[+] Pico erkannt an {}!", port_name);
// WICHTIG: 1.5 Sekunden warten, bis der USB-Serial Stack auf dem Pico hochgefahren ist
thread::sleep(Duration::from_millis(1500));
send_auth_data(&port_name, &aes_key);
pico_connected = true;
}
// Fall B: Pico wurde abgezogen
None if pico_connected => {
println!("[-] Pico wurde entfernt. Warte...");
pico_connected = false;
}
// Fall C: Status unverändert -> Nichts tun
_ => {}
}
// 1 Sekunde schlafen, um die CPU-Auslastung bei ~0% zu halten
thread::sleep(Duration::from_secs(1));
}
}
(Mit cargo build --release erzeugst du eine winzige .exe, die unsichtbar in den Autostart gelegt werden kann).
Der Ablauf in der Praxis: Aegis Export -> Exporter starten -> secrets.h in der Arduino IDE mit dem Code hochladen -> PC Daemon starten -> Pico anstecken -> Button klicken. Fertig ist der hochsichere 2FA-Stick!