Skip to content

feat(secrets): secret management #1605

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions gradle/elide.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,10 @@ kotlinx-io-bytestring-jvm = { group = "org.jetbrains.kotlinx", name = "kotlinx-i
kotlinx-knit = { group = "org.jetbrains.kotlinx", name = "kotlinx-knit", version.ref = "kotlinx-knit" }
kotlinx-metadata-jvm = { group = "org.jetbrains.kotlinx", name = "kotlinx-metadata-jvm", version.ref = "kotlinx-metadata-jvm" }
kotlinx-nodejs = { group = "org.jetbrains.kotlinx", name = "kotlinx-nodejs", version.ref = "kotlinx-nodejs" }
kotlinx-serialization-cbor = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor", version.ref = "kotlinx-serialization" }
kotlinx-serialization-cbor-js = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor-js", version.ref = "kotlinx-serialization" }
kotlinx-serialization-cbor-jvm = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor-jvm", version.ref = "kotlinx-serialization" }
kotlinx-serialization-cbor-wasm = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor-wasm", version.ref = "kotlinx-serialization" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-core-js = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core-js", version.ref = "kotlinx-serialization" }
kotlinx-serialization-core-jvm = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core-jvm", version.ref = "kotlinx-serialization" }
Expand Down Expand Up @@ -685,6 +689,10 @@ kotlinx-wrappers-web = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin
ksp = { group = "com.google.devtools.ksp", name = "symbol-processing", version.ref = "ksp" }
ksp-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" }
ksp-cmdline = { group = "com.google.devtools.ksp", name = "symbol-processing-cmdline", version.ref = "ksp" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
ktor-client-contentNegotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
Expand Down Expand Up @@ -1017,3 +1025,9 @@ ktor-server = [
"ktor-server-hsts",
"ktor-server-websockets",
]

ktor-client = [
"ktor-client-core",
"ktor-client-contentNegotiation",
"ktor-serialization-kotlinx-json",
]
62 changes: 62 additions & 0 deletions packages/secrets/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2024-2025 Elide Technologies, Inc.
*
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://opensource.org/license/mit/
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/

import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import kotlin.io.path.absolutePathString
import elide.internal.conventions.kotlin.KotlinTarget

plugins {
alias(libs.plugins.elide.conventions)
kotlin("jvm")
kotlin("plugin.serialization")
alias(libs.plugins.ksp)
}

elide {
kotlin {
ksp = true
target = KotlinTarget.JVM
explicitApi = true
}
checks {
diktat = false
}
}

dependencies {
ksp(mn.micronaut.inject.kotlin)

api(projects.packages.base)
api(projects.packages.core)

implementation(libs.kotlinx.io)
implementation(libs.kotlinx.io.bytestring)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.cbor)

implementation(libs.bouncycastle)

// Ktor
implementation(libs.bundles.ktor.client)
implementation(libs.ktor.client.cio)

testImplementation(projects.packages.test)
testImplementation(libs.kotlin.test.junit5)
}

tasks.test {
systemProperty("elide.root", rootProject.layout.projectDirectory.asFile.toPath().absolutePathString())
jvmArgs.add("--enable-native-access=ALL-UNNAMED")
}
7 changes: 7 additions & 0 deletions packages/secrets/detekt-baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>ForbiddenComment:JvmLibraries.kt$JvmLibraries$// @TODO: don't hard-code any of this</ID>
</CurrentIssues>
</SmellBaseline>
28 changes: 28 additions & 0 deletions packages/secrets/src/main/kotlin/dev/elide/secrets/Console.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024-2025 Elide Technologies, Inc.
*
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://opensource.org/license/mit/
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/
package dev.elide.secrets

/**
* Console functions for secrets.
*
* @author Lauri Heino <datafox>
*/
internal interface Console {
fun print(string: String)

fun println(string: String)

fun readln(): String

fun readPassword(): String
}
110 changes: 110 additions & 0 deletions packages/secrets/src/main/kotlin/dev/elide/secrets/DataHandler.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2024-2025 Elide Technologies, Inc.
*
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://opensource.org/license/mit/
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/
package dev.elide.secrets

import dev.elide.secrets.dto.persisted.SecretCollection
import dev.elide.secrets.dto.persisted.SecretMetadata
import kotlinx.io.bytestring.ByteString

/**
* Data handling functions for secrets.
*
* @author Lauri Heino <datafox>
*/
internal interface DataHandler {
/** Returns `true` if the metadata file exists. */
suspend fun metadataExists(): Boolean

/** Reads and deserializes the metadata file. */
suspend fun readMetadata(): SecretMetadata

/** Serializes [metadata] and writes it to the metadata file. */
suspend fun writeMetadata(metadata: SecretMetadata)

/** Deserializes [data] to metadata. */
suspend fun deserializeMetadata(data: ByteString): SecretMetadata

/** Serializes [metadata]. */
suspend fun serializeMetadata(metadata: SecretMetadata): ByteString

/** Returns `true` if the local collection file exists. */
suspend fun localExists(): Boolean

/** Reads, decrypts and deserializes the local collection file with hashed [passphrase]. */
suspend fun readLocal(passphrase: ByteString): SecretCollection

/**
* Serializes [local], encrypts it with hashed [passphrase] and writes it to the local
* collection file.
*/
suspend fun writeLocal(passphrase: ByteString, local: SecretCollection)

/** Returns `true` if a collection file for [profile] exists. */
suspend fun collectionExists(profile: String): Boolean

/**
* Reads, decrypts and deserializes a collection file for [profile]. Decryption is done by
* reading an encrypted key with [readKey] and decrypting it with the hashed [passphrase].
*/
suspend fun readCollection(profile: String, passphrase: ByteString): SecretCollection

/** Reads an encrypted collection file for [profile]. */
suspend fun readEncryptedCollection(profile: String): ByteString

/**
* Serializes [collection], encrypts it and writes it to a collection file for [profile].
* Encryption is done by reading an encrypted key with [readKey] and decrypting it with the
* hashed [passphrase].
*/
suspend fun writeCollection(
profile: String,
passphrase: ByteString,
collection: SecretCollection,
): String

/** Deletes a collection file and a key file for [profile]. */
suspend fun deleteCollection(profile: String)

/** Decrypts [encrypted] collection with [key] and deserializes it. */
suspend fun decryptCollection(key: ByteString, encrypted: ByteString): SecretCollection

/** Serializes [collection] and encrypts it with [key]. */
suspend fun encryptCollection(key: ByteString, collection: SecretCollection): ByteString

/** Returns `true` if a key file for [profile] exists. */
suspend fun keyExists(profile: String): Boolean

/** Reads and decrypts a key file for [profile] with the hashed [passphrase]. */
suspend fun readKey(profile: String, passphrase: ByteString): ByteString

/** Encrypts [key] with the hashed [passphrase] and writes it to a key file for [profile]. */
suspend fun writeKey(profile: String, passphrase: ByteString, key: ByteString)

/** Decrypts an [encrypted] key with the hashed [passphrase]. */
suspend fun decryptKey(passphrase: ByteString, encrypted: ByteString): ByteString

/** Encrypts [key] with the hashed [passphrase]. */
suspend fun encryptKey(passphrase: ByteString, key: ByteString): ByteString

/** Creates remote passphrase validator data from [metadata] and hashed remote [passphrase] */
suspend fun createValidator(metadata: SecretMetadata, passphrase: ByteString): ByteString

/**
* Validates a remote passphrase [validator] with the [metadata] and hashed remote [passphrase]
*/
suspend fun validate(
metadata: SecretMetadata,
passphrase: ByteString,
validator: ByteString,
): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024-2025 Elide Technologies, Inc.
*
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://opensource.org/license/mit/
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/
package dev.elide.secrets

import kotlinx.io.bytestring.ByteString

/**
* Cryptography functions for secrets.
*
* @author Lauri Heino <datafox>
*/
internal interface Encryption {
/** Encrypts [data] with [key]. */
fun encrypt(key: ByteString, data: ByteString): ByteString

/** Decrypts [encrypted] with [key]. */
fun decrypt(key: ByteString, encrypted: ByteString): ByteString

/** Cryptographically hashes [passphrase] into a valid key. */
fun hash(passphrase: String): ByteString
}
109 changes: 109 additions & 0 deletions packages/secrets/src/main/kotlin/dev/elide/secrets/Secrets.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2024-2025 Elide Technologies, Inc.
*
* Licensed under the MIT license (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* https://opensource.org/license/mit/
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under the License.
*/
package dev.elide.secrets

import dev.elide.secrets.dto.persisted.Secret

/**
* Main entrypoint for secrets.
*
* @author Lauri Heino <datafox>
*/
public interface Secrets {
/** Initializes the secret system. */
public suspend fun init()

/** Creates a new profile. */
public suspend fun createProfile(profile: String)

/** Removes a profile. */
public suspend fun removeProfile(profile: String)

/** Returns names of all profiles. */
public suspend fun getProfiles(): List<String>

/**
* Returns names of all profiles on a remote, connecting to a remote if a connection has not
* been made.
*/
public suspend fun getRemoteProfiles(): List<String>

/**
* Updates specified [profiles] on the local by downloading them from a remote, connecting to a
* remote if a connection has not been made. If [profiles] is empty, downloads all profiles on
* the remote.
*/
public suspend fun updateLocal(vararg profiles: String)

/**
* Uploads specified [profiles] to a remote, connecting to a remote if a connection has not been
* made. If [profiles] is empty, uploads all profiles to the remote.
*/
public suspend fun updateRemote(vararg profiles: String)

/** Selects a profile for access to secrets. */
public suspend fun selectProfile(profile: String)

/**
* Returns a secret value with [name] from the selected profile, or throws an
* [IllegalStateException] if a profile has not been selected.
*/
public suspend fun getSecret(name: String): Any?

/** Returns a secret value with [name] from [profile]. */
public suspend fun getSecret(profile: String, name: String): Any?

/**
* Sets a secret to the selected profile, overwriting any existing secret with the same name, or
* throws an [IllegalStateException] if a profile has not been selected.
*/
public suspend fun setSecret(secret: Secret<*>)

/**
* Removes a secret value with [name] from the selected profile, or throws an
* [IllegalStateException] if a profile has not been selected.
*/
public suspend fun removeSecret(name: String)

/**
* Writes changes to the selected profile, or throws an [IllegalStateException] if a profile has
* not been selected.
*/
public suspend fun writeChanges()

/**
* Deselects the currently selected profile. Does nothing if a profile has not been selected.
*/
public suspend fun deselectProfile()

/** Removes the currently selected profile. */
public suspend fun removeProfile()

/**
* Removes the specified profile from a remote, connecting to a remote if a connection has not
* been made.
*/
public suspend fun removeRemoteProfile(profile: String)

public companion object {
/** Returns the return value of [getSecret] cast to the reified type [T]. */
public suspend inline fun <reified T> Secrets.getTypedSecret(name: String): T? =
getSecret(name) as T?

/** Returns the return value of [getSecret] cast to the reified type [T]. */
public suspend inline fun <reified T> Secrets.getTypedSecret(
profile: String,
name: String,
): T? = getSecret(profile, name) as T?
}
}
Loading
Loading