Skip to Content
ReferenceServicesIssuer Registry

Issuer Registry — Service Reference

Module: backends/issuer-registry · REST: 8083 · gRPC: 50053

Architecture

Registration goes through a PENDING_APPROVAL state requiring an admin decision — handled in Core Engine’s IssuerManagementController, not here. Issuers register as either CUSTODIAL (Attest ID custodies the signing key) or BYOK (issuer holds its own key). OIDC/SAML SSO configuration and the approve/reject workflow live in Core Engine, layered on top of this service’s UpdateIssuerStatus RPC and a REST/JPA path for authority.issuer_sso_config.

RPC surface (registry.proto) — 7 methods

RegisterIssuer

message RegisterIssuerRequest { string name = 1; string website_url = 2; bytes public_key = 3; // BYOK only string key_algorithm = 4; // "Ed25519" string contact_email = 5; string country_code = 6; bool byok = 7; // false (default) = CUSTODIAL } message RegisterIssuerResponse { string issuer_id = 1; string issuer_did = 2; string status = 3; // always "PENDING_APPROVAL" string error_message = 4; bytes public_key = 5; string signing_mode = 6; // "CUSTODIAL" or "BYOK" }

Admin approval is not part of this RPC. An admin must separately call Core Engine’s POST /api/v1/issuers/{issuer_did}/approve (or /reject), which calls this service’s UpdateIssuerStatus to move the issuer to ACTIVE or REJECTED.

Errors: INVALID_ARGUMENT (missing/invalid fields), ALREADY_EXISTS (duplicate public key), INTERNAL.

GetIssuer

Cache-through (Redis, 1h TTL) lookup by DID. Errors: INVALID_ARGUMENT, NOT_FOUND, INTERNAL.

Known field-mapping quirk: the response’s contact_email is actually populated from getOrganizationUrl(), and country_code is always returned empty — getIssuer doesn’t read the real contactEmail/countryCode columns the way listIssuers does. Those columns are set correctly at registration; only this read path mismaps them.

ListIssuers

message ListIssuersRequest { string status_filter = 1; // e.g. "PENDING_APPROVAL"; empty = all int32 page = 2; int32 page_size = 3; // 0 = unpaged }

Backs the admin console’s pending-issuers queue.

GetDIDDocument

Returns the full W3C DID document (JSON-LD) plus complete key-version history:

{ "@context": "https://www.w3.org/ns/did/v1", "id": "did:key:z...", "publicKey": [{ "id": "did:key:z...#key-1", "type": "Ed25519VerificationKey2020", "controller": "did:key:z...", "publicKeyMultibase": "base64(publicKey)" }], "authentication": ["did:key:z...#key-1"], "assertionMethod": ["did:key:z...#key-1"], "updated": "2025-02-27T10:30:00Z" }

RotateKey

ModeBehavior
CUSTODIALRejects a caller-supplied new_public_key — server generates the replacement, re-encrypts, persists to authority.signing_keys
BYOKRequires a 32-byte new_public_key and old_key_signature — a signature over "attest-id:rotate-issuer-key:" + did + ":" + newVersion + ":" + base64(newKey), verified against the current registered public key. PERMISSION_DENIED if it doesn’t verify — without this proof, anyone who learned a BYOK issuer’s DID could hijack it

Marks the current did_registry row is_current = false, inserts a new row at key_version + 1. Both key versions remain queryable for historical verification.

UpdateIssuerStatus

The low-level primitive both the admin approve/reject workflow and manual suspend/revoke actions call. Status set: ACTIVE, SUSPENDED, REVOKED, PENDING_APPROVAL, REJECTED — this RPC does not enforce which transition is legal; any valid status can follow any other.

reason is not persisted here — Core Engine’s RegistryOrchestrator.updateIssuerStatus writes reason, admin ID, and previous/new status to core.audit_logs via AuditService.logAdminAction, so the change is auditable from the caller’s side. admin_signature on the request is accepted but never populated by any caller and never validated by this RPC — admin authorization is enforced entirely by Core Engine’s JWT-authenticated, role-checked endpoints.

SignPayload

Mirrors a KMS Sign API — called by Credential Issuance at issuance time for CUSTODIAL issuers.

The plaintext private key exists only for the duration of the call and is never logged or returned.

Database schema (authority)

CREATE TABLE authority.issuers ( issuer_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), did VARCHAR(512) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, public_key BYTEA NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'PENDING_APPROVAL' CHECK (status IN ('ACTIVE', 'SUSPENDED', 'REVOKED', 'PENDING_APPROVAL', 'REJECTED')), signing_mode VARCHAR(20) NOT NULL DEFAULT 'CUSTODIAL' CHECK (signing_mode IN ('CUSTODIAL', 'BYOK')), organization_url VARCHAR(512), contact_email VARCHAR(255), country_code VARCHAR(2), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE authority.did_registry ( did VARCHAR(512) NOT NULL, issuer_id UUID NOT NULL REFERENCES authority.issuers(issuer_id) ON DELETE CASCADE, public_key BYTEA NOT NULL, key_version INT NOT NULL DEFAULT 1 CHECK (key_version > 0), key_algorithm VARCHAR(50) NOT NULL DEFAULT 'Ed25519', is_current BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, key_rotation_date TIMESTAMP, PRIMARY KEY (did, key_version) ); -- unique index: (did) WHERE is_current — at most one current key version per DID CREATE TABLE authority.signing_keys ( did VARCHAR(512) NOT NULL REFERENCES authority.issuers(did) ON DELETE CASCADE, key_version INT NOT NULL DEFAULT 1 CHECK (key_version > 0), public_key BYTEA NOT NULL, encrypted_private_key BYTEA NOT NULL, -- AES-256-GCM ciphertext encryption_nonce VARCHAR(24) NOT NULL, algorithm VARCHAR(50) NOT NULL DEFAULT 'Ed25519', is_current BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (did, key_version) ); CREATE TABLE authority.issuer_sso_config ( sso_config_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), issuer_id UUID NOT NULL UNIQUE REFERENCES authority.issuers(issuer_id) ON DELETE CASCADE, protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('SAML2.0', 'OIDC')), provider VARCHAR(100) NOT NULL, status VARCHAR(30) NOT NULL DEFAULT 'PENDING_VALIDATION' CHECK (status IN ('PENDING_VALIDATION', 'VALIDATED', 'ACTIVE', 'INACTIVE', 'FAILED')), validation_error TEXT, validated_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

issuer_sso_config is not touched by this service’s gRPC surface — it’s managed through Core Engine’s REST endpoints and consumed by IssuerAuthController for OIDC/SAML issuer SSO.

Caching

  • Key: issuer:{did} · TTL: 1 hour · invalidated on registration, rotation, or status update.
  • Read-through: cache miss falls through to Postgres.

Error codes

StatusScenario
INVALID_ARGUMENTMissing/invalid fields
NOT_FOUNDIssuer or DID registry entry not found
ALREADY_EXISTSDuplicate registration by public key
PERMISSION_DENIEDBYOK old_key_signature fails to verify
INTERNALDatabase, cache, or server error

Performance — targets, not measurements

No load-test harness exists. Original-authoring estimates: GetIssuer cache hit ~5–15ms, cache miss ~20–40ms; RegisterIssuer/RotateKey ~50–100ms; GetDIDDocument ~10–30ms.

Security

  • Ed25519 only.
  • BYOK: only the public key is ever stored. CUSTODIAL: the private key is stored, AES-256-GCM-encrypted at the application layer — a documented pilot-only stopgap for a real HSM/KMS.
  • BYOK RotateKey requires and verifies a signature proving possession of the current private key.
  • UpdateIssuerStatus’s admin_signature field is unused — admin authorization comes entirely from Core Engine’s endpoint security.

See Quick Start: Issuer Registry for grpcurl walkthroughs of every RPC.