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_emailis actually populated fromgetOrganizationUrl(), andcountry_codeis always returned empty —getIssuerdoesn’t read the realcontactEmail/countryCodecolumns the waylistIssuersdoes. 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
| Mode | Behavior |
|---|---|
| CUSTODIAL | Rejects a caller-supplied new_public_key — server generates the replacement, re-encrypts, persists to authority.signing_keys |
| BYOK | Requires 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
| Status | Scenario |
|---|---|
INVALID_ARGUMENT | Missing/invalid fields |
NOT_FOUND | Issuer or DID registry entry not found |
ALREADY_EXISTS | Duplicate registration by public key |
PERMISSION_DENIED | BYOK old_key_signature fails to verify |
INTERNAL | Database, 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
RotateKeyrequires and verifies a signature proving possession of the current private key. UpdateIssuerStatus’sadmin_signaturefield is unused — admin authorization comes entirely from Core Engine’s endpoint security.
See Quick Start: Issuer Registry for grpcurl walkthroughs of every RPC.