Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.31% covered (success)
92.31%
48 / 52
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
registry
92.31% covered (success)
92.31%
48 / 52
62.50% covered (warning)
62.50%
5 / 8
16.12
0.00% covered (danger)
0.00%
0 / 1
 secret
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 issue
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 verify
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 revoke
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 verify_url
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 normalise
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 sign
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 new_code
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2// This file is part of Moodle - https://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16
17namespace local_transcript\local;
18
19/**
20 * Issues, verifies and revokes transcripts.
21 *
22 * An issued transcript is a frozen JSON copy of the student's record, signed with HMAC-SHA256 using a
23 * secret only this site knows. Verifying recomputes the signature, so a record edited in the database
24 * shows as altered, and later grade changes never change a transcript that was already issued.
25 *
26 * @package    local_transcript
27 * @copyright  2026 DevLMS <contact@devlms.com>
28 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29 */
30class registry {
31    /** @var string The record exists and its signature matches. */
32    public const VALID = 'valid';
33    /** @var string The record exists but was revoked. */
34    public const REVOKED = 'revoked';
35    /** @var string The record exists but its contents no longer match the signature. */
36    public const ALTERED = 'altered';
37    /** @var string No transcript has this code. */
38    public const UNKNOWN = 'unknown';
39
40    /** @var string Code alphabet without 0, 1, I and O, which are easy to misread. */
41    private const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
42
43    /**
44     * The site's signing secret, created the first time it is needed.
45     *
46     * @return string
47     */
48    public static function secret(): string {
49        $secret = (string) get_config('local_transcript', 'secret');
50        if (strlen($secret) < 32) {
51            $secret = bin2hex(random_bytes(32));
52            set_config('secret', $secret, 'local_transcript');
53        }
54        return $secret;
55    }
56
57    /**
58     * Issue a transcript for a student, signed and stored.
59     *
60     * @param int $userid The student.
61     * @return \stdClass The stored record.
62     */
63    public static function issue(int $userid): \stdClass {
64        global $DB, $USER;
65        $record = (object) [
66            'userid' => $userid,
67            'code' => self::new_code(),
68            'payload' => json_encode(collector::collect($userid), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
69            'issuedby' => (int) $USER->id,
70            'timecreated' => time(),
71        ];
72        $record->signature = self::sign($record);
73        $record->id = $DB->insert_record('local_transcript', $record);
74        \local_transcript\event\transcript_issued::create([
75            'objectid' => $record->id,
76            'relateduserid' => $userid,
77            'context' => \context_system::instance(),
78        ])->trigger();
79        return $record;
80    }
81
82    /**
83     * Look a code up and check it.
84     *
85     * @param string $code As typed: case, spaces and dashes do not matter.
86     * @return \stdClass {status, record|null, data|null}
87     */
88    public static function verify(string $code): \stdClass {
89        global $DB;
90        $code = self::normalise($code);
91        $record = $code === null ? false : $DB->get_record('local_transcript', ['code' => $code]);
92        if (!$record) {
93            return (object) ['status' => self::UNKNOWN, 'record' => null, 'data' => null];
94        }
95        if (!hash_equals(self::sign($record), (string) $record->signature)) {
96            return (object) ['status' => self::ALTERED, 'record' => $record, 'data' => null];
97        }
98        $status = $record->timerevoked ? self::REVOKED : self::VALID;
99        return (object) ['status' => $status, 'record' => $record, 'data' => json_decode($record->payload, true)];
100    }
101
102    /**
103     * Revoke a transcript. It still verifies, as revoked, so anyone holding a copy finds out.
104     *
105     * @param int $id
106     * @param string $reason
107     */
108    public static function revoke(int $id, string $reason): void {
109        global $DB, $USER;
110        $record = $DB->get_record('local_transcript', ['id' => $id], '*', MUST_EXIST);
111        if ($record->timerevoked) {
112            return;
113        }
114        $DB->update_record('local_transcript', (object) ['id' => $id, 'revokedby' => (int) $USER->id,
115            'revokedreason' => trim($reason), 'timerevoked' => time()]);
116        \local_transcript\event\transcript_revoked::create([
117            'objectid' => $id,
118            'relateduserid' => $record->userid,
119            'context' => \context_system::instance(),
120            'other' => ['reason' => trim($reason)],
121        ])->trigger();
122    }
123
124    /**
125     * The URL of the public verify page for a code.
126     *
127     * @param string $code
128     * @return \moodle_url
129     */
130    public static function verify_url(string $code): \moodle_url {
131        return new \moodle_url('/local/transcript/verify.php', ['code' => $code]);
132    }
133
134    /**
135     * A code as stored (XXXX-XXXX-XXXX), or null when it cannot be one.
136     *
137     * @param string $code
138     * @return string|null
139     */
140    public static function normalise(string $code): ?string {
141        $plain = preg_replace('/[^0-9A-Z]/', '', strtoupper($code));
142        if (strlen($plain) !== 12) {
143            return null;
144        }
145        return implode('-', str_split($plain, 4));
146    }
147
148    /**
149     * Signature over the fields that make a transcript what it is.
150     *
151     * @param \stdClass $record
152     * @return string
153     */
154    private static function sign(\stdClass $record): string {
155        $data = implode("\n", [$record->code, (int) $record->userid, (int) $record->timecreated, $record->payload]);
156        return hash_hmac('sha256', $data, self::secret());
157    }
158
159    /**
160     * A new, unused code.
161     *
162     * @return string
163     */
164    private static function new_code(): string {
165        global $DB;
166        do {
167            $chars = '';
168            for ($i = 0; $i < 12; $i++) {
169                $chars .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
170            }
171            $code = implode('-', str_split($chars, 4));
172        } while ($DB->record_exists('local_transcript', ['code' => $code]));
173        return $code;
174    }
175}