@synet/credential
Version:
VC Credentials - Simple, Robust, Unit-based Verifiable Credentials service
518 lines (441 loc) ⢠15.6 kB
text/typescript
/**
* @synet/credential - Unit-based W3C Verifiable Credential operations
*
* This unit provides W3C-compatible verifiable credential operations
* using the learning pattern to acquire crypto capabilities from
* other units (@synet/signer, @synet/keys, etc.)
*
* Key features:
* - Learning-based architecture (no tight coupling)
* - Progressive capability acquisition
* - W3C-compatible credential formats using existing types
* - Composable with crypto units
* - Uses existing issueVC/verifyVC functions internally
*
* Usage pattern:
* ```typescript
* const signer = new Signer();
* const credential = new Credential();
*
* // Learn crypto capabilities
* credential.learn([signer.teach()]);
*
* // Issue credential
* const vc = await credential.execute('issue', subject, type, issuer);
* ```
*
* @author Synet Team
*/
import {
Unit,
createUnitSchema,
type TeachingContract,
type UnitProps,
type UnitSchema
} from "@synet/unit";
import { createId, base64urlDecode, base64urlEncode } from "./utils";
import { Result, type VerificationResult } from "./result";
import type {
W3CVerifiableCredential,
BaseCredentialSubject,
CredentialIssueOptions,
CredentialVerifyOptions,
ProofType,
} from "./types-base";
const DEFAULT_CONTEXT = ["https://www.w3.org/2018/credentials/v1"];
// ==========================================
// CREDENTIAL IMPLEMENTATION
// ==========================================
export interface CredentialConfig {
metadata?: Record<string, unknown>;
}
export interface CredentialProps extends UnitProps {
created: Date;
metadata: Record<string, unknown>;
}
export class Credential extends Unit<CredentialProps> {
private constructor(props: CredentialProps) {
super(props);
}
static create(config?: CredentialConfig ): Credential {
const props: CredentialProps = {
dna: createUnitSchema({
id: "credential",
version: "1.0.0"
}),
created: new Date(),
metadata: config?.metadata || {}
};
return new Credential(props);
}
whoami(): string {
return `${this.props.dna.id}@${this.props.dna.version} - W3C Verifiable Credential operations`;
}
capabilities(): string[] {
return this._getAllCapabilities();
}
help(): void {
console.log(`
š Credential - W3C Verifiable Credential Operations
Native Methods:
- issueCredential(subject, type, issuerDid, options?): Create W3C Verifiable Credentials
- verifyCredential(credential, options?): Verify credential signatures and structure
- validateStructure(credential): Validate credential structure without crypto
Required Learning:
- Learn from @synet/keys Key unit to get: getPublicKey, sign, verify
Usage Pattern:
1. Create unit: const credential = new Credential()
2. Learn from key: credential.learn([key.teach()])
3. Issue VC: await credential.issueCredential(subject, type, issuer)
Example:
const signer = Signer.generate('ed25519');
const key = signer.createKey();
const credential = new Credential();
credential.learn([key.teach()]);
const result = await credential.issueCredential(
{ id: 'did:example:123', name: 'Alice' },
'UniversityDegree',
'did:example:university'
);
`);
}
teach(): TeachingContract {
return {
unitId: this.props.dna.id,
capabilities: {
issueCredential: (...args: unknown[]) =>
this.issueCredential(
args[0] as BaseCredentialSubject,
args[1] as string | string[],
args[2] as string,
args[3] as CredentialIssueOptions | undefined,
),
verifyCredential: (...args: unknown[]) =>
this.verifyCredential(
args[0] as W3CVerifiableCredential,
args[1] as CredentialVerifyOptions | undefined,
),
validateStructure: (...args: unknown[]) =>
this.validateStructure(args[0] as W3CVerifiableCredential),
// Deprecated methods for backwards compatibility
},
};
}
// ==========================================
// CORE CREDENTIAL OPERATIONS
// ==========================================
/**
* Issue a verifiable credential using Result pattern
* Requires learned getPublicKey and sign capabilities from Key
*/
async issueCredential<S extends BaseCredentialSubject>(
subject: S,
type: string | string[],
issuerDid: string,
options?: CredentialIssueOptions,
): Promise<Result<W3CVerifiableCredential<S>>> {
try {
// Check capabilities first
if (!this.can("getPublicKey") || !this.can("sign")) {
return Result.fail(
"Cannot issue credential: missing getPublicKey or sign capability. Learn from a crypto unit.",
);
}
// Create credential payload
const payload = this.createCredentialPayload(
subject,
type,
issuerDid,
options,
);
// Create proof based on format
const proofFormat = options?.proofFormat || "jwt";
let proof: ProofType;
if (proofFormat === "jwt") {
const proofResult = await this.createJWTProof(payload, issuerDid);
if (!proofResult.isSuccess) {
return Result.fail(
`Failed to create JWT proof: ${proofResult.errorMessage}`,
);
}
proof = proofResult.value;
} else {
// Unsupported proof format
return Result.fail(`Unsupported proof format: ${proofFormat}`);
}
const credential: W3CVerifiableCredential<S> = {
...payload,
proof: proof as ProofType,
};
return Result.success(credential);
} catch (error) {
return Result.fail(
`Failed to issue credential: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined,
);
}
}
/**
* Verify a verifiable credential using Result pattern
*
* Verifies a W3C verifiable credential using the learned capabilities.
*/
async verifyCredential(
credential: W3CVerifiableCredential,
options?: CredentialVerifyOptions,
): Promise<Result<VerificationResult>> {
try {
// Check capabilities first
if (!this.can("getPublicKey") || !this.can("verify")) {
return Result.fail("Missing getPublicKey or verify capability");
}
// Validate credential structure
if (!credential || !credential.proof) {
return Result.fail("Invalid credential: missing proof");
}
const { proof } = credential;
// Verify based on proof type
if (proof.type === "JwtProof2020" && proof.jwt) {
return await this.verifyJWTProof(credential, options);
}
return Result.fail(`Unsupported proof type: ${proof.type}`);
} catch (error) {
return Result.fail(
`Failed to verify credential: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined,
);
}
}
createCredentialPayload<S extends BaseCredentialSubject>(
subject: S,
type: string | string[],
issuerDid: string,
options?: CredentialIssueOptions,
): Omit<W3CVerifiableCredential<S>, "proof"> {
const typeArray = Array.isArray(type) ? type : [type];
const appType =
typeArray.find((t) => t !== "VerifiableCredential") || "Generic";
const context = options?.context
? [...DEFAULT_CONTEXT, ...options.context]
: DEFAULT_CONTEXT;
return {
"@context": context,
id: options?.vcId || this.generateCredentialId(appType),
type: ["VerifiableCredential", ...typeArray],
issuer: { id: issuerDid },
issuanceDate: options?.issuanceDate || new Date().toISOString(),
expirationDate: options?.expirationDate,
credentialSubject: subject,
meta: options?.meta,
};
}
generateCredentialId(type: string): string {
const cuid = createId();
return `urn:synet:${type}:${cuid}`;
}
/**
* Create a JWT proof for a credential
* Follows JWT standard (RFC 7515) - uses base64url encoding for signature
*/
async createJWTProof(
payload: Omit<W3CVerifiableCredential, "proof">,
issuerDid?: string,
): Promise<Result<ProofType>> {
try {
const jwtHeader = {
alg: "EdDSA",
typ: "JWT",
};
const jwtPayload = {
vc: payload,
jti: payload.id,
nbf: Math.floor(new Date(payload.issuanceDate).getTime() / 1000),
iss: issuerDid || payload.issuer.id,
...(payload.expirationDate && {
exp: Math.floor(new Date(payload.expirationDate).getTime() / 1000),
}),
};
const headerB64 = base64urlEncode(JSON.stringify(jwtHeader));
const payloadB64 = base64urlEncode(JSON.stringify(jwtPayload));
const signingInput = `${headerB64}.${payloadB64}`;
// Get signature from signer (returns base64)
const base64Signature = (await this.execute(
"sign",
signingInput,
)) as string;
if (!base64Signature) {
return Result.fail("Failed to sign JWT proof");
}
// The signature is already in base64url format from the Signer
const jwt = `${signingInput}.${base64Signature}`;
const proof: ProofType = {
type: "JwtProof2020",
jwt,
verificationMethod: issuerDid,
};
return Result.success(proof);
} catch (error) {
return Result.fail(
`Failed to create JWT proof: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined,
);
}
}
/**
* Verify a JWT proof using Result pattern
* Converts base64url signature back to base64 for verification
*/
async verifyJWTProof(
credential: W3CVerifiableCredential,
options?: CredentialVerifyOptions,
): Promise<Result<VerificationResult>> {
try {
const { proof } = credential;
if (!proof.jwt) {
return Result.fail("No JWT found in proof");
}
const jwtParts = proof.jwt.split(".");
if (jwtParts.length !== 3) {
return Result.fail("Invalid JWT format");
}
const [headerB64, payloadB64, base64urlSignature] = jwtParts;
const signingInput = `${headerB64}.${payloadB64}`;
try {
const jwtPayload = JSON.parse(base64urlDecode(payloadB64));
const issuerDid = jwtPayload.iss;
if (!this.can("getPublicKey") || !this.can("verify")) {
return Result.fail("Missing getPublicKey or verify capability");
}
// The signature is already in base64url format - our verify function handles both formats
// Verify signature
const isValidSignature = await this.execute(
"verify",
signingInput,
base64urlSignature,
);
if (!isValidSignature) {
return Result.fail("Invalid signature");
}
// Verify that the JWT payload matches the credential payload
const expectedVc = jwtPayload.vc;
if (!expectedVc) {
return Result.fail("Missing vc claim in JWT payload");
}
// Create a copy of the credential without the proof for comparison
const { proof: _proof, ...credentialWithoutProof } = credential;
// Veramo-style verification: JWT vc claim contains subset of credential fields
// We verify that all fields in JWT vc exist and match in the credential
for (const [key, value] of Object.entries(expectedVc)) {
const credentialValue = (
credentialWithoutProof as Record<string, unknown>
)[key];
if (JSON.stringify(credentialValue) !== JSON.stringify(value)) {
return Result.fail(
`JWT payload field "${key}" does not match credential data`,
);
}
}
// Additional verification: ensure required fields are present in credential
if (
!credentialWithoutProof.id ||
!credentialWithoutProof.issuer ||
!credentialWithoutProof.issuanceDate
) {
return Result.fail("Missing required credential fields");
}
// Check expiration if requested
if (options?.checkExpiration !== false && jwtPayload.exp) {
const now = Math.floor(Date.now() / 1000);
if (now > jwtPayload.exp) {
return Result.fail("Credential has expired");
}
}
// Check issuer if requested
if (options?.expectedIssuer && issuerDid !== options.expectedIssuer) {
return Result.fail(`Unexpected issuer: ${issuerDid}`);
}
const verificationResult: VerificationResult = {
verified: true,
issuer: issuerDid,
subject: credential.credentialSubject.holder.id,
issuanceDate: credential.issuanceDate,
expirationDate: credential.expirationDate,
};
return Result.success(verificationResult);
} catch (parseError) {
return Result.fail(
`Failed to parse JWT payload: ${parseError instanceof Error ? parseError.message : String(parseError)}`,
parseError instanceof Error ? parseError : undefined,
);
}
} catch (error) {
return Result.fail(
`Failed to verify JWT proof: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error : undefined,
);
}
}
/**
* Validate credential structure without cryptographic verification
*/
async validateStructure(credential: W3CVerifiableCredential): Promise<{
valid: boolean;
reason?: string;
}> {
// Check required fields
if (!credential || typeof credential !== "object") {
return { valid: false, reason: "Invalid credential structure" };
}
if (!credential["@context"] || !Array.isArray(credential["@context"])) {
return { valid: false, reason: "Missing or invalid @context" };
}
if (
!credential.type ||
!Array.isArray(credential.type) ||
!credential.type.includes("VerifiableCredential")
) {
return { valid: false, reason: "Missing or invalid type" };
}
if (!credential.issuer) {
return { valid: false, reason: "Missing issuer" };
}
if (!credential.issuanceDate) {
return { valid: false, reason: "Missing issuanceDate" };
}
if (!credential.credentialSubject || !credential.credentialSubject.holder) {
return { valid: false, reason: "Missing or invalid credentialSubject" };
}
if (!credential.proof) {
return { valid: false, reason: "Missing proof" };
}
return { valid: true };
}
/**
* Check if this unit can perform a capability
*/
can(capability: string): boolean {
return this.capabilities().includes(capability);
}
/**
* Learn capabilities from other units (especially @synet/keys Key)
*/
learn(contracts: TeachingContract[]): void {
for (const contract of contracts) {
for (const [cap, impl] of Object.entries(contract.capabilities)) {
// Learn key capabilities that we need for credential operations
if (cap === "getPublicKey" || cap === "sign" || cap === "verify") {
this._addCapability(cap, impl);
/* console.debug(
`Credential learned: ${cap} from ${contract.unitId}`,
); */
}
}
}
// Call parent learn for any other capabilities
super.learn(contracts);
}
}
// ==========================================
// EXPORTS
// ==========================================
export default Credential;