diff --git a/gradle/elide.versions.toml b/gradle/elide.versions.toml index b8ec58dfd6..1614c8cb24 100644 --- a/gradle/elide.versions.toml +++ b/gradle/elide.versions.toml @@ -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" } @@ -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" } @@ -1017,3 +1025,9 @@ ktor-server = [ "ktor-server-hsts", "ktor-server-websockets", ] + +ktor-client = [ + "ktor-client-core", + "ktor-client-contentNegotiation", + "ktor-serialization-kotlinx-json", +] diff --git a/packages/secrets/build.gradle.kts b/packages/secrets/build.gradle.kts new file mode 100644 index 0000000000..cb1c23f21c --- /dev/null +++ b/packages/secrets/build.gradle.kts @@ -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") +} diff --git a/packages/secrets/detekt-baseline.xml b/packages/secrets/detekt-baseline.xml new file mode 100644 index 0000000000..f4dd1b9697 --- /dev/null +++ b/packages/secrets/detekt-baseline.xml @@ -0,0 +1,7 @@ + + + + + ForbiddenComment:JvmLibraries.kt$JvmLibraries$// @TODO: don't hard-code any of this + + diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/Console.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/Console.kt new file mode 100644 index 0000000000..abdc661022 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/Console.kt @@ -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 + */ +internal interface Console { + fun print(string: String) + + fun println(string: String) + + fun readln(): String + + fun readPassword(): String +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/DataHandler.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/DataHandler.kt new file mode 100644 index 0000000000..9db036e8b2 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/DataHandler.kt @@ -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 + */ +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 +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/Encryption.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/Encryption.kt new file mode 100644 index 0000000000..c2c096195d --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/Encryption.kt @@ -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 + */ +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 +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/Secrets.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/Secrets.kt new file mode 100644 index 0000000000..2c94bfa0a9 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/Secrets.kt @@ -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 + */ +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 + + /** + * Returns names of all profiles on a remote, connecting to a remote if a connection has not + * been made. + */ + public suspend fun getRemoteProfiles(): List + + /** + * 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 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 Secrets.getTypedSecret( + profile: String, + name: String, + ): T? = getSecret(profile, name) as T? + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/SecretsState.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/SecretsState.kt new file mode 100644 index 0000000000..b58e189fcd --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/SecretsState.kt @@ -0,0 +1,34 @@ +/* + * 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.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.io.files.Path + +/** + * State of secrets. + * + * @property interactive If `true`, reading input from the console is permitted. + * @property path path containing secrets files. + */ +internal class SecretsState(val interactive: Boolean, val path: Path) { + companion object { + private val _instance: CompletableDeferred = CompletableDeferred() + val instance: Deferred = _instance + + fun set(state: SecretsState) = _instance.complete(state) + + suspend fun get(): SecretsState = instance.await() + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/Utils.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/Utils.kt new file mode 100644 index 0000000000..853ff29356 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/Utils.kt @@ -0,0 +1,198 @@ +/* + * 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.Utils.confirm +import dev.elide.secrets.Utils.options +import dev.elide.secrets.dto.persisted.SecretMetadata +import java.security.MessageDigest +import java.security.SecureRandom +import kotlinx.io.bytestring.ByteString + +/** + * Internal utilities for secrets. + * + * @author Lauri Heino + */ +internal object Utils { + /** Generates [size] bytes. */ + fun generateBytes(size: Int): ByteString = + ByteString(ByteArray(size).apply { SecureRandom().nextBytes(this) }) + + /** + * Lists strings from [options] to [console's][console] output, then reads user's choice from + * input and invokes the selected option's lambda. Loops if invalid input is given. + */ + fun options(console: Console, options: List Unit>>) { + while (true) { + console.println("Options: ") + options.forEachIndexed { index, (prompt, _) -> + console.println(" ${index + 1}: $prompt") + } + console.print("Please select an option: ") + val result: String = console.readln().trim() + val choice = result.toIntOrNull() + if (choice == null || choice < 1 || choice > options.size) + console.println("Invalid option '$result'") + else { + options[choice - 1].second() + return + } + } + } + + /** + * Prints a yes/no confirmation prompt to [console] and reads user's choice and returns a + * boolean. Loops if invalid input is given. + */ + fun confirm(console: Console, confirmation: String): Boolean { + while (true) { + console.print("$confirmation [y/n]: ") + when (console.readln().trim()) { + "y" -> return true + "n" -> return false + } + } + } + + /** + * Reads an input from [console], then [confirms][confirm] that the correct input was given. + * Loops until [confirm] returns `true`. + */ + fun readWithConfirm( + console: Console, + prompt: String, + prefix: String = "Is \"", + suffix: String = "\" correct?", + ): String { + while (true) { + console.print(prompt) + val input: String = console.readln() + if (confirm(console, "$prefix$input$suffix")) return input + } + } + + /** Returns the file name of a collection for [profile]. */ + fun collectionName(profile: String): String = + "${name(profile)}${Values.COLLECTION_FILE_EXTENSION}" + + /** Returns the file name of a key for [profile]. */ + fun keyName(profile: String): String = "${name(profile)}${Values.KEY_FILE_EXTENSION}" + + /** Returns the file name of the files for a [profile] without an extension. */ + fun name(profile: String): String = + "${Values.FILE_NAME_PREFIX}${Values.PROFILE_SEPARATOR}$profile" + + /** Calculates the GitHub-specific SHA-1 hash of [data]. */ + @OptIn(ExperimentalStdlibApi::class) + fun sha(data: ByteString): String = + MessageDigest.getInstance("SHA-1") + .digest("blob ${data.size}\u0000".encodeToByteArray() + data.toByteArray()) + .toHexString() + + /** + * Reads a passphrase from [console] twice and checks if they are identical. Returns the + * passphrase if they are, looping otherwise. + */ + fun passphrase( + console: Console, + prompt: String, + repeatPrompt: String, + invalid: String, + ): String { + while (true) { + console.print(prompt) + val entry: String = console.readPassword() + console.print(repeatPrompt) + val repeat: String = console.readPassword() + if (entry == repeat) { + return entry + } + console.println(invalid) + } + } + + /** + * Throws an [IllegalArgumentException] if a profile name is invalid (is empty or contains + * whitespace). + */ + fun checkName(name: String, type: String) { + if (name.isEmpty() || ' ' in name) + throw IllegalArgumentException("$type name must not be empty or contain spaces") + } + + /** + * Checks if [initialPassphrase] is valid by calling [decrypt]. If it returns an [O], a [Pair] + * is returned that contains the passphrase and the returned [O]. If it returns `null` and + * [interactive] is `true`, reads a new [P] with [readPassphrase] up to + * [Values.INVALID_PASSPHRASE_TRIES] times, checking each one with [decrypt] and returning a + * pair of [P] and [O] if not `null`, and throws an [IllegalArgumentException] if the [P] was + * invalid too many times. If [interactive] is `false` and the initial [decrypt] returns `null`, + * an [IllegalStateException] is thrown. + */ + suspend fun checkPassphrase( + console: Console, + interactive: Boolean, + initialPassphrase: P, + name: String, + readPassphrase: suspend () -> P, + decrypt: suspend (P) -> O?, + ): Pair { + var tries = Values.INVALID_PASSPHRASE_TRIES + var passphrase = initialPassphrase + while (tries > 0) { + tries-- + val out = decrypt(passphrase) + if (out != null) return Pair(passphrase, out) + if (!interactive) + throw IllegalStateException("Invalid $name was given and interactive mode is off") + console.println("Invalid $name") + passphrase = readPassphrase() + } + return decrypt(passphrase)?.let { Pair(passphrase, it) } + ?: throw IllegalArgumentException("Invalid $name entered too many times") + } + + /** + * Checks if [initialPassphrase] is valid by calling [DataHandler.validate]. If it returns + * `true`, [initialPassphrase] is returned. Otherwise, if [interactive] is `true`, reads a new + * passphrase with [readPassphrase] up to [Values.INVALID_PASSPHRASE_TRIES] times, checking each + * one with [DataHandler.validate] and returning it if `true`, and throws an + * [IllegalArgumentException] if the passphrase was invalid too many times. If [interactive] is + * `false` and the initial [DataHandler.validate] returns `false`, an [IllegalStateException] is + * thrown. + */ + suspend fun checkValidatorPassphrase( + dataHandler: DataHandler, + console: Console, + interactive: Boolean, + metadata: SecretMetadata, + initialPassphrase: ByteString, + validator: ByteString, + name: String, + readPassphrase: suspend () -> ByteString, + ): ByteString { + var tries = Values.INVALID_PASSPHRASE_TRIES + var passphrase = initialPassphrase + while (tries > 0) { + tries-- + if (dataHandler.validate(metadata, passphrase, validator)) return passphrase + if (!interactive) + throw IllegalStateException("Invalid $name was given and interactive mode is off") + console.println("Invalid $name") + passphrase = readPassphrase() + } + if (dataHandler.validate(metadata, passphrase, validator)) return passphrase + else throw IllegalStateException("Invalid $name entered too many times") + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/Values.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/Values.kt new file mode 100644 index 0000000000..60c315233b --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/Values.kt @@ -0,0 +1,34 @@ +/* + * 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 + +/** + * Internal constants for secrets. + * + * @author Lauri Heino + */ +internal object Values { + const val KEY_SIZE = 32 + const val IV_SIZE = 16 + const val HASH_ITERATIONS = 4096 + const val DEFAULT_PATH = ".elide-secrets" + const val METADATA_NAME = "metadata.json" + const val LOCAL_COLLECTION_NAME = "local.db" + const val VALIDATOR_NAME = "validator" + const val FILE_NAME_PREFIX = "secrets" + const val PROFILE_SEPARATOR = "-" + const val COLLECTION_FILE_EXTENSION = ".db" + const val KEY_FILE_EXTENSION = ".key" + const val PASSPHRASE_ENVIRONMENT_VARIABLE = "ELIDE_SECRETS_PASSPHRASE" + const val INVALID_PASSPHRASE_TRIES = 3 +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCommitsRequest.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCommitsRequest.kt new file mode 100644 index 0000000000..0856ec25c2 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCommitsRequest.kt @@ -0,0 +1,23 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * API request for GitHub commit information. + * + * @author Lauri Heino + */ +@Serializable internal data class GithubCommitsRequest(@SerialName("per_page") val perPage: Int) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCommitsResponse.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCommitsResponse.kt new file mode 100644 index 0000000000..36fc48f710 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCommitsResponse.kt @@ -0,0 +1,22 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.Serializable + +/** + * API response for a GitHub commit information request. + * + * @author Lauri Heino + */ +@Serializable internal data class GithubCommitsResponse(val sha: String) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCreateRefRequest.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCreateRefRequest.kt new file mode 100644 index 0000000000..caacf10c7c --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCreateRefRequest.kt @@ -0,0 +1,22 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.Serializable + +/** + * API request for creating a reference on GitHub. + * + * @author Lauri Heino + */ +@Serializable internal data class GithubCreateRefRequest(val ref: String, val sha: String) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCreateRefResponse.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCreateRefResponse.kt new file mode 100644 index 0000000000..9e618d7817 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubCreateRefResponse.kt @@ -0,0 +1,22 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.Serializable + +/** + * API response for a GitHub reference creation request. + * + * @author Lauri Heino + */ +@Serializable internal data class GithubCreateRefResponse(val ref: String) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubDeleteFileRequest.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubDeleteFileRequest.kt new file mode 100644 index 0000000000..a70e9456c6 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubDeleteFileRequest.kt @@ -0,0 +1,27 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.Serializable + +/** + * API request for deleting a file on GitHub. + * + * @author Lauri Heino + */ +@Serializable +internal data class GithubDeleteFileRequest( + val message: String, + val sha: String, + val branch: String, +) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubFileResponse.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubFileResponse.kt new file mode 100644 index 0000000000..dcfb1a9205 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubFileResponse.kt @@ -0,0 +1,27 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * API response for a GitHub file request. + * + * @author Lauri Heino + */ +@Serializable +internal data class GithubFileResponse( + val sha: String, + @SerialName("download_url") val downloadUrl: String, +) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubMergeRequest.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubMergeRequest.kt new file mode 100644 index 0000000000..837d190bc6 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubMergeRequest.kt @@ -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.dto.api.github + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * API request for a GitHub merge. + * + * @author Lauri Heino + */ +@Serializable +internal data class GithubMergeRequest( + val base: String, + val head: String, + @SerialName("commit_message") val commitMessage: String, +) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubMergeResponse.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubMergeResponse.kt new file mode 100644 index 0000000000..2485fe9a35 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubMergeResponse.kt @@ -0,0 +1,22 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.Serializable + +/** + * API response for a GitHub merge request. + * + * @author Lauri Heino + */ +@Serializable internal data class GithubMergeResponse(val ref: String) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubRepositoryResponse.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubRepositoryResponse.kt new file mode 100644 index 0000000000..c771bac753 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubRepositoryResponse.kt @@ -0,0 +1,30 @@ +/* + * 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.dto.api.github + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * API response for a GitHub repository information request. + * + * @author Lauri Heino + */ +@Serializable +internal data class GithubRepositoryResponse(val private: Boolean, val permissions: Permissions) { + @Serializable + data class Permissions( + @SerialName("push") val write: Boolean, + @SerialName("pull") val read: Boolean, + ) +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubUploadFileRequest.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubUploadFileRequest.kt new file mode 100644 index 0000000000..7ed5335e17 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/api/github/GithubUploadFileRequest.kt @@ -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.dto.api.github + +import kotlinx.serialization.Serializable + +/** + * API request for uploading a file to GitHub. + * + * @author Lauri Heino + */ +@Serializable +internal data class GithubUploadFileRequest( + val message: String, + val content: String, + val sha: String = "", + val branch: String = "", +) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/BinarySecret.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/BinarySecret.kt new file mode 100644 index 0000000000..edcede95e8 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/BinarySecret.kt @@ -0,0 +1,49 @@ +/* + * 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.dto.persisted + +import dev.elide.secrets.Utils +import kotlinx.io.bytestring.ByteString +import kotlinx.serialization.Serializable + +/** + * [Secret] containing binary data. + * + * @author Lauri Heino + */ +@Serializable +public data class BinarySecret(override val name: String, override val value: ByteArray) : + Secret { + + public constructor(name: String, value: ByteString) : this(name, value.toByteArray()) + + init { + Utils.checkName(name, "Secret") + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BinarySecret) return false + + if (name != other.name) return false + if (!value.contentEquals(other.value)) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + value.contentHashCode() + return result + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/CollectionMetadata.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/CollectionMetadata.kt new file mode 100644 index 0000000000..9a48aca4ab --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/CollectionMetadata.kt @@ -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.dto.persisted + +import dev.elide.secrets.Utils +import kotlinx.serialization.Serializable + +/** + * Metadata for a [SecretCollection]. + * + * @author Lauri Heino + */ +@Serializable +public data class CollectionMetadata(val profile: String, val sha: String) { + init { + Utils.checkName(profile, "Profile") + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/Secret.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/Secret.kt new file mode 100644 index 0000000000..a1a08ca3e7 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/Secret.kt @@ -0,0 +1,26 @@ +/* + * 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.dto.persisted + +import kotlinx.serialization.Serializable + +/** + * A stored secret. + * + * @author Lauri Heino + */ +@Serializable +public sealed interface Secret { + public val name: String + public val value: T +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/SecretCollection.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/SecretCollection.kt new file mode 100644 index 0000000000..2ead3ac2f8 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/SecretCollection.kt @@ -0,0 +1,47 @@ +/* + * 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.dto.persisted + +import kotlinx.io.bytestring.ByteString +import kotlinx.serialization.Serializable + +/** + * Collection for [Secrets][Secret]. + * + * @author Lauri Heino + */ +@Serializable +public data class SecretCollection(val secrets: Map>) { + public constructor() : this(emptyMap()) + + init { + secrets.forEach { + if (it.key != it.value.name) + throw IllegalStateException( + "Secret name ${it.value.name} does not match map key ${it.key}" + ) + } + } + + public fun add(secret: Secret<*>): SecretCollection { + return copy(secrets = secrets + (secret.name to secret)) + } + + public inline operator fun get(name: String): T? { + return if (T::class == ByteString::class) { + (secrets[name]?.value as? ByteArray)?.let { ByteString(it) as T } + } else { + secrets[name]?.value as? T + } + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/SecretMetadata.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/SecretMetadata.kt new file mode 100644 index 0000000000..8b39f0f6a8 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/SecretMetadata.kt @@ -0,0 +1,40 @@ +/* + * 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.dto.persisted + +import kotlinx.serialization.Serializable + +/** + * Metadata for secrets. + * + * @author Lauri Heino + */ +@Serializable +public data class SecretMetadata( + val name: String, + val organization: String, + val collections: Map, +) { + init { + collections.forEach { + if (it.key != it.value.profile) + throw IllegalStateException( + "Collection profile ${it.value.profile} does not match map key ${it.key}" + ) + } + } + + public fun add(collection: CollectionMetadata): SecretMetadata { + return copy(collections = collections + (collection.profile to collection)) + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/StringSecret.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/StringSecret.kt new file mode 100644 index 0000000000..213be89bd3 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/dto/persisted/StringSecret.kt @@ -0,0 +1,29 @@ +/* + * 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.dto.persisted + +import dev.elide.secrets.Utils +import kotlinx.serialization.Serializable + +/** + * [Secret] containing string data. + * + * @author Lauri Heino + */ +@Serializable +public data class StringSecret(override val name: String, override val value: String) : + Secret { + init { + Utils.checkName(name, "Secret") + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/exception/RemoteNotInitializedException.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/exception/RemoteNotInitializedException.kt new file mode 100644 index 0000000000..7058e690e8 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/exception/RemoteNotInitializedException.kt @@ -0,0 +1,22 @@ +/* + * 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.exception + +import dev.elide.secrets.remote.Remote + +/** + * Exception that is thrown if a [Remote] has not been initialized. + * + * @author Lauri Heino + */ +public class RemoteNotInitializedException(message: String) : IllegalStateException(message) diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/ConsoleImpl.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/ConsoleImpl.kt new file mode 100644 index 0000000000..c280674562 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/ConsoleImpl.kt @@ -0,0 +1,38 @@ +/* + * 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.impl + +import dev.elide.secrets.Console + +/** + * Implementation of [Console]. + * + * @author Lauri Heino + */ +public class ConsoleImpl : Console { + override fun print(string: String) { + kotlin.io.print(string) + } + + override fun println(string: String) { + kotlin.io.println(string) + } + + override fun readln(): String { + return kotlin.io.readln() + } + + override fun readPassword(): String { + return System.console().readPassword().concatToString() + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/DataHandlerImpl.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/DataHandlerImpl.kt new file mode 100644 index 0000000000..5c6fa2e38d --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/DataHandlerImpl.kt @@ -0,0 +1,175 @@ +/* + * 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.impl + +import dev.elide.secrets.DataHandler +import dev.elide.secrets.SecretsState +import dev.elide.secrets.Utils +import dev.elide.secrets.Values +import dev.elide.secrets.dto.persisted.SecretCollection +import dev.elide.secrets.dto.persisted.SecretMetadata +import kotlinx.io.buffered +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.decodeToString +import kotlinx.io.bytestring.encodeToByteString +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readByteString +import kotlinx.io.write +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.cbor.Cbor +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.json.Json + +/** + * Implementation of [DataHandler], using [Json] for metadata and [Cbor] for collections. + * + * @author Lauri Heino + */ +public class DataHandlerImpl( + // Using impl for visibility reasons until DI is set up + private val encryption: EncryptionImpl, + private val json: Json = Json.Default, + @OptIn(ExperimentalSerializationApi::class) private val cbor: BinaryFormat = Cbor.Default, +) : DataHandler { + override suspend fun metadataExists(): Boolean = SystemFileSystem.exists(metadataPath()) + + override suspend fun readMetadata(): SecretMetadata { + val data = SystemFileSystem.source(metadataPath()).buffered().use { it.readByteString() } + return deserializeMetadata(data) + } + + override suspend fun writeMetadata(metadata: SecretMetadata) { + val data = serializeMetadata(metadata) + SystemFileSystem.sink(metadataPath()).buffered().use { it.write(data) } + } + + override suspend fun deserializeMetadata(data: ByteString): SecretMetadata = + json.decodeFromString(data.decodeToString(Charsets.UTF_8)) + + override suspend fun serializeMetadata(metadata: SecretMetadata): ByteString = + json.encodeToString(metadata).encodeToByteString(Charsets.UTF_8) + + override suspend fun localExists(): Boolean = SystemFileSystem.exists(localPath()) + + override suspend fun readLocal(passphrase: ByteString): SecretCollection { + val encrypted = SystemFileSystem.source(localPath()).buffered().use { it.readByteString() } + val data = encryption.decrypt(passphrase, encrypted) + return cbor.decodeFromByteArray(data.toByteArray()) + } + + override suspend fun writeLocal(passphrase: ByteString, local: SecretCollection) { + val data = ByteString(cbor.encodeToByteArray(local)) + val encrypted = encryption.encrypt(passphrase, data) + SystemFileSystem.sink(localPath()).buffered().use { it.write(encrypted) } + } + + override suspend fun collectionExists(profile: String): Boolean = + SystemFileSystem.exists(collectionPath(profile)) + + override suspend fun readCollection(profile: String, passphrase: ByteString): SecretCollection { + val key = readKey(profile, passphrase) + val encrypted = readEncryptedCollection(profile) + return decryptCollection(key, encrypted) + } + + override suspend fun readEncryptedCollection(profile: String): ByteString = + SystemFileSystem.source(collectionPath(profile)).buffered().use { it.readByteString() } + + override suspend fun writeCollection( + profile: String, + passphrase: ByteString, + collection: SecretCollection, + ): String { + val key = readKey(profile, passphrase) + val encrypted = encryptCollection(key, collection) + SystemFileSystem.sink(collectionPath(profile)).buffered().use { it.write(encrypted) } + return Utils.sha(encrypted) + } + + override suspend fun deleteCollection(profile: String) { + SystemFileSystem.delete(collectionPath(profile)) + SystemFileSystem.delete(keyPath(profile)) + } + + override suspend fun decryptCollection( + key: ByteString, + encrypted: ByteString, + ): SecretCollection { + val data = encryption.decrypt(key, encrypted) + return cbor.decodeFromByteArray(data.toByteArray()) + } + + override suspend fun encryptCollection( + key: ByteString, + collection: SecretCollection, + ): ByteString { + val data = ByteString(cbor.encodeToByteArray(collection)) + return encryption.encrypt(key, data) + } + + override suspend fun keyExists(profile: String): Boolean = + SystemFileSystem.exists(keyPath(profile)) + + override suspend fun readKey(profile: String, passphrase: ByteString): ByteString { + val encrypted = + SystemFileSystem.source(keyPath(profile)).buffered().use { it.readByteString() } + return decryptKey(passphrase, encrypted) + } + + override suspend fun writeKey(profile: String, passphrase: ByteString, key: ByteString) { + val encrypted = encryptKey(passphrase, key) + SystemFileSystem.sink(keyPath(profile)).buffered().use { it.write(encrypted) } + } + + override suspend fun decryptKey(passphrase: ByteString, encrypted: ByteString): ByteString { + return encryption.decrypt(passphrase, encrypted) + } + + override suspend fun encryptKey(passphrase: ByteString, key: ByteString): ByteString { + return encryption.encrypt(passphrase, key) + } + + override suspend fun createValidator( + metadata: SecretMetadata, + passphrase: ByteString, + ): ByteString { + val data = encryption.hash(metadata.name + metadata.organization) + val encrypted = encryption.encrypt(passphrase, data) + return encrypted + } + + override suspend fun validate( + metadata: SecretMetadata, + passphrase: ByteString, + validator: ByteString, + ): Boolean { + val expected = encryption.hash(metadata.name + metadata.organization) + val actual = encryption.decrypt(passphrase, validator) + return expected == actual + } + + private suspend fun metadataPath(): Path = + Path(SecretsState.Companion.get().path, Values.METADATA_NAME) + + private suspend fun localPath(): Path = + Path(SecretsState.Companion.get().path, Values.LOCAL_COLLECTION_NAME) + + private suspend fun collectionPath(profile: String): Path = + Path(SecretsState.Companion.get().path, Utils.collectionName(profile)) + + private suspend fun keyPath(profile: String): Path = + Path(SecretsState.Companion.get().path, Utils.keyName(profile)) +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/EncryptionImpl.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/EncryptionImpl.kt new file mode 100644 index 0000000000..dd60487136 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/EncryptionImpl.kt @@ -0,0 +1,70 @@ +/* + * 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.impl + +import dev.elide.secrets.Encryption +import dev.elide.secrets.Utils +import dev.elide.secrets.Values +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.engines.AESEngine +import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator +import org.bouncycastle.crypto.modes.SICBlockCipher +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.params.ParametersWithIV +import kotlinx.io.bytestring.ByteString + +/** + * Implementation of [Encryption], using `AES` for encryption and `SHA-256` for hashing. + * + * @author Lauri Heino + */ +public class EncryptionImpl : Encryption { + override fun encrypt(key: ByteString, data: ByteString): ByteString { + val iv: ByteArray = Utils.generateBytes(Values.IV_SIZE).toByteArray() + val out: ByteArray = iv.copyOf(Values.IV_SIZE + data.size) + val cipher = createCipher() + cipher.init(true, ParametersWithIV(KeyParameter(key.toByteArray()), iv)) + cipher.processBytes(data.toByteArray(), 0, data.size, out, Values.IV_SIZE) + return ByteString(out) + } + + override fun decrypt(key: ByteString, encrypted: ByteString): ByteString { + val out = ByteArray(encrypted.size - Values.IV_SIZE) + val cipher = createCipher() + cipher.init( + false, + ParametersWithIV( + KeyParameter(key.toByteArray()), + encrypted.toByteArray(0, Values.IV_SIZE), + 0, + Values.IV_SIZE, + ), + ) + cipher.processBytes(encrypted.toByteArray(Values.IV_SIZE), 0, out.size, out, 0) + return ByteString(out) + } + + override fun hash(passphrase: String): ByteString { + val parameterGenerator = PKCS5S2ParametersGenerator(SHA256Digest()) + parameterGenerator.init( + passphrase.toByteArray(Charsets.UTF_8), + null, + Values.HASH_ITERATIONS, + ) + return ByteString( + (parameterGenerator.generateDerivedParameters(Values.KEY_SIZE * 8) as KeyParameter).key + ) + } + + private fun createCipher() = SICBlockCipher.newInstance(AESEngine.newInstance()) +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/SecretsImpl.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/SecretsImpl.kt new file mode 100644 index 0000000000..6f79109d0c --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/impl/SecretsImpl.kt @@ -0,0 +1,462 @@ +/* + * 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.impl + +import dev.elide.secrets.Secrets +import dev.elide.secrets.SecretsState +import dev.elide.secrets.Utils +import dev.elide.secrets.Values +import dev.elide.secrets.dto.persisted.* +import dev.elide.secrets.remote.Remote +import dev.elide.secrets.remote.RemoteInitializer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.io.bytestring.ByteString +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.serialization.SerializationException + +/** + * Implementation of [Secrets]. + * + * @author Lauri Heino + */ +public class SecretsImpl( + private val interactive: Boolean, + private val path: Path = Path(System.getProperty("user.dir"), Values.DEFAULT_PATH), + // Using impl for visibility reasons until DI is set up + private val encryption: EncryptionImpl = EncryptionImpl(), + private val dataHandler: DataHandlerImpl = DataHandlerImpl(encryption), + private val console: ConsoleImpl = ConsoleImpl(), +) : Secrets { + private val passphrase: MutableStateFlow = MutableStateFlow(ByteString()) + private val local: MutableStateFlow = MutableStateFlow(SecretCollection()) + private val remote: MutableStateFlow = MutableStateFlow(null) + private val selected: MutableStateFlow?> = MutableStateFlow(null) + private val selectedProfile: String? + get() = selected.value?.first + + private val selectedCollection: SecretCollection? + get() = selected.value?.second + + override suspend fun init() { + SecretsState.Companion.set(SecretsState(interactive, path)) + if (dataHandler.metadataExists() && dataHandler.localExists()) loadSystem() + else if (dataHandler.metadataExists()) + throw IllegalStateException( + "Secrets system is invalid, \"${Values.LOCAL_COLLECTION_NAME}\" is missing in \"$path\"" + ) + else if (dataHandler.localExists()) + throw IllegalStateException( + "Secrets system is invalid, \"${Values.METADATA_NAME}\" is missing in \"$path\"" + ) + else if (interactive) createSystem() + else + throw IllegalStateException( + "Secrets have not been initialized and interactive mode is off" + ) + } + + private suspend fun loadSystem() { + passphrase.value = + encryption.hash( + readPassphraseFromEnvironment() + ?: if (interactive) readPassphraseFromConsole(false) + else + throw IllegalStateException( + "No passphrase was found in the environment and interactive mode is off" + ) + ) + Utils.checkPassphrase( + console, + SecretsState.Companion.get().interactive, + passphrase.value, + "passphrase", + { encryption.hash(readRemotePassphraseFromConsole(false)) }, + ) { + try { + dataHandler.readLocal(passphrase.value) + } catch (_: SerializationException) { + null + } + } + .let { + passphrase.value = it.first + local.value = it.second + } + validateCollections() + } + + private suspend fun createSystem() { + console.println("Welcome to Elide Secrets!") + console.println("First, you need a personal passphrase") + console.println( + "This passphrase protects all secrets stored locally on your system, so keep it safe!" + ) + passphrase.value = + encryption.hash( + readPassphraseFromEnvironment()?.apply { + console.println("Your passphrase was read from the environment") + } ?: readPassphraseFromConsole(true) + ) + SystemFileSystem.createDirectories(path) + dataHandler.writeLocal(passphrase.value, local.value) + if (Utils.confirm(console, "Do you want to import existing secrets from a remote?")) { + importSystem() + } else { + val name = Utils.readWithConfirm(console, "Please enter the name of your project: ") + val organization = + Utils.readWithConfirm(console, "Please enter the name of your organization: ") + val metadata = SecretMetadata(name, organization, emptyMap()) + dataHandler.writeMetadata(metadata) + console.println("Secrets system has been created") + } + } + + private suspend fun importSystem() { + if (remote.value == null) initRemote() + val remote = remote.value!! + val metadata = + remote.getMetadata()?.let { dataHandler.deserializeMetadata(it) } + ?: throw IllegalStateException("Remote has not been initialized") + val profiles = metadata.collections.keys + val profilesToGet: Set + while (true) { + console.println("The remote has following profiles: ${profiles.joinToString(", ")}") + console.println( + "Please enter the profiles you want separated by whitespace, or leave empty to get all" + ) + console.print("Profiles: ") + val input = console.readln().trim() + if (input.isEmpty()) { + profilesToGet = profiles + break + } + val inputs = input.replace(Regex(" +"), " ").split(" ") + if (inputs.all { it in profiles }) { + profilesToGet = inputs.toSet() + break + } + console.println( + "Invalid profiles: ${inputs.filter { it !in profiles }. joinToString(", ")}" + ) + } + val collections = profilesToGet.map { it to remote.getCollection(it) } + val remotePassphrase = validateRemotePassphrase(metadata) + collections.forEach { (profile, encrypted) -> + val key = dataHandler.decryptKey(remotePassphrase, encrypted.first) + val collection = dataHandler.decryptCollection(key, encrypted.second) + dataHandler.writeKey(profile, passphrase.value, key) + dataHandler.writeCollection(profile, passphrase.value, collection) + } + local.update { it.add(BinarySecret(Remote.PASSPHRASE_NAME, remotePassphrase)) } + dataHandler.writeMetadata( + metadata.copy(collections = metadata.collections.filter { it.key in profilesToGet }) + ) + dataHandler.writeLocal(passphrase.value, local.value) + } + + private suspend fun initRemote() { + lateinit var initializer: RemoteInitializer + var remoteName = local.value.get(Remote.REMOTE_NAME) + remoteName?.let { + Remote.remotes[it]?.let { block -> initializer = block(dataHandler, console) } + } + if (interactive && remote.value == null) { + Utils.options( + console, + Remote.remotes.map { (name, block) -> + name to + { + remoteName = name + initializer = block(dataHandler, console) + } + }, + ) + } + remote.value = initializer.initialize(interactive, local.value) + if (remote.value == null) throw IllegalStateException("Remote could not be initialized") + local.update { + initializer.updateLocal(it).add(StringSecret(Remote.REMOTE_NAME, remoteName!!)) + } + dataHandler.writeLocal(passphrase.value, local.value) + } + + private suspend fun validateCollections() { + var isCorrupted = false + dataHandler.readMetadata().collections.forEach { + try { + dataHandler.readCollection(it.key, passphrase.value) + } catch (_: SerializationException) { + console.println("Collection for profile \"${it.key}\" is corrupted") + isCorrupted = true + } + } + if (isCorrupted) throw IllegalStateException("One or more corrupted collections were found") + } + + private fun readPassphraseFromEnvironment(): String? = + System.getenv(Values.PASSPHRASE_ENVIRONMENT_VARIABLE) + + private fun readPassphraseFromConsole(confirm: Boolean): String = + if (confirm) + Utils.passphrase( + console, + "Please enter your passphrase: ", + "Please enter your passphrase again: ", + "Passphrases were not identical", + ) + else { + console.print("Please enter your passphrase: ") + console.readPassword() + } + + private fun readRemotePassphraseFromConsole(confirm: Boolean): String = + if (confirm) + Utils.passphrase( + console, + "Please enter the remote passphrase: ", + "Please enter the remote passphrase again: ", + "Passphrases were not identical", + ) + else { + console.print("Please enter the remote passphrase: ") + console.readPassword() + } + + override suspend fun createProfile(profile: String) { + val metadata = dataHandler.readMetadata() + if (profile in metadata.collections) + throw IllegalArgumentException("Profile \"$profile\" already exists") + val key = Utils.generateBytes(Values.KEY_SIZE) + val collection = SecretCollection(emptyMap()) + dataHandler.writeKey(profile, passphrase.value, key) + val sha = dataHandler.writeCollection(profile, passphrase.value, collection) + val newMetadata = metadata.add(CollectionMetadata(profile, sha)) + dataHandler.writeMetadata(newMetadata) + } + + override suspend fun removeProfile(profile: String) { + val metadata = dataHandler.readMetadata() + if (profile !in metadata.collections) + throw IllegalArgumentException("Profile \"$profile\" does not exist") + dataHandler.deleteCollection(profile) + val newMetadata = metadata.copy(collections = metadata.collections - profile) + dataHandler.writeMetadata(newMetadata) + } + + override suspend fun getProfiles(): List = + dataHandler.readMetadata().collections.keys.toList() + + override suspend fun getRemoteProfiles(): List { + if (remote.value == null) initRemote() + return remote.value!! + .getMetadata() + ?.let { dataHandler.deserializeMetadata(it) } + ?.collections + ?.keys + ?.toList() ?: emptyList() + } + + override suspend fun updateLocal(vararg profiles: String) { + if (remote.value == null) initRemote() + val remote = remote.value!! + val remoteMetadata = + remote.getMetadata()?.let { dataHandler.deserializeMetadata(it) } + ?: throw IllegalStateException("Remote is not initialized") + val metadata = dataHandler.readMetadata() + val updated = + if (profiles.isNotEmpty()) { + val nonExistent = remoteMetadata.collections.keys.filter { it !in profiles } + if (nonExistent.isNotEmpty()) { + if (interactive) { + console.println( + "Profiles \"${nonExistent.joinToString(", ")}\" do not exist on the remote" + ) + if (!Utils.confirm(console, "Do you want to update the existing profiles?")) + return + } else + throw IllegalStateException( + "Profiles \"${nonExistent.joinToString(", ")}\" do not exist on the remote" + ) + } + remoteMetadata.collections.keys.filter { it in profiles } + } else remoteMetadata.collections.keys.toList() + val remotePassphrase = validateRemotePassphrase(metadata) + val updatedWithSha = + updated.map { + val (encryptedKey, encryptedCollection) = remote.getCollection(it) + val key = dataHandler.decryptKey(remotePassphrase, encryptedKey) + val collection = dataHandler.decryptCollection(key, encryptedCollection) + dataHandler.writeKey(it, passphrase.value, key) + it to dataHandler.writeCollection(it, passphrase.value, collection) + } + val newMetadata = + metadata.copy( + collections = + metadata.collections.filterNot { it.key in updated } + + updatedWithSha.map { (profile, sha) -> + profile to CollectionMetadata(profile, sha) + } + ) + dataHandler.writeMetadata(newMetadata) + } + + private suspend fun validateRemotePassphrase(metadata: SecretMetadata): ByteString { + if (remote.value == null) initRemote() + val remote = remote.value!! + val validator = remote.getValidator()!! + var remotePassphrase: ByteString = + local.value[Remote.PASSPHRASE_NAME] + ?: encryption.hash(readRemotePassphraseFromConsole(false)) + remotePassphrase = + Utils.checkValidatorPassphrase( + dataHandler, + console, + SecretsState.Companion.get().interactive, + metadata, + remotePassphrase, + validator, + "remote passphrase", + ) { + encryption.hash(readRemotePassphraseFromConsole(false)) + } + local.update { it.add(BinarySecret(Remote.PASSPHRASE_NAME, remotePassphrase)) } + dataHandler.writeLocal(passphrase.value, local.value) + return remotePassphrase + } + + override suspend fun updateRemote(vararg profiles: String) { + if (remote.value == null) initRemote() + val remote = remote.value!! + val metadata = dataHandler.readMetadata() + val updated = + if (profiles.isNotEmpty()) { + val nonExistent = metadata.collections.keys.filter { it !in profiles } + if (nonExistent.isNotEmpty()) { + if (interactive) { + console.println( + "Profiles \"${nonExistent.joinToString(", ")}\" do not exist" + ) + if ( + !Utils.confirm( + console, + "Do you want to update the remote with the existing profiles?", + ) + ) + return + } else + throw IllegalStateException( + "Profiles \"${nonExistent.joinToString(", ")}\" do not exist" + ) + } + metadata.collections.keys.filter { it in profiles } + } else metadata.collections.keys.toList() + val remoteMetadata = remote.getMetadata()?.let { dataHandler.deserializeMetadata(it) } + val remotePassphrase: ByteString + if (remoteMetadata == null) { + if (!interactive) + throw IllegalStateException("Remote is not initialized and interactive mode is off") + console.println("Remotes use a different passphrase than your personal one.") + remotePassphrase = + local.value[Remote.PASSPHRASE_NAME] + ?: encryption.hash(readRemotePassphraseFromConsole(true)) + remote.init(metadata, dataHandler.createValidator(metadata, remotePassphrase)) + local.update { it.add(BinarySecret(Remote.PASSPHRASE_NAME, remotePassphrase)) } + dataHandler.writeLocal(passphrase.value, local.value) + } else { + remotePassphrase = validateRemotePassphrase(remoteMetadata) + } + remote.update( + updated.associateWith { + Pair( + dataHandler.encryptKey( + remotePassphrase, + dataHandler.readKey(it, passphrase.value), + ), + dataHandler.readEncryptedCollection(it), + ) + } + ) + } + + override suspend fun selectProfile(profile: String) { + val metadata = dataHandler.readMetadata() + if (profile !in metadata.collections) + throw IllegalArgumentException("Profile \"${profile}\" does not exist") + val collection = dataHandler.readCollection(profile, passphrase.value) + selected.update { it?.copy(profile, collection) ?: Pair(profile, collection) } + } + + override suspend fun getSecret(name: String): Any? = + selectedCollection?.secrets[name] ?: throw IllegalStateException("No profile is selected") + + override suspend fun getSecret(profile: String, name: String): Any? { + val metadata = dataHandler.readMetadata() + if (profile !in metadata.collections) + throw IllegalArgumentException("Profile \"${profile}\" does not exist") + return dataHandler.readCollection(profile, passphrase.value).secrets[name] + } + + override suspend fun setSecret(secret: Secret<*>): Unit = + selected.update { + it?.copy(second = it.second.add(secret)) + ?: throw IllegalStateException("No profile is selected") + } + + override suspend fun removeSecret(name: String) { + selectedCollection?.apply { + if (name !in secrets) + throw IllegalArgumentException( + "Secret \"$name\" does not exist in profile \"$selectedProfile\"" + ) + selected.update { + it?.copy( + second = + it.second.copy( + secrets = it.second.secrets.filterKeys { key -> key != name } + ) + ) ?: throw IllegalStateException("No profile is selected") + } + } ?: throw IllegalStateException("No profile is selected") + } + + override suspend fun writeChanges() { + selectedCollection?.let { + val profile = selectedProfile!! + val sha = dataHandler.writeCollection(profile, passphrase.value, it) + val metadata = dataHandler.readMetadata() + val newMetadata = + metadata.copy( + collections = + metadata.collections.mapValues { (p, c) -> + if (p == profile) c.copy(sha = sha) else c + } + ) + dataHandler.writeMetadata(newMetadata) + } + } + + override suspend fun deselectProfile() { + selected.value = null + } + + override suspend fun removeProfile() { + selectedProfile?.let { removeProfile(it) } + } + + override suspend fun removeRemoteProfile(profile: String) { + if (remote.value == null) initRemote() + remote.value!!.removeCollection(profile) + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/GithubRemote.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/GithubRemote.kt new file mode 100644 index 0000000000..cfb2512a46 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/GithubRemote.kt @@ -0,0 +1,350 @@ +/* + * 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.remote + +import dev.elide.secrets.DataHandler +import dev.elide.secrets.Utils +import dev.elide.secrets.Values +import dev.elide.secrets.dto.api.github.* +import dev.elide.secrets.dto.persisted.SecretMetadata +import dev.elide.secrets.exception.RemoteNotInitializedException +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.encode +import kotlinx.serialization.json.Json +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * GitHub [Remote] connection handling for secrets. + * + * @property writeAccess `true` if writing to this remote is permitted. + * @property repository connected repository name in format `organization/repository`. + * @property token GitHub personal access token. + * @author Lauri Heino + */ +internal class GithubRemote( + override val writeAccess: Boolean, + private val repository: String, + private val token: String, + private val dataHandler: DataHandler, +) : Remote { + override suspend fun getMetadata(): ByteString? = getFile(Values.METADATA_NAME) + + override suspend fun getValidator(): ByteString? = getFile(Values.VALIDATOR_NAME) + + override suspend fun getCollection(profile: String): Pair { + val key = + getFile(Utils.keyName(profile)) + ?: throw IllegalStateException( + "Key for profile \"$profile\" was not found in remote \"$repository\"" + ) + val value = + getFile(Utils.collectionName(profile)) + ?: throw IllegalStateException( + "Collection for profile \"$profile\" was not found in remote \"$repository\"" + ) + return key to value + } + + override suspend fun removeCollection(profile: String) { + val metadataBytes = + getMetadata() ?: throw RemoteNotInitializedException("Remote is not initialized") + val metadata = dataHandler.deserializeMetadata(metadataBytes) + val sha = + metadata.collections[profile]?.sha + ?: throw IllegalArgumentException( + "Profile \"$profile\" was not found in remote \"$repository\"" + ) + val (key, collection) = getCollection(profile) + if (sha != Utils.sha(collection)) + throw IllegalStateException( + "Remote is corrupted (collection for profile \"$profile\" SHA-1 has does not match metadata" + ) + val keySha = Utils.sha(key) + val branch = createBranch() + deleteFile(Utils.collectionName(profile), "remove profile $profile collection", sha, branch) + deleteFile(Utils.keyName(profile), "remove profile $profile key", keySha, branch) + val newMetadata = + metadata.copy(collections = metadata.collections.filterKeys { it != profile }) + writeFile( + Values.METADATA_NAME, + dataHandler.serializeMetadata(newMetadata), + "new metadata (profile \"$profile\" removed)", + Utils.sha(metadataBytes), + branch, + ) + post( + "repos/$repository/merges", + token, + GithubMergeRequest("main", branch, "merge $branch"), + HttpStatusCode.Created, + ) + } + + override suspend fun init(metadata: SecretMetadata, validator: ByteString) { + writeFile( + Values.METADATA_NAME, + dataHandler.serializeMetadata(metadata.copy(collections = emptyMap())), + "initial metadata", + ) + writeFile(Values.VALIDATOR_NAME, validator, "validator") + } + + override suspend fun update(collections: Map>) { + val remoteMetadataBytes = + getMetadata() ?: throw RemoteNotInitializedException("Remote is not initialized") + val remoteMetadata = dataHandler.deserializeMetadata(remoteMetadataBytes) + val localMetadata = dataHandler.readMetadata() + val branch = createBranch() + val (newRemoteMetadata, updated) = + createMetadata(localMetadata, remoteMetadata, collections.keys) + val oldMetadataSha = Utils.sha(remoteMetadataBytes) + writeFile( + Values.METADATA_NAME, + dataHandler.serializeMetadata(newRemoteMetadata), + "new metadata", + oldMetadataSha, + branch, + ) + updated.forEach { (sha, profile) -> + writeFile( + Utils.collectionName(profile), + collections[profile]!!.second, + "new $profile collection", + sha, + branch, + ) + if (sha.isEmpty()) { + writeFile( + Utils.keyName(profile), + collections[profile]!!.first, + "new $profile key", + "", + branch, + ) + } + } + post( + "repos/$repository/merges", + token, + GithubMergeRequest("main", branch, "merge $branch"), + HttpStatusCode.Created, + ) + } + + private fun createMetadata( + localMetadata: SecretMetadata, + remoteMetadata: SecretMetadata, + profiles: Set, + ): Pair>> { + val unvisited = profiles.toMutableSet() + val updated = mutableSetOf>() + return Pair( + remoteMetadata.copy( + collections = + remoteMetadata.collections.mapValues { (key, collection) -> + if (key in profiles) { + unvisited.remove(key) + updated.add(Pair(collection.sha, key)) + localMetadata.collections[key]!! + } else collection + } + + unvisited.map { + updated.add(Pair("", it)) + it to localMetadata.collections[it]!! + } + ), + updated, + ) + } + + private suspend fun createBranch(): String { + val (_, commits) = + get>( + "repos/$repository/commits", + token, + GithubCommitsRequest(1), + HttpStatusCode.OK, + ) + val head = commits!!.first().sha + val branch = "update/$head" + post( + "repos/$repository/git/refs", + token, + GithubCreateRefRequest("refs/heads/$branch", head), + HttpStatusCode.Created, + ) + return branch + } + + private suspend fun getFile(path: String): ByteString? { + val (_, content) = + get( + "repos/$repository/contents/$path", + token, + HttpStatusCode.OK, + HttpStatusCode.NotFound, + ) + if (content == null) return null + // Use download url because sometimes base64 for a binary file comes out wrong somehow + return ByteString(client.get(content.downloadUrl).bodyAsBytes()) + } + + @OptIn(ExperimentalEncodingApi::class) + private suspend fun writeFile( + path: String, + data: ByteString, + message: String, + sha: String = "", + branch: String = "", + ) { + put( + "repos/$repository/contents/$path", + token, + GithubUploadFileRequest(message, Base64.Default.encode(data), sha, branch), + HttpStatusCode.OK, + HttpStatusCode.Created, + ) + } + + private suspend fun deleteFile(path: String, message: String, sha: String, branch: String) { + delete( + "repos/$repository/contents/$path", + token, + GithubDeleteFileRequest(message, sha, branch), + HttpStatusCode.OK, + ) + } + + companion object { + const val REPOSITORY_NAME = "github:repository" + const val TOKEN_NAME = "github:token" + const val GITHUB_URL = "https://api.github.com/" + val client = + HttpClient(CIO) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + install(HttpTimeout) { + requestTimeoutMillis = 10000 + connectTimeoutMillis = 10000 + } + } + + suspend inline fun put( + path: String, + token: String, + body: B, + vararg allowedCodes: HttpStatusCode, + block: HttpRequestBuilder.() -> Unit = {}, + ): Pair { + val response: HttpResponse = + client.put("$GITHUB_URL$path") { + headers(token) + setBody(body) + block() + } + return parseContent(response, allowedCodes.toSet()) + } + + suspend inline fun delete( + path: String, + token: String, + body: B, + vararg allowedCodes: HttpStatusCode, + block: HttpRequestBuilder.() -> Unit = {}, + ): Pair { + val response: HttpResponse = + client.delete("$GITHUB_URL$path") { + headers(token) + setBody(body) + block() + } + return parseContent(response, allowedCodes.toSet()) + } + + suspend inline fun get( + path: String, + token: String, + vararg allowedCodes: HttpStatusCode, + block: HttpRequestBuilder.() -> Unit = {}, + ): Pair { + val response: HttpResponse = + client.get("$GITHUB_URL$path") { + headers(token) + block() + } + return parseContent(response, allowedCodes.toSet()) + } + + suspend inline fun get( + path: String, + token: String, + body: B, + vararg allowedCodes: HttpStatusCode, + block: HttpRequestBuilder.() -> Unit = {}, + ): Pair = + get(path, token, *allowedCodes) { + setBody(body) + block() + } + + suspend inline fun post( + path: String, + token: String, + body: B, + vararg allowedCodes: HttpStatusCode, + block: HttpRequestBuilder.() -> Unit = {}, + ): Pair { + val response: HttpResponse = + client.post("$GITHUB_URL$path") { + headers(token) + setBody(body) + block() + } + return parseContent(response, allowedCodes.toSet()) + } + + suspend inline fun parseContent( + response: HttpResponse, + allowedCodes: Set, + ): Pair { + val content: T? = + try { + response.body() + } catch (_: JsonConvertException) { + null + } + if (response.status !in allowedCodes) + throw IllegalArgumentException( + "Something went wrong: $response, Content: ${content ?: response.bodyAsText()}" + ) + return Pair(response, content) + } + + fun HttpMessageBuilder.headers(token: String) { + header("Accept", "application/vnd.github+json") + header("Content-Type", "application/json") + header("Authorization", "Bearer $token") + header("X-GitHub-Api-Version", "2022-11-28") + } + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/GithubRemoteInitializer.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/GithubRemoteInitializer.kt new file mode 100644 index 0000000000..298e0ca772 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/GithubRemoteInitializer.kt @@ -0,0 +1,88 @@ +/* + * 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.remote + +import dev.elide.secrets.Console +import dev.elide.secrets.DataHandler +import dev.elide.secrets.Utils +import dev.elide.secrets.dto.api.github.GithubRepositoryResponse +import dev.elide.secrets.dto.persisted.SecretCollection +import dev.elide.secrets.dto.persisted.StringSecret +import io.ktor.http.* +import kotlinx.coroutines.runBlocking + +/** + * Initializer for connecting to GitHub. + * + * @author Lauri Heino + */ +internal class GithubRemoteInitializer( + private val dataHandler: DataHandler, + private val console: Console, +) : RemoteInitializer { + private lateinit var repository: String + private lateinit var token: String + + override fun initialize(interactive: Boolean, local: SecretCollection): GithubRemote { + repository = + local[GithubRemote.REPOSITORY_NAME] + ?: if (interactive) askRepository() + else throw IllegalStateException("A GitHub repository has not been registered") + token = + local[GithubRemote.TOKEN_NAME] + ?: if (interactive) askToken() + else throw IllegalStateException("A GitHub token has not been registered") + val writeAccess = runBlocking { validateConnection(token, repository) } + return GithubRemote(writeAccess, repository, token, dataHandler) + } + + override fun updateLocal(local: SecretCollection): SecretCollection = + local + .add(StringSecret(GithubRemote.REPOSITORY_NAME, repository)) + .add(StringSecret(GithubRemote.TOKEN_NAME, token)) + + private fun askRepository(): String { + console.println( + "Elide Secrets on GitHub are stored as encrypted files committed to a private repository." + ) + return Utils.readWithConfirm( + console, + "Please enter the repository identity (\"owner/repository\"): ", + ) + } + + private fun askToken(): String { + console.println( + "To access GitHub, you need a personal access token. If you do not have one with the required " + + "permissions, please head over to https://github.com/settings/personal-access-tokens and " + + "generate a new token. The token must at least have read access to \"Contents\" of the " + + "repository ($repository). Optional write access to \"Contents\" allows you to update the remote " + + "secrets." + ) + console.print("Please enter your token: ") + return console.readPassword() + } + + private suspend fun validateConnection(token: String, repository: String): Boolean { + val (_, content) = + GithubRemote.get( + "repos/$repository", + token, + HttpStatusCode.OK, + ) + if (!content!!.private) throw IllegalArgumentException("Repository is not private") + if (!content.permissions.read) + throw IllegalArgumentException("No read access to repository contents") + return content.permissions.write + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/Remote.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/Remote.kt new file mode 100644 index 0000000000..c3ee22b5e9 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/Remote.kt @@ -0,0 +1,58 @@ +/* + * 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.remote + +import dev.elide.secrets.Console +import dev.elide.secrets.DataHandler +import dev.elide.secrets.dto.persisted.SecretMetadata +import kotlinx.io.bytestring.ByteString + +/** + * Remote connection handling for secrets. + * + * @property writeAccess `true` if writing to this remote is permitted. + * @author Lauri Heino + */ +internal interface Remote { + val writeAccess: Boolean + + /** Returns the metadata file from this remote, or `null` if none was present. */ + suspend fun getMetadata(): ByteString? + + /** Returns the passphrase validator file from this remote, or `null` if none was present. */ + suspend fun getValidator(): ByteString? + + /** Returns a key and collection file for [profile] in a pair from this remote. */ + suspend fun getCollection(profile: String): Pair + + /** Removes a key and collection file for [profile] from this remote */ + suspend fun removeCollection(profile: String) + + /** Initializes this remote with [metadata] and passphrase [validator]. */ + suspend fun init(metadata: SecretMetadata, validator: ByteString) + + /** + * Updates [collections] on this remote. The map keys are profile names, and the pairs contain + * encrypted key and collection data. + */ + suspend fun update(collections: Map>) + + companion object { + const val REMOTE_NAME = "remote" + const val PASSPHRASE_NAME = "passphrase" + + /** Implemented remotes. */ + val remotes: Map RemoteInitializer> = + mapOf("GitHub" to { d, c -> GithubRemoteInitializer(d, c) }) + } +} diff --git a/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/RemoteInitializer.kt b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/RemoteInitializer.kt new file mode 100644 index 0000000000..5a3c5901a6 --- /dev/null +++ b/packages/secrets/src/main/kotlin/dev/elide/secrets/remote/RemoteInitializer.kt @@ -0,0 +1,26 @@ +/* + * 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.remote + +import dev.elide.secrets.dto.persisted.SecretCollection + +/** + * Initializer for connecting to a [Remote]. + * + * @author Lauri Heino + */ +internal interface RemoteInitializer { + fun initialize(interactive: Boolean, local: SecretCollection): Remote + + fun updateLocal(local: SecretCollection): SecretCollection +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 102c6ca8e0..576e1f31d6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -208,6 +208,7 @@ include( ":packages:local-ai", ":packages:platform", ":packages:runner", + ":packages:secrets", ":packages:server", ":packages:sqlite", ":packages:ssr",