How to enroll identity using REST APis

Hi,
I'm following the example JVM project: ziti-sdk-jvm/samples/jdbc-postgres/cheatsheet.md at main · openziti/ziti-sdk-jvm · GitHub

In the document there are the following commands:

ziti edge create identity pg-client -o pg-client.jwt -a postgres-clients
ziti edge enroll pg-client.jwt

I am trying to replicate those using the APIs.

So far, all I can manage is the "create identity" part, but on the management API, and not the edge one.
https://localhost:1280/edge/management/v1/identities
with body:

{
    "isAdmin": false,
    "name": "Test Client",   
    "type": "Device",
    "roleAttributes": ["test-clients"],
    "enrollment": {
        "ott": true
    }
}

I can then use the id in the response to obtain a JWT with:
https://localhost:1280/edge/management/v1/identities/

But I'm unable to perform the enroll API call.

It seems I require a CA.

Actually, I am able to create a private key and csr using openssl and i can send that to the enroll API:
https://localhost:1280/edge/client/v1/enroll?token=

But where I get stuck is creating the identity.json file that is needed in the Java application.

Any info on creating the identity.json file?

Hi @quintonn, welcome to the community and to OpenZiti (and zrok/BrowZer)!

You can emulate creating a json file but it's an intrictae dance. Andrew wrote a great 5-part series on bootstrapping trust years ago. You can find them here if you want to read through them: Bootstrapping Trust Part 1.

There's a fair bit to it. I would go so far as to say it'd be better to use one of our SDKs that knows how do to the full process.

If you would prefer, you could always use the "third-party CA" feature of OpenZiti. With that, you can make you rown cert, your own key and then produce an identity file relatively easily. You still need to know how to properly bootstrap trust in order to create that identity. Although there are fewer steps, it's still kinda complex imo. I'm happy to help you through that if you like, just know that it's complex. Bootstrapping trust "the right way", is probably more work than most people are looking to get into... So if you want to go down that rabbit hole, we can help you out with that. :slight_smile:

Since you're using the Java SDK -- by far the easiest thing for you to do is to call [org.openziti.identity.Enroller.enroll](https://github.com/openziti/ziti-sdk-jvm/blob/main/ziti/src/main/kotlin/org/openziti/identity/Enroller.kt#L72). Have a look at this sample ziti-sdk-jvm/samples/ziti-enroller/src/main/kotlin/org/openziti/ZitiEnroller.kt at main · openziti/ziti-sdk-jvm · GitHub

Have you discovered these samples yet? Did they not work for you or are you looking to learn about this process? Does that give you the info you need?

Cheers

Hi @TheLumberjack,

Thanks for the info.
I have seen the JVM examples, but they still use the CLI commands to generate the json file?

I did notice the enroll method, but that requires a keystore.
I guess I also didn't mention, when I run the CLI enroll command, it's somehow generating public and private keys?
Does that happen on the client side (i.e. by the CLI tool), or does the server generate those and return them to the calling CLI tool?
That's the part I basically want to replicate.
I don't want to manage my own keys, I just want to use the REST APIs to enroll a new identity.

Lots of questions packed in here :slight_smile:

It's often a lot easier in samples to use the ziti CLI to enroll an identity since it's literally one line. To do it with code ends up with, oh like 20 lines since you should catch the exception, write the file, etc.... So it doesn't surprise me that the examples use the ziti CLI to write the identity file.

Yes, the act of enrolling a zero trust identity will require the client to generate a key, generate a CSR, bootstrap trust with the controller, send the CSR to the controller for signing, then write the resulting cert to disk somewhere along with the CA bundle. If you look into one of your identity files you produced, you'll see all that data in there. (all that is explained in depth in that 5-part bootstrapping trust series). So there's "a lot" going on in that one little command.

described above

This is where you start to lose me, and it's where you would start wandering off into replicating what the OpenZiti project has already done for you, which is why I'm kinda stuck here. :slight_smile: Can we level-set on this particular topic? Are you building an application using Java? If you are, I really do think it'll save you time and effort to use the Java SDK. The result of the enroll function is the identity you're after and it's going to have a key inside of it if you are going to enroll the identity. So it would seem to me that you're going to be required to manage your own key. That's the part that has me hung up.

If you want to use the REST API to create an identity in the controller - that's one thing, but when you use the term "enroll" an identity and when you refer to the .json file, that mandates a key be created, so I feel like we might be talking past one another, and it's why I'd like to maybe understand more because given my confusion, I don't feel like I am helping you... :frowning:

The ziti cli seems very manual.
If I want to add a new client identity, and allow them to call my server (for example), I would have to either install the Ziti CLI on my computer, or connect into the controller server.
Then execute the ziti edge enroll command in order to generate an identity.json file.
Then I need to send the client the json file so that they could use it inside their application.

Is everything correct so far ??

What we are planning to do, is have our application be distributed to our customers (via a docker image).
Then, if they need or want to connect into our server/s, they would need to be enrolled.
We're looking into how that can be automated.
We would probably look to expose our own dashboard to our customers where they can download an API-KEY, or secret or something, which our pre-baked docker image would then be able to use to automatically retrieve the json identity file (if that's possible).
Or can the image enroll itself at this point?

Ok! Now we're getting somewhere! :slight_smile: I feel like I have a much better idea of what you're trying to do now. What you're thinking about doing is quite similar to what the NetFoundry console does to make it easier for our customers to enroll their own edge routers in their own locations...

You mentioned you have a server of your own and that you would have your users use your console and download an API key. That sounds perfect to me.

Assuming I understand what you're doing, here's how I'd go about it...

  • User wants to install your software, user goes to your portal and obtains an API key
  • user pulls your docker container and sets the API key into an env var
  • user starts docker container
  • docker container checks the API key with your server
  • docker container decides if it needs an identity
  • docker container asks your servers to generate an identity/.jwt
  • API returns .jwt to docker container via your server
  • docker enrolls the .jwt and puts the resultant identity.json file either into a bind mount or managed volume or something like that

If you follow this flow, you could choose to embed the ziti cli directly into the docker container. That does add mb to the container though (go binaries are kinda biggish) but if you have your own app running inside that container, I would embed an OpenZiti SDK within that dockerized application and "enroll" that way.

If you don't want to follow that flow, then I suppose you could also choose to enroll the identity on behalf of the docker container within your servers/API and just deliver an identity file to the docker container, but then you're transferring a private key within the identity.json file. Maybe that's ok for you but it's certainly not "a best practice" type of thing imo. BUT it'd be just fine to do that...

I realize I'm still not answering the "how do i enroll an identity with the REST API", so if you really just don't want to use an OpenZiti SDK to do that, then you would need to:

  • generate a private key
  • bootstrap trust to the OpenZiti controller (or just "trust" it I suppose, but I'm sure you recognize that does sort of go against the whole notion of OpenZiti which is why it might seem like I'm being difficult in just answering the question) to obtain the proper CA bundle
  • generate a CSR for the identity
  • send a POST to ${openziti_controller}/edge/client/v1/enroll
  • craft an identity file using the resultant bootstrapped trust CA bundle, the key you generated, and the pem that comes back from the enroll command

Is this helping? :confused:

Thanks, that is exactly what i want.
I can use the SDK, but does that also require that I generate my own private key?

Every client connecting to an OpenZiti overlay must have a private key. This is not negotiable. The OpenZiti overlay is built on mutual TLS and will only operate with mutual TLS connections. Therefore, your clients cannot connect to the OpenZiti overlay without the client having a key, a cert, and a CA bundle that allows it to connect to the overlay (controller and router).

If you use an SDK, you/your code won't be generating a private key per-se but a private key will be generated by the OpenZiti SDK on your behalf. So in a way, yes "you" generate your own private key. But it's all done for you through the SDK when you generate that identity.

This key/cert is transparent to you and any clients. It's not used by your application's traffic itself, OpenZiti will encapsulate whatever traffic you send through it's own mTLS connection. So it's not like you will need to have TLS enabled in your server.

I guess that's the part I missed.
In the Java SDK, the enroll method takes a KeyStore as the first argument.
Is that then optional?
I.E. if i leave it null, the SDK will generate the required key-pair etc?

I have got this Java code to try and enroll:

        if (zitiConfigPath == null) {
            System.err.println("ZITI_IDENTITY_FILE environment variable not set.");
            return;
        }

        final String serviceName = System.getenv("ZITI_SERVICE_NAME");
        if (serviceName == null) {
            System.err.println("ZITI_SERVICE_NAME environment variable not set.");
            return;
        }

        //final ZitiContext zitiContext = Ziti.newContext(zitiConfigPath, new char[0]);
        byte[] jwt;
        try {
            System.out.println("reading jwt file: " + zitiConfigPath);

            jwt = Files.readAllBytes(new File(zitiConfigPath).toPath());
        } catch (final IOException e) {
            // TODO Auto-generated catch block
            System.out.println("Error: " + e.getMessage());
            return;
        }

        KeyStore ks;
        try {
            ks = KeyStore.getInstance("JKS");
        } catch (final KeyStoreException e) {
            // TODO Auto-generated catch block
            System.out.println("Error getting keyStore");
            e.printStackTrace();
            return;
        }

        final ZitiContext zitiContext = Ziti.enroll(ks, jwt, serviceName);

But it's giving me this error:

[main] INFO org.openziti.impl.ZitiImpl - ZitiSDK version 0.27.5 @cab1b71()
Error enrolling: "The supplied token is not valid"
java.lang.IllegalArgumentException: "The supplied token is not valid"
        at org.openziti.identity.Enroller.enrollOtt(Enroller.kt:171)
        at org.openziti.identity.Enroller.enroll(Enroller.kt:79)
        at org.openziti.impl.ZitiImpl.enroll(ZitiImpl.kt:136)
        at org.openziti.Ziti.enroll(Ziti.kt:95)

Any advice why it's invalid?
I created the jwt using the cli tool and it's mounted to a file in the docker container running this Java code.

made some progress:

eyStore keyStore;
        try {
            keyStore = KeyStore.getInstance("pkcs12");
            keyStore.load(null, "".toCharArray());
            //ks = KeyStore.getInstance(KeyStore.getDefaultType());
        } catch (final KeyStoreException | NoSuchAlgorithmException | CertificateException | IOException e) {
            // TODO Auto-generated catch block
            System.out.println("Error getting keyStore");
            e.printStackTrace();
            return;
        }

I had to set the keystore password.
But now i'm getting this error:

java.net.BindException: no permission to bind to service[http.svc]

But that's after I created services and policies using the cli.

Also, if the enrollment fails, the jwt seems no longer valid.
Is there a way to get a new JWT?
As i currently have to delete all the resources manually and redo it all

make sure to write keystore to a file after you enroll.
that way to don't have to re-do everything from scratch.

here is the sample code for enrollment: ziti-sdk-jvm/samples/ziti-enroller/src/main/kotlin/org/openziti/ZitiEnroller.kt at main · openziti/ziti-sdk-jvm · GitHub

This means you haven't authorized the identity to bind the service. You should make a service-policy that authorizes the identity to bind that service.

It will depend on how it fails. If your request made it through to the controller, the .jwt is a one time use token, so using it again will mean the .jwt is invalid. You can get a new JWT by "re-enrolling" the identity in the ZAC (or by the rest api).

Thanks for the info @TheLumberjack & @ekoby.

I have some more questions though:

  1. How do I get the identity.json file after I have enrolled. I am saving the keystore and that seems to work.
  2. How do I perform a "re-enroll" using the CLI?
  3. How do I perform a "re-enroll" using the REST API?

It looks like I am making progress, my server is enrolling and I am able to start an http server.
I will now test calling my server with a test java client.

every controller has the web api docs hosted on it. You can find it at https://controller:port/edge/management/v1/docs. I forgot to mention this before, but I meant to. You'll find re-enroll there: https://controller:port/edge/management/v1/authenticators/{id}/re-enroll. Right now, the ziti-cli only supports re-enrollment for routers.

As for the identity file, I would expect you wrote it to app storage at some point and would just retrieve it to be used. I've done precious little Android development though, I'm not well-versed with it.

thanks.
This is not for Android, it's server back-end but Java.

I've got an HTTP Server and HTTP Client app in java, and i've created the identities, bindings, service, etc.
When I run both, both enroll.

Is it correct that the Client also enroll with the same service? It seems strange, but i have to give a value.

When I try and call the zitified name (http.ziti in my case), i can see that ziti resolves it to the IP address 100.64.1.2, but I get a SocketTimeoutException.

Here is part of my code:

final ZitiContext zitiContext = Ziti.newContext(identityFile, "".toCharArray());

        try {
            Thread.sleep(5000); // sleep to let ZitiContext initialize

            final OkHttpClient client = newHttpClient();

            final String url = "http://http.ziti:8081/api/hello";
            final Request req = new Request.Builder()
                    .get()
                    .url(url).build();

            System.out.println("Calling: " + url);
            final Response resp = client.newCall(req).execute();
            System.out.println(resp);
        } catch (final Exception e) {
            System.err.println("Error making http call: " + e.getMessage());
            e.printStackTrace();
            return;
        } finally {
            zitiContext.destroy();
        }
    }

    private static final OkHttpClient newHttpClient() throws Exception {
        final KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
        final TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());

        tmf.init(ks);

        final X509TrustManager tm = (X509TrustManager) tmf.getTrustManagers()[0];

        final OkHttpClient clt = new OkHttpClient.Builder()
                .socketFactory(Ziti.getSocketFactory())
                .sslSocketFactory(Ziti.getSSLSocketFactory(), tm)
                .dns(hostname -> {
                    System.out.println("resolving hostname " + hostname);

                    System.out.println("DNS DUMP:");
                    final StringWriter stringWriter = new StringWriter();
                    Ziti.getDNSResolver().dump(stringWriter);
                    final String output = stringWriter.toString();
                    System.out.println("Data written to the Writer:");
                    System.out.println(output);

                    InetAddress address = Ziti.getDNSResolver().resolve(hostname);
                    if (address == null) {
                        System.out.println("Address is null");
                        address = InetAddress.getByName(hostname);
                    } else {
                        System.out.println("1. Address is " + address);
                    }
                    System.out.println("2. Address is " + address);

                    final List<InetAddress> result = address != null ? Collections.singletonList(address) : Collections.emptyList();
                    System.out.println("Returning result: ");
                    return result;
                })
                .callTimeout(5, TimeUnit.MINUTES)
                .build();
        return clt;
    }

And this is the output I see:

Calling: http://http.ziti:8081/api/hello
resolving hostname http.ziti
DNS DUMP:
Data written to the Writer:
http.ziti -> http.ziti/100.64.1.2

== Wildcard Domains ==

1. Address is http.ziti/100.64.1.2
2. Address is http.ziti/100.64.1.2
Returning result:
[DefaultDispatcher-worker-5] INFO org.openziti.api.Controller - POST https://ziti-edge-controller:1280/edge/client/v1/sessions session=cm4tyibz401qqqcmtk0busw97 t[DefaultDispatcher-worker-5]
[DefaultDispatcher-worker-1] WARN ziti-conn[xdjyxpxtv3/1] - closed
Error making http call: timeout
java.net.SocketTimeoutException: timeout
        at okio.SocketAsyncTimeout.newTimeoutException(JvmOkio.kt:146)
        at okio.AsyncTimeout.access$newTimeoutException(AsyncTimeout.kt:161)
        at okio.AsyncTimeout$source$1.read(AsyncTimeout.kt:339)
        at okio.RealBufferedSource.indexOf(RealBufferedSource.kt:430)
        at okio.RealBufferedSource.readUtf8LineStrict(RealBufferedSource.kt:323)
        at okhttp3.internal.http1.HeadersReader.readLine(HeadersReader.kt:29)
        at okhttp3.internal.http1.Http1ExchangeCodec.readResponseHeaders(Http1ExchangeCodec.kt:180)
        at okhttp3.internal.connection.Exchange.readResponseHeaders(Exchange.kt:110)
        at okhttp3.internal.http.CallServerInterceptor.intercept(CallServerInterceptor.kt:93)
        at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
        at okhttp3.internal.connection.ConnectInterceptor.intercept(ConnectInterceptor.kt:34)
        at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
        at okhttp3.internal.cache.CacheInterceptor.intercept(CacheInterceptor.kt:95)
        at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
        at okhttp3.internal.http.BridgeInterceptor.intercept(BridgeInterceptor.kt:83)
        at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
        at okhttp3.internal.http.RetryAndFollowUpInterceptor.intercept(RetryAndFollowUpInterceptor.kt:76)
        at okhttp3.internal.http.RealInterceptorChain.proceed(RealInterceptorChain.kt:109)
        at okhttp3.internal.connection.RealCall.getResponseWithInterceptorChain$okhttp(RealCall.kt:201)
        at okhttp3.internal.connection.RealCall.execute(RealCall.kt:154)
        at com.rhapsody.BasicHttpClient.main(BasicHttpClient.java:81)
Caused by: java.nio.channels.AsynchronousCloseException
        at org.openziti.net.InputChannel$DefaultImpls.read$lambda$11(InputChannel.kt:159)
        at kotlinx.coroutines.InvokeOnCompletion.invoke(JobSupport.kt:1534)
        at kotlinx.coroutines.JobSupport.notifyCompletion(JobSupport.kt:1625)
        at kotlinx.coroutines.JobSupport.completeStateFinalization(JobSupport.kt:316)
        at kotlinx.coroutines.JobSupport.finalizeFinishingState(JobSupport.kt:233)
        at kotlinx.coroutines.JobSupport.tryMakeCompletingSlowPath(JobSupport.kt:946)
        at kotlinx.coroutines.JobSupport.tryMakeCompleting(JobSupport.kt:894)
        at kotlinx.coroutines.JobSupport.makeCompletingOnce$kotlinx_coroutines_core(JobSupport.kt:859)
        at kotlinx.coroutines.AbstractCoroutine.resumeWith(AbstractCoroutine.kt:98)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:46)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:99)
        at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:113)
        at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:89)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:589)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:823)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:720)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:707)

These are the CLI commands I used to set the client and server up:

ziti edge create identity http-client -a 'my-http-clients' -o http-client.jwt 
        ziti edge create identity http-server -a 'my-http-servers' -o http-server.jwt 

        ziti edge create config http.intercept.v1 intercept.v1 '{"protocols":["tcp"],"addresses":["http.ziti"], "portRanges":[{"low":8081, "high":8081}]}'
        ziti edge create config http.host.v1 host.v1 '{"protocol":"tcp", "address":"ziti-http-server", "port":8081}'

        ziti edge create service http.svc --configs http.intercept.v1,http.host.v1

        ziti edge create service-policy http.policy.dial Dial --service-roles "@http.svc" --identity-roles '#my-http-clients'

        ziti edge create service-policy http.policy.bind Bind --service-roles '@http.svc' --identity-roles "@fXzy2PmKV3"

I am struggling to figure out why my client can't call my server.

Any ideas would be much appreciated.

Ok, i figured out the issue was my server code wasn't responding to client requests.

So i've got it all working end-to-end now.

Thanks for all the help

2 Likes

Oh cool, i was writing up a big reply in between other tasks so it was taking a while to reply. I'm happy to hear you got it resolved! :slight_smile: