giar /otp

Entwickler-API

Geheimnisse aus dem Code teilen.

Für Skripte, CI/CD-Pipelines und interne Tools: dieselbe Verschlüsselung wie im Browser, als REST-API und als SDK für JavaScript/TypeScript und Python, jeweils mit Kommandozeile.

Schnellstart

Die SDKs verschlüsseln lokal. Beim Server kommt nur Ciphertext an, der Schlüssel bleibt im Link.

import { OtpClient } from 'otp-giar';

const otp = new OtpClient({ baseUrl: 'https://otp.giar.digital', apiKey: process.env.OTP_API_KEY });

// Verschlüsselt lokal, der Server bekommt nur den Ciphertext.
const { link, deleteToken } = await otp.create('DB_PASSWORD=hunter2', {
  expiresIn: 3600, // Sekunden, maximal 7 Tage
  maxViews: 1,
  password: 'optional'
});

// Öffnen (verbraucht eine Öffnung) und lokal entschlüsseln.
const { secret, viewsRemaining } = await otp.reveal(link, { password: 'optional' });
npm
npm install otp-giar
pip
pip install otp-giar

Endpunkte

Basis-URL https://otp.giar.digital. JSON rein, JSON raus. CORS ist für alle Ursprünge offen, die API nutzt keine Cookies.

POST/api/v1/secrets

Zwei Varianten, je nachdem was im Body steht:

  • Ende-zu-Ende (SDKs, Web-App): der Client verschlüsselt selbst und schickt ciphertext, iv, salt, kdf und authToken. Den Link baut er aus url + # + Schlüssel.
  • Klartext (schnell mit cURL): secret und optional password. Der Server verschlüsselt im Arbeitsspeicher, speichert nur den Ciphertext und gibt key und link genau einmal zurück.
FeldBedeutung
expiresInLebensdauer in Sekunden, 60 bis 604800 (7 Tage). Standard 86400.
maxViewsWie oft es geöffnet werden kann, 1 bis 100. Standard 1.
secretKlartext-Modus: der Text, maximal 64 KB.
passwordKlartext-Modus: optionales Passwort zum Öffnen.
Body Ende-zu-Ende
{
  "version": 1,
  "ciphertext": "<base64url>",
  "iv": "<base64url, 12 Bytes>",
  "salt": "<base64url, 16 Bytes>",
  "kdf": null,               // oder { "name": "pbkdf2-sha256", "iterations": 600000 }
  "authToken": "<base64url, 32 Bytes>",
  "expiresIn": 3600,
  "maxViews": 1
}
Antwort 201
{
  "id": "vN4MP8Hdk0rjKY06fWMxVVZDA-OPAkLI",
  "url": "https://otp.giar.digital/s/vN4MP8Hdk0rjKY06fWMxVVZDA-OPAkLI",
  "expiresAt": "2026-09-25T14:32:00.000Z",
  "maxViews": 1,
  "deleteToken": "lnNzBFYmszeV1pnADnKpmYuSVKJ6IIZTG2GZI3P3yxg",

  // nur im Klartext-Modus:
  "key": "hbEP-kstB6NdtzT_E86eOgH5iOt2oM1m-vedLUx469I",
  "link": "https://otp.giar.digital/s/vN4MP8Hdk0rjKY06fWMxVVZDA-OPAkLI#hbEP-kstB6N…"
}

Im Klartext-Modus sieht der Server das Geheimnis kurz im Arbeitsspeicher. Wenn das nicht in Frage kommt, nimm ein SDK oder die CLI.

GET/api/v1/secrets/{id}

Liefert expiresAt, maxViews, viewsRemaining, passwordRequired sowie salt und kdf, die ein Client zum Ableiten des authToken braucht. Verbraucht keine Öffnung.

POST/api/v1/secrets/{id}/reveal

Öffnet das Geheimnis und zieht eine Öffnung ab. Bei der letzten Öffnung wird es sofort gelöscht.

  • { "authToken": "…" } liefert ciphertext, iv, salt, kdf. Der Client entschlüsselt selbst.
  • { "key": "…", "password": "…" } liefert secret im Klartext.

Ein falsches Passwort verbraucht keine Öffnung, zählt aber als Fehlversuch. Nach 10 Fehlversuchen wird das Geheimnis gelöscht.

DELETE/api/v1/secrets/{id}

Löscht ein Geheimnis vor Ablauf. Erwartet den deleteToken aus der Antwort beim Anlegen im Header X-Delete-Token. Antwortet mit 204.

GET/api/v1/openapi.json

Maschinenlesbare Beschreibung für Postman, Insomnia oder Codegeneratoren: openapi.json

Verschlüsselung

Jedes Geheimnis bekommt einen eigenen Schlüssel. Browser schicken den Teil nach dem # nie an einen Server, deshalb kennt die Instanz den Schlüssel nicht.

Protokoll v1
key        = 32 Zufallsbytes              → steht nur im Link nach dem #
salt, iv   = 16 / 12 Zufallsbytes
ikm        = key                           ohne Passwort
           = key ‖ PBKDF2-SHA256(Passwort, salt, 600 000)
encKey     = HKDF-SHA256(ikm, salt, "otp-giar:v1:enc")
authToken  = HKDF-SHA256(ikm, salt, "otp-giar:v1:auth")
ciphertext = AES-256-GCM(encKey, iv, Text, aad = "otp-giar:v1")
  • Gespeichert werden nur Ciphertext, iv, salt und SHA-256 des authToken. Wer nur die ID kennt, kann das Geheimnis weder lesen noch eine Öffnung verbrauchen.
  • IDs haben 192 Bit, Schlüssel 256 Bit, beides aus dem Zufallsgenerator des Betriebssystems. Raten ist aussichtslos; zusätzlich gibt es Rate-Limits pro IP.
  • Abgelaufene und verbrauchte Geheimnisse werden sofort gelöscht, SQLite überschreibt freie Seiten.

API-Keys und Limits

Ohne Key funktioniert alles, aber mit engeren Limits. Ein Key wird als Authorization: Bearer ogk_… mitgeschickt. Keys vergibt, wer die Instanz betreibt: npm run apikey -- create "GitHub Actions".

AktionOhne KeyMit Key
Anlegen20 pro 10 Min. und IP1000 pro 10 Min.
Öffnen30 pro 10 Min. und IPgleich
Grösse64 KB Text
Lebensdauer1 Minute bis 7 Tage
Öffnungen1 bis 100

Fehler

Immer im Format { "error": { "code": "…", "message": "…" } }, teils mit Zusatzfeldern.

StatuscodeBedeutung
400invalid_requestFeld fehlt oder ist ungültig, siehe field
400password_requiredGeheimnis ist passwortgeschützt, password fehlt
401invalid_api_keyAPI-Key unbekannt oder widerrufen
401invalid_credentialsPasswort oder Schlüssel falsch, siehe attemptsRemaining
403invalid_delete_tokenLösch-Token passt nicht
404not_foundExistiert nicht, abgelaufen oder schon geöffnet
410destroyedNach 10 Fehlversuchen gelöscht
413secret_too_largeMehr als 64 KB
415unsupported_media_typeBody muss application/json sein
429rate_limitedZu viele Anfragen, siehe Retry-After

Beispiel: GitHub Actions

Ein Pipeline-Schritt, der ein Staging-Passwort als Einmal-Link in Slack postet, statt es im Klartext zu verschicken.

.github/workflows/deploy.yml
- name: Staging-Zugang an QA schicken
  env:
    OTP_BASE_URL: https://otp.giar.digital
    OTP_API_KEY: ${{ secrets.OTP_API_KEY }}
    STAGING_PASSWORD: ${{ secrets.STAGING_PASSWORD }}
    SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
  run: |
    LINK=$(printf '%s' "$STAGING_PASSWORD" | npx -y otp-giar create --ttl 1d --views 1)
    echo "::add-mask::$LINK"
    curl -s -X POST "$SLACK_WEBHOOK" -H 'Content-Type: application/json' \
      -d "{\"text\": \"Staging-Zugang (einmal lesbar): $LINK\"}"
Verschlüsselt im Browser mit AES-256-GCM. Der Schlüssel steht nur im Link und erreicht den Server nie.