#!/usr/bin/env php * php license-verify.php --key * php license-verify.php --rsa-key * * Ohne `--key`/`--rsa-key` wird gegen die unten eingebetteten öffentlichen * Schlüssel geprüft — dieselben, die in den Apps stecken. * * Voraussetzungen: PHP 8+ (macOS: `brew install php`, Linux: Paket `php-cli`). * Ed25519 nutzt die Sodium-Erweiterung, RSA die OpenSSL-Erweiterung — beide * gehören zum PHP-Standardumfang. * * ## Wie eine Lizenzdatei aufgebaut ist * * Base64 über JSON (das „Umschlag"-Format, identisch zu * `LicenseTokenEnvelope.php` auf dem Server und `LicenseToken.swift` in den * Apps): * * ```json * { "version": 2, "alg": "Ed25519", "payload": "{…}", "signature": "" } * ``` * * `version`/`alg` fehlen bei Lizenzen aus dem Altbestand — die sind * RSA-signiert (Version 1) und bleiben **unbefristet** gültig. * * Exit-Codes: 0 = Lizenz gültig · 1 = ungültig oder Fehler · 2 = Usage/Voraussetzung fehlt. */ // --------------------------------------------------------------------------- // Eingebettete öffentliche Schlüssel — dieselben wie in den Apps. // --------------------------------------------------------------------------- /** * Ed25519, Hex (32 Byte). Mehrere Einträge sind erlaubt — ein * Schlüsselwechsel nimmt den neuen ZUSÄTZLICH auf, der alte prüft die * bereits ausgegebenen Lizenzen weiter. * * [PRÜFEN] Sonny ②: Nach `license-keygen.php --install` den ausgegebenen * Hex-Wert hier eintragen — gleiche Liste wie * `AnnexLicenseKit.defaultEd25519PublicKeys`. */ const EMBEDDED_ED25519_PUBLIC_KEYS_HEX = [ // Produktionsschlüssel, erzeugt 04.08.2026 — identisch mit // AnnexLicenseKit.defaultEd25519PublicKeys. 'ad4fa0d8f3447a129de16037fff844efc03a4d97303212d056efc66cd0e3bb9b', ]; /** RSA (Altbestand, PKCS#1 v1.5 / SHA-256) — `license-public.pem`. */ const EMBEDDED_RSA_PUBLIC_KEY_PEM = <<<'PEM' -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApYI+XQKp8VD+2sT/F9GD oS8FaV2do4P+OL2SuIlnsB61cEFV6RxSk1vV7ORHanJTyyPXk9quqDYJsT8fnbM/ s1a2iCpdjTGvTthNkuza4xJ0ux36poj4Sy0IZias8hKuREu/eSfVIaP/OPK8NgHz QGVSrCWf5FLcEyYWSDgVuxQbFYkufuMmal2iaUftzvnKt+ikfGjOdcvihTQ9CJA5 4orry2dmkrrKfNNmDOnftBb+OMxhSVve6LA4MYOH4x7+CztdRSzTKNp5Od36Tdyg ZrDvIS7t8zWjeGG0dGnypJcingqumhFhR2izkKumFwq466i0masXBu9Xjq/LumIM gQIDAQAB -----END PUBLIC KEY----- PEM; // --------------------------------------------------------------------------- // Kein Code unterhalb dieser Linie muss angepasst werden. // --------------------------------------------------------------------------- function usage(): void { echo "Verwendung: php " . basename(__FILE__) . " [--key ] [--rsa-key ]\n"; echo "Prüft eine AnnexCREATE-Lizenzdatei vollständig offline. Details im Kopfkommentar.\n"; } function fail(string $message, int $code = 1): never { fwrite(STDERR, "FEHLER: {$message}\n"); exit($code); } // --- Argumente --- $file = null; $extraEd25519 = []; $rsaPemPath = null; $args = array_slice($argv ?? [], 1); for ($i = 0; $i < count($args); $i++) { switch ($args[$i]) { case '-h': case '--help': usage(); exit(0); case '--key': $extraEd25519[] = (string) ($args[++$i] ?? ''); break; case '--rsa-key': $rsaPemPath = (string) ($args[++$i] ?? ''); break; default: if ($file !== null) { usage(); exit(2); } $file = $args[$i]; } } if ($file === null) { usage(); exit(2); } if (!is_file($file)) { fail("Lizenzdatei nicht gefunden: {$file}", 2); } $raw = file_get_contents($file); if ($raw === false) { fail("Lizenzdatei nicht lesbar: {$file}", 2); } // --- Umschlag zerlegen (Spiegel von LicenseTokenEnvelope::parse) --- $decoded = base64_decode(trim($raw), true); if ($decoded === false || $decoded === '') { fail('Die Datei ist keine Base64-kodierte Lizenz (Umschlag nicht lesbar).'); } $envelope = json_decode($decoded, true); if (!is_array($envelope) || !isset($envelope['payload'], $envelope['signature'])) { fail('Der Lizenz-Umschlag ist unvollständig (payload/signature fehlen).'); } $version = isset($envelope['version']) ? (int) $envelope['version'] : 1; if ($version < 1) { fail('Der Lizenz-Umschlag nennt eine unmögliche Version.'); } if ($version > 2) { fail("Diese Lizenz nutzt Umschlag-Version {$version} — bitte die aktuelle Fassung dieses Prüfers verwenden."); } // Verfahren: fehlt die Angabe, ist es der RSA-Altbestand. Ein unbekannter // Name wird NICHT geraten und NICHT auf RSA heruntergestuft. $algorithm = 'RSA-PKCS1-SHA256'; if (isset($envelope['alg']) && is_string($envelope['alg']) && trim($envelope['alg']) !== '') { switch (strtolower(trim($envelope['alg']))) { case 'ed25519': case 'eddsa': case 'ed25519-sha512': case 'curve25519': $algorithm = 'Ed25519'; break; case 'rsa-pkcs1-sha256': case 'rsa-sha256': case 'rsa': case 'rs256': case 'sha256withrsa': case 'rsassa-pkcs1-v1_5-sha256': $algorithm = 'RSA-PKCS1-SHA256'; break; default: fail('Die Lizenz nennt ein unbekanntes Signaturverfahren: ' . $envelope['alg']); } } // Inhalt: Version 2 trägt ihn als Zeichenkette (genau die signierten Bytes), // der Altbestand als Objekt. if (is_string($envelope['payload'])) { $payloadJson = $envelope['payload']; $payload = json_decode($payloadJson, true); if (!is_array($payload)) { fail('Der Lizenzinhalt ist kein lesbares JSON.'); } } elseif (is_array($envelope['payload'])) { $payload = $envelope['payload']; $payloadJson = (string) json_encode($payload); } else { fail('Der Lizenzinhalt hat eine unbekannte Form.'); } $signature = base64_decode((string) $envelope['signature'], true); if ($signature === false || $signature === '') { fail('Die Signatur ist nicht lesbar.'); } // --- Signatur prüfen --- $valid = false; $checkedAgainst = ''; if ($algorithm === 'Ed25519') { if (!function_exists('sodium_crypto_sign_verify_detached')) { fail('PHP ohne Sodium-Erweiterung — Ed25519 kann nicht geprüft werden.', 2); } $keys = array_merge(EMBEDDED_ED25519_PUBLIC_KEYS_HEX, $extraEd25519); if ($keys === []) { fail( 'Kein Ed25519-Schlüssel eingebettet und keiner mit --key angegeben. ' . 'Der öffentliche Schlüssel steht auf annexcreate.com/notfallversprechen.html.' ); } foreach ($keys as $hex) { $bin = @hex2bin(trim((string) $hex)); if ($bin === false || strlen($bin) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { fail('Ein Ed25519-Schlüssel ist kein 32-Byte-Hex-Wert: ' . $hex, 2); } if (strlen($signature) === SODIUM_CRYPTO_SIGN_BYTES && sodium_crypto_sign_verify_detached($signature, $payloadJson, $bin)) { $valid = true; $checkedAgainst = 'Ed25519 ' . substr((string) $hex, 0, 12) . '…'; break; } } } else { if (!function_exists('openssl_verify')) { fail('PHP ohne OpenSSL-Erweiterung — RSA kann nicht geprüft werden.', 2); } $pem = EMBEDDED_RSA_PUBLIC_KEY_PEM; if ($rsaPemPath !== null) { $pemRead = file_get_contents($rsaPemPath); if ($pemRead === false) { fail("RSA-Schlüsseldatei nicht lesbar: {$rsaPemPath}", 2); } $pem = $pemRead; } $valid = openssl_verify($payloadJson, $signature, $pem, OPENSSL_ALGO_SHA256) === 1; $checkedAgainst = 'RSA-PKCS1-SHA256 (' . ($rsaPemPath !== null ? $rsaPemPath : 'eingebettet') . ')'; } // --- Ergebnis --- echo "AnnexCREATE Offline-Lizenzprüfung\n"; echo str_repeat('-', 50) . "\n"; echo "Datei: {$file}\n"; echo "Umschlag: Version {$version}\n"; echo "Verfahren: {$algorithm}\n"; if (!$valid) { echo "\nERGEBNIS: UNGÜLTIG — die Signatur passt zu keinem geprüften Schlüssel.\n"; echo "Hinweis: Entweder wurde die Datei verändert, oder sie stammt nicht von AnnexCREATE,\n"; echo " oder der passende öffentliche Schlüssel wurde nicht angegeben.\n"; exit(1); } echo "Schlüssel: {$checkedAgainst}\n\n"; echo "Inhalt (signiert und unverändert):\n"; $known = [ 'product' => 'Produkt', 'edition' => 'Edition', 'license_key' => 'Lizenzschlüssel', 'hardware_id' => 'Gerätebindung', 'assigned_user_id' => 'Zugewiesen an (ID)', 'team_id' => 'Team (ID)', 'role' => 'Rolle', 'iat' => 'Ausgestellt', 'expires_at' => 'Läuft ab', ]; foreach ($known as $field => $label) { if (!array_key_exists($field, $payload)) { continue; } $value = $payload[$field]; if ($field === 'iat' && is_numeric($value)) { $value = date('Y-m-d H:i:s', (int) $value) . ' UTC'; } if ($field === 'hardware_id') { $value = ($value === '' || $value === null) ? 'keine — läuft auf jedem Rechner des Käufers' : 'gebunden (' . substr((string) $value, 0, 8) . '…)'; } if ($value === null || $value === '') { continue; } printf(" %-22s %s\n", $label . ':', (string) $value); } if (!array_key_exists('expires_at', $payload) || $payload['expires_at'] === null || $payload['expires_at'] === '') { printf(" %-22s %s\n", 'Läuft ab:', 'nie — unbefristet gültig'); } echo "\nERGEBNIS: GÜLTIG — die Signatur stimmt, der Inhalt ist unverändert.\n"; echo " Diese Prüfung lief vollständig offline. Sie funktioniert auch dann,\n"; echo " wenn es den AnnexCREATE-Lizenzserver nicht mehr gibt.\n"; exit(0);