import express from 'express';
import { Verifier, type PersonaSession } from '@persona-claims/verifier';
import { PersonaResponseSchema, dns } from '@persona-claims/core';
// DNS: _claims.store.example TXT "service:<VERIFIER_PUBLIC_KEY>"
const verifier = new Verifier({
publicKey: process.env.VERIFIER_PUBLIC_KEY!,
signingKey: loadVerifierKeyPair(),
receiverUrl: 'https://store.example/api/persona/respond',
resolver: new dns.NodeResolver(),
dnsPolicy: 'strict'
});
const pending = new Map<string, PersonaSession>();
const app = express();
app.use(express.json());
app.post('/api/persona/request', async (_req, res) => {
const session = await verifier.request([
{ typ: 'name.first', reason: 'We need your given name on the ticket', required: true },
{ typ: 'persona.is18', reason: 'Age-restricted venue', required: true }
]);
pending.set(session.request.id, session);
res.json({ request: session.request });
});
app.post('/api/persona/respond', async (req, res) => {
const response = PersonaResponseSchema.parse(req.body);
const session = pending.get(response.id);
if (!session) return res.status(400).json({ error: 'неизвестный запрос' });
pending.delete(response.id);
const status = await session.verify(response);
if (!status.ok) {
return res.status(400).json({ error: 'ответ не прошёл проверку' });
}
res.json({ ok: true });
});Интеграция
Пакеты @persona-claims/verifier и @persona-claims/issuer — в ваш бэкенд. Либо вход через OAuth 2.0 / OpenID Connect на Persona Cloud — без своего
DNS.
DNS
Публичные ключи лежат в TXT-записи _claims.<домен>, значение — kid:ключ.
| Роль | Публикует | Кто читает | Зачем |
|---|---|---|---|
| Приёмник | _claims.store.example | Бумажник | Проверка подписи запроса |
| Источник | _claims.university.example | Приёмник | Проверка подписи утверждений (поле iss) |
_claims.university.example. 300 IN TXT "issuer:Ed25519PublicKeyBase64url..."
TTL — 60–300 секунд. При ротации добавляйте новую TXT, старую не удаляйте, пока живы подписанные ею данные. В продакшене включайте DNSSEC.
Проверка
Verifier
npm install @persona-claims/verifier. Сгенерируйте пару ключей сервиса.
DNS
Опубликуйте TXT _claims.<домен> — домен из receiver URL, например _claims.store.example.
Запрос
Сформируйте Persona request и передайте в бумажник: QR, ссылка или локально.
Проверка
Примите ответ, сверьте подписи источников по DNS и владение ключами.
Пример
Выпуск
Организация подписывает факты и отдаёт их в бумажник. Утверждение содержит sub — публичный ключ бумажника.
Persona Pass (pass.ticket) — схема layout / fields / images .
- 1
Установите issuer
id — домен организации; он же попадёт в поле iss утверждений.
npm install @persona-claims/issuer
const university = await Issuer.open({ id: 'university.example', publicUrl: 'https://university.example', stateFile: './issuer-state.json' }); - 2
Опубликуйте DNS
TXT с публичным ключом issuer. Имя — _claims.<id> из Issuer.open().
_claims.university.example. 300 IN TXT "issuer:Ed25519PublicKey…"
- 3
Возьмите sub из бумажника
Публичный ключ бумажника — через Persona request / QR. Передайте в том же запросе на выпуск или сохраните заранее.
const sub = req.body.sub; // публичный ключ бумажника
- 4
Выпустите утверждение
Утверждение привязано к ключу бумажника. Укажите тип, данные и срок.
const claim = await university.issue({ sub, typ: 'edu.student', dat: { faculty: 'MIEM', year: 2 }, exp: '2026-06-30T00:00:00.000Z' }); - 5
Отдайте в бумажник
Готовый claim — пользователю. При смене ключа — revoke / update.
res.json({ claim });await university.revoke(uid);
Полный пример
import express from 'express';
import { Issuer } from '@persona-claims/issuer';
import { ClaimSchema, type RotationRequest } from '@persona-claims/core';
const university = await Issuer.open({
id: 'university.example',
publicUrl: 'https://university.example',
stateFile: './issuer-state.json'
});
for (const txt of university.dnsRecords) {
console.log(`_claims.${university.id} IN TXT "${txt}"`);
}
const app = express();
app.use(express.json());
// sub приходит от бумажника (Persona request / QR) — прямо в запросе на выпуск
// или из БД, если ключ привязывали раньше.
app.post('/api/student-status', async (req, res) => {
const sub = req.body.sub ?? await db.getWalletKey(req.body.studentId);
if (!sub) return res.status(400).json({ error: 'нужен ключ бумажника (sub)' });
const claim = await university.issue({
sub,
typ: 'edu.student',
dat: { faculty: 'MIEM', year: 2 },
exp: '2026-06-30T00:00:00.000Z'
});
res.json({ claim });
});
app.post('/api/claims/:uid/revoke', async (req, res) => {
const wasActive = await university.revoke(req.params.uid);
res.json({ ok: true, wasActive });
});
app.post('/api/claims/update', async (req, res) => {
const newClaim = await university.update(req.body as RotationRequest);
res.json({ claim: ClaimSchema.parse(newClaim) });
});OAuth 2.0 / OpenID Connect
Вход в приложение через бумажник: authorization code + PKCE на Persona Cloud. Вы
запрашиваете уникальный идентификатор (например СНИЛС) — пользователь подтверждает его в
бумажнике, но вам приходит не сам идентификатор, а sub : непрозрачный id «тот же человек» для вашего сервиса. При повторном входе —
тот же sub ; у другого приложения для того же человека будет другой id, склеить профили между
сервисами нельзя.
Сервис
В панели создайте OAuth / Gatekeeper. DNS и ключи проверки публикует Persona Cloud.
Утверждения
Выберите уникальный идентификатор (например СНИЛС) — из него для вас соберут sub. Плюс опциональные поля (имя и т.п.): в /userinfo попадут только те, что пользователь реально отдал.
Клиент
Возьмите client_id и redirect_uri. Для public-приложений обязателен PKCE (S256).
Authorize
Откройте /authorize: бумажник, согласие, redirect с ?code=. Scope: openid persona.gatekeeper.
Token · userinfo
Обменяйте code на access_token. Профиль читайте из /userinfo: там sub и опциональные поля — не из id_token.
Пример
import { createHash, randomBytes } from 'node:crypto';
// From dashboard → OAuth / Gatekeeper service
const OAUTH_BASE = 'https://persona.claims/oauth/s/<serviceId>';
const CLIENT_ID = 'client_…';
const REDIRECT_URI = 'https://app.example/oauth/callback';
function b64url(buf: Buffer) {
return buf.toString('base64url');
}
function pkce() {
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
return { verifier, challenge };
}
// 1. Discovery (optional)
// GET ${OAUTH_BASE}/.well-known/openid-configuration
// JWKS: ${OAUTH_BASE}/jwks.json
// 2. Start login — open in browser / QR → wallet
const { verifier, challenge } = pkce();
const state = b64url(randomBytes(16));
const authorizeUrl =
`${OAUTH_BASE}/authorize` +
`?response_type=code` +
`&client_id=${encodeURIComponent(CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&scope=${encodeURIComponent('openid persona.gatekeeper')}` +
`&code_challenge=${encodeURIComponent(challenge)}` +
`&code_challenge_method=S256` +
`&state=${encodeURIComponent(state)}`;
// 3. Callback: ?code=…&state=… → exchange code
const tokenRes = await fetch(`${OAUTH_BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: '<authorization_code>',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: verifier
})
});
const token = await tokenRes.json();
// { access_token, token_type, expires_in, scope, id_token }
// 4. Claims — only from /userinfo (not from id_token)
const userinfoRes = await fetch(`${OAUTH_BASE}/userinfo`, {
headers: { Authorization: `Bearer ${token.access_token}` }
});
const userinfo = await userinfoRes.json();
// {
// sub: "<hmac-pseudonym>",
// claims: [
// { typ: "gatekeeper.pseudonym", dat: "…" },
// { typ: "name.first", dat: "…" } // if user allowed
// ]
// }Клиент и утверждения настраиваются в панели.
Нужна помощь?
hello@persona.claims — проверка, выпуск или OAuth.