Kotlin / JVM SDK

Read secrets from any Kotlin or Java application using Ed25519 machine authentication, on persistent hosts or serverless and ephemeral environments.

Installation

Gradle

dependencies {
    implementation("io.github.sikkerkeyofficial:sikkerkey-sdk:1.2.0")
}

Maven

<dependency>
    <groupId>io.github.sikkerkeyofficial</groupId>
    <artifactId>sikkerkey-sdk</artifactId>
    <version>1.2.0</version>
</dependency>

Single dependency: kotlinx-serialization-json. Requires Java 17+.

Quick Start

import com.sikker.key.sdk.SikkerKey

val sk = SikkerKey("vault_abc123")
val secret = sk.getSecret("sk_a1b2c3d4e5")

The SDK loads the machine identity from ~/.sikkerkey/vaults/{vaultId}/identity.json, signs every request with the machine's Ed25519 private key, and returns the decrypted value. All methods are synchronous (blocking).

Client Creation

// Explicit vault ID
val sk = SikkerKey("vault_abc123")

// Direct path to identity file
val sk = SikkerKey("/etc/sikkerkey/vaults/vault_abc123/identity.json")

// Auto-detect from SIKKERKEY_IDENTITY env or single vault on disk
val sk = SikkerKey()

Throws ConfigurationException if the identity is missing, the key can't be loaded, or multiple vaults exist without a specified vault ID.

Serverless (Memory-Only Bootstrap)

On a long-lived host the SDK loads a persistent identity from disk. Serverless and other ephemeral or read-only-filesystem environments (AWS Lambda, Google Cloud Run, Fly.io, and similar) have no identity to persist. SikkerKey.bootstrapInMemory() handles that case: it generates an Ed25519 keypair in memory, enrolls a short-lived ephemeral machine with an enrollment token, and returns a ready client, without writing anything to disk.

val sk = SikkerKey.bootstrapInMemory(
    System.getenv("SIKKERKEY_VAULT_ID"),
    System.getenv("SIKKERKEY_ENROLLMENT_TOKEN"),
)

val dbUrl = sk.getSecret("sk_db_prod")

Create an enrollment token in the dashboard and supply its plaintext plus your vault ID. The token only registers an ephemeral machine scoped to the policy you set (projects, secrets, lifetime); it cannot read secrets on its own.

How It Works

  • bootstrapInMemory() enrolls once, when you call it: it generates a keypair in memory, registers an ephemeral machine, and returns a ready SikkerKey.
  • The returned client signs every read with the in-memory key, exactly like one created from disk.
  • Nothing is written to disk. The private key lives only in process memory and is gone when the process exits.
  • The ephemeral machine lives for the lifetime set on the enrollment token. Reading after it expires throws AuthenticationException, so set the token's machine lifetime to suit your workload. The common path is to read secrets at startup and hold the values.

Options

val sk = SikkerKey.bootstrapInMemory(
    vaultId,
    token,
    hostname = "worker-1",   // defaults to $HOSTNAME, then "serverless"
    name = "batch-runner",   // overridden if the token defines a name pattern
)

Provisioning the Token for Serverless

When you create the enrollment token for a serverless deployment:

  • Set a short machine lifetime (minutes). Each cold start mints a fresh ephemeral machine, and short-lived ones free their slot quickly as they expire.
  • Set max-uses high enough for your cold-start and concurrency volume.
  • Leave the source-CIDR restriction unset, since serverless egress IPs are dynamic.
  • If the vault has an IP allowlist, make sure it permits the platform's egress or leave it off. Enrollment enforces the allowlist.
  • Set a name pattern on the token (for example serverless-{uuid8}) so each cold-start machine gets a unique name. A name pattern takes precedence over name. {uuidN} inserts N random characters (4 to 32, default 8); {uuid} inserts 8.

Each live ephemeral machine counts against your plan's machine limit until it expires and is cleaned up. Requires outbound HTTPS.

Reading Secrets

Single Value

val apiKey = sk.getSecret("sk_stripe_prod")

Structured (Multiple Fields)

val fields = sk.getFields("sk_db_prod")
val host = fields["host"]       // "db.example.com"
val password = fields["password"] // "hunter2"

Throws SecretStructureException if the secret value is not a JSON object.

Single Field

val password = sk.getField("sk_db_prod", "password")

Throws FieldNotFoundException if the field doesn't exist. The error message includes available field names.

Listing Secrets

// All secrets this machine can access
val secrets = sk.listSecrets()
for (s in secrets) {
    println("${s.id}: ${s.name}")
}

// Secrets in a specific project
val projectSecrets = sk.listSecretsByProject("proj_production")

Returns List<SecretListItem> with id, name, fieldNames (nullable), and projectId (nullable).

Export

// All secrets as a flat map
val env = sk.export()
// {API_KEY=sk-live-..., DB_CREDS_HOST=db.example.com, DB_CREDS_PASSWORD=s3cret}

// Scoped to a project
val env = sk.export("proj_production")

// Inject into system properties
sk.export().forEach { (k, v) -> System.setProperty(k, v) }

Structured secrets are flattened: SECRET_NAME_FIELD_NAME.

Watching for Changes

Watch secrets for real-time updates. When a secret is rotated, updated, or deleted, the callback fires with the new value.

sk.watch("sk_db_credentials") { event ->
    when (event.status) {
        WatchStatus.CHANGED -> {
            // event.value has the new value
            // event.fields has parsed key-value pairs for structured secrets
            Database.configureCredentials(
                event.fields!!["username"]!!,
                event.fields["password"]!!
            )
        }
        WatchStatus.DELETED -> println("Secret deleted")
        WatchStatus.ACCESS_DENIED -> println("Access revoked")
        WatchStatus.ERROR -> println("Error: ${event.error}")
    }
}

Polling starts automatically on the first watch() call and runs on a background daemon thread. Default interval is 15 seconds (server enforces a 10-second minimum).

sk.setPollInterval(30) // seconds
sk.unwatch("sk_db_credentials") // stop watching one secret
sk.close() // stop all watches

SikkerKey implements AutoCloseable.

Java Interop

import com.sikker.key.sdk.SikkerKey;

var sk = SikkerKey.Companion.invoke("vault_abc123");
var secret = sk.getSecret("sk_stripe_key");

var fields = sk.getFields("sk_db_prod");
var host = fields.get("host");

Multi-Vault

val prod = SikkerKey("vault_a1b2c3")
val staging = SikkerKey("vault_x9y8z7")

val prodKey = prod.getSecret("sk_api_key")
val stagingKey = staging.getSecret("sk_api_key")

List Registered Vaults

val vaults = SikkerKey.listVaults()
// ["vault_a1b2c3", "vault_x9y8z7"]

Companion object function.

Identity Resolution

  1. Explicit path - starts with / or contains identity.json
  2. Vault ID - looks up ~/.sikkerkey/vaults/{vaultId}/identity.json
  3. SIKKERKEY_IDENTITY env - path to identity file
  4. Auto-detect - single vault on disk

The vault_ prefix is added automatically if not present. Override base directory with SIKKERKEY_HOME.

Error Handling

import com.sikker.key.sdk.*

try {
    val secret = sk.getSecret("sk_nonexistent")
} catch (e: NotFoundException) {
    // 404 - secret doesn't exist
} catch (e: AccessDeniedException) {
    // 403 - machine not approved or no grant
} catch (e: AuthenticationException) {
    // 401 - invalid signature or unknown machine
} catch (e: ApiException) {
    // any other HTTP error
    println(e.httpStatus)
}

Exception Hierarchy

SikkerKeyException (extends RuntimeException)
├── ConfigurationException      - identity/key issues
├── SecretStructureException    - secret is not a JSON object (getFields)
├── FieldNotFoundException      - field not in structured secret (getField)
└── ApiException                - HTTP error (has httpStatus property)
    ├── AuthenticationException - 401
    ├── AccessDeniedException   - 403
    ├── NotFoundException       - 404
    ├── ConflictException       - 409
    ├── RateLimitedException    - 429
    └── ServerSealedException   - 503

All exceptions extend RuntimeException (unchecked).

Properties

PropertyTypeDescription
machineIdStringMachine UUID
machineNameStringHostname from bootstrap
vaultIdStringVault this identity belongs to
apiUrlStringSikkerKey API URL

Method Reference

MethodReturnsDescription
SikkerKey(vaultOrPath?)SikkerKeyCreate client (companion invoke)
SikkerKey.bootstrapInMemory(vaultId, token, hostname?, name?)SikkerKeyMemory-only serverless bootstrap (companion)
SikkerKey.listVaults()List<String>List registered vault IDs (companion)
getSecret(secretId)StringRead a secret value
getFields(secretId)Map<String, String>Read structured secret
getField(secretId, field)StringRead single field
listSecrets()List<SecretListItem>List all accessible secrets
listSecretsByProject(projectId)List<SecretListItem>List secrets in a project
export(projectId?)Map<String, String>Export as env map
watch(secretId, callback)UnitWatch a secret for changes
unwatch(secretId)UnitStop watching a secret
setPollInterval(seconds)UnitSet poll interval (min 10s)
close()UnitStop all watches, shut down polling

Retry Behavior

429 and 503 responses are retried up to 3 times with exponential backoff (1s, 2s, 4s). Each retry uses a fresh timestamp and nonce. Network errors (IOException) are also retried.

Environment Variables

VariableDescription
SIKKERKEY_IDENTITYPath to identity.json - overrides vault lookup
SIKKERKEY_HOMEBase config directory (default: ~/.sikkerkey)

Dependencies

DependencyVersionPurpose
kotlinx-serialization-json>=1.7.3JSON parsing

Ed25519 signing uses java.security.Signature (JDK built-in, Java 17+). HTTP uses java.net.HttpURLConnection. No external HTTP client.

All HTTP requests have 15-second connect and read timeouts. HTTPS enforced for non-localhost.