DNS

Публичные ключи лежат в TXT-записи _claims.<домен>, значение — kid:ключ.

РольПубликуетЗачем
Приёмник_claims.store.exampleПроверка подписи запроса
Источник_claims.university.exampleПроверка подписи утверждений (поле iss)
Пример записи
_claims.university.example.  300  IN  TXT  "issuer:Ed25519PublicKeyBase64url..."

TTL — 60–300 секунд. При ротации добавляйте новую TXT, старую не удаляйте, пока живы подписанные ею данные. В продакшене включайте DNSSEC.

Проверка

1

Verifier

npm install @persona-claims/verifier. Сгенерируйте пару ключей сервиса.

2

DNS

Опубликуйте TXT _claims.<домен> — домен из receiver URL, например _claims.store.example.

3

Запрос

Сформируйте Persona request и передайте в бумажник: QR, ссылка или локально.

4

Проверка

Примите ответ, сверьте подписи источников по DNS и владение ключами.

Пример

Express · persona-request
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 });
});

Выпуск

Организация подписывает факты и отдаёт их в бумажник. Утверждение содержит sub — публичный ключ бумажника.

Persona Pass (pass.ticket) — схема layout / fields / images .

  1. 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. 2

    Опубликуйте DNS

    TXT с публичным ключом issuer. Имя — _claims.<id> из Issuer.open().

    _claims.university.example.  300  IN  TXT  "issuer:Ed25519PublicKey…"
  3. 3

    Возьмите sub из бумажника

    Публичный ключ бумажника — через Persona request / QR. Передайте в том же запросе на выпуск или сохраните заранее.

    const sub = req.body.sub; // публичный ключ бумажника
  4. 4

    Выпустите утверждение

    Утверждение привязано к ключу бумажника. Укажите тип, данные и срок.

    const claim = await university.issue({
      sub,
      typ: 'edu.student',
      dat: { faculty: 'MIEM', year: 2 },
      exp: '2026-06-30T00:00:00.000Z'
    });
  5. 5

    Отдайте в бумажник

    Готовый claim — пользователю. При смене ключа — revoke / update.

    res.json({ claim });
    await university.revoke(uid);

Полный пример

Express · issuer
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, склеить профили между сервисами нельзя.

1

Сервис

В панели создайте OAuth / Gatekeeper. DNS и ключи проверки публикует Persona Cloud.

2

Утверждения

Выберите уникальный идентификатор (например СНИЛС) — из него для вас соберут sub. Плюс опциональные поля (имя и т.п.): в /userinfo попадут только те, что пользователь реально отдал.

3

Клиент

Возьмите client_id и redirect_uri. Для public-приложений обязателен PKCE (S256).

4

Authorize

Откройте /authorize: бумажник, согласие, redirect с ?code=. Scope: openid persona.gatekeeper.

5

Token · userinfo

Обменяйте code на access_token. Профиль читайте из /userinfo: там sub и опциональные поля — не из id_token.

Пример

OIDC · authorization code + PKCE
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.