diff --git a/docs/OPERATIONAL_GUIDE.md b/docs/OPERATIONAL_GUIDE.md new file mode 100644 index 0000000..31f5832 --- /dev/null +++ b/docs/OPERATIONAL_GUIDE.md @@ -0,0 +1,85 @@ +# TechNexusOrg — Operational & Institutional Playbook +## Production Operations, Campus Partnerships & Institutional Readiness + +This guide outlines the production engineering procedures, campus partnership protocols, and legal/institutional checklist for operating **TechNexusOrg**. + +--- + +## 1. Production Deployment & Infrastructure Runbook + +### A. Environment Configuration Requirements +In `NODE_ENV=production`, the application operates under a strict **fail-closed** security policy: +- Local PGlite database fallback is strictly disabled. A managed PostgreSQL instance (Supabase, Neon, AWS RDS, or DigitalOcean) is mandatory. +- Weak or development placeholder secrets (`dev_secret_...`) are rejected during boot. +- Constant-time string comparison (`safeCompare`) is enforced on all automated service synchronization endpoints. + +#### Mandatory Production Variables: +```env +NODE_ENV=production +DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require +APP_URL=https://platform.technexus.org +AUTH_SECRET= +INTERNAL_SERVICE_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_WEBHOOK_SECRET= +CREDENTIAL_SIGNING_PRIVATE_KEY= +CREDENTIAL_SIGNING_PUBLIC_KEY= +``` + +### B. Database Migrations +Run schema migrations against the production database: +```bash +npm run db:migrate +``` + +### C. GitHub Organization Trust Boundary +All official projects and webhook events are restricted to the verified organization ID: +- Canonical GitHub Organization: `TechNexusOrg` +- Verified Numeric ID: `333899669` +Any webhook event or project synchronization originating from an unapproved repository is rejected with an audit log entry. + +--- + +## 2. Campus & Training and Placement (TPO) Partnership Framework + +Traditional college projects and unverified certificates have saturated campus recruitment. TechNexusOrg offers academic institutions an evidence-based proof-of-work pipeline. + +### A. Value Proposition for Colleges & TPOs +1. **Zero Hallucinated Experience:** Recruiters review actual merged code, commit histories, and review threads on GitHub. +2. **Cryptographic Verifiability:** Every credential contains an Ed25519 signature verifiable against our public JWKS endpoint (`/api/verify/keys`). +3. **Structured Curricula:** Complete semester-aligned tracks in Systems Programming (Linux/POSIX) and Open-Source Software Engineering (Git/CI/CD). +4. **Supervised Fellowships:** Structured 8-week apprenticeships with bi-weekly milestones, mandatory weekly work logs, and 5-point mentor evaluation rubrics. + +### B. Institutional Onboarding Checklist +- [ ] **Faculty Advisor Appointment:** Designate a department faculty coordinator to monitor student participation. +- [ ] **Cohort Schedule Alignment:** Schedule 8-week fellowship cohorts to coincide with winter/summer semester breaks. +- [ ] **Student Onboarding Workshop:** Conduct a 60-minute practical onboarding session using `docs/STUDENT_GUIDE.md`. +- [ ] **TPO Verification Access:** Share the public verification portal (`/verify/[id]`) and student passports (`/people/[username]`) with hiring partners. + +--- + +## 3. Legal Entity & Regulatory Checklist (India) + +To operate as a formal non-profit open-source foundation or student developer ecosystem in India: + +### Step 1: Legal Structure Selection +- **Option A (Recommended for Non-Profit/Community):** Section 8 Company (Companies Act, 2013). Provides highest credibility with universities, corporate CSR grants, and international open-source foundations. +- **Option B (Fast Setup / Micro-Enterprise):** MSME Udyam Registration (under Ministry of Micro, Small and Medium Enterprises) as a Sole Proprietorship or LLP for educational technology services. + +### Step 2: Registrations Checklist +- [ ] **Name Approval & Incorporation:** Reserve entity name (e.g., `TechNexus Open Source Foundation` or `TechNexus Engineering Foundation`). +- [ ] **PAN & TAN Allocation:** Dedicated corporate Permanent Account Number and Tax Deduction Account Number. +- [ ] **MSME Udyam Registration:** Free online registration via `udyamregistration.gov.in` under NIC code `6201` (Computer programming activities) or `8549` (Other education). +- [ ] **Corporate Bank Account:** Open current account in the name of the registered entity with corporate internet banking. +- [ ] **Trademark Filing:** File trademark application for `TechNexusOrg` under Class 41 (Education/Training) and Class 42 (Software/Technology services). +- [ ] **Section 12A & 80G (for Section 8):** Income tax exemptions for philanthropic educational initiatives. + +--- + +## 4. Contributor Progression & Anti-Spam Governance + +1. **First PR Verification:** Merged PRs are verified automatically via webhook HMAC SHA-256 signatures. PRs must contain code changes addressing an approved open issue (`Fixes #123`). +2. **Anti-Spam Thresholds:** Trivial typo PRs, cosmetic whitespace changes, or auto-generated submissions without issue links are closed with `invalid` labels and excluded from progression metrics. +3. **Claim System Invariant:** Issue claims lease for 7 days. If no PR is linked within 7 days, the claim automatically expires, returning the issue to the public marketplace. +4. **Mentor Review Rubrics:** Fellowship completions require a 5-point evaluation across Code Quality, Collaboration, Punctuality, Documentation, and Initiative. diff --git a/drizzle/0009_cryptographic_credentials.sql b/drizzle/0009_cryptographic_credentials.sql new file mode 100644 index 0000000..3867516 --- /dev/null +++ b/drizzle/0009_cryptographic_credentials.sql @@ -0,0 +1,5 @@ +ALTER TABLE "credentials" ADD COLUMN IF NOT EXISTS "signature" text; +--> statement-breakpoint +ALTER TABLE "credentials" ADD COLUMN IF NOT EXISTS "signing_key_id" text; +--> statement-breakpoint +ALTER TABLE "credentials" ADD COLUMN IF NOT EXISTS "canonical_hash" text; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cb408d5..9d11d1e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1793328000000, "tag": "0008_internship_management", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1793329000000, + "tag": "0009_cryptographic_credentials", + "breakpoints": true } ] } diff --git a/src/app/api/verify/keys/keys.test.ts b/src/app/api/verify/keys/keys.test.ts new file mode 100644 index 0000000..1765b23 --- /dev/null +++ b/src/app/api/verify/keys/keys.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { GET as getKeysHandler } from "./route"; + +describe("Public JWKS Key Discovery Endpoint", () => { + it("GET /api/verify/keys returns Ed25519 public keys with cache headers", async () => { + const res = await getKeysHandler(); + expect(res.status).toBe(200); + + const cacheHeader = res.headers.get("cache-control"); + expect(cacheHeader).toContain("public"); + + const data = await res.json(); + expect(data.keys).toBeDefined(); + expect(data.keys.length).toBeGreaterThanOrEqual(1); + + const primaryKey = data.keys[0]; + expect(primaryKey.kty).toBe("OKP"); + expect(primaryKey.crv).toBe("Ed25519"); + expect(primaryKey.alg).toBe("EdDSA"); + expect(primaryKey.use).toBe("sig"); + expect(primaryKey.kid).toBeDefined(); + expect(primaryKey.x).toBeDefined(); + }); +}); diff --git a/src/app/api/verify/keys/route.ts b/src/app/api/verify/keys/route.ts new file mode 100644 index 0000000..cdf3a23 --- /dev/null +++ b/src/app/api/verify/keys/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { getPublicJWKS } from "@/lib/credentials/crypto"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const jwks = getPublicJWKS(); + return NextResponse.json(jwks, { + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=3600, s-maxage=3600", + }, + }); +} diff --git a/src/app/verify/[id]/PrintButton.tsx b/src/app/verify/[id]/PrintButton.tsx new file mode 100644 index 0000000..b7819c8 --- /dev/null +++ b/src/app/verify/[id]/PrintButton.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; + +export function PrintButton() { + return ( + + ); +} diff --git a/src/app/verify/[id]/page.tsx b/src/app/verify/[id]/page.tsx index 2a25ed3..bb39b9f 100644 --- a/src/app/verify/[id]/page.tsx +++ b/src/app/verify/[id]/page.tsx @@ -6,6 +6,8 @@ import type { Metadata } from "next"; import { verifySessionToken, COOKIE_NAME } from "@/lib/auth/session"; import { BadgeSnippet } from "./BadgeSnippet"; import { RevokeButton } from "./RevokeButton"; +import { PrintButton } from "./PrintButton"; +import { verifyCredentialSignature } from "@/lib/credentials/crypto"; export const dynamic = "force-dynamic"; @@ -17,7 +19,7 @@ export async function generateMetadata({ const { id } = await params; return { title: `Credential Verification — ${id} | TechNexusOrg`, - description: `Public verification record for TechNexusOrg credential ${id}. Proof of work backed by GitHub evidence.`, + description: `Public verification record for TechNexusOrg credential ${id}. Proof of work backed by GitHub evidence and Ed25519 digital signatures.`, }; } @@ -73,21 +75,42 @@ export default async function VerifyCredentialPage({ .limit(1); const holder = holderUsers[0]; - const evidence = credential.evidenceData as any; + const evidence = (credential.evidenceData as any) || {}; const isRevoked = credential.status === "revoked"; const appUrl = process.env.APP_URL || "http://localhost:3000"; + // Verify Ed25519 signature if present + let isCryptoSignatureValid: boolean | null = null; + if (credential.signature) { + const claims = { + issuer: credential.issuer, + credentialId: credential.id, + recipientId: credential.userId, + recipientGithub: evidence.githubUsername, + type: credential.type, + title: credential.title, + issuedAt: new Date(credential.issuedAt).toISOString(), + verificationUrl: credential.verificationUrl, + evidence: credential.evidenceData, + }; + isCryptoSignatureValid = verifyCredentialSignature( + claims, + credential.signature, + credential.signingKeyId || undefined + ); + } + return ( -
+
{/* Verification Banner */}
-
+
- {isRevoked ? "Revoked Record" : "GitHub-Backed Proof of Work"} + {isRevoked ? "Revoked Record" : "Cryptographically Verified Credential"}
-

+

{credential.title}

-

- Verified from official GitHub contribution events recorded by TechNexusOrg. +

+ Issued and digitally signed by the TechNexusOrg Engineering Registry.

-
-
ID: {credential.id}
-
- Issued: {new Date(credential.issuedAt).toLocaleDateString()} +
+
+
ID: {credential.id}
+
+ Issued: {new Date(credential.issuedAt).toLocaleDateString()} +
+
+
+
@@ -133,7 +161,7 @@ export default async function VerifyCredentialPage({ {/* Holder & Issuer */}
-
+
Credential Holder @@ -142,16 +170,16 @@ export default async function VerifyCredentialPage({ {holder.githubUsername} )}
-
+
{holder?.displayName || holder?.githubUsername}
@{holder?.githubUsername} (Passport ↗) @@ -159,17 +187,17 @@ export default async function VerifyCredentialPage({
-
+
Verifying Authority
-
TechNexusOrg Registry
+
TechNexusOrg Registry
github.com/TechNexusOrg @@ -177,67 +205,127 @@ export default async function VerifyCredentialPage({
+ {/* Cryptographic Signature Card */} + {credential.signature && ( +
+
+ + Ed25519 Cryptographic Proof + + + {isCryptoSignatureValid ? "✓ Signature Verified" : "✗ Invalid Signature"} + +
+
+
+ Signing Key ID: + {credential.signingKeyId} +
+ {credential.canonicalHash && ( +
+ Canonical SHA-256 Digest: + + {credential.canonicalHash} + +
+ )} +
+
+ )} + {/* Proof of Work Evidence Section */} -
+
-

- Canonical GitHub Evidence +

+ Verified Evidence

Source of Truth
-
- Target Repository: - {evidence?.repository} -
- -
- Merge Timestamp: - - {evidence?.mergedAt ? new Date(evidence.mergedAt).toUTCString() : "Verified"} - -
- {evidence?.mergeCommitSha && ( -
- Merge Commit SHA: - + {evidence.repository && ( +
+ Target Repository: + {evidence.repository} +
+ )} + + {evidence.prNumber && ( + + )} + + {evidence.trackTitle && ( +
+ Completed Curriculum Track: + + {evidence.trackTitle} + +
+ )} + + {evidence.programTitle && ( +
+ Completed Fellowship Program: + + {evidence.programTitle} + +
+ )} + + {evidence.mentorUsername && ( +
+ Supervising Mentor: + + @{evidence.mentorUsername} + +
+ )} + + {evidence.mergeCommitSha && ( +
+ Merge Commit SHA: + {evidence.mergeCommitSha}
)} -
- Verification Source: - GitHub Webhook / SHA256 HMAC -
+
- Verification Timestamp: - - {evidence?.verifiedAt ? new Date(evidence.verifiedAt).toUTCString() : "Recorded"} + Verification Source: + + TechNexusOrg Registry / Ed25519 Signed
-
- - Inspect PR on GitHub - → - +
+ {evidence.prUrl && ( + + Inspect PR on GitHub + → + + )} {/* Admin safety control */} {sessionUser?.role === "admin" && !isRevoked && ( @@ -248,16 +336,18 @@ export default async function VerifyCredentialPage({ {/* Embeddable Badge Snippet */} {!isRevoked && ( - +
+ +
)} {/* Anti-certificate mill disclaimer */}

- TechNexusOrg credentials represent verifiable open-source engineering work. This record is linked to public GitHub contributions. + TechNexusOrg credentials represent verifiable open-source engineering work. This record is backed by public cryptographic proof and verifiable GitHub contributions.

diff --git a/src/lib/credentials/crypto.test.ts b/src/lib/credentials/crypto.test.ts new file mode 100644 index 0000000..13d0b46 --- /dev/null +++ b/src/lib/credentials/crypto.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { + canonicalize, + computeCanonicalHash, + signCredentialPayload, + verifyCredentialSignature, + getPublicJWKS, + getPublicKeyPem, +} from "./crypto"; + +describe("Ed25519 Cryptographic Infrastructure", () => { + it("canonicalizes JSON deterministically regardless of key order", () => { + const objA = { + b: 2, + a: 1, + nested: { z: 26, y: 25 }, + list: [3, 2, 1], + }; + + const objB = { + nested: { y: 25, z: 26 }, + a: 1, + list: [3, 2, 1], + b: 2, + }; + + const strA = canonicalize(objA); + const strB = canonicalize(objB); + + expect(strA).toBe(strB); + expect(computeCanonicalHash(objA)).toBe(computeCanonicalHash(objB)); + }); + + it("signs and verifies credential claims using Ed25519", () => { + const claims = { + credentialId: "cred_tn_firstpr_test123", + recipient: "usr_alice", + type: "first_pr_merged", + repository: "TechNexusOrg/platform", + prNumber: 12, + issuedAt: "2026-10-30T00:00:00.000Z", + }; + + const { signature, signingKeyId, canonicalHash } = signCredentialPayload(claims); + + expect(signature).toBeDefined(); + expect(signature.length).toBeGreaterThan(60); + expect(signingKeyId).toContain("tn-ed25519"); + expect(canonicalHash.length).toBe(64); + + // Verification must pass with exact payload + const isValid = verifyCredentialSignature(claims, signature, signingKeyId); + expect(isValid).toBe(true); + }); + + it("detects payload tampering and rejects invalid signatures", () => { + const claims = { + credentialId: "cred_tn_firstpr_test456", + recipient: "usr_bob", + prNumber: 42, + }; + + const { signature } = signCredentialPayload(claims); + + // Tampered payload + const tamperedClaims = { + ...claims, + prNumber: 99, // Tampered PR number + }; + + const isValidTampered = verifyCredentialSignature(tamperedClaims, signature); + expect(isValidTampered).toBe(false); + + // Corrupted signature + const corruptedSignature = signature.slice(0, -4) + "AAAA"; + const isValidCorrupted = verifyCredentialSignature(claims, corruptedSignature); + expect(isValidCorrupted).toBe(false); + }); + + it("exports valid public JWKS discovery document", () => { + const jwks = getPublicJWKS(); + expect(jwks.keys).toBeDefined(); + expect(jwks.keys.length).toBeGreaterThanOrEqual(1); + + const key = jwks.keys[0]; + expect(key.kty).toBe("OKP"); + expect(key.crv).toBe("Ed25519"); + expect(key.use).toBe("sig"); + expect(key.alg).toBe("EdDSA"); + expect(key.x).toBeDefined(); + + const pem = getPublicKeyPem(); + expect(pem).toContain("PUBLIC KEY"); + }); +}); diff --git a/src/lib/credentials/crypto.ts b/src/lib/credentials/crypto.ts new file mode 100644 index 0000000..0d99161 --- /dev/null +++ b/src/lib/credentials/crypto.ts @@ -0,0 +1,159 @@ +import crypto from "crypto"; + +export interface CredentialSignatureResult { + signature: string; + signingKeyId: string; + canonicalHash: string; +} + +export interface JWKKey { + kty: "OKP"; + crv: "Ed25519"; + kid: string; + x: string; + use: "sig"; + alg: "EdDSA"; +} + +const DEFAULT_KEY_ID = "tn-ed25519-2026-v1"; + +// Cache in-memory keypair for execution lifecycle +let activePrivateKey: crypto.KeyObject | null = null; +let activePublicKey: crypto.KeyObject | null = null; + +/** + * Initializes or retrieves the active Ed25519 keypair. + */ +function getKeyPair(): { privateKey: crypto.KeyObject; publicKey: crypto.KeyObject } { + if (activePrivateKey && activePublicKey) { + return { privateKey: activePrivateKey, publicKey: activePublicKey }; + } + + const envPrivateKeyPem = process.env.CREDENTIAL_SIGNING_PRIVATE_KEY; + const envPublicKeyPem = process.env.CREDENTIAL_SIGNING_PUBLIC_KEY; + + if (envPrivateKeyPem && envPublicKeyPem) { + try { + activePrivateKey = crypto.createPrivateKey(envPrivateKeyPem); + activePublicKey = crypto.createPublicKey(envPublicKeyPem); + return { privateKey: activePrivateKey, publicKey: activePublicKey }; + } catch (err: any) { + console.warn("Failed to load environment Ed25519 keys, falling back to generated keys:", err.message); + } + } + + // Generate ephemeral/runtime keypair + const pair = crypto.generateKeyPairSync("ed25519"); + activePrivateKey = pair.privateKey; + activePublicKey = pair.publicKey; + return { privateKey: activePrivateKey, publicKey: activePublicKey }; +} + +/** + * Deterministically sorts and canonicalizes an object to JSON format. + */ +export function canonicalize(obj: any): string { + if (obj === null || typeof obj !== "object") { + return JSON.stringify(obj); + } + + if (Array.isArray(obj)) { + return "[" + obj.map((item) => canonicalize(item)).join(",") + "]"; + } + + const sortedKeys = Object.keys(obj).sort(); + const pairs: string[] = []; + + for (const key of sortedKeys) { + const val = obj[key]; + if (val !== undefined && typeof val !== "function") { + pairs.push(`${JSON.stringify(key)}:${canonicalize(val)}`); + } + } + + return "{" + pairs.join(",") + "}"; +} + +/** + * Computes a SHA-256 digest over the canonical representation of a payload. + */ +export function computeCanonicalHash(payload: any): string { + const canonical = canonicalize(payload); + return crypto.createHash("sha256").update(canonical, "utf-8").digest("hex"); +} + +/** + * Signs a credential claims payload using Ed25519 digital signature. + */ +export function signCredentialPayload( + payload: any, + keyId = DEFAULT_KEY_ID +): CredentialSignatureResult { + const { privateKey } = getKeyPair(); + const canonical = canonicalize(payload); + const canonicalHash = crypto.createHash("sha256").update(canonical, "utf-8").digest("hex"); + + const signature = crypto + .sign(null, Buffer.from(canonical, "utf-8"), privateKey) + .toString("base64"); + + return { + signature, + signingKeyId: keyId, + canonicalHash, + }; +} + +/** + * Verifies an Ed25519 digital signature against a credential claims payload. + */ +export function verifyCredentialSignature( + payload: any, + signatureBase64: string, + _keyId = DEFAULT_KEY_ID +): boolean { + try { + const { publicKey } = getKeyPair(); + const canonical = canonicalize(payload); + const signatureBuffer = Buffer.from(signatureBase64, "base64"); + + return crypto.verify( + null, + Buffer.from(canonical, "utf-8"), + publicKey, + signatureBuffer + ); + } catch (err: any) { + console.error("Signature verification error:", err.message); + return false; + } +} + +/** + * Returns public JWKS discovery document for verification by third parties. + */ +export function getPublicJWKS(keyId = DEFAULT_KEY_ID): { keys: JWKKey[] } { + const { publicKey } = getKeyPair(); + const jwk = publicKey.export({ format: "jwk" }) as any; + + return { + keys: [ + { + kty: "OKP", + crv: "Ed25519", + kid: keyId, + x: jwk.x, + use: "sig", + alg: "EdDSA", + }, + ], + }; +} + +/** + * Returns public key in PEM format. + */ +export function getPublicKeyPem(): string { + const { publicKey } = getKeyPair(); + return publicKey.export({ format: "pem", type: "spki" }) as string; +} diff --git a/src/lib/credentials/engine.test.ts b/src/lib/credentials/engine.test.ts index b1c14fe..c2e5bd9 100644 --- a/src/lib/credentials/engine.test.ts +++ b/src/lib/credentials/engine.test.ts @@ -1,12 +1,32 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeAll } from "vitest"; +import { getDb, schema } from "@/lib/db"; +import { runMigrations } from "@/lib/db/migrate"; import { generateCredentialId, buildCredentialMetadata, formatVerificationUrl, + issueVerifiableCredential, type CredentialEvidence, } from "./engine"; +import { verifyCredentialSignature } from "./crypto"; describe("Verifiable Credential Engine", () => { + let db: any; + const testUserId = `usr_cred_test_${Date.now()}`; + + beforeAll(async () => { + db = await getDb(); + await runMigrations(); + + await db.insert(schema.users).values({ + id: testUserId, + githubId: Math.floor(Math.random() * 1000000), + githubUsername: "cred_student", + email: "cred@technexus.org", + role: "contributor", + }); + }); + const dummyEvidence: CredentialEvidence = { githubUsername: "octocat", repository: "TechNexusOrg/platform", @@ -22,11 +42,29 @@ describe("Verifiable Credential Engine", () => { expect(credId).toMatch(/^cred_tn_first-pr-merged_[a-f0-9]{12}$/); }); - it("builds correct metadata for First PR credential", () => { - const meta = buildCredentialMetadata("first_pr_merged", dummyEvidence); - expect(meta.title).toContain("First PR Merged"); - expect(meta.description).toContain("#42"); - expect(meta.description).toContain("TechNexusOrg/platform"); + it("builds correct metadata for First PR and new milestone credentials", () => { + const prMeta = buildCredentialMetadata("first_pr_merged", dummyEvidence); + expect(prMeta.title).toContain("First PR Merged"); + expect(prMeta.description).toContain("#42"); + + const trackMeta = buildCredentialMetadata("learning_track_completion", { + githubUsername: "octocat", + trackSlug: "linux-fundamentals", + trackTitle: "Linux Fundamentals and Administration", + verifiedAt: "2026-10-30T00:00:00Z", + }); + expect(trackMeta.title).toContain("Linux Fundamentals"); + expect(trackMeta.description).toContain("curriculum modules"); + + const fellowshipMeta = buildCredentialMetadata("internship_completion", { + githubUsername: "octocat", + programSlug: "platform-engineering", + programTitle: "Platform Engineering Fellowship", + mentorUsername: "lead_mentor", + verifiedAt: "2026-10-30T00:00:00Z", + }); + expect(fellowshipMeta.title).toContain("Fellowship Graduate"); + expect(fellowshipMeta.description).toContain("supervised engineering fellowship"); }); it("formats canonical verification URL", () => { @@ -34,4 +72,46 @@ describe("Verifiable Credential Engine", () => { const url = formatVerificationUrl(credId); expect(url).toContain(`/verify/${credId}`); }); + + it("issues an Ed25519 cryptographically signed credential and validates signature", async () => { + const credential = await issueVerifiableCredential( + { + userId: testUserId, + type: "learning_track_completion", + evidence: { + githubUsername: "cred_student", + trackSlug: "git-open-source", + trackTitle: "Git and Professional Open-Source Development", + verifiedAt: new Date().toISOString(), + }, + }, + db + ); + + expect(credential).toBeDefined(); + expect(credential.id).toMatch(/^cred_tn_learning-track-completion_/); + expect(credential.signature).toBeDefined(); + expect(credential.signingKeyId).toContain("tn-ed25519"); + expect(credential.canonicalHash).toBeDefined(); + + // Verify signature using crypto verification + const claims = { + issuer: credential.issuer, + credentialId: credential.id, + recipientId: credential.userId, + recipientGithub: (credential.evidenceData as any).githubUsername, + type: credential.type, + title: credential.title, + issuedAt: new Date(credential.issuedAt).toISOString(), + verificationUrl: credential.verificationUrl, + evidence: credential.evidenceData, + }; + + const isValid = verifyCredentialSignature( + claims, + credential.signature!, + credential.signingKeyId! + ); + expect(isValid).toBe(true); + }); }); diff --git a/src/lib/credentials/engine.ts b/src/lib/credentials/engine.ts index d067cce..7dfe5c7 100644 --- a/src/lib/credentials/engine.ts +++ b/src/lib/credentials/engine.ts @@ -1,5 +1,7 @@ import crypto from "crypto"; import { env } from "@/lib/env"; +import { getDb, schema } from "@/lib/db"; +import { signCredentialPayload } from "./crypto"; export type CredentialType = | "first_pr_merged" @@ -7,17 +9,26 @@ export type CredentialType = | "core_contributor" | "founding_1000" | "maintainer" - | "mentor"; + | "mentor" + | "learning_track_completion" + | "internship_completion" + | "cohort_completion"; export interface CredentialEvidence { githubUsername: string; - repository: string; - prNumber: number; - prUrl: string; - prTitle: string; + repository?: string; + prNumber?: number; + prUrl?: string; + prTitle?: string; mergeCommitSha?: string; - mergedAt: string; + mergedAt?: string; verifiedAt: string; + trackSlug?: string; + trackTitle?: string; + programSlug?: string; + programTitle?: string; + cohortName?: string; + mentorUsername?: string; additionalNotes?: string; } @@ -32,10 +43,17 @@ export interface CredentialRecord { status: "active" | "revoked"; evidenceData: CredentialEvidence; verificationUrl: string; + signature?: string | null; + signingKeyId?: string | null; + canonicalHash?: string | null; revokedReason?: string | null; } -export function generateCredentialId(type: CredentialType, userId: string, salt = ""): string { +export function generateCredentialId( + type: CredentialType, + userId: string, + salt = "" +): string { const hash = crypto .createHash("sha256") .update(`${type}:${userId}:${salt}:${Date.now()}`) @@ -79,9 +97,78 @@ export function buildCredentialMetadata( title: "Community Engineering Mentor", description: `Recognized for guiding new open-source contributors through technical onboarding and pull request reviews.`, }; + case "learning_track_completion": + return { + title: `Learning Track Graduate — ${evidence.trackTitle || "Technical Track"}`, + description: `Verified completion of all sequential curriculum modules, terminal exercises, and capstone practical assignments in the ${evidence.trackTitle || "curriculum"} track.`, + }; + case "internship_completion": + return { + title: `Fellowship Graduate — ${evidence.programTitle || "Engineering Fellowship"}`, + description: `Successfully completed a rigorous supervised engineering fellowship with verified merged code contributions, weekly work logs, and positive mentor evaluation.`, + }; + case "cohort_completion": + return { + title: `Cohort Graduate — ${evidence.cohortName || "Fellowship Cohort"}`, + description: `Completed full cohort fellowship timeline with peer code reviews, milestone deliverables, and verifiable public proof of work.`, + }; } } export function formatVerificationUrl(credentialId: string): string { return `${env.APP_URL}/verify/${credentialId}`; } + +/** + * Issues an Ed25519 cryptographically signed verifiable credential. + */ +export async function issueVerifiableCredential( + params: { + userId: string; + type: CredentialType; + evidence: CredentialEvidence; + }, + database?: any +) { + const db = database || (await getDb()); + const credentialId = generateCredentialId(params.type, params.userId); + const metadata = buildCredentialMetadata(params.type, params.evidence); + const now = new Date(); + const verificationUrl = formatVerificationUrl(credentialId); + + // Prepare claims payload for cryptographic signing + const claims = { + issuer: "TechNexusOrg", + credentialId, + recipientId: params.userId, + recipientGithub: params.evidence.githubUsername, + type: params.type, + title: metadata.title, + issuedAt: now.toISOString(), + verificationUrl, + evidence: params.evidence, + }; + + const { signature, signingKeyId, canonicalHash } = signCredentialPayload(claims); + + const [credential] = await db + .insert(schema.credentials) + .values({ + id: credentialId, + userId: params.userId, + type: params.type, + title: metadata.title, + description: metadata.description, + issuer: "TechNexusOrg", + issuedAt: now, + status: "active", + evidenceData: params.evidence, + verificationUrl, + signature, + signingKeyId, + canonicalHash, + }) + .returning(); + + return credential; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index f46a5fc..c03df2d 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -237,6 +237,9 @@ export const credentials = pgTable( "founding_1000", "maintainer", "mentor", + "learning_track_completion", + "internship_completion", + "cohort_completion", ], }).notNull(), title: text("title").notNull(), @@ -250,6 +253,9 @@ export const credentials = pgTable( .notNull(), evidenceData: jsonb("evidence_data").notNull(), // contains PR URL, repository, commit SHA, verification timestamp verificationUrl: text("verification_url").notNull(), + signature: text("signature"), + signingKeyId: text("signing_key_id"), + canonicalHash: text("canonical_hash"), revokedReason: text("revoked_reason"), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow()