Skip to Content
ReferenceServicesCustodian Registry

Custodian Registry — Service Reference

Module: backends/custodian-registry · REST: 8087 (Actuator only) · gRPC: 50056 · Schema: custodian

Purpose

A Custodian is not an accredited issuer. It’s a third-party organization (workforce-compliance platform, background-check vendor, or similar) that collects/reviews documents a subject already holds and attests to what it reviewed — signed with its own Ed25519 key — without ever claiming to be the credential’s original issuing authority. See Custodian Onboarding for the full two-tier provenance model (ISSUED vs CUSTODIAN_ATTESTED).

Custodian Registry is the DID directory and key-custody service for Custodians, exactly as Issuer Registry is for Issuers — built by copying Issuer Registry’s proven registration/rotation/status/custodial-signing shape, then deliberately stripping what only makes sense for an accredited issuer:

Carried over (same code path)Deliberately not carried over
did:key generation via the shared DidKeyCodecAccreditation / credential-type-scope registration
CUSTODIAL vs BYOK signing modesAPI keys for headless integration
Key rotation with BYOK proof-of-possessionFederated did:web trust registry participation
KeyEncryptionService (AES-256-GCM at rest, same pilot-only KMS stopgap as Issuer Registry)—
Per-organization SSO config (OIDC/SAML), mirrors authority.issuer_sso_config—
Status lifecycle: PENDING_APPROVAL → ACTIVE / SUSPENDED / REVOKED / REJECTED—

No Redis caching layer — custodian registration/lookup volume is far lower than issuer lookup volume (verification traffic hits Issuer Registry on every credential check; this service is hit only when a custodian signs an attestation or a portal loads its profile), so a direct DB read per lookup was judged simpler and sufficient.

Interface surface

gRPC only — no REST controller package exists in this module at all. Port 8087 exists only for Spring Boot Actuator. Everything the Custodian Portal and Core Engine need goes through Core Engine’s own REST controllers, which call this service over gRPC.

Two gRPC services:

  1. CustodianRegistryService — registration, lookup, DID document, key rotation, status transitions, custodial signing.
  2. CustodianSsoService — per-custodian OIDC/SAML SSO config CRUD, authorization-URL construction, and assertion validation. Structural mirror of IssuerSsoServiceImpl, calling the same shared validators (OidcAssertionValidator, SamlAssertionValidator, SsoEncryptionService) so custodian SSO login is provably the same validation logic as issuer SSO, not a parallel copy that could silently diverge.

Data model (custodian schema)

TablePurpose
custodian.custodiansOne row per registered custodian: DID, name, current public key, status, signing_mode
custodian.did_registryFull key-version history, (did, key_version) PK — rotation inserts rather than overwrites
custodian.signing_keysCustodied Ed25519 private keys, CUSTODIAL only — AES-256-GCM ciphertext + nonce; empty for BYOK
custodian.custodian_sso_configPer-custodian OIDC/SAML config — column-for-column mirror of authority.issuer_sso_config

RPC walkthrough (custodian_registry.proto)

All 7 RPCs share the same pattern: validate input → look up entities → mutate/build response → onNext + onCompleted, with every failure path returning a specific io.grpc.Status.

RegisterCustodian

Always lands in PENDING_APPROVAL — no self-activation path.

DidKeyCodec is the same shared codec Issuer Registry and the holder wallet use — a did:key:... never diverges in format across the three roles. Response returns custodian_id, custodian_did, status, verification public_key, signing_mode — never the private key.

GetCustodian

Looks up by DID only — the proto comment mentions an id fallback, but the current implementation has no such branch. NOT_FOUND if either the custodian row or its current did_registry entry is missing (treated as a hard error rather than silently returning stale data, in case the two tables desync from a partial failure mid-registration).

ListCustodians

Optional status_filter (case-insensitive) plus page/page_size (<= 0 = unpaged). Each returned row does a per-row did_registry lookup for key_created_at — an N+1 pattern, acceptable given the low-custodian-volume assumption.

GetCustodianDIDDocument

Returns the full W3C DID Core document plus the complete verification-key history (not just current) — lets a verifier check a signature made with a since-rotated key. Built via String.format, using @context ["https://www.w3.org/ns/did/v1", ".../ed25519-2020/v1"] and a verificationMethod array (the DID Core term, deliberately not the pre-2019 draft publicKey property). The public key is multibase-encoded via the same DidKeyCodec.encode(...), then the did:key: prefix stripped — guaranteeing byte-for-byte consistency with the DID identifier’s own embedded key.

RotateCustodianKey

Structurally identical to Issuer Registry’s rotation:

ModeRequirement
BYOKExactly 32-byte new_public_key + non-blank old_key_signature, verified against "attest-id:rotate-custodian-key:" + did + ":" + newVersion + ":" + base64(newKey) signed with the current private key. Accepts Base64 or hex signatures. PERMISSION_DENIED on failure
CUSTODIALCaller must not supply new_public_key (INVALID_ARGUMENT if they do) — server generates the replacement

Both converge: mark current did_registry row is_current = false, insert new row at key_version + 1; CUSTODIAL also rotates signing_keys. custodian.custodians.public_key is updated so GetCustodian/ListCustodians reflect the current key without a join.

UpdateCustodianStatus

Transitions among PENDING_APPROVAL, ACTIVE, SUSPENDED, REVOKED, REJECTED — the only path to ACTIVE. reason is persisted onto custodian.custodians.last_status_reason/ last_status_changed_at as a local, queryable convenience copy; the authoritative record of who made the change remains Core Engine’s core.audit_logs (via CustodianManagementController’s auditService.logAdminAction). admin_signature is accepted but not cryptographically verified — same situation as Issuer Registry’s UpdateIssuerStatus.

SignCustodianPayload

byte[] privateKeyBytes = signingKeyEncryptionService.decrypt( signingKey.getEncryptedPrivateKey(), signingKey.getEncryptionNonce()); try { Ed25519PrivateKeyParameters privateKeyParams = new Ed25519PrivateKeyParameters(privateKeyBytes, 0); Ed25519Signer signer = new Ed25519Signer(); signer.init(true, privateKeyParams); signer.update(payload, 0, payload.length); signature = signer.generateSignature(); } finally { Arrays.fill(privateKeyBytes, (byte) 0); // zeroed even on exception }

A BYOK custodian has no custodian.signing_keys row — returns NOT_FOUND with a message steering the caller toward submitting a pre-computed signature instead, so the calling service’s error handling doesn’t need its own signing-mode check duplicated.

Key encryption

Both SignCustodianPayload and key rotation/registration go through com.attestpro.shared.signing.KeyEncryptionService — the exact same shared class Issuer Registry uses. Each service supplies its own independently-rotatable master key, so a compromise of one service’s master key doesn’t expose the other’s custodied keys. Real KMS-backed encryption (AwsKmsSigningKeyEncryptionService) exists but is off by default (SIGNING_KMS_AWS_ENABLED=false).

Error-handling summary

ConditionStatus code
Missing/blank required fieldINVALID_ARGUMENT
Unsupported key algorithm, wrong key lengthINVALID_ARGUMENT
DID already registeredALREADY_EXISTS
Custodian / DID entry / signing key not foundNOT_FOUND
BYOK rotation signature fails to verifyPERMISSION_DENIED
Unhandled exceptionINTERNAL (with cause preserved)

What this service does not do

  • No REST endpoints of its own — everything gRPC-only, fronted by Core Engine.
  • No accreditation/credential-type scoping, API-key issuance, or federated did:web trust-registry participation — Issuer Registry concepts tied to being an accredited original issuer.
  • No Redis cache — every lookup is a direct Postgres read.

Configuration notes

  • mTLS on, no plaintext fallback: grpc.server.security.enabled: true, client-auth: REQUIRE — see Internal gRPC mTLS.
  • Dev-only secrets ship with loud warnings, not silent defaults: SSO_ENCRYPTION_KEY and CUSTODIAN_REGISTRY_KMS_MASTER_KEY log a startup warning if a deployed environment boots on the unchanged committed dev default.
  • Real KMS is opt-in: SIGNING_KMS_AWS_ENABLED gates AwsKmsSigningKeyEncryptionService; off by default, using the local AES-256-GCM path.
  • Migrations connect directly to Postgres (never through PgBouncer); runtime queries go through PgBouncer (prepareThreshold=0 for transaction-pooling mode).