├── .gitignore ├── src ├── nim.cfg ├── runepkg │ ├── command │ │ ├── version.nim │ │ ├── list.nim │ │ ├── set.nim │ │ ├── get.nim │ │ ├── usage.nim │ │ └── find.nim │ ├── models.nim │ ├── defaults.nim │ ├── configuration.nim │ └── database.nim └── rune.nim ├── Dockerfile ├── circle.yml ├── rune.nimble └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | nimcache/ 2 | .DS_Store 3 | build/ 4 | -------------------------------------------------------------------------------- /src/nim.cfg: -------------------------------------------------------------------------------- 1 | 2 | --define: NimblePkgName:rune 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nimlang/nim:latest 2 | RUN mkdir -p /usr/src/app 3 | WORKDIR /usr/src/app 4 | COPY . /usr/src/app 5 | RUN nimble install --depsOnly --accept 6 | RUN nimble build 7 | RUN nimble install 8 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | 5 | dependencies: 6 | override: 7 | - docker info 8 | 9 | test: 10 | override: 11 | - docker build -t secure-env . 12 | -------------------------------------------------------------------------------- /src/runepkg/command/version.nim: -------------------------------------------------------------------------------- 1 | 2 | import strformat 3 | 4 | # import runepkg/defaults 5 | import "../defaults.nim" 6 | 7 | proc cmdVersion*(): string = 8 | result = fmt"{NimblePkgName} v{NimblePkgVersion}" 9 | -------------------------------------------------------------------------------- /src/runepkg/command/list.nim: -------------------------------------------------------------------------------- 1 | 2 | #import runepkg/models 3 | import "../models.nim" 4 | import "../database.nim" 5 | 6 | proc cmdList*(configuration: RuneConfiguration) = 7 | for entry in configuration.getRunes(): 8 | echo(entry) 9 | -------------------------------------------------------------------------------- /src/runepkg/models.nim: -------------------------------------------------------------------------------- 1 | 2 | 3 | type 4 | ShellCommand* = object 5 | cmd*: string 6 | args*: seq[string] 7 | 8 | RuneConfiguration* = object 9 | database*: string 10 | encrypt*: ShellCommand 11 | decrypt*: ShellCommand 12 | -------------------------------------------------------------------------------- /src/runepkg/command/set.nim: -------------------------------------------------------------------------------- 1 | 2 | #import runepkg/models 3 | import "../models.nim" 4 | # import runepkg/database 5 | import "../database.nim" 6 | 7 | proc cmdSet*(configuration: RuneConfiguration, name: string, value: string) = 8 | configuration.setRuneValue(name, value) 9 | -------------------------------------------------------------------------------- /src/runepkg/command/get.nim: -------------------------------------------------------------------------------- 1 | 2 | #import runepkg/models 3 | import "../models.nim" 4 | # import runepkg/database 5 | import "../database.nim" 6 | 7 | proc cmdGet*(configuration: RuneConfiguration, name: string) = 8 | let output = configuration.getRune(name) 9 | echo(output) 10 | -------------------------------------------------------------------------------- /rune.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.5.4" 4 | author = "Samantha Marshall" 5 | description = "library to securely store secrets" 6 | license = "BSD 3-Clause" 7 | 8 | srcDir = "src/" 9 | binDir = "build/" 10 | bin = @["rune"] 11 | installExt = @["nim"] 12 | 13 | # Dependencies 14 | 15 | requires "nim >= 0.19.0" 16 | 17 | requires "parsetoml" 18 | requires "commandeer" 19 | -------------------------------------------------------------------------------- /src/runepkg/defaults.nim: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | const 5 | NimblePkgName* {.strdefine.} = "" 6 | NimblePkgVersion* {.strdefine.} = "" 7 | 8 | Flag_Long_Help* = "help" 9 | Flag_Short_Help* = "h" 10 | 11 | Flag_Long_Version* = "version" 12 | Flag_Short_Version* = "" 13 | 14 | Flag_Long_Config* = "config" 15 | Flag_Short_Config* = "c" 16 | DefaultConfigPath* = getConfigDir() / NimblePkgName / "config.toml" 17 | EnvVar_Config* = "RUNE_CONFIG" 18 | 19 | Flag_Long_Key* = "key" 20 | Flag_Short_Key* = "" 21 | 22 | Flag_Long_Value* = "value" 23 | Flag_Short_Value* = "" 24 | 25 | -------------------------------------------------------------------------------- /src/runepkg/command/usage.nim: -------------------------------------------------------------------------------- 1 | 2 | import strutils 3 | import strformat 4 | 5 | # import runepkg/defaults 6 | import "../defaults.nim" 7 | 8 | proc cmdUsage*(section: string = ""): string = 9 | var msg = newSeq[string]() 10 | case section 11 | of "get": 12 | msg.add fmt"usage: {NimblePkgName} get --key:" 13 | of "set": 14 | msg.add fmt"usage: {NimblePkgName} set --key: --value:" 15 | of "find": 16 | msg.add fmt"usage: {NimblePkgName} find " 17 | of "list": 18 | msg.add fmt"usage: {NimblePkgName} list" 19 | else: 20 | msg.add fmt"usage: {NimblePkgName} [-h|--help] [--version] [-c|--config ] [get|set|list|find] ..." 21 | result = msg.join("\n") 22 | -------------------------------------------------------------------------------- /src/runepkg/command/find.nim: -------------------------------------------------------------------------------- 1 | 2 | import strutils 3 | 4 | # import runepkg/models 5 | import "../models.nim" 6 | # import runepkg/database 7 | import "../database.nim" 8 | 9 | proc cmdFind*(configuration: RuneConfiguration, name: string) = 10 | var token_key_substring = name 11 | 12 | let prefix_search = token_key_substring.endsWith("*") 13 | let suffix_search = token_key_substring.startsWith("*") 14 | let full_search = (not prefix_search) and (not suffix_search) 15 | 16 | if prefix_search: 17 | token_key_substring.removeSuffix('*') 18 | if suffix_search: 19 | token_key_substring.removePrefix('*') 20 | 21 | let pattern = token_key_substring.toLowerAscii() 22 | if pattern.len == 0: 23 | echo("Error! Invalid pattern input, when using a prefix or suffix wildcard the seach pattern must be of non-zero length without it.") 24 | echo(" If you want to display all entries, use the 'list' command instead.") 25 | quit(QuitFailure) 26 | else: 27 | for entry in configuration.getRunes(): 28 | let entry_name = entry.toLowerAscii() 29 | if prefix_search: 30 | if entry_name.startsWith(pattern): 31 | echo(entry) 32 | if suffix_search: 33 | if entry_name.endsWith(pattern): 34 | echo(entry) 35 | if full_search: 36 | if entry_name.contains(pattern): 37 | echo(entry) 38 | -------------------------------------------------------------------------------- /src/runepkg/configuration.nim: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | 4 | import parsetoml 5 | 6 | #import runepkg/[ models, defaults ] 7 | import "defaults.nim" 8 | import "models.nim" 9 | 10 | proc resolveConfigPath*(path: string, envvar: string): string = 11 | result = path 12 | if existsEnv(envvar): 13 | let envvar_path = getEnv(envvar) 14 | if envvar_path.len > 0: 15 | result = envvar_path 16 | 17 | proc parseArrayValue(settings: TomlTableRef, section: string, key: string): seq[string] = 18 | let entry = settings[section].tableVal 19 | let values = entry[key].arrayVal 20 | for value in values: 21 | let value_string = value.stringVal 22 | result.add(value_string) 23 | 24 | proc parseStringValue(settings: TomlTableRef, section: string, key: string): string = 25 | let entry = settings[section].tableVal 26 | result = entry[key].stringVal 27 | 28 | proc initConfiguration*(path: string): RuneConfiguration = 29 | let config: TomlTableRef = parseFile(path).getTable() 30 | result.database = expandTilde(config.parseStringValue("database", "path")) 31 | result.encrypt = ShellCommand(cmd: config.parseStringValue("encrypt", "cmd"), args: config.parseArrayValue("encrypt", "args")) 32 | result.decrypt = ShellCommand(cmd: config.parseStringValue("decrypt", "cmd"), args: config.parseArrayValue("decrypt", "args")) 33 | # result = RuneConfiguration(database: database_path, encrypt: encrypt_cmd, decrypt: decrypt_cmd) 34 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # rune 2 | 3 | 4 | ## build 5 | 6 | `nimble build` 7 | 8 | ## usage 9 | this stores key-value pairs in a sqlite database that can be placed into version control or shared however. the secrets are encrypted/decrypted based on the values of the "encrypt_cmd" and "decrypt_cmd" that is set in the config file (`$XDG_CONFIG_HOME/rune/config.toml` or the `RUNE_CONFIG` environment variable). I am using gpg keys to do this, so my config looks like this: 10 | 11 | ``` 12 | [database] 13 | path = "~/.config/storage/secure" 14 | 15 | [encrypt] 16 | cmd = "/usr/local/bin/gpg" 17 | args = ["--armor", "--recipient", "hello@example.com", "--encrypt"] 18 | 19 | [decrypt] 20 | cmd = "/usr/local/bin/gpg" 21 | args = ["--no-tty", "--quiet", "--decrypt"] 22 | ``` 23 | 24 | ### commands 25 | 26 | there are four commands: 27 | 28 | * `get`: decrypts a secret with a given key name 29 | usage: `rune get --key:GITHUB_API_TOKEN` 30 | * `set`: encrypts a secret with a given key name 31 | usage: `rune set --key:GITHUB_API_TOKEN --value:"hello world!"` 32 | * `list`: lists all keys stored in the database 33 | usage: `rune list` 34 | * `find`: allows for glob-pattern search for saved secrets 35 | usage: `rune find *_TOKEN` 36 | 37 | ## installation 38 | 39 | ### Homebrew Tap 40 | 41 | `brew install samdmarshall/formulae/rune` 42 | 43 | 44 | ### Build from Source 45 | 46 | ``` 47 | $ nimble build 48 | $ nimble install 49 | ``` 50 | -------------------------------------------------------------------------------- /src/rune.nim: -------------------------------------------------------------------------------- 1 | import os 2 | import strformat 3 | 4 | import commandeer 5 | 6 | import runepkg/[ defaults, configuration, models ] 7 | import runepkg/command/[ find, get, set, list, usage, version ] 8 | 9 | # ======================= 10 | # this is the entry-point 11 | # ======================= 12 | 13 | proc main() = 14 | commandline: 15 | option setConfigurationPath, string, Flag_Long_Config, Flag_Short_Config, DefaultConfigPath 16 | subcommand Command_Get, ["get"]: 17 | option flagGetSecretName, string, Flag_Long_Key, Flag_Short_Key 18 | exitoption Flag_Long_Help, Flag_Short_Help, cmdUsage("get") 19 | subcommand Command_Set, ["set"]: 20 | option flagSetSecretName, string, Flag_Long_Key, Flag_Short_Key 21 | option flagSetSecretValue, string, Flag_Long_Value, Flag_Short_Value 22 | exitoption Flag_Long_Help, Flag_Short_Help, cmdUsage("set") 23 | subcommand Command_List, ["list"]: 24 | exitoption Flag_Long_Help, Flag_Short_Help, cmdUsage("list") 25 | subcommand Command_Find, ["find"]: 26 | argument NamePattern, string 27 | exitoption Flag_Long_Help, Flag_Short_Help, cmdUsage("find") 28 | exitoption Flag_Long_Help, Flag_Short_Help, cmdUsage("") 29 | exitoption Flag_Long_Version, Flag_Short_Version, cmdVersion() 30 | 31 | let conf_path = resolveConfigPath(setConfigurationPath, EnvVar_Config) 32 | if not fileExists(conf_path): 33 | echo(fmt"Unable to locate the configuration file, please create it at path: `{DefaultConfigPath}` or define `{EnvVar_Config}` with the path value in your shell environment.") 34 | quit(QuitFailure) 35 | 36 | let configuration = initConfiguration(conf_path) 37 | 38 | if Command_Get: 39 | cmdGet(configuration, flagGetSecretName) 40 | 41 | if Command_Set: 42 | cmdSet(configuration, flagSetSecretName, flagSetSecretValue) 43 | 44 | if Command_List: 45 | cmdList(configuration) 46 | 47 | if Command_Find: 48 | cmdFind(configuration, NamePattern) 49 | 50 | if not (Command_Find or Command_Get or Command_Set or Command_List): 51 | echo(cmdUsage("")) 52 | 53 | when isMainModule: 54 | main() 55 | -------------------------------------------------------------------------------- /src/runepkg/database.nim: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | import osproc 4 | import strutils 5 | import db_sqlite 6 | import algorithm 7 | 8 | # import runepkg/models 9 | import "models.nim" 10 | 11 | proc encryptData*(config_data: RuneConfiguration, input: string): string {.gcsafe.} = 12 | let encryption_process = startProcess(config_data.encrypt.cmd, "", config_data.encrypt.args) 13 | 14 | let input_handle = encryption_process.inputHandle() 15 | let output_handle = encryption_process.outputHandle() 16 | 17 | var output_file: File 18 | discard open(output_file, output_handle, fmRead) 19 | 20 | var input_file: File 21 | if open(input_file, input_handle, fmWrite): 22 | write(input_file, input) 23 | input_file.close() 24 | 25 | let output = output_file.readAll().string 26 | 27 | var output_data: seq[string] = newSeq[string]() 28 | for data_char in output: 29 | output_data.add($ord(data_char)) 30 | encryption_process.close() 31 | 32 | result = output_data.join(":") 33 | 34 | proc decryptData*(config_data: RuneConfiguration, input: string): string {.gcsafe.} = 35 | let decryption_process = startProcess(config_data.decrypt.cmd, "", config_data.decrypt.args) 36 | 37 | let input_handle = decryption_process.inputHandle() 38 | let output_handle = decryption_process.outputHandle() 39 | 40 | var output_file: File 41 | discard open(output_file, output_handle, fmRead) 42 | 43 | var input_file: File 44 | if open(input_file, input_handle, fmWrite): 45 | for byte_rep in input.split(":"): 46 | let hex_byte_int = byte_rep.parseUInt() 47 | let hex_byte = chr(hex_byte_int) 48 | write(input_file, hex_byte) 49 | input_file.close() 50 | 51 | result = output_file.readAll().string 52 | decryption_process.close() 53 | 54 | proc openRuneDB(config_data: RuneConfiguration): DbConn = 55 | let database_path = expandTilde(config_data.database) 56 | result = open(database_path, "", "", "") 57 | result.exec(sql"CREATE TABLE IF NOT EXISTS vault (id INTEGER PRIMARY KEY, key TEXT, value BLOB)") 58 | 59 | proc getRune*(config: RuneConfiguration, token_key: string): string = 60 | let secure_db = config.openRuneDB() 61 | let encrypted_value = secure_db.getValue(sql"SELECT value FROM vault WHERE key = ?", token_key) 62 | secure_db.close() 63 | result = decryptData(config, encrypted_value) 64 | 65 | proc setRuneValue*(config: RuneConfiguration, token_key: string, token_value: string) = 66 | let secure_db = config.openRuneDB() 67 | if token_value.len == 0: 68 | secure_db.exec(sql"DELETE FROM vault WHERE key = ?", token_key) 69 | else: 70 | let exists = secure_db.getValue(sql"SELECT id FROM vault WHERE key = ?", token_key) 71 | let encrypted_value = encryptData(config, token_value) 72 | if exists.len > 0: 73 | secure_db.exec(sql"UPDATE vault SET value = ? WHERE key = ?", $encrypted_value, token_key) 74 | else: 75 | discard secure_db.insertID(sql"INSERT INTO vault(key, value) VALUES (?, ?)", token_key, encrypted_value) 76 | secure_db.close() 77 | 78 | proc getRunes*(config: RuneConfiguration): seq[string] = 79 | let secure_db = config.openRuneDB() 80 | for row in secure_db.fastRows(sql"SELECT id,key FROM vault"): 81 | result.add(row[1]) 82 | secure_db.close() 83 | result.sort(cmpIgnoreCase) 84 | --------------------------------------------------------------------------------