Secrets and Certificates

I've been diving deep on the new API and I don't see anything in the new SecretProvider interface that would help control access to the private part of SSL encryption key pairs. Did I miss something, or is it just not yet implemented?

I suppose I should be using the byte array methods of the Plaintext class? With an ephemeral KeyStore?

Could you give some more information regarding your use case:

  • How is the key pair managed? Is this a config secret that a user with config write permission will manage from the gateway config page? Is this a key pair managed by the user somewhere else in the product? Is this a key pair that is not managed by a user, but instead managed by the system?
  • How do you want to store your key pair?

You could make the key pair itself the secret. In that case, you'll need to serialize the key pair as a String or byte array in whatever format you want, then wrap that in a Plaintext instance.

Or you might store the key pair in a Keystore file outside of the secrets system. You might make the password used to protect the Keystore the secret. Again, wrap that password in a Plaintext instance.

There are two ways to store a secret using the new 8.3 APIs:

  • Embedded
    • You use the SystemEncryptionService provided by the GatewayContext to encrypt your plaintext into a ciphertext using the platform-managed encryption keys.
    • You embed the ciphertext in your config
  • Referenced
    • The secret is stored in a SecretProvider.
    • You include a reference to the secret in the secret provider within your config.
    • The reference is the pair of (1) the name of the Secret Provider and (2) the name of the Secret stored in the Secret Provider

We have a SecretConfig class which offers structures for these two types of secrets.

  1. SecretConfig#embedded static factory method:
    /**
     * Factory method for creating a new instance of an {@link Embedded} secret config given the ciphertext secret
     * retrieved from a call to {@link SystemEncryptionService#encryptToJson(Plaintext)}.
     *
     * @param ciphertext the ciphertext secret. cannot be null.
     * @return a new instance of an {@link Embedded} secret config with the given ciphertext secret
     * @throws NullPointerException if the given ciphertext argument is null
     */
    public static Embedded embedded(JsonElement ciphertext) {
        return new Embedded(ciphertext);
    }
  1. SecretConfig#referenced static factory method:
    /**
     * Factory method for creating a new instance of a {@link Referenced} secret config given the name of the
     * secret provider and the name of the secret stored in the provider.
     *
     * @param providerName the name of the secret provider where the secret is stored. cannot be null.
     * @param secretName the name of the secret stored in the secret provider. cannot be null.
     * @return a new instance of a {@link Referenced} secret config pointing to the target secret provider and secret
     * @throws NullPointerException if either the providerName or secretNames arguments are null
     */
    public static Referenced referenced(String providerName, String secretName) {
        return new Referenced(providerName, secretName);
    }

As far as getting access to the plaintext value of the secret, we have a Secret class which abstracts away the implementation details for dealing with embedded vs referenced secrets.

You first grab the SecretConfig from wherever you are storing your configuration settings. Then create an instance of Secret given the GatewayContext and your SecretConfig using the Secret#create static factory method:

    /**
     * Factory method to create a new instance of a Secret
     *
     * @param context the current {@link GatewayContext}. cannot be null.
     * @param config  the {@link SecretConfig} of the secret. cannot be null.
     * @return a new instance of Secret
     * @throws IllegalArgumentException if the provided config is not an embedded or referenced secret
     * @throws NullPointerException     if either context or config arguments are null
     */
    public static Secret<?> create(GatewayContext context, SecretConfig config) {
        if (config.isEmbedded()) {
            SecretConfig.Embedded embedded = config.getAsEmbedded();
            return new Embedded(context, embedded);
        } else if (config.isReferenced()) {
            SecretConfig.Referenced referenced = config.getAsReferenced();
            return new Referenced(context, referenced);
        } else {
            throw new IllegalArgumentException("unsupported secret config '%s'".formatted(config));
        }
    }

Then call Secret#getPlaintext from your instance of Secret every time you wish to grab the plaintext secret value:

    /**
     * <p>Get the current {@link Plaintext} value of the configured secret.</p>
     * <p>If the configured secret is embedded, the Plaintext value is retrieved by passing the embedded ciphertext
     * to the {@link SystemEncryptionService} retrieved from the {@link GatewayContext}.</p>
     * <p>If the configured secret is referenced, the Plaintext value is retrieved by getting the
     * {@link SecretProviderManager} from the {@link GatewayContext}, then getting the {@link SecretProvider} with the
     * configured name from the SecretProviderManager, and finally {@link SecretProvider#read(String) reading} the
     * Plaintext value of the secret with the configured name from the SecretProvider.</p>
     *
     * @return the current {@link Plaintext} value of the configured secret. never null.
     * @throws SecretException if there is a problem getting the current Plaintext value of the configured secret
     */
    public abstract Plaintext getPlaintext() throws SecretException;

Your code should expect a SecretException and deal with being unable to grab the plaintext value of the secret. For example: if the secret is encrypted with keys which no longer exist or if it is referencing a provider or secret in the provider which no longer exists, or perhaps the secret provider is remote and a network connection is failing.

If possible, your code should also be written to grab the plaintext from the Secret instance each time it is needed, instead of grabbing it once at startup and holding onto that value for the lifetime of the object. This allows for dynamically rotating secrets in a secret provider, otherwise you would have to have a way to manually reload the plaintext from the Secret instance every time the value of the secret in the provider changes.

Hope that helps start you in the right direction at least. But please let me know if you have any further questions or if something isn't adding up right for you. I believe you are the first adopter outside of our own devs for these APIs :slight_smile:

1 Like

And for most of us this is just the thing we have to deal with instead of the old EncodedStringField :upside_down_face:

Yeah that's a good point: when you are migrating your 8.1 PersistentRecords to 8.3's config resources, you will typically store what were EncodedStringFields from your old record class into SecretConfigs on your new resource class. Ignition's default resource migration logic will migrate these EncodedStringFields to SecretConfigs for you, unless you use your own custom migration logic, in which case you will have to grab the plaintext from the EncodedStringField, wrap it in a Plaintext, encrypt it using SystemEncryptionService, wrap that in a SecretConfig.Embedded, and then store that SecretConfig onto your new resource.

I made myself some extension functions:

object SecretConfigKtx {

    /**
     * Get the plaintext bytes from the secret described by this [SecretConfig].
     */
    fun SecretConfig.getPlaintextBytes(context: GatewayContext): Result<ByteArray> {
        val secret = Secret.create(context, this)
        return runCatching { secret.plaintext.use { it.bytes } }
    }

    /**
     * Get the plaintext string from the secret described by this [SecretConfig].
     */
    fun SecretConfig.getPlaintextString(context: GatewayContext): Result<String> {
        val secret = Secret.create(context, this)
        return runCatching { secret.plaintext.use { it.asString } }
    }

    /**
     * Encrypt this String using the system encryption service and create an embedded [SecretConfig].
     */
    fun String.toSecretConfig(context: GatewayContext): Result<SecretConfig> {
        val plaintext = Plaintext.fromString(this)

        return runCatching {
            val ciphertext: JsonElement = context.systemEncryptionService.encryptToJson(plaintext)
            SecretConfig.embedded(ciphertext)
        }
    }

    /**
     * Encrypt this ByteArray using the system encryption service and create an embedded [SecretConfig].
     */
    fun ByteArray.toSecretConfig(context: GatewayContext): Result<SecretConfig> {
        val plaintext = Plaintext.fromBytes(this)

        return runCatching {
            val ciphertext: JsonElement = context.systemEncryptionService.encryptToJson(plaintext)
            SecretConfig.embedded(ciphertext)
        }
    }

}

Used like:

val keyPair: KeyPair? = OpcUaModule.clientKeyStore.getKeyPair(
    extensionConfig.security.keyStoreAlias,
    extensionConfig.security.keyStoreAliasPassword.getPlaintextString(context).getOrThrow()
)

Unfortunately this highlights a bit of a missed opportunity, and might be what Phil is sort of asking about, which is that a number of KeyStores are still sitting around on the filesystem, despite being something you may consider a secret itself. But at least it's password protected and that password is managed now...

You don't have to store your keystore directly on the filesystem - you can store any arbitrary byte array in the Plaintext. It follows that you can serialize your keystore into a byte array, wrap that into a Plaintext, and store that as a secret (either embedded or referenced).

Storing the keystore directly as a file has its advantages. Easier to work with directly on the filesystem outside of Ignition using standard open source tooling. Think of workflows like Let's Encrypt where the TLS certificate is periodically rotated by an external agent such as certbot. It's handy to be able to generate the new keystore and drop it into a well known config file location, then poke the Gateway to pick that up without any downtime.

Though I suppose we could build ACME support into the Gateway directly some day so that Ignition does not have to rely on external agents or custom modules / user scripts to automate cert rotation...

Ok. This all is very helpful.

  • Two protocols I implement have SSL extensions that I do not yet implement, EtherNet/IP and Modbus. Neither cares much how I store/manage secrets, except that client certificates must be signed by a CA that the target devices trust. This is typically not in the collection of global roots.

  • Multiple connections to different devices may require unique certs, even if the private key underneath is shared. A redundant pair may need to use the same key on some connections, and unique keys on others. I'd prefer that private key, instance certificate, root certificate, and intermediate be independent secrets. Ephemeral KeyStore and ephemeral TrustManager in ephemeral SSLContext, it seems.

  • I want to hew as close as possible to the storage methods and standards that minimize the amount of React I have to do, as I suck at that. :man_shrugging: Putting just the pass phrases for the above into the Secrets Provider sounds attractive.

  • If I build my structure around SecretConfig, can I conveniently support either embedded or referenced on user choice?

  • Is there a way to load a secret into a keystore such that SSL renegotiation uses the new content? I was otherwise going to build the context on every connection setup.

I don't have any secrets in any of my current modules for v8.1. So, no encoded string fields. :grin:

So sounds like these secrets are tied to configuration resources, yeah?

If that's the case, make each of your settings which you want to be secrets of type SecretConfig on your resource class.

If I build my structure around SecretConfig, can I conveniently support either embedded or referenced on user choice?

Yes, if we are talking about Gateway config resources here, right?

The web UI devs are working on a reusable react component for editing secrets, driven by the SecretConfig. Here's a sneak peak at the mockup:

This component should be available to third party devs writing their own custom config editor components. There is also work being done to allow the option of having the Platform generate config UI based on a descriptor of your settings. This automatically generated UI should be smart enough to render this Secret Editor component each time it encounters a secret field in the descriptor.

Is there a way to load a secret into a keystore such that SSL renegotiation uses the new content? I was otherwise going to build the context on every connection setup.

This should be possible. This gets into the JSSE APIs. I'll have to circle back after I have some time to write a proof of concept...

1 Like