Skip to content

GDS Client

Kevin Herron edited this page Oct 12, 2026 · 11 revisions

Milo 1.2.0-RC1, Java 17. Source baseline 69cb25087804.

A Global Discovery Server (GDS) manages certificates and trust for a site. Your application registers with it, requests certificates from it, and retrieves trust lists from it, so installations do not each manage their certificates by hand. Milo provides typed GDS client operations, and your application drives this Pull workflow. It decides when to enroll, keeps what it needs to resume, and installs what it receives. Read Client security first, because the certificate groups and trust list managers described there are what this workflow fills in.

The Pull workflow

Your application is an ordinary client of the GDS. It registers its application record once and keeps the ApplicationId the GDS assigns. Then, for each certificate group it manages, it does two independent jobs:

  • Certificate enrollment starts a signing request with a CSR, polls until the GDS finishes it, verifies the issued certificate, and installs it in the matching local certificate group.
  • Trust list updates read the group's trust list from the GDS and apply it to the local trust list manager.

A request can stay pending until the GDS's approval process completes, so the certificate job is a resumable process rather than one call. Persist the RequestId, poll on a schedule, and resume after a restart. Milo supplies GdsClient for the GDS Methods, and TrustListReader and TrustListApplier for trust lists. Authorization, scheduling, persistence, and rollout stay with your application.

Add the module and connect

Add org.eclipse.milo:milo-sdk-client-gds:1.2.0-RC1 using the repository and version alignment in Getting started. The module depends on the client SDK. The separate milo-sdk-client-gds-testing artifact adds server-backed test facilities and belongs in test scope. The core SDK's generated GDS information-model classes do not by themselves implement a production GDS or an automatic Pull engine.

Connect an ordinary OpcUaClient to the GDS with explicit application trust and an authorized user identity. The GDS protocol requires an encrypted channel for certificate request and completion operations, and the server must enforce that requirement, so choose an encrypting endpoint as in Client security.

Then call GdsClient.create(client). It resolves the GDS namespace URI, registers the necessary codecs and types, and locates Directory. If needed, it refreshes the namespace array once. It fails with Bad_NotFound when the server lacks the GDS namespace. Because the wrapper resolves the namespace by URI, do not hard-code that namespace index. The wrapper is thread-safe and reusable for one connection. Recreate it if the client is repointed to a different GDS.

Register or find the application

An ApplicationRecordDataType includes the ApplicationUri, application type, localized names, product URI, discovery URLs, and server capability identifiers as appropriate. Use a null NodeId for an unassigned ApplicationId. The URI must describe the same application that will request and use the certificate, not the GDS itself.

This helper reuses an existing record, creates one only when none exists, and refuses an ambiguous result. The caller supplies a client connected to the GDS and the complete record.

static NodeId findOrRegister(OpcUaClient client, ApplicationRecordDataType record)
    throws UaException {
  GdsClient gds = GdsClient.create(client);
  ApplicationRecordDataType[] found = gds.findApplications(record.getApplicationUri());

  if (found.length == 0) {
    return gds.registerApplication(record);
  } else if (found.length == 1) {
    return found[0].getApplicationId();
  } else {
    throw new IllegalStateException("Ambiguous application URI");
  }
}

Persist the returned ApplicationId with the GDS identity and record configuration. This short helper matches an existing record by ApplicationUri only, so check its application type and product URI against the intended application before adopting it. Use updateApplication for an authorized change. Avoid concurrent first-registration attempts from several instances without coordination. Authentication and authorization failures remain UaException or UaMethodException, not a signal to keep registering new identities.

Request a certificate

Choose the group and certificate type

Call getCertificateGroups(applicationId) and map each returned GDS group to the application's intended local certificate group. These are different NodeId domains, and not every group issues application certificates.

Then select a concrete certificate type and call resolveCertificateTypeId(groupId, desiredTypeId). An exact match returns that type. When all advertised non-null types are compatible abstract ancestors, it returns null. Types outside namespace zero match only exactly. Pass the returned value, including null, unchanged to getCertificateStatus and startSigningRequest, and keep the intended local certificate type for installation. Bad_NotSupported, for example on a user-token group, means skip signing that group but still pull its trust list.

Start the request

Use getCertificateStatus in your enrollment or renewal policy to decide when to request. Generate a CSR with CertificateUtil.generateCsr(keyPair, subject, applicationUri, dnsNames, ipAddresses, signatureAlgorithm) and pass its DER encoding as a ByteString. A CSR URI that differs from the registered record can fail with Bad_CertificateUriInvalid. This method resolves the type and submits the request.

static NodeId startCertificateRequest(
    GdsClient gds, NodeId applicationId, NodeId groupId, ByteString csr) throws UaException {
  // Null asks for the group's default type when it advertises only compatible ancestors.
  NodeId requestType =
      gds.resolveCertificateTypeId(groupId, NodeIds.RsaSha256ApplicationCertificateType);

  return gds.startSigningRequest(applicationId, groupId, requestType, csr);
}

Persist the returned RequestId before it can be lost, together with the key pair the CSR was made from. Everything after this point may happen in a later run of your application.

startNewKeyPairRequest is the alternative that asks the GDS to generate the private-key material. Check Known limitations before you use it.

Poll until the GDS finishes

Call finishRequest on later scheduled attempts. Operation failures arrive as UaMethodException, and only Bad_NothingToDo means the request is still pending. FinishRequest must use the same client application certificate as its corresponding start request, so keep the renewal configuration stable enough to finish outstanding requests before rotating that connection. This method makes one attempt and returns empty while the request is pending. When the GDS has issued the certificate, it returns the chain: the issued certificate first, followed by the issuer certificates from issuerCertificates().

static Optional<List<X509Certificate>> pollIssuedChain(
    GdsClient gds, NodeId applicationId, NodeId requestId) throws UaException {
  GdsClient.FinishRequestResult issued;
  try {
    issued = gds.finishRequest(applicationId, requestId);
  } catch (UaMethodException e) {
    if (e.getStatusCode().value() == StatusCodes.Bad_NothingToDo) {
      // Still pending. Keep the RequestId and poll again later.
      return Optional.empty();
    }
    throw e;
  }

  // The issued certificate comes first, followed by the issuers the GDS returned.
  List<X509Certificate> chain = new ArrayList<>();
  chain.add(CertificateUtil.decodeCertificate(issued.certificate().bytesOrEmpty()));
  for (ByteString issuer : issued.issuerCertificates()) {
    chain.add(CertificateUtil.decodeCertificate(issuer.bytesOrEmpty()));
  }
  return Optional.of(chain);
}

Run it from your scheduler with an overall deadline and bounded polling, and resume persisted requests after a restart. Any other status ends normal polling for that request, as described in When things go wrong. A signing request normally has no new private-key output, and the wrapper normalizes empty key output to null.

Verify and install

The GDS is trusted to sign, but your application still checks what it receives before using it. GdsClient.verifyIssuedCertificate rejects a public-key mismatch with Bad_CertificateInvalid and an ApplicationUri mismatch with Bad_CertificateUriInvalid. Also validate the chain to the designated GDS CA, and check certificate validity and intended key usage.

When a poll returns a chain, this method checks the issued certificate, verifies that each certificate is signed by the next one and that the chain ends at, or is signed by, a CA the application already trusts, and then installs the whole chain with the caller's key pair in an application-owned group. Installing the issuers the GDS returned lets peers that need an intermediate CA build the path.

static void installIssuedChain(
    List<X509Certificate> chain,
    KeyPair keyPair,
    String applicationUri,
    X509Certificate trustedCa,
    CertificateGroup group)
    throws Exception {
  X509Certificate certificate = chain.get(0);
  GdsClient.verifyIssuedCertificate(certificate, keyPair.getPublic(), applicationUri);
  certificate.checkValidity();

  // Each certificate must be signed by the next one in the chain.
  for (int i = 0; i < chain.size() - 1; i++) {
    chain.get(i).verify(chain.get(i + 1).getPublicKey());
  }

  // The chain must end at, or be signed by, a CA the application already trusts.
  X509Certificate last = chain.get(chain.size() - 1);
  if (!last.equals(trustedCa)) {
    last.verify(trustedCa.getPublicKey());
  }

  X509Certificate[] certificates = chain.toArray(X509Certificate[]::new);
  group.updateCertificate(NodeIds.RsaSha256ApplicationCertificateType, keyPair, certificates);
}

These signature checks are not full path validation. They do not check revocation, issuer validity, or key usage, so apply your complete path-validation and key-usage policies before installation. In production, also persist the key and certificate before you install them through the intended local CertificateGroup.updateCertificate. Coordinate channels, Sessions, and reconnection under the application's certificate-rotation policy. Receiving a certificate does not install it automatically or make every peer trust it.

Read and apply a trust list

For GDS-managed trust, configure an application-owned TrustListManager in the group passed to OpcUaClientConfigBuilder.setCertificateGroup(...), not the simple setCertificateIdentity shortcut. The group validator must read the same manager that receives the pulled update. setCertificateValidator(...) overrides the group validator, and an override backed by another manager will not see this trust update.

This helper retrieves the selected GDS group's trust list and applies it to that manager. The connection and manager must already be trusted and configured, because no trust decision comes from an arbitrary discovery response.

static void pullTrustList(
    OpcUaClient client, NodeId applicationId, NodeId groupId, TrustListManager trust)
    throws UaException {
  GdsClient gds = GdsClient.create(client);
  NodeId trustListId = gds.getTrustList(applicationId, groupId);
  TrustListDataType update = TrustListReader.read(client, trustListId);
  TrustListApplier.apply(update, trust);
}

TrustListReader owns the remote FileType handle. It opens the file, reads chunks, attempts to close it after success or failure, and decodes the bare Binary TrustListDataType. A close failure after a complete read is logged, and the body is still decoded. Decoding failures still propagate. If reading also fails, the read failure is primary and the close failure is suppressed.

The reader considers the optional MaxByteStringLength and the client message limits when choosing chunks. Truncated or trailing bytes fail decoding before anything is applied. Chunk limits are not a total-file-size cap. The reader continues until a short chunk, so the connected GDS controls the total size.

TrustListApplier replaces the lists selected by SpecifiedLists instead of appending everything to the current trust store. It decodes all entries first, then applies the selected lists through the manager's snapshot update. Unspecified lists remain unchanged. A selected list with an empty or null array clears that local list, so keep manual trust separate from GDS-managed lists.

Use readTrustListInfo for LastUpdateTime and optional UpdateFrequency when scheduling refreshes. Part 12 recommends checking again within twice UpdateFrequency. Persist the last successfully applied state, and update it only after applying and persisting both succeed.

Own the workflow

The application owns enrollment authorization, schedules, retries, durable IDs, private-key storage, request deadlines, installation, and rollout. GdsClient does not own or close the supplied OpcUaClient. Disconnect the client after pending work stops, and close any stores or executors you supplied according to their contracts. Server-specific enrollment permissions and approval processes remain external requirements, so validate enrollment policy, encryption, mutual trust, CA, key formats, and approval with the intended GDS.

Test against FakeGdsNamespace

FakeGdsNamespace, from milo-sdk-client-gds-testing, is an in-memory namespace added to an application-owned OpcUaServer, not a standalone server. To use it:

  1. Construct it with that server.
  2. Call startup() before starting the server.
  3. Call reset() between tests.
  4. Call shutdown() during teardown.

setRegisterApplicationAccess and setCertificateDirectoryAccess accept MethodAccess.ANYONE, CREDENTIALED, or NOBODY. The credentialed check uses the identity token type. setPollsBeforeIssued, setRejectRequests, preRegister, and setApplicationCertificateTypes configure enrollment cases. Its TrustListFile offers setTrustList, setBody, failReadAfter, openHandles, and calls for success, malformed data, I/O failure, and cleanup assertions.

It differs from a real GDS in ways that hide deployment errors. It accepts signing calls on a None channel and does not enforce the encrypted-channel requirement. It returns Bad_NotFound for an unknown, mismatched, or already-finished RequestId, and also for an unregistered ApplicationId, so tests against it cannot distinguish those cases or exercise the specified Bad_InvalidArgument lost-completion recovery branch. It is not a production GDS or evidence of vendor interoperability. Test those branches against the intended GDS.

When things go wrong

Part 12 FinishRequest defines these operation results:

Status Meaning and response
Bad_NothingToDo Still pending. Keep the RequestId and poll later.
Bad_RequestNotAllowed The GDS rejected the request. Get the reason from its administrator or server logs. GdsClient does not expose the GDS diagnostic text.
Bad_NotFound ApplicationId is not registered. Correct the registration.
Bad_InvalidArgument RequestId is invalid for this application. Stop polling that ID.
Bad_UserAccessDenied The Session user lacks the required rights. Correct authorization.
Bad_SecurityModeInsufficient The channel is not encrypted. Correct its security mode.

A non-pending operation failure ends normal polling. Correct the cause and submit a new request when needed.

After a lost completion response, the next call can return Bad_InvalidArgument even though the request finished. For a signing request, getCertificates can recover the issued certificate when the GDS supports that method. Verify it before installation. A new-key-pair request must be restarted in that case.

Classify any other UaException by its cause. Retain the RequestId and retry transient connection or service failures within the deadline. Across GdsClient operations, Milo raises Bad_UnexpectedError for an invalid result count or output shape and Bad_DecodingError for undecodable structures. FinishRequest returns ByteStrings rather than structures, so its output validation uses Bad_UnexpectedError. Stop and diagnose those malformed responses instead of blindly retrying. An interrupted blocking call also becomes Bad_UnexpectedError and preserves the thread's interrupt flag, so honor cancellation.

When applying a trust list, a malformed certificate fails with Bad_CertificateInvalid and a malformed CRL fails with Bad_DecodingError. Either failure leaves the manager unchanged.

Known limitations

  • getCertificates, checkRevocationStatus, and revokeCertificate require GDS NodeSet 1.05.07. Older servers can return Bad_MethodInvalid or Bad_NodeIdUnknown.
  • KeyCredential management, AuthorizationServices, and GDS audit events have generated model classes only.
  • Milo has no helper to decode the PFX or PEM result of startNewKeyPairRequest. Prefer startSigningRequest, or decode that result with your own key-handling code.

Examples and reference

GdsPullExample targets an external GDS selected by gds.endpoint, gds.username, and gds.password. Their defaults are opc.tcp://localhost:58810/GlobalDiscoveryServer, appadmin, and demo. The example requires enrollment rights and mutual certificate trust. It requests a certificate for a throwaway key pair, polling at most 30 times, two seconds apart. It verifies and prints the issued certificate without installing it, then applies the pulled trust list to its file-based group.

Next steps

Your application can now enroll with a GDS and keep its trust lists current. To give server nodes stable names that clients can look up instead of hard-coding NodeIds, continue with Alias Names.

Related: Client SDK · Client security · Server security · Recovery and limits

Clone this wiki locally