src/registrar/registration-certificate.service.ts
Handles registration certificate lifecycle:
Properties |
|
Methods |
|
constructor(configRepository: Repository<RegistrarConfigEntity>, authService: RegistrarAuthService)
|
|||||||||
|
Parameters :
|
| Async addRegistrationCertificate | |||||||||||||||
addRegistrationCertificate(req: literal type, dcqlQuery: any, requestId: string, tenantId: string)
|
|||||||||||||||
|
Resolve a registration certificate and return only the JWT string.
Parameters :
Returns :
Promise<string>
|
| Private canonicalJson | ||||||
canonicalJson(value: any)
|
||||||
|
Recursively canonicalize a JSON value: object keys sorted, arrays preserved in their original order (DCQL arrays are order-significant).
Parameters :
Returns :
string
|
| Public computeAuthorizedCredentialsFingerprint | ||||||
computeAuthorizedCredentialsFingerprint(credentials: unknown)
|
||||||
|
Compute a canonical fingerprint over a registration certificate's
authorized
Parameters :
Returns :
string
|
| Public computeDcqlFingerprint | ||||||
computeDcqlFingerprint(dcqlQuery: any)
|
||||||
|
Compute a canonical fingerprint over a
Parameters :
Returns :
string
|
| Public computeSpecFingerprint | ||||||
computeSpecFingerprint(spec: unknown)
|
||||||
|
Compute a stable fingerprint over a registration-certificate spec
(
Parameters :
Returns :
string
|
| Private dcqlCredentialFingerprint | ||||||
dcqlCredentialFingerprint(cred: any)
|
||||||
|
Compute a stable canonical fingerprint of a single credential entry. Normalizes to registrar CredentialDef shape (
Parameters :
Returns :
string
|
| Private hash | ||||||
hash(input: string)
|
||||||
|
Parameters :
Returns :
string
|
| Async resolveRegistrationCertificate | |||||||||||||||
resolveRegistrationCertificate(req: literal type, dcqlQuery: any, requestId: string, tenantId: string)
|
|||||||||||||||
|
Resolve a registration certificate from a spec ( This is the canonical entry point used both at presentation-config save-time (eager caching) and at VP-request time (cache miss / refresh).
Parameters :
Returns :
Promise<literal type>
|
| Private validateRegistrationCertificate | ||||||||||||||||||
validateRegistrationCertificate(jwt: string, dcqlQuery: any, tenantId: string, requestId: string, source: "jwt" | "id" | "body")
|
||||||||||||||||||
|
Validate a registration certificate JWT against the effective DCQL query. Performs:
Fails closed by throwing
Parameters :
Returns :
Record<string, any>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(RegistrationCertificateService.name)
|
import { createHash } from "node:crypto";
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { decodeJwt } from "jose";
import { Repository } from "typeorm";
import { RegistrarConfigEntity } from "./entities/registrar-config.entity";
import {
type RegistrationCertificateCreation,
registrationCertificateControllerAll,
registrationCertificateControllerRegister,
} from "./generated";
import { RegistrarAuthService } from "./registrar-auth.service";
/**
* Handles registration certificate lifecycle:
* - Resolving (import / lookup / create) registration certificates
* - JWT temporal validation and DCQL authorization checks
* - Stable fingerprint computation used for caching in the VP flow
*/
@Injectable()
export class RegistrationCertificateService {
private readonly logger = new Logger(RegistrationCertificateService.name);
constructor(
@InjectRepository(RegistrarConfigEntity)
private readonly configRepository: Repository<RegistrarConfigEntity>,
private readonly authService: RegistrarAuthService,
) {}
/**
* Resolve a registration certificate and return only the JWT string.
*/
async addRegistrationCertificate(
req: {
id?: string;
body?: Partial<RegistrationCertificateCreation>;
jwt?: string;
},
dcqlQuery: any,
requestId: string,
tenantId: string,
): Promise<string> {
const resolved = await this.resolveRegistrationCertificate(
req,
dcqlQuery,
requestId,
tenantId,
);
return resolved.jwt;
}
/**
* Resolve a registration certificate from a spec (`jwt` import / `id` lookup
* / `body` creation), validate it against the effective DCQL query and
* return both the JWT and its decoded payload.
*
* This is the canonical entry point used both at presentation-config
* save-time (eager caching) and at VP-request time (cache miss / refresh).
*/
async resolveRegistrationCertificate(
req: {
id?: string;
body?: Partial<RegistrationCertificateCreation>;
jwt?: string;
},
dcqlQuery: any,
requestId: string,
tenantId: string,
): Promise<{
jwt: string;
payload: Record<string, any>;
source: "imported" | "registrar";
}> {
if (req.jwt) {
const payload = this.validateRegistrationCertificate(
req.jwt,
dcqlQuery,
tenantId,
requestId,
"jwt",
);
return { jwt: req.jwt, payload, source: "imported" };
}
if (!req.id && !req.body) {
throw new BadRequestException(
"registrationCert must provide either jwt (import existing), id (reuse existing), or body (create new via registrar)",
);
}
const config = await this.configRepository.findOneBy({ tenantId });
if (!config) {
throw new NotFoundException(
`No registrar configuration found for tenant ${tenantId}`,
);
}
const client = await this.authService.getClient(tenantId);
const relyingPartyId =
await this.authService.getRelyingPartyId(tenantId);
if (req.id) {
const existingCerts = await registrationCertificateControllerAll({
client,
query: { rp: relyingPartyId },
});
if (existingCerts.error) {
this.logger.error(
{ error: existingCerts.error },
`[${tenantId}] Failed to fetch existing registration certificates`,
);
throw new BadRequestException(
"Failed to query registration certificates",
);
}
const validCerts = existingCerts.data?.filter(
(cert) => cert.revoked == null && cert.id === req.id,
);
if (validCerts && validCerts.length > 0) {
const payload = this.validateRegistrationCertificate(
validCerts[0].jwt,
dcqlQuery,
tenantId,
requestId,
"id",
);
return { jwt: validCerts[0].jwt, payload, source: "registrar" };
}
if (!req.body) {
throw new BadRequestException(
`No active registration certificate found for id '${req.id}'. Provide registrationCert.jwt or registrationCert.body to proceed.`,
);
}
}
const mergedBody: Partial<RegistrationCertificateCreation> = {
...(config.registrationCertificateDefaults ?? {}),
...(req.body ?? {}),
};
if (!Array.isArray(mergedBody.credentials)) {
const dcqlCredentials = Array.isArray(dcqlQuery?.credentials)
? dcqlQuery.credentials
: [];
mergedBody.credentials = dcqlCredentials.map((credential: any) => ({
format: credential?.format,
claims: Array.isArray(credential?.claims)
? credential.claims
: undefined,
meta:
credential?.meta && typeof credential.meta === "object"
? credential.meta
: {},
})) as any;
}
if (!mergedBody.privacy_policy || !mergedBody.support_uri) {
throw new BadRequestException(
"registrationCert.body must include privacy_policy and support_uri (directly or via registrar registrationCertificateDefaults)",
);
}
if (
!Array.isArray(mergedBody.purpose) ||
mergedBody.purpose.length === 0
) {
throw new BadRequestException(
"registrationCert.body.purpose must be provided in the presentation config",
);
}
if (
!Array.isArray(mergedBody.credentials) ||
mergedBody.credentials.length === 0
) {
throw new BadRequestException(
"registrationCert.body.credentials could not be derived from dcql_query.credentials",
);
}
const bodyWithRpId: RegistrationCertificateCreation = {
...mergedBody,
rpId: relyingPartyId,
} as RegistrationCertificateCreation;
const res = await registrationCertificateControllerRegister({
client,
body: bodyWithRpId,
});
if (res.error) {
const statusCode = Number(
(res.error as any)?.statusCode ?? (res.error as any)?.status,
);
const upstreamMessage =
(res.error as any)?.message ||
(res.error as any)?.error ||
"Unknown registrar error";
this.logger.error(
{
error: res.error,
statusCode: Number.isFinite(statusCode)
? statusCode
: undefined,
requestBody: bodyWithRpId,
},
`[${tenantId}] Failed to create registration certificate`,
);
if (
Number.isFinite(statusCode) &&
statusCode >= 400 &&
statusCode < 500
) {
throw new BadRequestException(
`Failed to create registration certificate: ${upstreamMessage}`,
);
}
throw new InternalServerErrorException(
`Registrar temporarily unavailable while creating registration certificate${Number.isFinite(statusCode) ? ` (upstream status ${statusCode})` : ""}`,
);
}
const newJwt = res.data!.jwt;
const payload = this.validateRegistrationCertificate(
newJwt,
dcqlQuery,
tenantId,
requestId,
"body",
);
return { jwt: newJwt, payload, source: "registrar" };
}
// -------------------------------------------------------------------------
// Fingerprint helpers (public – consumed by PresentationsService)
// -------------------------------------------------------------------------
/**
* Compute a canonical fingerprint over a `dcql_query.credentials` array.
*/
public computeDcqlFingerprint(dcqlQuery: any): string {
const credentials = Array.isArray(dcqlQuery?.credentials)
? dcqlQuery.credentials
: [];
const fps = credentials
.map((c: any) => this.dcqlCredentialFingerprint(c))
.sort();
return this.hash(fps.join("|"));
}
/**
* Compute a canonical fingerprint over a registration certificate's
* authorized `credentials` claim.
*/
public computeAuthorizedCredentialsFingerprint(
credentials: unknown,
): string {
const arr = Array.isArray(credentials) ? credentials : [];
const fps = arr
.map((c: any) => this.dcqlCredentialFingerprint(c))
.sort();
return this.hash(fps.join("|"));
}
/**
* Compute a stable fingerprint over a registration-certificate spec
* (`{ jwt?, id?, body? }`).
*/
public computeSpecFingerprint(spec: unknown): string {
return this.hash(this.canonicalJson(spec ?? null));
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Validate a registration certificate JWT against the effective DCQL query.
*
* Performs:
* 1. JWT decoding (structural validation).
* 2. Temporal validity check (`exp`, `nbf`) with a small clock skew tolerance.
* 3. DCQL fingerprint comparison: every credential being requested in
* `dcqlQuery.credentials` MUST be present in the certificate's authorized
* `credentials` claim. Prevents overasking with a cert issued for a
* different/narrower set of credentials.
*
* Fails closed by throwing `BadRequestException` on any mismatch.
*/
private validateRegistrationCertificate(
jwt: string,
dcqlQuery: any,
tenantId: string,
requestId: string,
source: "jwt" | "id" | "body",
): Record<string, any> {
let payload: Record<string, any>;
try {
payload = decodeJwt(jwt) as Record<string, any>;
} catch (err) {
this.logger.error(
{ err, requestId, source },
`[${tenantId}] Registration certificate is not a valid JWT`,
);
throw new BadRequestException(
"Registration certificate is not a valid JWT",
);
}
const now = Math.floor(Date.now() / 1000);
const skew = 60;
if (typeof payload.exp === "number" && payload.exp + skew < now) {
this.logger.warn(
{ requestId, source, exp: payload.exp, now },
`[${tenantId}] Registration certificate is expired`,
);
throw new BadRequestException(
"Registration certificate is expired",
);
}
if (typeof payload.nbf === "number" && payload.nbf - skew > now) {
this.logger.warn(
{ requestId, source, nbf: payload.nbf, now },
`[${tenantId}] Registration certificate is not yet valid`,
);
throw new BadRequestException(
"Registration certificate is not yet valid",
);
}
const authorizedCredentials = Array.isArray(payload.credentials)
? payload.credentials
: null;
if (!authorizedCredentials || authorizedCredentials.length === 0) {
this.logger.warn(
{ requestId, source },
`[${tenantId}] Registration certificate has no authorized credentials claim`,
);
throw new BadRequestException(
"Registration certificate has no authorized credentials",
);
}
const requestedCredentials = Array.isArray(dcqlQuery?.credentials)
? dcqlQuery.credentials
: [];
if (requestedCredentials.length === 0) {
return payload;
}
const authorizedFingerprints = new Set(
authorizedCredentials.map((c: any) =>
this.dcqlCredentialFingerprint(c),
),
);
const unauthorized: any[] = [];
for (const cred of requestedCredentials) {
const fp = this.dcqlCredentialFingerprint(cred);
if (!authorizedFingerprints.has(fp)) {
unauthorized.push(cred);
}
}
if (unauthorized.length > 0) {
this.logger.error(
{
requestId,
source,
unauthorizedIds: unauthorized.map((c) => c?.id),
},
`[${tenantId}] Registration certificate does not authorize the requested DCQL credentials (overasking prevented)`,
);
throw new BadRequestException(
"Registration certificate does not authorize the requested DCQL credentials",
);
}
return payload;
}
/**
* Compute a stable canonical fingerprint of a single credential entry.
*
* Normalizes to registrar CredentialDef shape (`format`, `claims`, `meta`)
* before hashing, because DCQL credentials may include transport/query fields
* that are not present in registrar certificates.
*/
private dcqlCredentialFingerprint(cred: any): string {
if (!cred || typeof cred !== "object") {
return JSON.stringify(cred ?? null);
}
const normalizedClaims = Array.isArray(
(cred as Record<string, any>).claims,
)
? (cred as Record<string, any>).claims
: Array.isArray((cred as Record<string, any>).claim)
? (cred as Record<string, any>).claim
: undefined;
const normalized = {
format: (cred as Record<string, any>).format,
claims: normalizedClaims,
meta:
(cred as Record<string, any>).meta &&
typeof (cred as Record<string, any>).meta === "object"
? (cred as Record<string, any>).meta
: {},
};
return this.canonicalJson(normalized);
}
/**
* Recursively canonicalize a JSON value: object keys sorted, arrays
* preserved in their original order (DCQL arrays are order-significant).
*/
private canonicalJson(value: any): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((v) => this.canonicalJson(v)).join(",")}]`;
}
const keys = Object.keys(value).sort();
return `{${keys
.map((k) => `${JSON.stringify(k)}:${this.canonicalJson(value[k])}`)
.join(",")}}`;
}
private hash(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
}