Passwörter und Geheimnisse über einen Einmal-Link teilen, der sich nach dem
Abruf selbst zerstört. Die Verschlüsselung passiert im Browser (AES-256-GCM);
der Schlüssel steht nur im URL-Fragment (#…) und erreicht den Server nie — der
Server speichert ausschließlich Ciphertext (Zero-Knowledge).
Self-hosted-Variante des integrisec-Tools, als echtes Zero-Knowledge gebaut. Läuft als ein Docker-Container (oder direkt mit Node) — keine npm-Abhängigkeiten, dank Nodes eingebautem SQLite.
White-label: Wortmarke im
<header>der HTML-Dateien und Farben inpublic/style.css(:root) anpassen.
- Zero-Knowledge — Ver-/Entschlüsselung (AES-256-GCM) im Browser; der Schlüssel steht nur im URL-Fragment, der Server speichert ausschließlich Ciphertext.
- Burn-after-read — der Einmal-Link wird beim Abruf atomar gelöscht; ein zweiter Aufruf läuft ins Leere.
- Optionales Passwort — zusätzlicher Schutz (PBKDF2 + Verifier); ein falsches Passwort verbrennt das Geheimnis nicht.
- Ablaufzeit — 1 Stunde bis 7 Tage (konfigurierbar); nicht abgerufene Geheimnisse löschen sich selbst.
- Scanner-sicher — id + Schlüssel im Fragment, Anzeige erst per Klick → Link-Prefetch durch Mail-/Chat-Scanner verbrennt nichts.
- Optionale Server-Encrypt-API — fertige Einmal-Links per Token-Aufruf (
{url}) fürs eigene, vertrauenswürdige Netz (in diesem Modus kein Zero-Knowledge). - Ein Container —
docker compose up, keine externe Datenbank, keine npm-Abhängigkeiten. - Automatisches HTTPS — via Caddy (Let's Encrypt) oder lokale CA (
tls internal) für abgeschottete Netze. - White-label — Wortmarke und Farben in Minuten anpassbar.
- Schlank & auditierbar — ein
server.js, statisches Frontend, kein Build-Schritt.
- Docker-Variante (empfohlen): Docker Engine + Compose v2; offene Ports 80 und 443; eine Domain mit A/AAAA-Record auf den Host (für automatisches HTTPS).
- Ohne Docker: Node.js ≥ 22.13 und ein eigener TLS-Reverse-Proxy.
- Internes/abgeschottetes Netz: kein öffentliches DNS nötig — Caddy stellt mit
tls internaleine eigene lokale CA bereit.
Das Installieren dieser Pakete erfordert Root-Rechte (sudo).
Achtung bei den Paketquellen: Ältere LTS-Stände liefern über
aptnur Node 18 oder 20 — damit startet PassOnce nicht, weil das eingebautenode:sqlitefehlt. Prüfen mitapt-cache policy nodejs. Ist der Kandidat zu alt, Node über nodejs.org oder einen Versionsmanager wienvmbeziehen. Ubuntu 26.04 liefert Node 22 direkt mit, ebensodocker.iounddocker-compose-v2.
git clone https://github.com/integrisec-de/passonce.git
cd passonceOhne Git? Dann das Archiv laden und entpacken:
curl -fsSL -o passonce.tar.gz https://github.com/integrisec-de/passonce/archive/refs/heads/main.tar.gz
tar -xzf passonce.tar.gz
cd passonce-mainFertige Release-Archive liegen unter https://github.com/integrisec-de/passonce/releases.
Alle folgenden Befehle laufen in diesem Verzeichnis.
Am einfachsten — setup.sh (fragt nach Domain, erzeugt .env inkl. optional
sicher generiertem API-Token und startet alles):
./setup.sh # interaktiv
./setup.sh --domain secret.example.com # nicht-interaktiv
./setup.sh --help # alle OptionenOder manuell (Docker + Caddy, automatisches HTTPS):
cp .env.example .env
# in .env: SITE_ADDRESS=secret.example.com (zeigt per DNS auf diesen Host)
docker compose up -dCaddy holt für die Domain automatisch ein Let's-Encrypt-Zertifikat. Fertig:
https://secret.example.com
Internes Netz ohne öffentliches DNS? In der Caddyfile den Block auf
:443 { tls internal … } umstellen (Caddy nutzt dann eine eigene lokale CA) oder
ein eigenes Zertifikat hinterlegen. Beispiele stehen auskommentiert in der Datei.
Nur schnell lokal testen (ohne TLS): SITE_ADDRESS=:80 in der .env.
Voraussetzung: Node.js ≥ 22.13.
set -a; . ./.env; set +a # Konfiguration laden — nicht weglassen!
node server.js
# läuft auf http://localhost:8080 — gehört hinter einen TLS-Reverse-Proxy!Warum die erste Zeile Pflicht ist:
server.jsliest seine Konfiguration ausschließlich aus Umgebungsvariablen und liest die.envnicht von selbst ein. Wer nurnode server.jsstartet, betreibt den Dienst mit den eingebauten Standardwerten —MAX_TTL,MIN_TTL,RATE_LIMIT_PER_MIN,ALLOW_SERVER_ENCRYPT,API_TOKENundBASE_URLaus der.envbleiben dann wirkungslos, ohne jede Warnung. Genau diesen Startbefehl gibt auch./setup.sh --nodeaus.
server.js bringt alles mit (Statik + API + SQLite). Für Produktion einen
Reverse-Proxy mit TLS davorstellen (Caddy, nginx, Traefik …).
Zum --experimental-sqlite-Flag: Ab Node 22.13 wird es nicht mehr
gebraucht — node:sqlite ist seitdem ohne Flag verfügbar (weiterhin als
experimentell markiert). Nur auf Node 22.5 bis 22.12 muss node --experimental-sqlite server.js gestartet werden. Das mitgelieferte
Docker-Image und npm start führen das Flag weiterhin mit; ab 22.13 ist es
wirkungslos, aber unschädlich.
Zur Startmeldung: Auf Node 22 erscheint beim Start
ExperimentalWarning: SQLite is an experimental feature. Das ist erwartbar und
kein Fehler; ab Node 24 entfällt die Meldung.
Beispiel systemd-Unit (/etc/systemd/system/passonce.service):
[Unit]
Description=PassOnce
After=network.target
[Service]
WorkingDirectory=/opt/passonce
EnvironmentFile=/opt/passonce/.env
ExecStart=/usr/bin/node server.js
Environment=PORT=8080
Restart=on-failure
User=passonce
[Install]
WantedBy=multi-user.target./setup.sh --node bereitet die .env dafür vor.
Zero-Knowledge schützt nur mit TLS. Der Schlüssel steht zwar im URL-Fragment (geht nie an den Server), aber ohne HTTPS könnte ein Angreifer im Netz die ausgelieferte Seite manipulieren und per eingeschleustem JavaScript den Schlüssel abgreifen. Deshalb immer hinter HTTPS betreiben (Caddy erledigt das von selbst).
Eine Schwachstelle gefunden? Bitte kein öffentliches Issue — der Meldeweg
steht in SECURITY.md.
Die Defaults unterscheiden sich teilweise zwischen den beiden Betriebsarten:
| Variable | Default (Docker) | Default (Node direkt) | Bedeutung |
|---|---|---|---|
SITE_ADDRESS |
:80 |
(ohne Wirkung) | Caddy-Adresse/Domain (TLS automatisch bei echter Domain). Wird nur von Caddy gelesen — im Node-Betrieb ohne Funktion. |
PORT |
8080 |
8080 |
Port der App (hinter dem Proxy) |
DB_PATH |
/data/secrets.db |
data/secrets.db relativ zum Verzeichnis von server.js |
SQLite-Datei. Im Container ein Volume, im Node-Betrieb ein Unterverzeichnis der Installation — dort liegen die Daten fürs Backup. |
MIN_TTL |
300 |
300 |
kleinste Lebensdauer in Sekunden (5 Minuten). Kleinere Werte werden stillschweigend angehoben — siehe Hinweis unter API. |
DEFAULT_TTL |
86400 |
86400 |
Lebensdauer, wenn die Anfrage kein ttl enthält (1 Tag) |
MAX_TTL |
604800 |
604800 |
größte Lebensdauer in Sekunden (7 Tage). Größere Werte werden stillschweigend gekappt. |
RATE_LIMIT_PER_MIN |
60 |
60 |
Anfragen pro IP/Minute auf /api/*; darüber HTTP 429 |
ALLOW_SERVER_ENCRYPT |
false |
false |
Server-Encrypt-API aktivieren (s. u.) |
API_TOKEN |
– | – | Token für die Server-Encrypt-API — siehe Hinweis unten zum Umgang damit |
BASE_URL |
– | – | Basis-URL für von der API zurückgegebene Links. Pflicht, sobald ALLOW_SERVER_ENCRYPT=true — sonst verweigert der Dienst den Start. |
Das Token ist der einzige Schutz der Server-Encrypt-API. Zwei Stellen, an denen es sichtbar wird und die man kennen sollte — beides ist kein Defekt, sondern Betreiberwissen:
setup.shgibt das erzeugte Token einmal auf dem Terminal aus. Das ist gewollt, Sie brauchen es ja. Es landet damit aber im Scrollback Ihrer Sitzung und, falls Sie das Skript untertee,scriptoder in einer CI-Pipeline laufen lassen, in deren Protokolldatei. Die erzeugte.envselbst wird mit Dateirechten600angelegt.- Im Docker-Betrieb steht das Token als Umgebungsvariable im Container. Es
ist damit über
docker inspectund in/proc/1/environlesbar — für jeden, der Zugriff auf den Docker-Daemon oder den Container hat. Für Compose ist das üblich; enger wird es mit einerenv_file, die Sie mit600selbst verwalten, oder mit Docker Secrets.
Wenn Sie den Verdacht haben, dass das Token in ein Protokoll geraten ist:
neu erzeugen (./setup.sh --server-encrypt --force) und den Dienst neu starten.
POST /api/secrets—{ ciphertext, iv, salt?, verifier?, needs_passphrase, ttl }→{ id, expires_at, ttl_effective, ttl_adjusted?, ttl_requested? }GET /api/secrets/:id/meta—{ exists, needs_passphrase, salt }(kein Burn)POST /api/secrets/:id/reveal—{ verifier? }→{ ciphertext, iv }(löscht atomar)
Der Client verschlüsselt/entschlüsselt selbst; der Schlüssel bleibt im Fragment.
ttlwird in den erlaubten Bereich gezwungen — die Antwort sagt es Ihnen. Der Server begrenzt jeden Wert aufMIN_TTL … MAX_TTL(Standard 300 s bis 604800 s).ttl: 60ergibt also ein Geheimnis, das 300 Sekunden lebt, nicht 60. Fehltttloder ist es keine Zahl, giltDEFAULT_TTL(1 Tag).Damit das nicht unbemerkt bleibt, weist die Antwort die tatsächliche Lebensdauer aus und kennzeichnet eine Korrektur:
ttl_effectivesteht immer in der Antwort;ttl_adjustedundttl_requestednur, wenn tatsächlich korrigiert wurde. Das Auffüllen eines fehlendenttlmitDEFAULT_TTLgilt nicht als Korrektur. Wer kürzere Laufzeiten braucht, mussMIN_TTLsenken; das gilt für beide Endpunkte, auch für Server-Encrypt.
Nur wenn ALLOW_SERVER_ENCRYPT=true und gültiges API_TOKEN. Hier verschlüsselt
der Server — also kein Zero-Knowledge mehr; nur fürs eigene vertrauenswürdige Netz.
curl -s -X POST https://secret.example.com/api/secrets/plain \
-H "Authorization: Bearer $API_TOKEN" \
-H "content-type: application/json" \
-d '{"secret":"mein geheimnis","ttl":3600}'
# -> {"url":"https://secret.example.com/s/#<id>.<key>","expires_at":...,"ttl_effective":3600}Der zurückgegebene Link funktioniert mit derselben Anzeige-Seite und wird beim Öffnen einmalig entschlüsselt und gelöscht.
Im Zero-Knowledge-Betrieb erreicht der Schlüssel den Server nie — er entsteht im Browser und bleibt im URL-Fragment. Hier ist das anders: Der Server erzeugt den Schlüssel selbst und gibt ihn als Teil der URL zurück (
…/s/#<id>.<key>). Diese URL steht damit im HTTP-Antwort-Body.Das ist die Stelle, an der ein Betreiber sein Zero-Knowledge verliert, ohne es zu merken — nicht durch einen Angriff, sondern durch gewöhnliche Betriebspraxis:
- Body-Logging am Reverse-Proxy. Viele Setups protokollieren Antwort-Bodies zur Fehlersuche. Wer das aktiviert, schreibt den Schlüssel jedes erzeugten Geheimnisses ins Log.
- APM- und Tracing-Agenten erfassen Antwortinhalte oft standardmäßig.
- Terminal-Scrollback und CI-Logs. Das
curl-Beispiel oben schreibt den Link auf die Standardausgabe. In einer Pipeline landet er in deren Protokoll.- Fehlerbehandlung im aufrufenden Programm. Eine Zeile
log.error("Antwort: " + body)genügt.Wer diesen Modus nutzt, sollte Body-Logging für
/api/secrets/plainausdrücklich ausschließen und die Aufrufe nicht in Protokolle schreiben. Im Zweifel: Der Zero-Knowledge-Weg über die Weboberfläche hat dieses Problem nicht.
BASE_URList in diesem Modus Pflicht. Ohne den Wert würde die Herkunft des zurückgegebenen Links aus dem Host-Header der Anfrage stammen, also vom Aufrufer bestimmt. Der Dienst verweigert deshalb den Start, wennALLOW_SERVER_ENCRYPT=truegesetzt ist undBASE_URLfehlt — sichtbares Scheitern statt stillschweigend falscher Links../setup.sh --server-encryptträgt den Wert automatisch ein.
Nach dem Start ein schneller Health-Check (kein Geheimnis nötig):
curl -fsS https://secret.example.com/api/secrets/zzzzzzzzzzzz/meta
# -> {"exists":false} (Function + Datenbank erreichbar)Dann im Browser die Seite öffnen, ein Test-Geheimnis anlegen, den Link in einem zweiten Browser/Inkognito-Fenster öffnen und prüfen, dass er nach dem ersten Abruf erlischt.
Mit Git geklont:
cd /pfad/zu/passonce
git pull
docker compose up -d --build # Node-Setup: Dienst neu startenÜber ein Archiv installiert (kein Git-Repo vorhanden — git pull schlägt
hier fehl): neues Archiv daneben entpacken und nur den Programmstand ersetzen.
cd /pfad/zu
curl -fsSL -o passonce-neu.tar.gz https://github.com/integrisec-de/passonce/archive/refs/heads/main.tar.gz
tar -xzf passonce-neu.tar.gz # entpackt nach passonce-main/
cp -r passonce-main/. passonce/ # Programmdateien ueberschreiben
cd passonce
docker compose up -d --build # Node-Setup: Dienst neu startenDiese beiden dürfen beim Update nicht überschrieben werden:
Pfad Inhalt .envIhre Konfiguration inklusive API_TOKEN— liegt nicht im Archiv und wird voncpdaher nicht angefasstdata/die SQLite-Datenbank im Node-Betrieb; im Docker-Betrieb liegen die Daten stattdessen im Volume secrets-dataBeides ist auch in der
.gitignoreausgenommen. Legen Sie vor dem Update trotzdem eine Kopie an — im Node-Betrieb genügtcp -a data data.bak, im Docker-Betrieb bleibt das Volume ohnehin unberührt.
Die Daten bleiben erhalten — sie liegen im Volume secrets-data bzw. unter
data/, nicht im Image und nicht im Archiv.
docker compose down -v # entfernt Container UND Volumes (inkl. aller Geheimnisse)-
Kein Zertifikat / HTTPS schlägt fehl: DNS muss auf den Host zeigen und die Ports 80 + 443 müssen erreichbar sein (Let's-Encrypt-Challenge). Logs:
docker compose logs caddy. -
Internes Netz ohne öffentliches DNS: in der
Caddyfileauf:443 { tls internal … }umstellen. -
./setup.sh: bad interpreter: Datei wurde mit CRLF gespeichert —sed -i 's/\r$//' setup.sh. (Das Repo liefert LF; betrifft nur manuelle Bearbeitung unter Windows.) -
Server-Encrypt antwortet
404:ALLOW_SERVER_ENCRYPT=trueundAPI_TOKENgesetzt? Danach Dienst neu starten. -
Seite bleibt stumm, obwohl
docker compose up -d„Started" meldet: Tritt auf, wenn der erste Start fehlschlug, weil Port 80 oder 443 schon belegt war (anderer Webserver, anderer Container). Der Container existiert dann zwar, veröffentlicht aber keine Ports — und ein erneutesdocker compose up -dstartet ihn bloß, ohne das zu heilen. Erkennen:docker inspect -f '{{json .NetworkSettings.Ports}}' passonce-caddy-1 # -> {} bedeutet: keine Ports veroeffentlicht
Beheben (nachdem der Port frei ist —
ss -tlnp | grep -E ':(80|443) 'zeigt den Belegier):docker compose up -d --force-recreate caddy
-
App-Logs:
docker compose logs -f app.
- Erstellen: Browser erzeugt Zufallsschlüssel K + verschlüsselt den Text
(AES-256-GCM). Optional: zusätzliches Passwort (PBKDF2 + Verifier — ein falsches
Passwort beim Abruf verbrennt das Geheimnis nicht). Server speichert nur
Ciphertext unter
SHA-256(id). Link:…/s/#<id>.<K>. - Abrufen: Anzeige-Seite liest
id+Kaus dem Fragment, holt die Metadaten (kein Burn), und erst per Klick den Ciphertext — der dabei atomar gelöscht wird (DELETE … RETURNING). Entschlüsselung im Browser. - Ablauf: Jedes Geheimnis hat ein TTL; der Server prunt abgelaufene Einträge periodisch. Abgelaufenes wird nie ausgeliefert.
- Node.js ≥ 22, ein
server.js, keine npm-Abhängigkeiten (node:sqlite) - SQLite als eine Datei (Volume)
- Caddy als Reverse-Proxy mit automatischem HTTPS
- Frontend: statisches HTML/CSS + Web Crypto, kein Build-Schritt
Apache License 2.0 — siehe LICENSE. Das NOTICE nennt
Copyright, Markenvorbehalt und den Umfang der Veröffentlichung.
Diese Veröffentlichung ist ein Auszug des Entwicklungs-Repositorys: Sie umfasst die Software, die ein Betreiber installiert — Server, Schema, die clientseitige Zero-Knowledge-Krypto und die Oberfläche. Nicht enthalten sind die Inhalte des gehosteten Dienstes passonce.de und dessen Cloudflare-spezifische Betriebsvariante.
Die Software enthält keinen Fremdcode und liefert keine Assets Dritter mit.
PassOnce ist kostenfrei nutzbar. Bei Einführung und Integration in die eigene Umgebung unterstützt integrisec als Beratungsleistung.