91334a46b5
Co-authored-by: Cursor <cursoragent@cursor.com>
176 lines
5.8 KiB
PHP
176 lines
5.8 KiB
PHP
<?php
|
|
// This file is part of Moodle - http://moodle.org/
|
|
|
|
/**
|
|
* Ed25519 + canonical JSON helpers (compatible with @meetlet/crypto).
|
|
*
|
|
* @package mod_meetletacbfree
|
|
* @copyright 2026 MeetLet
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
|
|
namespace mod_meetletacbfree\registration;
|
|
|
|
defined('MOODLE_INTERNAL') || die();
|
|
|
|
/**
|
|
* Crypto primitives for MeetLet registration protocol.
|
|
*/
|
|
class crypto {
|
|
/** MeetLet platform public key (SPKI PEM) — verify runtime authorizations only. */
|
|
public const PLATFORM_PUBLIC_KEY_PEM = <<<'PEM'
|
|
-----BEGIN PUBLIC KEY-----
|
|
MCowBQYDK2VwAyEAYyZTlJQzdGHyZ2aAZ45kJIOx4Rq1Opyy6OPpgRFoEtU=
|
|
-----END PUBLIC KEY-----
|
|
PEM;
|
|
|
|
/**
|
|
* Canonical JSON: sorted object keys, no whitespace.
|
|
*
|
|
* @param mixed $value
|
|
*/
|
|
public static function canonicalize($value): string {
|
|
return json_encode(self::sort_value($value), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return mixed
|
|
*/
|
|
protected static function sort_value($value) {
|
|
if ($value instanceof \stdClass) {
|
|
$asarray = get_object_vars($value);
|
|
if ($asarray === []) {
|
|
return new \stdClass();
|
|
}
|
|
ksort($asarray);
|
|
$out = new \stdClass();
|
|
foreach ($asarray as $k => $v) {
|
|
$out->{$k} = self::sort_value($v);
|
|
}
|
|
return $out;
|
|
}
|
|
if (is_array($value)) {
|
|
if (self::is_list($value)) {
|
|
return array_map([self::class, 'sort_value'], $value);
|
|
}
|
|
ksort($value);
|
|
$out = [];
|
|
foreach ($value as $k => $v) {
|
|
$out[$k] = self::sort_value($v);
|
|
}
|
|
return $out;
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* @param array $arr
|
|
*/
|
|
protected static function is_list(array $arr): bool {
|
|
if ($arr === []) {
|
|
return true;
|
|
}
|
|
return array_keys($arr) === range(0, count($arr) - 1);
|
|
}
|
|
|
|
public static function base64url_encode(string $bin): string {
|
|
return rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
|
|
}
|
|
|
|
public static function base64url_decode(string $b64): string {
|
|
$pad = 4 - (strlen($b64) % 4);
|
|
if ($pad < 4) {
|
|
$b64 .= str_repeat('=', $pad);
|
|
}
|
|
$raw = base64_decode(strtr($b64, '-_', '+/'), true);
|
|
if ($raw === false) {
|
|
throw new \moodle_exception('registration_crypto_error', 'meetletacbfree');
|
|
}
|
|
return $raw;
|
|
}
|
|
|
|
/**
|
|
* @return array{private_pem:string,public_pem:string,secret:string,public:string}
|
|
*/
|
|
public static function generate_installation_keypair(): array {
|
|
if (!function_exists('sodium_crypto_sign_keypair')) {
|
|
throw new \moodle_exception('registration_sodium_required', 'meetletacbfree');
|
|
}
|
|
$kp = sodium_crypto_sign_keypair();
|
|
$secret = sodium_crypto_sign_secretkey($kp);
|
|
$public = sodium_crypto_sign_publickey($kp);
|
|
$seed = substr($secret, 0, 32);
|
|
return [
|
|
'private_pem' => self::seed_to_pkcs8_pem($seed),
|
|
'public_pem' => self::raw_public_to_spki_pem($public),
|
|
'secret' => $secret,
|
|
'public' => $public,
|
|
];
|
|
}
|
|
|
|
public static function seed_to_pkcs8_pem(string $seed32): string {
|
|
$der = hex2bin('302e020100300506032b657004220420') . $seed32;
|
|
return "-----BEGIN PRIVATE KEY-----\n" .
|
|
chunk_split(base64_encode($der), 64, "\n") .
|
|
"-----END PRIVATE KEY-----\n";
|
|
}
|
|
|
|
public static function raw_public_to_spki_pem(string $public32): string {
|
|
$der = hex2bin('302a300506032b6570032100') . $public32;
|
|
return "-----BEGIN PUBLIC KEY-----\n" .
|
|
chunk_split(base64_encode($der), 64, "\n") .
|
|
"-----END PUBLIC KEY-----\n";
|
|
}
|
|
|
|
public static function spki_pem_to_raw(string $pem): string {
|
|
$pem = trim($pem);
|
|
$b64 = preg_replace('/-----[^-]+-----/', '', $pem);
|
|
$b64 = preg_replace('/\s+/', '', $b64);
|
|
$der = base64_decode($b64, true);
|
|
if ($der === false || strlen($der) < 32) {
|
|
throw new \moodle_exception('registration_crypto_error', 'meetletacbfree');
|
|
}
|
|
return substr($der, -32);
|
|
}
|
|
|
|
public static function pkcs8_pem_to_secret(string $pem): string {
|
|
$pem = trim($pem);
|
|
$b64 = preg_replace('/-----[^-]+-----/', '', $pem);
|
|
$b64 = preg_replace('/\s+/', '', $b64);
|
|
$der = base64_decode($b64, true);
|
|
if ($der === false || strlen($der) < 48) {
|
|
throw new \moodle_exception('registration_crypto_error', 'meetletacbfree');
|
|
}
|
|
$seed = substr($der, -32);
|
|
$kp = sodium_crypto_sign_seed_keypair($seed);
|
|
return sodium_crypto_sign_secretkey($kp);
|
|
}
|
|
|
|
public static function sign(string $privatepem, $payload): string {
|
|
$secret = self::pkcs8_pem_to_secret($privatepem);
|
|
$msg = self::canonicalize($payload);
|
|
$sig = sodium_crypto_sign_detached($msg, $secret);
|
|
return self::base64url_encode($sig);
|
|
}
|
|
|
|
public static function verify(string $publicpem, $payload, string $signatureb64url): bool {
|
|
try {
|
|
$public = self::spki_pem_to_raw($publicpem);
|
|
$msg = self::canonicalize($payload);
|
|
$sig = self::base64url_decode($signatureb64url);
|
|
return sodium_crypto_sign_verify_detached($sig, $msg, $public);
|
|
} catch (\Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static function random_nonce(): string {
|
|
return self::base64url_encode(random_bytes(16));
|
|
}
|
|
|
|
public static function random_installation_id(): string {
|
|
return 'mdl-' . bin2hex(random_bytes(16));
|
|
}
|
|
}
|