Skip to content

Commit 8b9ae03

Browse files
committed
feat(auth): W3C verifiable credential issuer for course graduation proofs
Closes #1107 - Add W3C VC Data Model v2.0 types and Ed25519Signature2020 Linked Data proof utilities (multibase base58btc, deterministic canonicalization, Ed25519 sign/verify) using the platform CERTIFICATE_SIGNING_SEED key. - Add VerifiableCredentialService: issues signed VC v2.0 course-completion credentials linked to student DIDs, verifies submitted credentials by resolving the issuer DID document and checking the Ed25519Signature2020 proof, and rejects tampered/forged/revoked credentials. - Add public endpoints: GET /certificates/:id/vc (issue), GET /certificates/:id/vc/download (wallet-importable package), POST /certificates/vc/verify (verify), GET /certificates/vc/issuer (issuer DID document). - Export service from certificates/index.js; document CERTIFICATE_SIGNING_SEED and ISSUER_GITHUB_HANDLE in .env.example. - Add unit tests covering deterministic keypair derivation, base58btc round-trip, canonicalization, sign/verify, tampered/forged rejection, DID document shape, VC v2.0 shape, and end-to-end verify-then-tamper.
1 parent 9f1779a commit 8b9ae03

8 files changed

Lines changed: 893 additions & 0 deletions

backend/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ ISSUER_DID="did:stellar:GBRPYHIL2CI3FYQMWVUGE62KMGOBQKLCYJ3HLKBUBIW5VZH4S4MNOWT"
8181
# Issuer Name (displayed on certificates)
8282
ISSUER_NAME="Web3 Student Lab"
8383

84+
# W3C Verifiable Credentials (issue #1107)
85+
# -----------------------------------------------------------------------------
86+
# Ed25519 issuer signing seed (64 hex chars) used to sign W3C VC v2.0
87+
# credentials (Ed25519Signature2020) and PDF diplomas. Derive one with:
88+
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
89+
# Leave empty for a random dev key (not stable across restarts).
90+
CERTIFICATE_SIGNING_SEED=""
91+
# Optional GitHub handle included in the issuer DID document.
92+
ISSUER_GITHUB_HANDLE=""
93+
8494
# Blockchain simulation mode (set to true for development without live blockchain)
8595
BLOCKCHAIN_SIMULATION_MODE=false
8696

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/**
2+
* Ed25519Signature2020 Linked Data proof utilities (issue #1107).
3+
*
4+
* Self-contained implementation that does not require the full
5+
* @digitalbazaar/ed25519-signature-2020 / jsonld stack. It produces and
6+
* verifies W3C-compatible `Ed25519Signature2020` proof objects:
7+
*
8+
* - `publicKeyMultibase` / `proofValue` are encoded as multibase
9+
* base58btc (`z`-prefixed) strings per the Ed25519Signature2020 suite.
10+
* - The signed message is a deterministic, canonical serialization of the
11+
* credential document (sorted keys, stable JSON) combined with the proof
12+
* options — the same canonicalization used by the existing PDF / content
13+
* hash signers in this codebase.
14+
*
15+
* Node's built-in `crypto` (Ed25519) is used so no native dependencies are
16+
* required in serverless / containerized deployments.
17+
*/
18+
19+
import crypto from 'crypto';
20+
21+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
22+
const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
23+
24+
// ─── base58btc (Bitcoin alphabet) ────────────────────────────────────────────
25+
26+
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
27+
28+
function base58Encode(input: Buffer): string {
29+
let zeros = 0;
30+
while (zeros < input.length && input[zeros] === 0) zeros += 1;
31+
32+
let digits = [0];
33+
for (let i = 0; i < input.length; i += 1) {
34+
let carry = input[i] as number;
35+
for (let j = 0; j < digits.length; j += 1) {
36+
carry += (digits[j] as number) << 8;
37+
digits[j] = carry % 58;
38+
carry = (carry / 58) | 0;
39+
}
40+
while (carry > 0) {
41+
digits.push(carry % 58);
42+
carry = (carry / 58) | 0;
43+
}
44+
}
45+
46+
let out = '';
47+
for (let i = 0; i < zeros; i += 1) out += BASE58_ALPHABET[0];
48+
for (let i = digits.length - 1; i >= 0; i -= 1) {
49+
out += BASE58_ALPHABET[digits[i] as number];
50+
}
51+
return out;
52+
}
53+
54+
function base58Decode(input: string): Buffer {
55+
const bytes = Buffer.alloc(input.length);
56+
let length = 0;
57+
58+
for (let i = 0; i < input.length; i += 1) {
59+
const digit = BASE58_ALPHABET.indexOf(input[i]);
60+
if (digit === -1) throw new Error('Invalid base58 character');
61+
let carry = digit;
62+
for (let j = 0; j < length; j += 1) {
63+
carry += (bytes[j] as number) * 58;
64+
bytes[j] = carry & 0xff;
65+
carry >>= 8;
66+
}
67+
while (carry > 0) {
68+
bytes[length++] = carry & 0xff;
69+
carry >>= 8;
70+
}
71+
}
72+
73+
// Preserve leading zero bytes.
74+
let zeros = 0;
75+
while (zeros < input.length && input[zeros] === '1') zeros += 1;
76+
const out = Buffer.alloc(length + zeros);
77+
for (let i = 0; i < zeros; i += 1) out[i] = 0;
78+
for (let i = 0; i < length; i += 1) out[zeros + i] = bytes[length - 1 - i] as number;
79+
return out;
80+
}
81+
82+
/** Encode a byte array as a multibase base58btc string (`z` prefix). */
83+
export function toMultibaseBase58(value: Buffer): string {
84+
return `z${base58Encode(value)}`;
85+
}
86+
87+
/** Decode a multibase base58btc string (`z` prefix) to bytes. */
88+
export function fromMultibaseBase58(value: string): Buffer {
89+
if (typeof value !== 'string' || !value.startsWith('z')) {
90+
throw new Error('Expected a multibase base58btc value (z-prefixed)');
91+
}
92+
return base58Decode(value.slice(1));
93+
}
94+
95+
// ─── Deterministic canonicalization ──────────────────────────────────────────
96+
97+
/**
98+
* Serialize a JSON value deterministically: object keys are sorted, all
99+
* arrays/objects are traversed recursively, and strings are emitted verbatim.
100+
* This mirrors the canonicalization used by the existing PDF signer so the
101+
* signed bytes are stable across key insertion order.
102+
*/
103+
export function canonicalSerialize(value: unknown): string {
104+
if (value === null || typeof value !== 'object') {
105+
return JSON.stringify(value);
106+
}
107+
if (Array.isArray(value)) {
108+
return `[${value.map((item) => canonicalSerialize(item)).join(',')}]`;
109+
}
110+
const obj = value as Record<string, unknown>;
111+
const keys = Object.keys(obj).sort();
112+
const parts = keys.map((key) => `${JSON.stringify(key)}:${canonicalSerialize(obj[key])}`);
113+
return `{${parts.join(',')}}`;
114+
}
115+
116+
/** Strip the `proofValue` from a proof so it can be signed / re-verified. */
117+
export function proofWithoutValue(proof: Record<string, unknown>): Record<string, unknown> {
118+
const { proofValue: _proofValue, ...rest } = proof;
119+
return rest;
120+
}
121+
122+
/**
123+
* Build the canonical message that is signed: the deterministic serialization
124+
* of the credential document with the proof options (minus proofValue) merged
125+
* in, so the proof itself is covered by the signature.
126+
*/
127+
export function createSignedMessage(
128+
credential: Record<string, unknown>,
129+
proof: Record<string, unknown>
130+
): Buffer {
131+
const doc = { ...credential, proof: proofWithoutValue(proof) };
132+
return Buffer.from(canonicalSerialize(doc), 'utf8');
133+
}
134+
135+
// ─── Key handling ────────────────────────────────────────────────────────────
136+
137+
/** Derive an Ed25519 private key (PKCS#8 DER) from a 32-byte seed. */
138+
function privateKeyFromSeed(seed: Buffer): crypto.KeyObject {
139+
const pkcs8Der = Buffer.concat([ED25519_PKCS8_PREFIX, seed]);
140+
return crypto.createPrivateKey({ key: pkcs8Der, format: 'der', type: 'pkcs8' });
141+
}
142+
143+
/** Wrap a raw 32-byte Ed25519 public key in SPKI DER so crypto can use it. */
144+
export function publicKeyFromRaw(raw: Buffer): crypto.KeyObject {
145+
const spkiDer = Buffer.concat([ED25519_SPKI_PREFIX, raw]);
146+
return crypto.createPublicKey({ key: spkiDer, format: 'der', type: 'spki' });
147+
}
148+
149+
/**
150+
* Deterministically derive an Ed25519 key pair from a seed/secret. Uses the
151+
* same `CERTIFICATE_SIGNING_SEED` env var the PDF signer relies on so every
152+
* credential (PDF, content hash, VC) shares the platform issuer identity.
153+
*/
154+
export function deriveIssuerKeyPair(seedHex?: string): {
155+
publicKey: Buffer;
156+
privateKey: crypto.KeyObject;
157+
publicKeyMultibase: string;
158+
} {
159+
const rawSeed = seedHex ? Buffer.from(seedHex, 'hex') : null;
160+
const hashed =
161+
rawSeed && rawSeed.length === 32
162+
? rawSeed
163+
: crypto
164+
.createHash('sha256')
165+
.update(seedHex ?? 'web3-student-lab-issuer')
166+
.digest();
167+
const privateKey = privateKeyFromSeed(hashed);
168+
// Derive the matching Ed25519 public key from the private key.
169+
const rawPublic = crypto
170+
.createPublicKey(privateKey)
171+
.export({ type: 'spki', format: 'der' })
172+
.slice(-32);
173+
return {
174+
publicKey: rawPublic,
175+
privateKey,
176+
publicKeyMultibase: toMultibaseBase58(rawPublic),
177+
};
178+
}
179+
180+
// ─── Sign / verify ───────────────────────────────────────────────────────────
181+
182+
/** Sign a message with the issuer Ed25519 private key. */
183+
export function signMessage(message: Buffer, privateKey: crypto.KeyObject): Buffer {
184+
return crypto.sign(null, message, privateKey);
185+
}
186+
187+
/**
188+
* Verify an Ed25519Signature2020 proof on a credential.
189+
* `verificationMethod` must be the issuer DID + '#key-1'; the public key is
190+
* resolved from the issuer's DID document.
191+
*/
192+
export function verifySignature(
193+
credential: Record<string, unknown>,
194+
proof: Record<string, unknown>,
195+
publicKeyRaw: Buffer
196+
): boolean {
197+
try {
198+
const message = createSignedMessage(credential, proof);
199+
const proofValue = fromMultibaseBase58(String(proof.proofValue));
200+
const key = publicKeyFromRaw(publicKeyRaw);
201+
return crypto.verify(null, message, key, proofValue);
202+
} catch {
203+
return false;
204+
}
205+
}

0 commit comments

Comments
 (0)