Skip to content

How-to: Einmalige-Geheimnisse-API und ATK-01~12 Cracker-Angriffstest

NENE2 Field Trial 184 — Cracker-Angriffstest-Zyklus (ATK-01~12). Das Token IST die Anmeldedaten. Atomarer Verbrauch verhindert Race Conditions.


Was dieser Trial beweist

Ein einmaliges Geheimnis speichert eine verschlüsselte Nachricht, die nur einmal gelesen werden kann. Nach dem ersten erfolgreichen Lesen wird das Geheimnis dauerhaft verbraucht.

Sicherheitsanforderungen:

  1. 256-Bit-Token-Entropie — Brute Force ist rechnerisch nicht machbar
  2. Atomarer VerbrauchUPDATE WHERE consumed=0 verhindert Double-Read-Race-Conditions
  3. IDOR-Prävention — Löschen erfordert sowohl Token als auch Benutzer-Eigentümerschaft
  4. Mass Assignment blockiert — consumed/token/created_at sind nur serverseitig
  5. Typsicherheit — V::str() / V::userId() / V::queryInt() lehnen Nicht-String-Eingaben ab

API

MethodePfadAuthBeschreibung
POST/secretsX-User-IdEin einmaliges Geheimnis erstellen
GET/secretsX-User-IdEigene Geheimnisse auflisten (nur Metadaten, keine Nachricht)
GET/secrets/{token}Lesen + Verbrauchen (Token IST die Anmeldedaten)
DELETE/secrets/{token}X-User-IdVor dem Lesen abbrechen (muss Eigentümer sein)

ATK-01~12 Ergebnisse

IDAngriffsvektorAbwehrErgebnis
ATK-01SQL-Injection in TokenPDO-parametrisierte Abfragen✅ PASS
ATK-02IDOR-mandantenübergreifendes LöschenWHERE token=? AND user_id=?✅ PASS
ATK-03Mass Assignment (consumed=1 im Body)Nur serverseitige Felder✅ PASS
ATK-04XSS-Payload in NachrichtJSON-API — kein HTML-Rendering✅ PASS
ATK-05Doppelt-kodiertes / fehlerhaftes Token/^[0-9a-f]{64}$/ Format-Prüfung✅ PASS
ATK-06Auth-Bypass beim LesenToken IST die Anmeldedaten — per Design✅ PASS
ATK-07Nachricht/Passwort als Nicht-StringV::str() erzwingt is_string()✅ PASS
ATK-0820-stelliger Overflow in limit/offsetV::queryInt() strlen > 18 Guard✅ PASS
ATK-09ReDoS im limit-Parameterctype_digit() — O(n), kein Backtracking✅ PASS
ATK-10Brute-Force-Tokenrandom_bytes(32) = 2^256 Entropie✅ PASS
ATK-11Race-Condition Double-ReadUPDATE WHERE consumed=0 + rowCount-Prüfung✅ PASS
ATK-12Header-Injection in X-User-IdV::userId() erzwingt ctype_digit()✅ PASS

12/12: PASS


Kernmuster: Atomarer Verbrauch

Die kritische Sicherheitsinvariante — ein Geheimnis kann nur einmal gelesen werden:

php
// SecretRepository::consumeByToken()

// Schritt 1: Geheimnis abrufen (normales SELECT — kein Guard)
$row = $pdo->prepare('SELECT * FROM secrets WHERE token = :token');
$row->execute(['token' => $token]);
$secret = $row->fetch(PDO::FETCH_ASSOC);

// Schritt 2: consumed-Flag prüfen (frühes Beenden für den häufigen Fall)
if ($secret['consumed']) return null;

// Schritt 3: Atomares UPDATE — das ist der echte Guard
$update = $pdo->prepare(
    'UPDATE secrets SET consumed = 1 WHERE token = :token AND consumed = 0'
);
$update->execute(['token' => $token]);

// Schritt 4: rowCount() === 0 bedeutet, ein anderer Leser hat das Race gewonnen
if ($update->rowCount() === 0) {
    return null; // Jemand anderes hat es zwischen unserem SELECT und diesem UPDATE verbraucht
}

// Schritt 5: Wir haben gewonnen — Geheimnis zurückgeben
return Secret::fromRow($secret);

Warum das funktioniert: SQLite und die meisten RDBMS garantieren, dass UPDATE WHERE consumed=0 atomar ist. Nur ein gleichzeitiger Schreiber kann consumed von 0→1 ändern. Der Verlierer's rowCount() gibt 0 zurück.


Token-Generierung

php
$token = bin2hex(random_bytes(32)); // 64 Hex-Zeichen = 32 Bytes = 256 Bits
  • random_bytes() verwendet das OS-CSPRNG (äquivalent zu /dev/urandom)
  • 2^256 Tokens bei 10^12 Versuchen/Sekunde ≈ 10^60 Jahre zum Brute-Force
  • Tokens sind in der DB eindeutig (UNIQUE-Constraint)

Token-Format-Validierung

php
private const TOKEN_PATTERN = '/^[0-9a-f]{64}$/';

// Lehnt ab: Großbuchstaben-Hex, Pfadtraversal ../../, URL-kodiert, Integer, leer
if (!preg_match(self::TOKEN_PATTERN, $rawToken)) {
    return $this->responseFactory->create(['error' => 'Secret not found.'], 404);
}

IDOR-Prävention (ATK-02)

php
// DELETE erfordert SOWOHL Token-Eigentümerschaft ALS AUCH user_id-Übereinstimmung
$stmt = $pdo->prepare(
    'DELETE FROM secrets WHERE token = :token AND user_id = :user_id AND consumed = 0'
);
$stmt->execute(['token' => $token, 'user_id' => $userId]);

// Gibt unabhängig vom Grund 404 zurück — vermeidet Enumerations-Oracle
return $stmt->rowCount() > 0;

Mass-Assignment-Prävention (ATK-03)

Serverseitige Felder werden niemals aus dem Request-Body gelesen:

php
// POST /secrets Handler — nur message, password, expires_at werden aus dem Body akzeptiert
$token        = bin2hex(random_bytes(32));  // server-generiert
$consumed     = 0;                          // beginnt immer unverbraucht
$createdAt    = (new DateTimeImmutable())->format(DateTimeInterface::ATOM); // Server-Zeit
$passwordHash = $password !== null ? hash('sha256', $password) : null;     // serverseitig gehasht

// body['consumed'], body['token'], body['user_id'], body['created_at'] werden stillschweigend ignoriert

V.php-Validierungskette

php
// ATK-07: message muss ein String sein (lehnt int, bool, null, array ab)
$message = V::str($body['message'] ?? null, 10000);

// ATK-12: X-User-Id muss ctype_digit + positiv + max 18 Zeichen sein
$userId = V::userId($request->getHeaderLine('X-User-Id'));

// ATK-08/09: limit muss numerisch sein, max 18 Ziffern, im Bereich 1–100
$limit = V::queryInt($params, 'limit', 1, 100, 20);

Optionaler Passwortschutz

php
// Speicherung: nur SHA-256-Hash (kein Klartext)
$passwordHash = $password !== null ? hash('sha256', $password) : null;

// Verifizierung: Constant-Time-Vergleich (timing-sicher)
if (!hash_equals($secret->passwordHash, hash('sha256', $submittedPassword))) {
    return null; // Falsches Passwort → stilles 404 (kein Oracle)
}

Hinweis: Falsches Passwort gibt 404 (nicht 403) zurück, um Oracle-Angriffe zu verhindern. Das Geheimnis wird bei falschem Passwort NICHT verbraucht — nur das korrekte Passwort verbraucht es.


Metadaten-Liste (keine Nachrichtenpreisgabe)

php
// GET /secrets — gibt nur Metadaten zurück, niemals die Nachricht
private function secretToMetadata(Secret $secret): array
{
    return [
        'token'        => $secret->token,
        'has_password' => $secret->passwordHash !== null,
        'consumed'     => $secret->consumed,
        'expires_at'   => $secret->expiresAt,
        'created_at'   => $secret->createdAt,
        // 'message' wird absichtlich weggelassen
    ];
}

Testergebnisse

85 Tests / 209 Assertions — alle PASS
PHPStan Level 8 — keine Fehler
PHP CS Fixer — sauber

Wichtigste Erkenntnisse

MusterRegel
Atomarer VerbrauchUPDATE WHERE consumed=0 + rowCount()-Prüfung — nicht SELECT dann UPDATE
Token-Entropierandom_bytes(32) Minimum (256 Bits) — niemals sequentielle IDs
Token-FormatAllowlist-Regex an beiden Enden verankert (/^[0-9a-f]{64}$/)
IDORAlle Schreiboperationen nach token AND user_id eingrenzen
Mass AssignmentToken, consumed, created_at — nur serverseitig, niemals aus Body
Passwort-Timinghash_equals() für Constant-Time-Vergleich
Falsches Passwort404 nicht 403 — vermeidet Bestätigung der Geheimnis-Existenz
Metadaten-ListeNachricht aus Listen-Endpunkt weglassen — nur beim Verbrauchen lesen

Veröffentlicht unter der MIT-Lizenz.