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",