@persona-claims/issuer
v0.3.0
Published
Transport-agnostic issuer logic.
Downloads
164
Readme
@persona-claims/issuer
Transport-agnostic issuer logic.
An issuer (источник данных in persona.claims terms — a bank, university, employer, government agency, event organizer, …) is the party that produces a signed Claim — an envelope that binds a subject's public key to a typed value (name, age, role, ticket, prescription, …) until a chosen expiry. Verifiers later trust the claim because they recognise the issuer's DNS-anchored signing key.
Use this package when you want to embed issuer behavior into your own service, queue worker, or background job without inheriting the reference REST API in apps/issuer.
What It Owns
- issuer signing-key bootstrap and rotation
- DNS TXT record projection for published issuer keys
- claim issuance (
Issue→Claim) - claim update to a new subject key (after rotation)
- claim revocation list
- pluggable state persistence
Issuing a Claim
import { Issuer } from "@persona-claims/issuer";
// A bank running as an issuer of name/age/residency claims.
const bank = await Issuer.open({
id: "bank.example", // DNS-anchored name; TXT record lives at _claims.bank.example
publicUrl: "https://bank.example",
stateFile: "./issuer.json", // persistence: keys + revoked uids
});
const claim = await bank.issue({
sub: customerPublicKey, // ed25519 public key of the holder
typ: "name.first", // claim type — a free-form string
dat: "Ivan", // the actual claimed value
exp: "2100-10-10T12:34:44.000Z",
});
// `claim` is a fully signed envelope ready to be delivered to the customer's wallet.Publishing Issuer Keys
for (const record of bank.dnsRecords) {
console.log(`_claims.${bank.id} IN TXT "${record}"`);
}Verifiers look up these TXT records (over DNSSEC when dnsPolicy: "strict") to find the public key corresponding to a claim's kid.
Revocation
await bank.revoke(claim_uid);
bank.isRevoked(claim_uid); // trueVerifiers query the URL in claim.crl (auto-populated from publicUrl) and reject revoked claims.
Key Rotation
const { kid, txtRecord } = await bank.rotateKey();
// publish `txtRecord` next to the existing one in DNS, then verifiers
// pick up the new key without breaking already-issued claims.To rotate the subject key of an existing claim (e.g. wallet user lost their device), the holder constructs a rotation request signed by both old and new keys; the issuer reissues:
const newClaim = await bank.update(rotationRequest);If the original issuer is unavailable (offline, decommissioned), a witness can perform the reissue instead.
Pluggable Storage
State (signing keys + revocation list) is persisted through a StateStore<IssuerState> adapter. The SDK ships with JsonFileStore and MemoryStore; you can plug in your own (database, KMS, blob storage, encrypted vault, ...) by implementing two methods:
import { Issuer, type IssuerStateStore, type IssuerState } from "@persona-claims/issuer";
class PostgresIssuerStore implements IssuerStateStore {
constructor(private readonly db: Pool, private readonly id: string) {}
async load(): Promise<IssuerState | null> {
const { rows } = await this.db.query("SELECT state FROM issuers WHERE id = $1", [this.id]);
return rows[0]?.state ?? null;
}
async save(state: IssuerState): Promise<void> {
await this.db.query(
"INSERT INTO issuers(id, state) VALUES($1, $2) ON CONFLICT (id) DO UPDATE SET state = EXCLUDED.state",
[this.id, state],
);
}
}
const bank = await Issuer.open({
id: "bank.example",
publicUrl: "https://bank.example",
store: new PostgresIssuerStore(pool, "bank.example"),
});stateFile is a convenience shortcut equivalent to store: new JsonFileStore(stateFile). If neither is provided, the issuer state stays in memory only.
Low-Level API
loadState(config)/saveState(config, state)activeKey(state)— the current signing keyissue(state, config, params)→Claimupdate(state, config, rotationRequest)→Claimrevoke(state, config, uid)/isRevoked(state, uid)rotateKey(state, config)→{ kid, txtRecord }dnsRecords(state)→string[]
Class API
Issuer.open(config)— load or initialize stateissuer.issue(params)→Claimissuer.update(rotationRequest)→Claimissuer.revoke(uid)/issuer.isRevoked(uid)issuer.rotateKey()issuer.dnsRecords/issuer.activeKeyissuer.save()
Boundaries
- No HTTP server, no
Fastify, no route registration - For the reference REST transport, see apps/issuer
- For the verifier side, see @persona-claims/verifier
