Kod nümunələri
Bu səhifə Tamga-nın beş əsas istifadə ssenarisini kopyalayıb işə sala biləcəyiniz kodla göstərir.
Nə vaxt oxumalı: hər hansı bələdçini oxuduqdan sonra işlək kodu görmək istədiyinizdə və ya birbaşa koddan başlamağı üstün tutduğunuzda. Hər nümunə onun təfərrüatlarını izah edən bələdçiyə keçid verir.
Aşağıdakı kodlar tamga-network/examples/ qovluğundakı real fayllardır: hər test icrasında real paketlərlə (mümkün olan hallarda real yoxlayan tərəflə) başdan sona sınaqdan keçirilir. Buna görə səhifədəki kod paketlərlə uyğunsuz vəziyyətə düşə bilməz.
Quraşdırma
npm install @tamga-network/verifier @tamga-network/trust @tamga-network/issuerpnpm add @tamga-network/verifier @tamga-network/trust @tamga-network/issueryarn add @tamga-network/verifier @tamga-network/trust @tamga-network/issuerVersiya
Paketlər npm-də 0.7.0 sınaq versiyası ilə dərc olunur; sabit 1.0.0 versiyası hazır olduqda təqdim ediləcək. Sınaq versiyasında interfeys dəyişə bilər. Paketlərdən mənbə repozitoriyasından da istifadə etmək olar (npm run release:check dərcə hazır paketləri .publish/ qovluğunda yaradır).
| Nə etmək istəyirsiniz? | Paket | İdxal |
|---|---|---|
| Veb saytınıza Tamga ilə giriş | @tamga-network/verifier (+ səhifə dəsti /web) | import { createRpAssertion } from "@tamga-network/verifier" |
| Öz serverinizdə rəqəmsal sənədi yoxlamaq | @tamga-network/verifier, @tamga-network/trust | import { verifyPresentation } from "@tamga-network/verifier" |
| Qurum olaraq rəqəmsal sənəd vermək | @tamga-network/issuer | import { createIssuerClient } from "@tamga-network/issuer/client" |
| Qurumun səlahiyyətini sorğulamaq | @tamga-network/trust | import { fetchListTrustSource } from "@tamga-network/trust" |
1. Veb saytda "Tamga ilə daxil ol"
Təqdimatı saytınızın serveri açır: etibar siyahısındakı (trust list)Bir ölkənin kök sertifikatlarının, sənədi verən qurumlarının və qeydiyyatlı yoxlayan tərəflərinin imzalanmış siyahısı. Bu gün Tamga-da etibar bu siyahılara əsaslanır; ortaq jurnal (ledger) sonra gələcək. qeydinizin açarı ilə imzalanmış, qısamüddətli bəyanla. Səhifə yalnız QR kodu göstərir və vəziyyəti izləyir; təsdiqlənmiş dəyərlər serverinizə bir dəfə ötürülür (ADR-0017). Təfərrüat: GUIDE-0001.
/**
* Example 01 — "Sign up / Sign in with Tamga" on your website, using the hosted verifier.
*
* Your SERVER opens each presentation, proving who it is with a short-lived assertion signed by the key of
* your trust-list registration (ADR-0017). The page only shows the QR code and polls a status token; the
* values the person approved are handed to your server once.
*
* npm install @tamga-network/verifier
*/
import { createHmac } from "node:crypto";
import { createRpAssertion, type RpSigner } from "@tamga-network/verifier";
export interface SignInConfig {
/** The hosted verifier, e.g. "https://verify.tamga.network". */
verifier: string;
/** Your registration: pemRpSigner(KEY_PEM, CERT_PEM) — client_id is x509_hash of your access certificate (HAIP 1.0). */
rp: RpSigner;
/** Your own secret; the stored account key is a keyed hash of the site pseudonym. */
siteSecret: string;
fetch?: typeof fetch;
}
/** What your page needs to draw the QR code and poll the status. */
export interface StartedPresentation {
presentation_id: string;
qr_payload: string;
expires_at: string;
status_token: string;
}
export type SignInResult =
| { ok: true; accountKey: string; givenName?: string; familyName?: string }
| { ok: false; outcome: "PENDING" | "REJECTED" | "INDETERMINATE" | "ALREADY_USED" };
export function tamgaSignIn(cfg: SignInConfig) {
const f = cfg.fetch ?? fetch;
const auth = async () => ({ authorization: `Bearer ${await createRpAssertion(cfg.rp, cfg.verifier)}` });
/** POST /tamga/start → return this JSON to your page (TamgaVerifier.mount({ start })). */
async function start(policy: "site-signup" | "site-signin"): Promise<StartedPresentation> {
const r = await f(`${cfg.verifier}/presentations`, {
method: "POST",
headers: { ...(await auth()), "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ policy_id: policy }),
});
if (!r.ok) throw new Error(`verifier refused the request (${r.status})`);
return (await r.json()) as StartedPresentation;
}
/** POST /tamga/session → your page sends the presentation id after ACCEPTED; decide here, on the server. */
async function finish(presentationId: string): Promise<SignInResult> {
const id = encodeURIComponent(presentationId);
const result = (await (await f(`${cfg.verifier}/presentations/${id}`, { headers: await auth() })).json()) as {
outcome?: "ACCEPTED" | "REJECTED" | "INDETERMINATE";
};
if (result.outcome !== "ACCEPTED") return { ok: false, outcome: result.outcome ?? "PENDING" };
const r = await f(`${cfg.verifier}/presentations/${id}/claims`, { headers: await auth() });
if (r.status === 410) return { ok: false, outcome: "ALREADY_USED" }; // values are handed out once
const { claims } = (await r.json()) as { claims: Record<string, unknown> };
// ADR-0031: the account key is the wallet's pseudonym for YOUR site (another site sees a different one); the verifier
// has checked its signature, audience, nonce and the wallet instance attestation. No document value is sent.
if (typeof claims.pseudonym !== "string") return { ok: false, outcome: "REJECTED" };
const accountKey = createHmac("sha256", cfg.siteSecret).update(claims.pseudonym).digest("base64url");
return {
ok: true,
accountKey,
givenName: claims.given_name as string | undefined,
familyName: claims.family_name as string | undefined,
};
}
return { start, finish };
}<!-- Example 01 — the page. The kit draws the QR code (or "Open in your wallet" on a phone) and polls
a status token. It never sees the person's data and never decides: your server does (server.ts). -->
<script src="https://verify.tamga.network/tamga-verifier.js"></script>
<div id="tamga"></div>
<script>
const post = (url, body) =>
fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
TamgaVerifier.mount(document.getElementById("tamga"), {
verifier: "https://verify.tamga.network",
policy: "site-signup", // sign-in for an existing account: "site-signin"
start: () => post("/tamga/start", { policy: "site-signup" }).then((r) => r.json()),
onResult: (presentationId) =>
post("/tamga/session", { presentation_id: presentationId }).then(() => location.reload()),
onError: (e) => console.warn("Tamga:", e),
});
</script>2. Öz serverinizdə rəqəmsal sənədin yoxlanılması
Host edilən yoxlayan tərəf (verifier)Təqdim edilən rəqəmsal sənədi yoxlayan tərəf: imza, etibar siyahısındakı sənədi verən qurum, status və siyasət. Yoxlayan tərəf olaraq da adlandırılır. xidməti olmadan: etibar siyahılarını yoxlayır, ləğv siyahılarını (status list)Sıxılmış, imzalanmış siyahı, burada hər rəqəmsal sənədin etibarlı, dayandırılmış və ya ləğv edilmiş olduğunu bildirən bir mövqeyi var. əvvəlcədən yükləyir, imzalı sorğu yaradır, şifrələnmiş cavabı deşifrə edir və yoxlama zəncirini (T0 + A–E) işə salır. Nəticə üç dəyərlidir: ACCEPTED, REJECTED, INDETERMINATE ("hazırda yoxlamaq mümkün olmadı" — bu, rəqəmsal sənədin etibarsız olması demək deyil). Təfərrüat: GUIDE-0002.
/**
* Example 02 — verify documents on your own server (hiring, campus, age checks), without the hosted verifier.
*
* 1. Load the signed trust lists (who may issue what) and pre-fetch the revocation lists.
* 2. Create a signed OpenID4VP request with your registration certificate → show it as a QR code. Tamga Wallet refuses a
* request that carries no registration certificate (from packages 0.7.0), so fetch yours from the trust lists.
* 3. The wallet posts an encrypted answer → decrypt it → run the canonical checks (T0 + A–E).
* Three outcomes: ACCEPTED, REJECTED (with the failing step) or INDETERMINATE ("could not check right now").
*
* npm install @tamga-network/verifier @tamga-network/trust @tamga-network/core
*/
import { pemToDer } from "@tamga-network/core";
import { fetchListTrustSource, verifyJws } from "@tamga-network/trust";
import {
createPresentationRequest,
dcqlFromPolicy,
decryptResponse,
PrefetchStatusCache,
verifyPresentation,
type Policy,
type PresentationRequest,
type RpSigner,
} from "@tamga-network/verifier";
/** A hiring policy: a bachelor's diploma, four fields, nothing else. */
export const DIPLOMA_POLICY: Policy = {
policy_id: "hiring-bachelor",
purpose: { "en-GB": "Confirm graduation for a job application" },
credentials: [
{
id: "diploma",
vct_values: ["urn:tamga:edu:DiplomaCredential:1"],
required_claims: ["is_graduate", "qualification_title", "eqf_level", "awarding_body_name"],
constraints: { is_graduate: true, eqf_level: { min: 6 } },
},
],
trust: { min_issuer_assurance: "I2", allowed_categories: ["EDUCATION"], require_recognition: true, state_code: "TR" },
freshness: { max_status_token_age_sec: 7200, max_trust_age_sec: 86400 },
};
export interface OwnVerifierConfig {
/** Where the trust lists are published, e.g. "https://trust.tamga.network". */
trustBase: string;
/** Root fingerprints, fixed in your configuration (published at tamga.network/trust-anchor). */
rootFingerprints: string[];
/** Your registration: pemRpSigner(KEY_PEM, CERT_PEM) — client_id is x509_hash of your access certificate (HAIP 1.0). */
signer: RpSigner;
/** Your public base URL; the wallet fetches /vp/req/:id and posts to /vp/response here. */
publicBase: string;
/**
* The registered use (`scope_id` of your trust-list record) this verifier asks for, e.g. "job-application-1". Its
* registration certificate (`<trustBase>/wrprc/…`) goes into every request (`verifier_info`). "none" only for wallets and
* tests that do not check registration certificates.
*/
registrationScope: string | "none";
fetch?: typeof fetch;
/** Revocation-list fetcher (defaults to fetch with a timeout). */
fetchStatus?: (url: string) => Promise<string | null>;
anchorMaxAgeMs?: number;
}
export async function createOwnVerifier(cfg: OwnVerifierConfig) {
const f = cfg.fetch ?? fetch;
const http = async (url: string) => {
const r = await f(url);
return { status: r.status, text: () => r.text() };
};
// 1) Trust lists, verified against your fixed root fingerprints; anchors included (revocation checks need them).
const { source: trust, store } = await fetchListTrustSource(cfg.trustBase, http, {
rootFingerprints: cfg.rootFingerprints,
verifyJws,
anchors: true,
anchorMaxAgeMs: cfg.anchorMaxAgeMs,
});
const rootCertsDer = [...store.root_cas.values()].flatMap((ca) => (ca.cert_pem ? [pemToDer(ca.cert_pem)] : []));
// Pre-fetch every anchored revocation list: no network call at verification time.
const statusCache = new PrefetchStatusCache(cfg.fetchStatus);
await statusCache.refresh([...store.status_anchors.values()].map((a) => a.list_uri));
// Open requests, in memory. Each expires with its request object (expiresAt) and the map is capped, so abandoned QR codes
// cannot grow it without limit. A multi-instance deployment keeps these in a shared store instead.
const MAX_PENDING = 10_000;
const pending = new Map<string, { req: PresentationRequest; policy: Policy }>();
const sweep = (now = Math.floor(Date.now() / 1000)) => {
for (const [id, e] of pending) if (e.req.expiresAt < now) pending.delete(id);
for (const id of pending.keys()) {
if (pending.size < MAX_PENDING) break;
pending.delete(id); // oldest first
}
};
const live = (id: string) => {
const e = pending.get(id);
if (e && e.req.expiresAt < Math.floor(Date.now() / 1000)) {
pending.delete(id);
return undefined;
}
return e;
};
// Your registration certificate: published with the trust lists, renewed by the registrar (at most 12 months); re-read hourly.
let registration: { jwt: string; at: number } | null = null;
async function registrationCert(): Promise<string | undefined> {
if (cfg.registrationScope === "none") return undefined;
if (registration && Date.now() - registration.at < 3600_000) return registration.jwt;
const index = JSON.parse(await (await http(`${cfg.trustBase}/wrprc/index.json`)).text()) as {
items: Array<{ kind: string; client_id?: string; scope_id?: string; path: string }>;
};
const item = index.items.find(
(i) => i.kind === "rp" && i.client_id === cfg.signer.clientId && i.scope_id === cfg.registrationScope,
);
if (!item)
throw new Error(
`no registration certificate for ${cfg.signer.clientId} / ${cfg.registrationScope}: wallets will refuse the request — check your registration (identity number in the access certificate, scope still valid)`,
);
registration = { jwt: (await (await http(`${cfg.trustBase}/${item.path}`)).text()).trim(), at: Date.now() };
return registration.jwt;
}
/** 2) Start: returns the QR payload. Serve `requestObject(id)` at GET /vp/req/:id. */
async function start(policy: Policy = DIPLOMA_POLICY) {
const req = await createPresentationRequest({
signer: cfg.signer,
dcql: dcqlFromPolicy(policy),
responseUri: `${cfg.publicBase}/vp/response`,
requestUriBase: `${cfg.publicBase}/vp/req`,
purpose: Object.values(policy.purpose)[0],
registrationCert: await registrationCert(),
});
sweep();
pending.set(req.presentationId, { req, policy });
return { presentationId: req.presentationId, qrPayload: req.qrPayload };
}
/** GET /vp/req/:id → body with content-type application/oauth-authz-req+jwt. */
const requestObject = (id: string) => live(id)?.req.requestJwt ?? null;
/** 3) POST /vp/response (form field `response`) → verify. Each request answers once. */
async function handleResponse(jwe: string) {
const kid = JSON.parse(Buffer.from(jwe.split(".")[0], "base64url").toString("utf8")).kid as string;
const entry = live(String(kid).replace(/^enc-/, ""));
if (!entry) throw new Error("unknown, expired or already answered request");
pending.delete(entry.req.presentationId);
const answer = await decryptResponse(jwe, entry.req.encPrivateKey);
if (answer.state !== entry.req.state) throw new Error("state mismatch");
const pc = entry.policy.credentials[0];
return verifyPresentation({
presentation: answer.vp_token[pc.id][0],
aud: cfg.signer.clientId,
nonce: entry.req.nonce,
policy: entry.policy,
policyCredentialId: pc.id,
trust,
statusCache,
rootCertsDer,
rp: trust.relyingParty(cfg.signer.clientId),
}); // → { result: { outcome, failed_step, … }, claims } — store names, never values, in your logs
}
/** Refresh revocation lists on a timer (e.g. every few minutes). */
const refreshStatus = () => statusCache.refresh([...store.status_anchors.values()].map((a) => a.list_uri));
return { start, requestObject, handleResponse, refreshStatus, trust, statusCache };
}3. Qurum olaraq rəqəmsal sənəd vermək (host edilən xidmət)
Tamga operatorunun qurumunuza verdiyi əhatə dairəsi müəyyən edilmiş API açarı ilə (ADR-0016). Təklif keçidi QR kod kimi göstərilir; PIN ayrıca kanalla verilir, heç vaxt keçidin daxilində ötürülmür. Təfərrüat: GUIDE-0003.
/**
* Example 03 — issue documents from your own system, using Tamga's hosted issuing service.
*
* Your institution gets a scoped API key from the Tamga operator (ADR-0016). Your server calls the service;
* the person scans the returned link as a QR code and types the PIN you show them separately.
*
* npm install @tamga-network/issuer
*/
import { createIssuerClient, IssuerClientError } from "@tamga-network/issuer/client";
export function institution(cfg: { apiKey: string; slug: string; baseUrl?: string; fetch?: typeof fetch }) {
const tamga = createIssuerClient({
baseUrl: cfg.baseUrl ?? "https://issuer.tamga.network",
slug: cfg.slug,
apiKey: cfg.apiKey, // tmg_<slug>_… — server side only
fetch: cfg.fetch,
});
return {
/** A university: offer a diploma to a graduate in your records (your own student number). */
async offerDiploma(studentNo: string) {
const offer = await tamga.createOffer({ subjectId: studentNo, vct: "urn:tamga:edu:DiplomaCredential:1" });
// offer.deepLink → show as a QR code or an "Add to wallet" link.
// offer.txCode → show on a DIFFERENT channel than the link (screen, SMS, e-mail): never inside the QR.
return offer;
},
/** A ticket seller: sell a ticket straight into the buyer's wallet (the ticket carries no personal data). */
async sellTicket(eventId: string, ticketClass: string) {
const sale = await tamga.sellTicket({ eventId, ticketClass });
return { ticketId: sale.ticketId, qr: sale.offer.deepLink, pin: sale.offer.txCode };
},
/** Revoke (final) or suspend (reversible); verifiers see it at the next fixed publication. */
async cancel(credentialId: string, reason: string) {
try {
return await tamga.revoke(credentialId, reason);
} catch (e) {
if (e instanceof IssuerClientError && e.status === 404) return null; // unknown credential
throw e;
}
},
};
}4. Qurumun səlahiyyətini sorğulamaq
Sənədi verən (issuer)Rəqəmsal sənədləri imzalayan və verən qurum: universitet, peşə təşkilatı, dövlət qurumu və ya şirkət. qurum etibar siyahısında qeydiyyatdadırmı, aktivdirmi, bu sənəd növünü verməyə səlahiyyəti varmı? Fərdi məlumatlar daxil deyil. Təfərrüat: GUIDE-0006.
/**
* Example 04 — ask the trust lists: is this institution registered, active, and authorized for this document?
* Useful for a directory page, an onboarding screen or a back-office check. No personal data involved.
*
* npm install @tamga-network/trust
*/
import { fetchListTrustSource, verifyJws } from "@tamga-network/trust";
export async function institutionStatus(
trustBase: string,
rootFingerprints: string[],
slug: string,
vct: string,
opts: { fetch?: typeof fetch; anchorMaxAgeMs?: number } = {},
) {
const f = opts.fetch ?? fetch;
const { source } = await fetchListTrustSource(
trustBase,
async (url) => {
const r = await f(url);
return { status: r.status, text: () => r.text() };
},
{ rootFingerprints, verifyJws },
);
const issuer = source.issuers().find((i) => i.slug === slug);
if (!issuer) return { registered: false as const };
const now = Date.now();
const authorized = issuer.schema_authorizations.some(
(a) =>
a.vct === vct &&
a.allowed &&
new Date(a.valid_from).getTime() <= now &&
(!a.valid_until || now < new Date(a.valid_until).getTime()),
);
return {
registered: true as const,
name: issuer.legal_name,
category: issuer.category,
status: issuer.status, // ACTIVE | SUSPENDED | REVOKED | RETIRED
authorized,
};
}5. Qurumun sorğu nöqtəsi (səlahiyyətli mənbə)
Host edilən sənəd vermə xidməti sənəd məlumatını vermə anında qurumun sistemindən oxuyur və saxlamır (ADR-0020). Bu nümunə tələbə informasiya sisteminizin (və ya onun müntəzəm ixracının) önündə yerləşdirəcəyiniz sorğu nöqtəsinin tam kodudur: imzalı sorğunu etibar siyahısı əsasında yoxlayır (razılaşdırılmış yoxlayan tərəf qeydi, aud, müddət, birdəfəlik istifadə) və sənəd növünün tələb etdiyi sahələri qaytarır; T.C. şəxsiyyət nömrəsini heç vaxt qaytarmır. Müqavilə: Qurumun sorğu nöqtəsi.
/**
* Example 05 — your institution's lookup endpoint for Tamga's hosted issuing service (contract:
* docs.tamga.network/api/institution-source).
*
* The authentic source stays with you: Tamga keeps no student records. When a student takes a credential, the hosted
* service asks this endpoint for exactly one record, signs the credential and forgets the answer. Put this handler in
* front of your student information system, or in front of a regularly refreshed export of it.
*
* What it checks on every request (all of them, or the request is refused with 401):
* 1. the body is a compact JWS with `typ: tamga-source-request+jwt` and ES256;
* 2. the signing certificate (`x5c[0]`) is the access certificate of a relying party in Tamga's signed trust list,
* the record is ACTIVE, and it is one of the records you agreed with the operator (`callers`, by `dns_name`);
* 3. `iss` equals that certificate's client identifier (`x509_hash:…`), `aud` equals your endpoint URL,
* the request is at most 60 seconds old and `jti` is used once.
* It then answers with only the fields the requested document type needs, never the national ID number.
*
* npm install @tamga-network/trust @tamga-network/core
*/
import { X509Certificate, createPublicKey, verify } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { x509HashClientId } from "@tamga-network/core";
import type { TrustSource } from "@tamga-network/trust";
const STUDENT = "urn:tamga:edu:StudentCredential:1";
const DIPLOMA = "urn:tamga:edu:DiplomaCredential:1";
const MAX_AGE_SEC = 60;
const SKEW_SEC = 30;
/** The education profile of the contract (`SubjectRecord`). `id` is your opaque, permanent person reference. */
export interface SubjectRecord {
id: string;
student_no: string;
family_name: string;
given_name: string;
birth_date: string;
/** Used only for matching; never returned to Tamga and never written into a credential. */
personal_administrative_number?: string;
programme_title_tr: string;
programme_title_en?: string;
faculty_tr?: string;
isced_f_code?: string | null;
study_level: number;
enrollment_year: number;
enrollment_date?: string;
programme_credit_points?: number;
student_status: "ACTIVE" | "ON_LEAVE";
expected_graduation_year?: number;
graduate?: {
qualification_tr: string;
qualification_en?: string;
eqf_level: number;
awarding_date: string;
grade?: string;
thesis_title_tr?: string;
mode_of_study?: "FULL_TIME" | "PART_TIME" | "DISTANCE" | "BLENDED";
};
}
/** Where the records come from: your database, your SIS API or an export. Return null when there is no such person. */
export interface RecordStore {
byMatch(nationalId: string, birthDate: string): Promise<SubjectRecord | null>;
byId(id: string): Promise<SubjectRecord | null>;
}
/** The simplest store: an array (for example a nightly JSON export). Replace with a query against your system. */
export function memoryStore(records: SubjectRecord[]): RecordStore {
return {
byMatch: async (pan, birth) =>
records.find((r) => r.personal_administrative_number === pan && r.birth_date === birth) ?? null,
byId: async (id) => records.find((r) => r.id === id) ?? null,
};
}
export interface SourceOptions {
/** Your endpoint's public URL, exactly as registered with the operator (it is the `aud` of every request). */
url: string;
/** Tamga's trust lists, refreshed regularly (see example 04: `fetchListTrustSource`). */
trust: () => TrustSource;
/** `dns_name` of the relying-party record(s) the hosted service uses for your institution (agreed at registration). */
callers: string[];
store: RecordStore;
/** Document types you issue through Tamga. */
vcts?: string[];
now?: () => number;
}
type Answer = { status: number; body: unknown };
/** Pure core (easy to test and to wrap in any framework): request body in, status + JSON body out. */
export function institutionSource(opts: SourceOptions) {
const vcts = new Set(opts.vcts ?? [STUDENT, DIPLOMA]);
const now = opts.now ?? (() => Math.floor(Date.now() / 1000));
const seen = new Map<string, number>(); // jti → expiry (memory; one process — use a shared store with several)
const deny = (why: string): Answer => ({ status: 401, body: { error: "unauthorized", reason: why } });
return async function answer(body: unknown): Promise<Answer> {
const request = (body as { request?: unknown } | null)?.request;
if (typeof request !== "string" || request.split(".").length !== 3)
return { status: 400, body: { error: "invalid_request" } };
const [h, p, s] = request.split(".");
let header: { alg?: string; typ?: string; x5c?: string[] };
let claims: Record<string, unknown>;
try {
header = JSON.parse(Buffer.from(h, "base64url").toString("utf8"));
claims = JSON.parse(Buffer.from(p, "base64url").toString("utf8"));
} catch {
return { status: 400, body: { error: "invalid_request" } };
}
if (header.alg !== "ES256" || header.typ !== "tamga-source-request+jwt" || !header.x5c?.length)
return deny("header");
// 2. the caller: an ACTIVE relying party of Tamga's signed list, and one you agreed to
const leaf = new Uint8Array(Buffer.from(header.x5c[0], "base64"));
const clientId = x509HashClientId(leaf);
const rp = opts.trust().relyingParty(clientId);
if (!rp || rp.status !== "ACTIVE" || !opts.callers.includes(rp.dns_name)) return deny("caller");
const cert = new X509Certificate(leaf);
const signed = Buffer.from(`${h}.${p}`);
const sig = Buffer.from(s, "base64url");
const key = createPublicKey(cert.publicKey.export({ type: "spki", format: "pem" }));
if (!verify("sha256", signed, { key, dsaEncoding: "ieee-p1363" }, sig)) return deny("signature");
// 3. bound to you, fresh and used once
const t = now();
const iat = Number(claims.iat),
exp = Number(claims.exp);
if (claims.iss !== clientId) return deny("iss");
if (claims.aud !== opts.url) return deny("aud");
if (!(iat <= t + SKEW_SEC && exp > t - SKEW_SEC && exp - iat <= MAX_AGE_SEC)) return deny("time");
const jti = String(claims.jti ?? "");
for (const [k, until] of seen) if (until < t) seen.delete(k);
if (!jti || seen.has(jti)) return deny("replay");
seen.set(jti, exp + SKEW_SEC);
const vct = String(claims.vct ?? "");
if (!vcts.has(vct)) return { status: 404, body: { subject: null } };
let rec: SubjectRecord | null = null;
if (claims.op === "lookup") {
const m = (claims.match ?? {}) as { personal_administrative_number?: string; birth_date?: string };
if (!m.personal_administrative_number || !m.birth_date)
return { status: 400, body: { error: "invalid_request" } };
rec = await opts.store.byMatch(m.personal_administrative_number, m.birth_date);
} else if (claims.op === "fetch") {
if (typeof claims.subject_ref !== "string") return { status: 400, body: { error: "invalid_request" } };
rec = await opts.store.byId(claims.subject_ref);
} else return { status: 400, body: { error: "invalid_request" } };
// a diploma only for graduates (the contract: `graduate` is required for DiplomaCredential)
if (!rec || (vct === DIPLOMA && !rec.graduate)) return { status: 404, body: { subject: null } };
return { status: 200, body: { subject: minimal(rec, vct) } };
};
}
/** Only what the document type needs; the national ID number never leaves your system. */
function minimal(r: SubjectRecord, vct: string): Omit<SubjectRecord, "personal_administrative_number"> {
const { personal_administrative_number: _pan, graduate, ...rest } = r;
return vct === DIPLOMA ? { ...rest, graduate } : rest;
}
/** Node `http` handler around the core (POST only, JSON, small bodies). Serve it over TLS only. */
export function nodeHandler(opts: SourceOptions) {
const answer = institutionSource(opts);
return async (req: IncomingMessage, res: ServerResponse) => {
const send = (a: Answer) => {
res.writeHead(a.status, { "content-type": "application/json", "cache-control": "no-store" });
res.end(JSON.stringify(a.body));
};
if (req.method !== "POST") return send({ status: 405, body: { error: "method_not_allowed" } });
const chunks: Buffer[] = [];
let size = 0;
for await (const c of req) {
size += (c as Buffer).length;
if (size > 16 * 1024) return send({ status: 413, body: { error: "too_large" } });
chunks.push(c as Buffer);
}
let body: unknown = null;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return send({ status: 400, body: { error: "invalid_request" } });
}
// Never log the request body: it carries the matching keys (national ID number, date of birth).
send(await answer(body));
};
}Nümunələrin işə salınması
git clone https://github.com/tamga-network/tamga-network && cd tamga-network
npm install && npm run setup # development PKI + trust lists
npx vitest run examples # five examples, with the real packages