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 DidKeyCodec | Accreditation / credential-type-scope registration |
| CUSTODIAL vs BYOK signing modes | API keys for headless integration |
| Key rotation with BYOK proof-of-possession | Federated 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:
CustodianRegistryService— registration, lookup, DID document, key rotation, status transitions, custodial signing.CustodianSsoService— per-custodian OIDC/SAML SSO config CRUD, authorization-URL construction, and assertion validation. Structural mirror ofIssuerSsoServiceImpl, 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)
| Table | Purpose |
|---|---|
custodian.custodians | One row per registered custodian: DID, name, current public key, status, signing_mode |
custodian.did_registry | Full key-version history, (did, key_version) PK — rotation inserts rather than overwrites |
custodian.signing_keys | Custodied Ed25519 private keys, CUSTODIAL only — AES-256-GCM ciphertext + nonce; empty for BYOK |
custodian.custodian_sso_config | Per-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:
| Mode | Requirement |
|---|---|
| BYOK | Exactly 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 |
| CUSTODIAL | Caller 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
| Condition | Status code |
|---|---|
| Missing/blank required field | INVALID_ARGUMENT |
| Unsupported key algorithm, wrong key length | INVALID_ARGUMENT |
| DID already registered | ALREADY_EXISTS |
| Custodian / DID entry / signing key not found | NOT_FOUND |
| BYOK rotation signature fails to verify | PERMISSION_DENIED |
| Unhandled exception | INTERNAL (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:webtrust-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_KEYandCUSTODIAN_REGISTRY_KMS_MASTER_KEYlog a startup warning if a deployed environment boots on the unchanged committed dev default. - Real KMS is opt-in:
SIGNING_KMS_AWS_ENABLEDgatesAwsKmsSigningKeyEncryptionService; off by default, using the local AES-256-GCM path. - Migrations connect directly to Postgres (never through PgBouncer); runtime queries go through
PgBouncer (
prepareThreshold=0for transaction-pooling mode).