2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증. 1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp. 서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약. DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마. 보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS. 문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
5.7 KiB
PHP
165 lines
5.7 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// admin_security.php - shared administrator MFA, session, and audit helpers
|
|
// =============================================================================
|
|
|
|
function admin_base32_decode(string $secret): string {
|
|
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
$secret = strtoupper(preg_replace('/[^A-Z2-7]/i', '', $secret));
|
|
$bits = '';
|
|
$output = '';
|
|
|
|
for ($i = 0, $len = strlen($secret); $i < $len; $i++) {
|
|
$value = strpos($alphabet, $secret[$i]);
|
|
if ($value === false) {
|
|
continue;
|
|
}
|
|
$bits .= str_pad(decbin($value), 5, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
for ($i = 0, $len = strlen($bits); $i + 8 <= $len; $i += 8) {
|
|
$output .= chr(bindec(substr($bits, $i, 8)));
|
|
}
|
|
|
|
return $output;
|
|
}
|
|
|
|
function admin_base32_encode(string $bytes): string {
|
|
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
$bits = '';
|
|
$output = '';
|
|
|
|
for ($i = 0, $len = strlen($bytes); $i < $len; $i++) {
|
|
$bits .= str_pad(decbin(ord($bytes[$i])), 8, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
for ($i = 0, $len = strlen($bits); $i < $len; $i += 5) {
|
|
$chunk = substr($bits, $i, 5);
|
|
if (strlen($chunk) < 5) {
|
|
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
|
|
}
|
|
$output .= $alphabet[bindec($chunk)];
|
|
}
|
|
|
|
return $output;
|
|
}
|
|
|
|
function admin_totp_secret(int $bytes = 20): string {
|
|
return admin_base32_encode(random_bytes($bytes));
|
|
}
|
|
|
|
function admin_totp_uri(string $issuer, string $account, string $secret): string {
|
|
$issuer = trim($issuer) !== '' ? trim($issuer) : 'SHT30 SMS';
|
|
$account = trim($account) !== '' ? trim($account) : ADMIN_USER;
|
|
$label = rawurlencode($issuer . ':' . $account);
|
|
|
|
return 'otpauth://totp/' . $label
|
|
. '?secret=' . rawurlencode(strtoupper(preg_replace('/[^A-Z2-7]/i', '', $secret)))
|
|
. '&issuer=' . rawurlencode($issuer)
|
|
. '&algorithm=SHA1'
|
|
. '&digits=6'
|
|
. '&period=30';
|
|
}
|
|
|
|
function admin_hotp(string $secret, int $counter, int $digits = 6): string {
|
|
$key = admin_base32_decode($secret);
|
|
if ($key === '') {
|
|
return '';
|
|
}
|
|
|
|
$binaryCounter = pack('N2', intdiv($counter, 0x100000000), $counter & 0xffffffff);
|
|
$hash = hash_hmac('sha1', $binaryCounter, $key, true);
|
|
$offset = ord($hash[19]) & 0x0f;
|
|
$value = (
|
|
((ord($hash[$offset]) & 0x7f) << 24) |
|
|
((ord($hash[$offset + 1]) & 0xff) << 16) |
|
|
((ord($hash[$offset + 2]) & 0xff) << 8) |
|
|
(ord($hash[$offset + 3]) & 0xff)
|
|
);
|
|
|
|
return str_pad((string)($value % (10 ** $digits)), $digits, '0', STR_PAD_LEFT);
|
|
}
|
|
|
|
function admin_totp_code(string $secret, ?int $time = null, int $step = 30, int $digits = 6): string {
|
|
$time = $time ?? time();
|
|
return admin_hotp($secret, intdiv($time, $step), $digits);
|
|
}
|
|
|
|
function admin_totp_verify(string $secret, string $code, ?int $time = null, int $window = 1): bool {
|
|
$code = preg_replace('/\D+/', '', $code);
|
|
if ($secret === '' || !preg_match('/^\d{6}$/', $code)) {
|
|
return false;
|
|
}
|
|
|
|
$time = $time ?? time();
|
|
for ($i = -$window; $i <= $window; $i++) {
|
|
$candidate = admin_totp_code($secret, $time + ($i * 30));
|
|
if ($candidate !== '' && hash_equals($candidate, $code)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function admin_client_ip(): string {
|
|
return filter_var($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0', FILTER_VALIDATE_IP) ?: '0.0.0.0';
|
|
}
|
|
|
|
function admin_audit_log(string $event, string $username = '', bool $success = true, array $detail = []): void {
|
|
$dir = __DIR__ . '/var';
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0755, true);
|
|
}
|
|
|
|
$safeDetail = [];
|
|
foreach ($detail as $key => $value) {
|
|
if (is_scalar($value) || $value === null) {
|
|
$safeDetail[(string)$key] = $value;
|
|
}
|
|
}
|
|
|
|
$record = [
|
|
'time' => date('c'),
|
|
'event' => $event,
|
|
'username' => substr($username, 0, 100),
|
|
'success' => $success,
|
|
'ip' => admin_client_ip(),
|
|
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
|
'detail' => $safeDetail,
|
|
];
|
|
|
|
@file_put_contents(
|
|
$dir . '/admin_audit.log',
|
|
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
|
|
FILE_APPEND | LOCK_EX
|
|
);
|
|
}
|
|
|
|
function admin_audit_log_path(): string {
|
|
return __DIR__ . '/var/admin_audit.log';
|
|
}
|
|
|
|
function admin_mfa_configured(): bool {
|
|
return defined('ADMIN_TOTP_SECRET') && ADMIN_TOTP_SECRET !== '';
|
|
}
|
|
|
|
// 인증 페이지 공통: 세션 유휴(idle) + 절대(absolute) 수명 타임아웃을 강제한다.
|
|
// 모든 인증 페이지가 logged_in 확인 직후 호출해야 한다(대시보드 외 페이지에서도
|
|
// 절대 수명이 보장되도록 — 한 페이지에서만 검사하면 다른 페이지로 우회 가능).
|
|
// 만료 시 세션을 파기하고 login.php?expired 로 보낸 뒤 종료한다.
|
|
// - 유휴(idle): 마지막 활동(last_activity)에서 SESSION_TIMEOUT 초과
|
|
// - 절대(absolute): 로그인 시각(login_time, 갱신하지 않음)에서 SESSION_ABSOLUTE_TIMEOUT 초과
|
|
// 호출 전 config.php(SESSION_TIMEOUT/SESSION_ABSOLUTE_TIMEOUT) 와 session_start() 필요.
|
|
function admin_enforce_session_timeout(): void {
|
|
$idle_at = $_SESSION['last_activity'] ?? ($_SESSION['login_time'] ?? null);
|
|
$login_at = $_SESSION['login_time'] ?? null;
|
|
$idle_expired = isset($idle_at) && (time() - $idle_at) > SESSION_TIMEOUT;
|
|
$absolute_expired = isset($login_at) && (time() - $login_at) > SESSION_ABSOLUTE_TIMEOUT;
|
|
if ($idle_expired || $absolute_expired) {
|
|
session_destroy();
|
|
header('Location: login.php?expired');
|
|
exit;
|
|
}
|
|
$_SESSION['last_activity'] = time();
|
|
}
|