This commit is contained in:
10
common.php
10
common.php
@ -153,6 +153,10 @@ function hkdf_session_key_from_token(string $tokenHex, string $info = 'qdb-aes',
|
|||||||
|
|
||||||
// --- Token-Storage (DB-backed via session table) ---
|
// --- Token-Storage (DB-backed via session table) ---
|
||||||
|
|
||||||
|
function token_storage_key(string $token): string {
|
||||||
|
return hash('sha256', $token);
|
||||||
|
}
|
||||||
|
|
||||||
function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void {
|
function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): void {
|
||||||
require_once __DIR__ . '/db_init.php';
|
require_once __DIR__ . '/db_init.php';
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
@ -161,7 +165,7 @@ function token_add(string $token, int $ttlSeconds = 86400, array $extra = []): v
|
|||||||
"INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
"INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
||||||
VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)"
|
VALUES (:t, :uid, :role, :eid, :ca, :ea, :tmp)"
|
||||||
)->execute([
|
)->execute([
|
||||||
':t' => $token,
|
':t' => token_storage_key($token),
|
||||||
':uid' => $extra['userID'] ?? '',
|
':uid' => $extra['userID'] ?? '',
|
||||||
':role' => $extra['role'] ?? '',
|
':role' => $extra['role'] ?? '',
|
||||||
':eid' => $extra['entityID'] ?? '',
|
':eid' => $extra['entityID'] ?? '',
|
||||||
@ -178,7 +182,7 @@ function token_get_record(string $token): ?array {
|
|||||||
require_once __DIR__ . '/db_init.php';
|
require_once __DIR__ . '/db_init.php';
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
$stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now");
|
$stmt = $pdo->prepare("SELECT * FROM session WHERE token = :t AND expiresAt >= :now");
|
||||||
$stmt->execute([':t' => $token, ':now' => time()]);
|
$stmt->execute([':t' => token_storage_key($token), ':now' => time()]);
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
qdb_discard($tmpDb, $lockFp);
|
qdb_discard($tmpDb, $lockFp);
|
||||||
if (!$row) return null;
|
if (!$row) return null;
|
||||||
@ -197,7 +201,7 @@ function token_get_record(string $token): ?array {
|
|||||||
function token_revoke(string $token): void {
|
function token_revoke(string $token): void {
|
||||||
require_once __DIR__ . '/db_init.php';
|
require_once __DIR__ . '/db_init.php';
|
||||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||||
$pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => $token]);
|
$pdo->prepare("DELETE FROM session WHERE token = :t")->execute([':t' => token_storage_key($token)]);
|
||||||
qdb_save($tmpDb, $lockFp);
|
qdb_save($tmpDb, $lockFp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* Application-layer encryption for sensitive mobile API payloads.
|
* Application-layer encryption for sensitive mobile API payloads.
|
||||||
* AES-256-CBC (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
|
* AES-256-GCM (IV prepended) + HKDF-SHA256 session key from the Bearer token (info: qdb-aes).
|
||||||
|
* Legacy CBC envelopes are accepted for compatibility with older app builds.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
|
function qdb_sensitive_envelope(string $plainJson, string $tokenHex): array {
|
||||||
$key = hkdf_session_key_from_token($tokenHex);
|
$key = hkdf_session_key_from_token($tokenHex);
|
||||||
return [
|
return [
|
||||||
'encrypted' => true,
|
'encrypted' => true,
|
||||||
'payload' => base64_encode(aes256_cbc_encrypt_bytes($plainJson, $key)),
|
'alg' => 'A256GCM',
|
||||||
|
'payload' => base64_encode(qdb_aes256_gcm_encrypt_bytes($plainJson, $key)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,5 +23,38 @@ function qdb_decrypt_sensitive_envelope(array $envelope, string $tokenHex): stri
|
|||||||
throw new InvalidArgumentException('Invalid base64 payload');
|
throw new InvalidArgumentException('Invalid base64 payload');
|
||||||
}
|
}
|
||||||
$key = hkdf_session_key_from_token($tokenHex);
|
$key = hkdf_session_key_from_token($tokenHex);
|
||||||
return aes256_cbc_decrypt_bytes($raw, $key);
|
$alg = isset($envelope['alg']) && is_string($envelope['alg']) ? $envelope['alg'] : 'A256CBC';
|
||||||
|
return match ($alg) {
|
||||||
|
'A256GCM' => qdb_aes256_gcm_decrypt_bytes($raw, $key),
|
||||||
|
'A256CBC' => aes256_cbc_decrypt_bytes($raw, $key),
|
||||||
|
default => throw new InvalidArgumentException('Unsupported encrypted envelope algorithm'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_aes256_gcm_encrypt_bytes(string $plain, string $key): string {
|
||||||
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||||
|
$iv = random_bytes(12);
|
||||||
|
$tag = '';
|
||||||
|
$cipher = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
|
||||||
|
if ($cipher === false || strlen($tag) !== 16) {
|
||||||
|
throw new RuntimeException('openssl_encrypt failed');
|
||||||
|
}
|
||||||
|
return $iv . $cipher . $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function qdb_aes256_gcm_decrypt_bytes(string $data, string $key): string {
|
||||||
|
$ivLen = 12;
|
||||||
|
$tagLen = 16;
|
||||||
|
if (strlen($data) <= $ivLen + $tagLen) {
|
||||||
|
throw new InvalidArgumentException('cipher too short');
|
||||||
|
}
|
||||||
|
$key = str_pad(substr($key, 0, 32), 32, "\0");
|
||||||
|
$iv = substr($data, 0, $ivLen);
|
||||||
|
$tag = substr($data, -$tagLen);
|
||||||
|
$ct = substr($data, $ivLen, -$tagLen);
|
||||||
|
$plain = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '');
|
||||||
|
if ($plain === false) {
|
||||||
|
throw new InvalidArgumentException('openssl_decrypt failed');
|
||||||
|
}
|
||||||
|
return $plain;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ final class SecurityPathsTest extends QdbTestCase
|
|||||||
'INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
'INSERT INTO session (token, userID, role, entityID, createdAt, expiresAt, temp)
|
||||||
VALUES (:t, :uid, :role, :eid, :ca, :ea, 0)'
|
VALUES (:t, :uid, :role, :eid, :ca, :ea, 0)'
|
||||||
)->execute([
|
)->execute([
|
||||||
':t' => $token,
|
':t' => token_storage_key($token),
|
||||||
':uid' => $f->adminUserId,
|
':uid' => $f->adminUserId,
|
||||||
':role' => 'admin',
|
':role' => 'admin',
|
||||||
':eid' => 'ent',
|
':eid' => 'ent',
|
||||||
|
|||||||
@ -20,6 +20,11 @@ final class TokenTest extends QdbTestCase
|
|||||||
$rec = token_get_record($token);
|
$rec = token_get_record($token);
|
||||||
$this->assertNotNull($rec);
|
$this->assertNotNull($rec);
|
||||||
$this->assertSame('admin', $rec['role']);
|
$this->assertSame('admin', $rec['role']);
|
||||||
|
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||||
|
$stored = $pdo->query('SELECT token FROM session LIMIT 1')->fetchColumn();
|
||||||
|
qdb_discard($tmpDb, $lockFp);
|
||||||
|
$this->assertSame(token_storage_key($token), $stored);
|
||||||
|
$this->assertNotSame($token, $stored);
|
||||||
|
|
||||||
token_revoke($token);
|
token_revoke($token);
|
||||||
$this->assertNull(token_get_record($token));
|
$this->assertNull(token_get_record($token));
|
||||||
|
|||||||
@ -16,10 +16,24 @@ final class EncryptedPayloadTest extends TestCase
|
|||||||
$json = '{"clientCode":"X","answers":[]}';
|
$json = '{"clientCode":"X","answers":[]}';
|
||||||
$env = qdb_sensitive_envelope($json, $token);
|
$env = qdb_sensitive_envelope($json, $token);
|
||||||
$this->assertTrue($env['encrypted']);
|
$this->assertTrue($env['encrypted']);
|
||||||
|
$this->assertSame('A256GCM', $env['alg']);
|
||||||
$plain = qdb_decrypt_sensitive_envelope($env, $token);
|
$plain = qdb_decrypt_sensitive_envelope($env, $token);
|
||||||
$this->assertSame($json, $plain);
|
$this->assertSame($json, $plain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLegacyCbcEnvelopeStillDecrypts(): void
|
||||||
|
{
|
||||||
|
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$json = '{"legacy":true}';
|
||||||
|
$key = hkdf_session_key_from_token($token);
|
||||||
|
$env = [
|
||||||
|
'encrypted' => true,
|
||||||
|
'payload' => base64_encode(aes256_cbc_encrypt_bytes($json, $key)),
|
||||||
|
];
|
||||||
|
$this->assertSame($json, qdb_decrypt_sensitive_envelope($env, $token));
|
||||||
|
}
|
||||||
|
|
||||||
public function testWrongTokenFailsDecrypt(): void
|
public function testWrongTokenFailsDecrypt(): void
|
||||||
{
|
{
|
||||||
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
require_once dirname(__DIR__, 2) . '/lib/encrypted_payload.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user