• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / auth / 22551289947

01 Mar 2026 07:53PM UTC coverage: 63.76% (+0.09%) from 63.668%
22551289947

push

github

daycry
Add pending TOTP flow and local QR generation

Introduce a two-phase TOTP enrollment (PENDING → CONFIRMED) via a new TotpState enum and confirmTotp() method. Store TOTP secrets encrypted (AES) and transparently decrypt in getTotpSecret(); enableTotp() now creates a pending secret. Replace Google Charts QR URLs with locally generated PNG data URIs using endroid/qr-code (added dependency). Update UserSecurityController comments/flow to keep pending secrets on verification failure and mark confirmed on success. Adjust tests and inject a fixed Encryption key for tests; update phpstan baseline counts accordingly.

19 of 25 new or added lines in 3 files covered. (76.0%)

1 existing line in 1 file now uncovered.

3100 of 4862 relevant lines covered (63.76%)

44.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.86
/src/Libraries/TOTP.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Auth.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Auth\Libraries;
15

16
use Endroid\QrCode\QrCode;
17
use Endroid\QrCode\Writer\PngWriter;
18
use InvalidArgumentException;
19

20
/**
21
 * RFC 6238 TOTP (Time-based One-Time Password) implementation.
22
 *
23
 * Pure PHP, no external dependencies.
24
 * Compatible with Google Authenticator, Authy, and any RFC 6238 app.
25
 */
26
class TOTP
27
{
28
    private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
29

30
    /**
31
     * Number of digits in the generated code.
32
     */
33
    private const DIGITS = 6;
34

35
    /**
36
     * Time step in seconds (standard: 30).
37
     */
38
    private const PERIOD = 30;
39

40
    /**
41
     * Generates a cryptographically random base32-encoded TOTP secret.
42
     *
43
     * @param int $length Number of bytes of random data (default 20 = 160-bit secret)
44
     */
45
    public static function generateSecret(int $length = 20): string
25✔
46
    {
47
        return self::base32Encode(random_bytes($length));
25✔
48
    }
49

50
    /**
51
     * Generates the current TOTP code for the given secret.
52
     *
53
     * @param string   $secret    Base32-encoded TOTP secret
54
     * @param int|null $timestamp Unix timestamp (null = now)
55
     */
56
    public static function getCode(string $secret, ?int $timestamp = null): string
6✔
57
    {
58
        $timestamp ??= time();
6✔
59
        $timeStep = (int) floor($timestamp / self::PERIOD);
6✔
60

61
        return self::computeCode($secret, $timeStep);
6✔
62
    }
63

64
    /**
65
     * Verifies a TOTP code against the given secret.
66
     * Accepts codes within ±$window time steps (default: ±1, i.e. 90-second window).
67
     *
68
     * @param string $secret    Base32-encoded TOTP secret
69
     * @param string $code      6-digit code to verify
70
     * @param int    $window    Number of adjacent time steps to accept
71
     * @param int    $timestamp Unix timestamp (useful for testing)
72
     */
73
    public static function verify(string $secret, string $code, int $window = 1, ?int $timestamp = null): bool
7✔
74
    {
75
        $timestamp ??= time();
7✔
76
        $timeStep = (int) floor($timestamp / self::PERIOD);
7✔
77

78
        for ($i = -$window; $i <= $window; $i++) {
7✔
79
            $expected = self::computeCode($secret, $timeStep + $i);
7✔
80

81
            if (hash_equals($expected, $code)) {
7✔
82
                return true;
4✔
83
            }
84
        }
85

86
        return false;
3✔
87
    }
88

89
    /**
90
     * Builds the `otpauth://` URI that can be encoded as a QR code
91
     * for scanning with Google Authenticator or any compatible app.
92
     *
93
     * @param string $secret  Base32-encoded TOTP secret
94
     * @param string $account User's email or username
95
     * @param string $issuer  Application / service name shown in the app
96
     */
97
    public static function getOtpAuthUrl(string $secret, string $account, string $issuer): string
18✔
98
    {
99
        $label = rawurlencode($issuer) . ':' . rawurlencode($account);
18✔
100

101
        return 'otpauth://totp/' . $label
18✔
102
            . '?secret=' . $secret
18✔
103
            . '&issuer=' . rawurlencode($issuer)
18✔
104
            . '&algorithm=SHA1'
18✔
105
            . '&digits=' . self::DIGITS
18✔
106
            . '&period=' . self::PERIOD;
18✔
107
    }
108

109
    /**
110
     * Returns a base64 data URI (data:image/png;base64,...) containing the QR
111
     * code for the given otpauth URI, generated locally via endroid/qr-code.
112
     * The result can be used directly as the `src` of an `<img>` tag.
113
     */
114
    public static function getQRCodeUrl(string $otpAuthUrl, int $size = 200): string
×
115
    {
NEW
116
        $qrCode = new QrCode($otpAuthUrl, size: $size);
×
117

NEW
118
        $writer = new PngWriter();
×
NEW
119
        $result = $writer->write($qrCode);
×
120

NEW
121
        return $result->getDataUri();
×
122
    }
123

124
    /**
125
     * Computes the TOTP code for a specific time step counter.
126
     */
127
    private static function computeCode(string $secret, int $counter): string
8✔
128
    {
129
        $key     = self::base32Decode(strtoupper($secret));
8✔
130
        $message = pack('J', $counter); // 8-byte big-endian counter
8✔
131

132
        $hash   = hash_hmac('sha1', $message, $key, true);
8✔
133
        $offset = ord($hash[19]) & 0x0F;
8✔
134

135
        $code = (
8✔
136
            ((ord($hash[$offset]) & 0x7F) << 24)
8✔
137
            | ((ord($hash[$offset + 1]) & 0xFF) << 16)
8✔
138
            | ((ord($hash[$offset + 2]) & 0xFF) << 8)
8✔
139
            | (ord($hash[$offset + 3]) & 0xFF)
8✔
140
        ) % (10 ** self::DIGITS);
8✔
141

142
        return str_pad((string) $code, self::DIGITS, '0', STR_PAD_LEFT);
8✔
143
    }
144

145
    /**
146
     * Encodes binary data as a base32 string (RFC 4648, no padding).
147
     */
148
    public static function base32Encode(string $data): string
26✔
149
    {
150
        $alphabet = self::BASE32_ALPHABET;
26✔
151
        $result   = '';
26✔
152
        $bits     = 0;
26✔
153
        $buffer   = 0;
26✔
154

155
        foreach (str_split($data) as $char) {
26✔
156
            $buffer = ($buffer << 8) | ord($char);
26✔
157
            $bits += 8;
26✔
158

159
            while ($bits >= 5) {
26✔
160
                $bits -= 5;
26✔
161
                $result .= $alphabet[($buffer >> $bits) & 0x1F];
26✔
162
            }
163
        }
164

165
        if ($bits > 0) {
26✔
166
            $result .= $alphabet[($buffer << (5 - $bits)) & 0x1F];
×
167
        }
168

169
        return $result;
26✔
170
    }
171

172
    /**
173
     * Decodes a base32 string (RFC 4648) into binary data.
174
     *
175
     * @throws InvalidArgumentException on invalid base32 characters
176
     */
177
    public static function base32Decode(string $base32): string
9✔
178
    {
179
        $alphabet = self::BASE32_ALPHABET;
9✔
180
        $base32   = strtoupper(rtrim($base32, '='));
9✔
181
        $result   = '';
9✔
182
        $bits     = 0;
9✔
183
        $buffer   = 0;
9✔
184

185
        foreach (str_split($base32) as $char) {
9✔
186
            $pos = strpos($alphabet, $char);
9✔
187

188
            if ($pos === false) {
9✔
189
                throw new InvalidArgumentException('Invalid base32 character: ' . $char);
×
190
            }
191

192
            $buffer = ($buffer << 5) | $pos;
9✔
193
            $bits += 5;
9✔
194

195
            if ($bits >= 8) {
9✔
196
                $bits -= 8;
9✔
197
                $result .= chr(($buffer >> $bits) & 0xFF);
9✔
198
            }
199
        }
200

201
        return $result;
9✔
202
    }
203
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc