Skip to content

Commit e4952c3

Browse files
authored
Try to fix OB3 badges comments from Xavi (#208)
1 parent e8b72a7 commit e4952c3

4 files changed

Lines changed: 223 additions & 14 deletions

File tree

src/Config/ConfigInfo.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,19 @@ class ConfigInfo {
935935
*/
936936
public $badge_organization_logo = null;
937937

938+
/**
939+
* OB3 DataIntegrityProof signing - Base64-encoded Ed25519 secret key (64 bytes).
940+
* Run: php scripts/badge_ob3_keygen.php to generate. Required for 1EdTech OB3 certification.
941+
* $CFG->badge_ob3_secret_key = 'base64-encoded-key';
942+
*/
943+
public $badge_ob3_secret_key = null;
944+
945+
/**
946+
* OB3 verification method URL for proof (e.g. issuer URL + #key-0).
947+
* $CFG->badge_ob3_verification_method_id = 'https://yoursite.com/tsugi/assertions/issuer.json#key-0';
948+
*/
949+
public $badge_ob3_verification_method_id = null;
950+
938951
/**
939952
* LinkedIn organization/company page URL
940953
*

src/Core/Badges.php

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Tsugi\Core;
44

55
use \Tsugi\Util\U;
6+
use \Tsugi\Util\Ob3DataIntegrity;
67
use \Tsugi\Crypt\AesCtr;
78

89
/**
@@ -427,20 +428,23 @@ public static function getOb3Assertion($encrypted, $date, $code, $badge, $title,
427428
// Legacy OB1 assertion reference for traceability
428429
$legacy_assertion = $CFG->wwwroot . "/badges/assert.php?id=" . urlencode($encrypted);
429430

431+
// VC 2.0 uses validFrom (not issuanceDate); evidence at credential level (not credentialSubject)
432+
$normalized_date = self::normalizeDateToZ($date);
430433
$credential = array(
431434
"@context" => array(
432435
self::OB3_CREDENTIALS_CONTEXT,
433436
self::OB3_OPENBADGES_CONTEXT
434437
),
435438
"id" => $credential_id,
436439
"type" => array("VerifiableCredential", "OpenBadgeCredential"),
440+
"name" => $badge->title,
437441
"issuer" => array(
438442
"id" => $issuer_id,
439443
"type" => "Profile",
440444
"name" => $CFG->servicename,
441445
"url" => $CFG->apphome
442446
),
443-
"issuanceDate" => $date,
447+
"validFrom" => $normalized_date,
444448
"credentialSubject" => array(
445449
"id" => "mailto:" . $email,
446450
"type" => "AchievementSubject",
@@ -457,13 +461,13 @@ public static function getOb3Assertion($encrypted, $date, $code, $badge, $title,
457461
"id" => $evidence,
458462
"narrative" => $narrative
459463
)
460-
),
461-
"evidence" => array(
462-
array(
463-
"id" => $evidence,
464-
"type" => "Evidence",
465-
"narrative" => $narrative
466-
)
464+
)
465+
),
466+
"evidence" => array(
467+
array(
468+
"id" => $evidence,
469+
"type" => "Evidence",
470+
"narrative" => $narrative
467471
)
468472
)
469473
);
@@ -475,6 +479,21 @@ public static function getOb3Assertion($encrypted, $date, $code, $badge, $title,
475479
);
476480
}
477481

482+
// Add DataIntegrityProof when OB3 signing is configured
483+
if (!empty($CFG->badge_ob3_secret_key) && !empty($CFG->badge_ob3_verification_method_id)) {
484+
$secretKey = base64_decode($CFG->badge_ob3_secret_key, true);
485+
if ($secretKey !== false && strlen($secretKey) === SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
486+
$proof = Ob3DataIntegrity::createProof(
487+
$credential,
488+
$CFG->badge_ob3_verification_method_id,
489+
$secretKey
490+
);
491+
if ($proof !== null) {
492+
$credential["proof"] = $proof;
493+
}
494+
}
495+
}
496+
478497
return json_encode($credential, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
479498
}
480499

@@ -593,6 +612,25 @@ public static function getOb3Issuer($encrypted = null, $code = null, $badge = nu
593612
$issuer["extensions"]["linkedIn"] = $CFG->linkedin_url;
594613
}
595614

615+
// Add verificationMethod for OB3 proof verification when signing is configured
616+
if (!empty($CFG->badge_ob3_secret_key) && !empty($CFG->badge_ob3_verification_method_id)) {
617+
$secretKey = base64_decode($CFG->badge_ob3_secret_key, true);
618+
if ($secretKey !== false && strlen($secretKey) === SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
619+
$publicKey = Ob3DataIntegrity::secretKeyToPublicKey($secretKey);
620+
$publicKeyMultibase = Ob3DataIntegrity::publicKeyToMultibase($publicKey);
621+
if ($publicKeyMultibase !== '') {
622+
$issuer["verificationMethod"] = array(
623+
array(
624+
"id" => $CFG->badge_ob3_verification_method_id,
625+
"type" => "Multikey",
626+
"controller" => $issuer_id,
627+
"publicKeyMultibase" => $publicKeyMultibase
628+
)
629+
);
630+
}
631+
}
632+
}
633+
596634
return json_encode($issuer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
597635
}
598636
}

src/Util/Ob3DataIntegrity.php

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace Tsugi\Util;
4+
5+
use Mmccook\JsonCanonicalizator\JsonCanonicalizatorFactory;
6+
7+
/**
8+
* Data Integrity Proof for Open Badges 3.0 (eddsa-jcs-2022 cryptosuite)
9+
*
10+
* Implements W3C VC Data Integrity EdDSA Cryptosuites - eddsa-jcs-2022.
11+
* Note: The 1EdTech OB3 verifier at vc.1ed.tech only accepts eddsa-rdfc-2022 for
12+
* certification. This implementation uses eddsa-jcs-2022 (also in the W3C spec).
13+
* For 1EdTech certification, consider filing a request to support eddsa-jcs-2022.
14+
* @see https://www.w3.org/TR/vc-di-eddsa/
15+
*/
16+
class Ob3DataIntegrity {
17+
18+
/** @var string eddsa-jcs-2022 cryptosuite */
19+
const CRYPTOSUITE = 'eddsa-jcs-2022';
20+
21+
/**
22+
* Create a DataIntegrityProof for an OB3 credential (eddsa-jcs-2022)
23+
*
24+
* @param array $credential The credential document (without proof)
25+
* @param string $verificationMethodId URL of the verification method (e.g. issuer#key-0)
26+
* @param string $secretKey Ed25519 secret key bytes (64 bytes: 32 seed + 32 public)
27+
* @return array|null Proof object or null on failure
28+
*/
29+
public static function createProof(array $credential, $verificationMethodId, $secretKey) {
30+
if (strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
31+
error_log('Ob3DataIntegrity: Invalid secret key length, expected ' . SODIUM_CRYPTO_SIGN_SECRETKEYBYTES);
32+
return null;
33+
}
34+
35+
$created = gmdate('Y-m-d\TH:i:s\Z');
36+
$proofOptions = array(
37+
'type' => 'DataIntegrityProof',
38+
'cryptosuite' => self::CRYPTOSUITE,
39+
'created' => $created,
40+
'verificationMethod' => $verificationMethodId,
41+
'proofPurpose' => 'assertionMethod'
42+
);
43+
if (isset($credential['@context'])) {
44+
$proofOptions['@context'] = $credential['@context'];
45+
}
46+
47+
// Proof config for hashing (clone without proofValue)
48+
$proofConfig = $proofOptions;
49+
50+
$canonicalizator = JsonCanonicalizatorFactory::getInstance();
51+
52+
// 1. Transformed data = JCS(canonicalize credential)
53+
$transformedData = $canonicalizator->canonicalize($credential);
54+
55+
// 2. Canonical proof config = JCS(proofConfig)
56+
$canonicalProofConfig = $canonicalizator->canonicalize($proofConfig);
57+
58+
// 3. hashData = SHA256(proofConfig) + SHA256(transformedData)
59+
$proofConfigHash = hash('sha256', $canonicalProofConfig, true);
60+
$transformedDocHash = hash('sha256', $transformedData, true);
61+
$hashData = $proofConfigHash . $transformedDocHash;
62+
63+
// 4. Sign hashData with Ed25519
64+
$proofBytes = sodium_crypto_sign_detached($hashData, $secretKey);
65+
66+
// 5. proofValue = base58btc(proofBytes)
67+
$proofValue = self::base58Encode($proofBytes);
68+
69+
$proofOptions['proofValue'] = 'z' . $proofValue;
70+
return $proofOptions;
71+
}
72+
73+
/**
74+
* Extract public key from libsodium secret key (last 32 bytes)
75+
*
76+
* @param string $secretKey 64-byte Ed25519 secret key from sodium
77+
* @return string 32-byte public key
78+
*/
79+
public static function secretKeyToPublicKey($secretKey) {
80+
if (strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
81+
return '';
82+
}
83+
return substr($secretKey, 32, 32);
84+
}
85+
86+
/**
87+
* Encode Ed25519 public key as Multikey (base58btc with 0xed01 prefix)
88+
* Multikey format: 0xed01 (2 bytes) + public key (32 bytes) = 34 bytes
89+
*
90+
* @param string $publicKey 32-byte Ed25519 public key
91+
* @return string Multibase multikey string (z prefix + base58)
92+
*/
93+
public static function publicKeyToMultibase($publicKey) {
94+
if (strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
95+
return '';
96+
}
97+
$multikeyBytes = "\xed\x01" . $publicKey;
98+
return 'z' . self::base58Encode($multikeyBytes);
99+
}
100+
101+
/**
102+
* Base58 (Bitcoin alphabet) encode - for multibase "z" prefix
103+
* Alphabet: 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz
104+
* Uses GMP or BCMath extension (at least one required for large byte strings).
105+
*/
106+
private static function base58Encode($input) {
107+
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
108+
$hex = bin2hex($input);
109+
$result = '';
110+
111+
if (function_exists('gmp_init')) {
112+
$num = gmp_init($hex, 16);
113+
$zero = gmp_init(0);
114+
while (gmp_cmp($num, $zero) > 0) {
115+
list($num, $remainder) = gmp_div_qr($num, 58);
116+
$result = $alphabet[(int) gmp_strval($remainder)] . $result;
117+
}
118+
} elseif (function_exists('bcadd')) {
119+
$num = '0';
120+
for ($i = 0; $i < strlen($hex); $i++) {
121+
$num = bcadd(bcmul($num, '16', 0), (string) hexdec($hex[$i]), 0);
122+
}
123+
while (bccomp($num, '0', 0) > 0) {
124+
$remainder = (int) bcmod($num, '58');
125+
$num = bcdiv(bcsub($num, (string) $remainder, 0), '58', 0);
126+
$result = $alphabet[$remainder] . $result;
127+
}
128+
} else {
129+
throw new \RuntimeException(
130+
'OB3 base58 encoding requires PHP gmp or bcmath extension. ' .
131+
'Install one: apt-get install php-gmp or apt-get install php-bcmath'
132+
);
133+
}
134+
135+
// Leading zeros become '1's in base58
136+
for ($i = 0; $i < strlen($input) && $input[$i] === "\x00"; $i++) {
137+
$result = $alphabet[0] . $result;
138+
}
139+
return $result;
140+
}
141+
142+
/**
143+
* Generate a new Ed25519 key pair for OB3 signing
144+
* Returns [ 'secretKey' => string (64 bytes), 'publicKey' => string (32 bytes) ]
145+
*
146+
* @return array Key pair
147+
*/
148+
public static function generateKeyPair() {
149+
$keypair = sodium_crypto_sign_keypair();
150+
return array(
151+
'secretKey' => sodium_crypto_sign_secretkey($keypair),
152+
'publicKey' => sodium_crypto_sign_publickey($keypair)
153+
);
154+
}
155+
}

tests/Core/BadgesTest.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,8 +260,11 @@ public function testGetOb3Assertion()
260260
$this->assertEquals($CFG->servicename, $decoded['issuer']['name'], 'Should have correct issuer name');
261261
$this->assertEquals($CFG->apphome, $decoded['issuer']['url'], 'Should have correct issuer URL');
262262

263-
// Check issuance date
264-
$this->assertEquals($date, $decoded['issuanceDate'], 'Should have correct issuance date');
263+
// Check validFrom (VC 2.0)
264+
$this->assertEquals($date, $decoded['validFrom'], 'Should have correct validFrom date');
265+
266+
// Check name at credential level
267+
$this->assertEquals('Test Badge', $decoded['name'], 'Should have credential name');
265268

266269
// Check credential subject
267270
$this->assertIsArray($decoded['credentialSubject'], 'Credential subject should be array');
@@ -279,10 +282,10 @@ public function testGetOb3Assertion()
279282
$this->assertEquals('Image', $decoded['credentialSubject']['achievement']['image']['type'], 'Image type should be Image');
280283
$this->assertEquals('http://localhost/badges/test-badge.png', $decoded['credentialSubject']['achievement']['image']['id'], 'Should have correct image ID');
281284

282-
// Check evidence
283-
$this->assertIsArray($decoded['credentialSubject']['evidence'], 'Evidence should be array');
284-
$this->assertCount(1, $decoded['credentialSubject']['evidence'], 'Should have one evidence entry');
285-
$this->assertEquals('Evidence', $decoded['credentialSubject']['evidence'][0]['type'], 'Evidence type should be Evidence');
285+
// Check evidence at credential level (not credentialSubject)
286+
$this->assertIsArray($decoded['evidence'], 'Evidence should be array at credential level');
287+
$this->assertCount(1, $decoded['evidence'], 'Should have one evidence entry');
288+
$this->assertEquals('Evidence', $decoded['evidence'][0]['type'], 'Evidence type should be Evidence');
286289
}
287290

288291
/**

0 commit comments

Comments
 (0)