Skip to content

How-To: One-Time Secret API & ATK-01~12 Cracker Attack Test

NENE2 Field Trial 184 — Cracker Attack Test Cycle (ATK-01~12). Token IS the credential. Atomic consumption prevents race conditions.


What This Trial Proves

A one-time secret stores an encrypted message that can only be read once. After the first successful read, the secret is permanently consumed.

Security requirements:

  1. 256-bit token entropy — brute force is computationally infeasible
  2. Atomic consumptionUPDATE WHERE consumed=0 prevents double-read race conditions
  3. IDOR prevention — delete requires both token + user ownership
  4. Mass assignment blocked — consumed/token/created_at are server-side only
  5. Type safety — V::str() / V::userId() / V::queryInt() reject non-string inputs

API

MethodPathAuthDescription
POST/secretsX-User-IdCreate a one-time secret
GET/secretsX-User-IdList own secrets (metadata only, no message)
GET/secrets/{token}Read + consume (token IS the credential)
DELETE/secrets/{token}X-User-IdCancel before reading (must own)

ATK-01~12 Results

IDAttack VectorDefenseResult
ATK-01SQL injection in tokenPDO parameterized queries✅ PASS
ATK-02IDOR cross-user deleteWHERE token=? AND user_id=?✅ PASS
ATK-03Mass assignment (consumed=1 in body)Server-side fields only✅ PASS
ATK-04XSS payload in messageJSON API — no HTML rendering✅ PASS
ATK-05Double-encoded / malformed token/^[0-9a-f]{64}$/ format check✅ PASS
ATK-06Auth bypass on readToken IS the credential — by design✅ PASS
ATK-07Message/password as non-stringV::str() enforces is_string()✅ PASS
ATK-0820-digit overflow in limit/offsetV::queryInt() strlen > 18 guard✅ PASS
ATK-09ReDoS in limit parameterctype_digit() — O(n), no backtracking✅ PASS
ATK-10Brute force tokenrandom_bytes(32) = 2^256 entropy✅ PASS
ATK-11Race condition double-readUPDATE WHERE consumed=0 + rowCount check✅ PASS
ATK-12Header injection in X-User-IdV::userId() enforces ctype_digit()✅ PASS

12/12: PASS


Core Pattern: Atomic Consumption

The critical security invariant — a secret can only be read once:

php
// SecretRepository::consumeByToken()

// Step 1: Fetch secret (ordinary SELECT — not the guard)
$row = $pdo->prepare('SELECT * FROM secrets WHERE token = :token');
$row->execute(['token' => $token]);
$secret = $row->fetch(PDO::FETCH_ASSOC);

// Step 2: Check consumed flag (early exit for common case)
if ($secret['consumed']) return null;

// Step 3: Atomic UPDATE — this is the real guard
$update = $pdo->prepare(
    'UPDATE secrets SET consumed = 1 WHERE token = :token AND consumed = 0'
);
$update->execute(['token' => $token]);

// Step 4: rowCount() === 0 means another reader won the race
if ($update->rowCount() === 0) {
    return null; // someone else consumed it between our SELECT and this UPDATE
}

// Step 5: We won — return the secret
return Secret::fromRow($secret);

Why this works: SQLite and most RDBMS guarantee that UPDATE WHERE consumed=0 is atomic. Only one concurrent writer can change consumed from 0→1. The loser's rowCount() returns 0.


Token Generation

php
$token = bin2hex(random_bytes(32)); // 64 hex chars = 32 bytes = 256 bits
  • random_bytes() uses the OS CSPRNG (equivalent to /dev/urandom)
  • 2^256 tokens at 10^12 guesses/second ≈ 10^60 years to brute force
  • Tokens are unique in the DB (UNIQUE constraint)

Token Format Validation

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

// Rejects: uppercase hex, path traversal ../../, URL-encoded, integers, empty
if (!preg_match(self::TOKEN_PATTERN, $rawToken)) {
    return $this->responseFactory->create(['error' => 'Secret not found.'], 404);
}

IDOR Prevention (ATK-02)

php
// DELETE requires BOTH token ownership AND user_id match
$stmt = $pdo->prepare(
    'DELETE FROM secrets WHERE token = :token AND user_id = :user_id AND consumed = 0'
);
$stmt->execute(['token' => $token, 'user_id' => $userId]);

// Returns 404 regardless of reason — avoids enumeration oracle
return $stmt->rowCount() > 0;

Mass Assignment Prevention (ATK-03)

Server-side fields are never read from the request body:

php
// POST /secrets handler — only message, password, expires_at are accepted from body
$token        = bin2hex(random_bytes(32));  // server-generated
$consumed     = 0;                          // always starts unconsumed
$createdAt    = (new DateTimeImmutable())->format(DateTimeInterface::ATOM); // server time
$passwordHash = $password !== null ? hash('sha256', $password) : null;     // hashed server-side

// body['consumed'], body['token'], body['user_id'], body['created_at'] are silently ignored

V.php Validation Chain

php
// ATK-07: message must be a string (rejects int, bool, null, array)
$message = V::str($body['message'] ?? null, 10000);

// ATK-12: X-User-Id must be ctype_digit + positive + max 18 chars
$userId = V::userId($request->getHeaderLine('X-User-Id'));

// ATK-08/09: limit must be numeric, max 18 digits, in range 1–100
$limit = V::queryInt($params, 'limit', 1, 100, 20);

Optional Password Protection

php
// Storage: SHA-256 hash only (not plaintext)
$passwordHash = $password !== null ? hash('sha256', $password) : null;

// Verification: constant-time comparison (timing-safe)
if (!hash_equals($secret->passwordHash, hash('sha256', $submittedPassword))) {
    return null; // wrong password → silent 404 (no oracle)
}

Note: Wrong password returns 404 (not 403) to prevent oracle attacks. The secret is NOT consumed on wrong password — only the correct password consumes it.


Metadata List (No Message Leakage)

php
// GET /secrets — returns metadata only, never the message
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' is intentionally omitted
    ];
}

Test Results

85 tests / 209 assertions — all PASS
PHPStan level 8 — no errors
PHP CS Fixer — clean

Key Takeaways

PatternRule
Atomic consumptionUPDATE WHERE consumed=0 + rowCount() check — not SELECT then UPDATE
Token entropyrandom_bytes(32) minimum (256 bits) — never sequential IDs
Token formatAllowlist regex anchored at both ends (/^[0-9a-f]{64}$/)
IDORAll write operations scope by token AND user_id
Mass assignmentToken, consumed, created_at — server-side only, never from body
Password timinghash_equals() for constant-time comparison
Wrong password404 not 403 — avoids confirming the secret exists
Metadata listOmit message from list endpoint — read only on consume

Full example: ../NENE2-FT/onetimelog/ in the examples repository.

Released under the MIT License.