ssh-tresor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e23a140ad7fd2c6f75c86669822b89e3cb5a640e099af9994dcd6b9acc4812f
4
+ data.tar.gz: 1012f7b0f6fb6dc97ead863eee8d0ae52857ff39edbd0ea7b3a41da403cc2c97
5
+ SHA512:
6
+ metadata.gz: ae65b38efa4debb392042bd0476d63ecebe08971fb33555fef4c0e01ac8333eaacb05832f0fdbd1089f226d471308709d3e80cfaed3def793aeebc8ff5609e3e
7
+ data.tar.gz: c972fb2e7d43e58c862501f4e9dc74383f7b68feac777bac3c8b676a3c2f17a0063d28e2bb82a651fffe6ae76835401d3f24c092c26b2bb4bba52f0f57af3dcc
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ssh-tresor-ruby contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # ssh-tresor-ruby
2
+
3
+ `ssh-tresor` encrypts and decrypts secrets using keys available through
4
+ `ssh-agent`.
5
+
6
+ The private key never leaves the SSH agent. Encryption creates a random master
7
+ key, asks the agent to sign random per-key challenges, derives slot keys with
8
+ HKDF-SHA256, and stores an AES-256-GCM encrypted master key slot for each SSH
9
+ key. Decryption works when one matching SSH key is loaded locally or forwarded
10
+ with `ssh -A`.
11
+
12
+ It is freely inspired by the [`ssh-tresor`][1] project but doesn't depend
13
+ on it.
14
+
15
+ [1]: https://github.com/haraldh/ssh-tresor
16
+
17
+ ## Usage
18
+
19
+ ```sh
20
+ ssh-tresor list-keys
21
+ echo -n "secret" | ssh-tresor encrypt -a > secret.tresor
22
+ ssh-tresor decrypt secret.tresor
23
+ ssh-tresor list-slots secret.tresor
24
+ ssh-tresor add-key -k SHA256:abc < secret.tresor > updated.tresor
25
+ ssh-tresor remove-key -k SHA256:abc < updated.tresor > reduced.tresor
26
+ ```
27
+
28
+ ## Library API
29
+
30
+ Other gems can depend on `ssh-tresor-ruby` and call it directly:
31
+
32
+ ```ruby
33
+ require "ssh_tresor"
34
+
35
+ vault = SshTresor::Vault.new
36
+
37
+ encrypted = vault.encrypt("secret", armor: true)
38
+ plaintext = vault.decrypt(encrypted)
39
+
40
+ updated = vault.add_key(encrypted, fingerprint: "SHA256:abc", armor: true)
41
+ slots = vault.list_slots(updated)
42
+ keys = vault.list_keys
43
+ ```
44
+
45
+ The `Vault` instance connects to `SSH_AUTH_SOCK` by default. You can inject a
46
+ custom agent object for tests or alternate transports:
47
+
48
+ ```ruby
49
+ vault = SshTresor::Vault.new(agent: my_agent)
50
+ ```
51
+
52
+ The lower-level `SshTresor::TresorBlob` parser and `SshTresor::Tresor` module
53
+ remain available if you need direct access to parsed slots.
54
+
55
+ ## Wire Format
56
+
57
+ The implementation writes and reads the `SSHTRESR` v3 format:
58
+
59
+ ```text
60
+ Header: SSHTRESR (8) + version (1) + slot_count (1)
61
+ Slot: fingerprint (32) + challenge (32) + nonce (12) + encrypted_key (48)
62
+ Data: nonce (12) + ciphertext including 16-byte AES-GCM auth tag
63
+ ```
data/exe/ssh-tresor ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ssh_tresor/cli"
5
+
6
+ exit SshTresor::CLI.new(ARGV).run
7
+
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "socket"
6
+
7
+ require_relative "error"
8
+ require_relative "ssh_encoding"
9
+
10
+ module SshTresor
11
+ AgentKey = Struct.new(:blob, :comment, keyword_init: true) do
12
+ def fingerprint_bytes
13
+ @fingerprint_bytes ||= Digest::SHA256.digest(blob)
14
+ end
15
+
16
+ def fingerprint
17
+ "SHA256:#{Base64.strict_encode64(fingerprint_bytes).delete("=")}"
18
+ end
19
+
20
+ def md5_fingerprint
21
+ Digest::MD5.digest(blob).bytes.map { |byte| "%02x" % byte }.join(":")
22
+ end
23
+
24
+ def ssh_type
25
+ @ssh_type ||= SSHEncoding::Reader.new(blob).string
26
+ end
27
+
28
+ def key_type
29
+ @key_type ||= Agent.format_key_type(blob)
30
+ end
31
+
32
+ def security_key?
33
+ ssh_type.start_with?("sk-")
34
+ end
35
+
36
+ def matches_fingerprint?(prefix)
37
+ normalized_prefix = prefix.delete_prefix("SHA256:")
38
+ normalized_fingerprint = fingerprint.delete_prefix("SHA256:")
39
+ normalized_fingerprint.start_with?(normalized_prefix)
40
+ end
41
+
42
+ def to_s
43
+ "#{fingerprint} #{key_type} #{comment}"
44
+ end
45
+ end
46
+
47
+ class Agent
48
+ SSH_AGENT_FAILURE = 5
49
+ SSH_AGENTC_REQUEST_IDENTITIES = 11
50
+ SSH_AGENT_IDENTITIES_ANSWER = 12
51
+ SSH_AGENTC_SIGN_REQUEST = 13
52
+ SSH_AGENT_SIGN_RESPONSE = 14
53
+ SSH_AGENT_SIGN_REQUEST_RSA_SHA2_256 = 2
54
+
55
+ def self.connect
56
+ socket_path = ENV["SSH_AUTH_SOCK"]
57
+ raise AgentError, "SSH agent not available\nHint: Is SSH_AUTH_SOCK set? Try running: eval $(ssh-agent) && ssh-add" if socket_path.nil? || socket_path.empty?
58
+
59
+ new(UNIXSocket.new(socket_path))
60
+ rescue SystemCallError => e
61
+ raise AgentError, "Failed to connect to SSH agent: #{e.message}"
62
+ end
63
+
64
+ def self.format_key_type(blob)
65
+ reader = SSHEncoding::Reader.new(blob)
66
+ type = reader.string
67
+
68
+ case type
69
+ when "ssh-ed25519"
70
+ "ED25519"
71
+ when "ssh-rsa"
72
+ reader.string
73
+ n = reader.string
74
+ "RSA-#{bit_length(n)}"
75
+ when /\Aecdsa-sha2-/
76
+ curve = reader.string
77
+ "ECDSA-#{curve.delete_prefix("nistp")}"
78
+ when "sk-ssh-ed25519@openssh.com"
79
+ "SK-ED25519"
80
+ when "sk-ecdsa-sha2-nistp256@openssh.com"
81
+ "SK-ECDSA-256"
82
+ else
83
+ type.upcase
84
+ end
85
+ rescue Error
86
+ "UNKNOWN"
87
+ end
88
+
89
+ def self.bit_length(bytes)
90
+ trimmed = bytes.b.sub(/\A\x00+/n, "")
91
+ return 0 if trimmed.empty?
92
+
93
+ ((trimmed.bytesize - 1) * 8) + trimmed.getbyte(0).bit_length
94
+ end
95
+
96
+ def initialize(socket)
97
+ @socket = socket
98
+ end
99
+
100
+ def list_keys
101
+ response = request(SSHEncoding.byte(SSH_AGENTC_REQUEST_IDENTITIES))
102
+ reader = SSHEncoding::Reader.new(response)
103
+ type = reader.byte
104
+ raise AgentError, "SSH agent refused identity request" if type == SSH_AGENT_FAILURE
105
+ raise AgentError, "Unexpected SSH agent response type #{type}" unless type == SSH_AGENT_IDENTITIES_ANSWER
106
+
107
+ count = reader.uint32
108
+ Array.new(count) do
109
+ blob = reader.string
110
+ comment = reader.string.force_encoding(Encoding::UTF_8)
111
+ comment = comment.valid_encoding? ? comment : comment.b.inspect
112
+ AgentKey.new(blob: blob, comment: comment)
113
+ end
114
+ end
115
+
116
+ def first_key
117
+ list_keys.first || raise(KeyNotFound, "No keys available in SSH agent\nHint: Try running: ssh-add")
118
+ end
119
+
120
+ def find_key(fingerprint)
121
+ matches = list_keys.select { |key| key.matches_fingerprint?(fingerprint) }
122
+
123
+ case matches.length
124
+ when 0
125
+ raise KeyNotFound, "Key not found: #{fingerprint}\nHint: Use 'ssh-tresor list-keys' to see available keys"
126
+ when 1
127
+ matches.first
128
+ else
129
+ raise KeyNotFound, "Key not found: #{fingerprint} (ambiguous: #{matches.length} keys match this prefix, please be more specific)"
130
+ end
131
+ end
132
+
133
+ def find_key_by_fingerprint_bytes(fingerprint_bytes)
134
+ list_keys.find { |key| key.fingerprint_bytes == fingerprint_bytes } ||
135
+ raise(KeyNotFound, "Key not found: SHA256:#{Base64.strict_encode64(fingerprint_bytes).delete("=")}")
136
+ end
137
+
138
+ def sign(key, data)
139
+ flags = key.ssh_type == "ssh-rsa" ? SSH_AGENT_SIGN_REQUEST_RSA_SHA2_256 : 0
140
+ payload = SSHEncoding.byte(SSH_AGENTC_SIGN_REQUEST) +
141
+ SSHEncoding.string(key.blob) +
142
+ SSHEncoding.string(data) +
143
+ SSHEncoding.uint32(flags)
144
+
145
+ response = request(payload)
146
+ reader = SSHEncoding::Reader.new(response)
147
+ type = reader.byte
148
+ raise AgentError, "SSH agent refused signing request" if type == SSH_AGENT_FAILURE
149
+ raise AgentError, "Unexpected SSH agent response type #{type}" unless type == SSH_AGENT_SIGN_RESPONSE
150
+
151
+ signature_blob = reader.string
152
+ signature_reader = SSHEncoding::Reader.new(signature_blob)
153
+ signature_reader.string
154
+ signature_reader.string
155
+ end
156
+
157
+ private
158
+
159
+ def request(payload)
160
+ @socket.write(SSHEncoding.uint32(payload.bytesize))
161
+ @socket.write(payload)
162
+
163
+ length = read_exact(4).unpack1("N")
164
+ read_exact(length)
165
+ rescue IOError, SystemCallError => e
166
+ raise AgentError, "SSH agent communication failed: #{e.message}"
167
+ end
168
+
169
+ def read_exact(length)
170
+ buffer = +"".b
171
+ while buffer.bytesize < length
172
+ chunk = @socket.read(length - buffer.bytesize)
173
+ raise AgentError, "SSH agent closed connection" if chunk.nil?
174
+
175
+ buffer << chunk
176
+ end
177
+ buffer
178
+ end
179
+ end
180
+ end
181
+
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "optparse"
5
+
6
+ require_relative "../ssh_tresor"
7
+
8
+ module SshTresor
9
+ class CLI
10
+ def initialize(argv)
11
+ @argv = argv.dup
12
+ end
13
+
14
+ def run
15
+ command = @argv.shift
16
+ return help if command.nil? || %w[-h --help help].include?(command)
17
+ return version if %w[-v --version version].include?(command)
18
+
19
+ case command
20
+ when "encrypt"
21
+ encrypt_command
22
+ when "decrypt"
23
+ decrypt_command
24
+ when "add-key"
25
+ add_key_command
26
+ when "remove-key"
27
+ remove_key_command
28
+ when "list-slots"
29
+ list_slots_command
30
+ when "list-keys"
31
+ list_keys_command
32
+ else
33
+ warn "Unknown command: #{command}"
34
+ help(1)
35
+ end
36
+ rescue Error => e
37
+ warn "Error: #{e.message}"
38
+ e.exit_code
39
+ rescue OptionParser::ParseError => e
40
+ warn "Error: #{e.message}"
41
+ Error::EXIT_GENERAL_ERROR
42
+ end
43
+
44
+ private
45
+
46
+ def help(exit_code = 0)
47
+ io = exit_code.zero? ? $stdout : $stderr
48
+ io.puts <<~HELP
49
+ Usage: ssh-tresor <command> [options]
50
+
51
+ Commands:
52
+ encrypt Encrypt data using SSH keys from the agent
53
+ decrypt Decrypt data using an SSH key from the agent
54
+ add-key Add a key to an existing tresor
55
+ remove-key Remove a key from an existing tresor
56
+ list-slots List key slots in a tresor
57
+ list-keys List available keys in the SSH agent
58
+ HELP
59
+ exit_code
60
+ end
61
+
62
+ def version
63
+ puts SshTresor::VERSION
64
+ 0
65
+ end
66
+
67
+ def encrypt_command
68
+ options = { fingerprints: [], armor: false }
69
+ parser = OptionParser.new do |opts|
70
+ opts.on("-k", "--key FINGERPRINT") { |value| options[:fingerprints] << value }
71
+ opts.on("-o", "--output FILE") { |value| options[:output] = value }
72
+ opts.on("-a", "--armor") { options[:armor] = true }
73
+ end
74
+ parser.parse!(@argv)
75
+
76
+ plaintext = read_input(@argv.shift)
77
+ blob = Tresor.encrypt(plaintext, fingerprints: options[:fingerprints])
78
+ output = options[:armor] ? blob.to_armored : blob.to_bytes
79
+ write_output(options[:output], output)
80
+ 0
81
+ end
82
+
83
+ def decrypt_command
84
+ options = {}
85
+ parser = OptionParser.new do |opts|
86
+ opts.on("-o", "--output FILE") { |value| options[:output] = value }
87
+ end
88
+ parser.parse!(@argv)
89
+
90
+ encrypted = read_input(@argv.shift)
91
+ blob = TresorBlob.from_bytes(encrypted)
92
+ write_output(options[:output], Tresor.decrypt(blob))
93
+ 0
94
+ end
95
+
96
+ def add_key_command
97
+ options = { all: false, armor: false, in_place: false }
98
+ parser = OptionParser.new do |opts|
99
+ opts.on("-k", "--key FINGERPRINT") { |value| options[:fingerprint] = value }
100
+ opts.on("-a", "--all") { options[:all] = true }
101
+ opts.on("-i", "--in-place") { options[:in_place] = true }
102
+ opts.on("-o", "--output FILE") { |value| options[:output] = value }
103
+ opts.on("--armor") { options[:armor] = true }
104
+ end
105
+ parser.parse!(@argv)
106
+
107
+ raise Error, "Invalid arguments: either --key or --all must be specified" if options[:fingerprint].nil? && !options[:all]
108
+ raise Error, "Invalid arguments: --key and --all are mutually exclusive" if options[:fingerprint] && options[:all]
109
+
110
+ input = @argv.shift
111
+ encrypted = read_input(input)
112
+ was_armored = armored?(encrypted)
113
+ blob = TresorBlob.from_bytes(encrypted)
114
+
115
+ updated = if options[:all]
116
+ new_blob, added = Tresor.add_all_keys(blob)
117
+ warn(added.zero? ? "No new keys added (all keys already present or unavailable)" : "Added #{added} key(s)")
118
+ new_blob
119
+ else
120
+ Tresor.add_key(blob, options[:fingerprint])
121
+ end
122
+
123
+ output = serialize(updated, options[:armor] || was_armored)
124
+ write_output(options[:in_place] ? input : options[:output], output)
125
+ 0
126
+ end
127
+
128
+ def remove_key_command
129
+ options = { armor: false, in_place: false }
130
+ parser = OptionParser.new do |opts|
131
+ opts.on("-k", "--key FINGERPRINT") { |value| options[:fingerprint] = value }
132
+ opts.on("-i", "--in-place") { options[:in_place] = true }
133
+ opts.on("-o", "--output FILE") { |value| options[:output] = value }
134
+ opts.on("--armor") { options[:armor] = true }
135
+ end
136
+ parser.parse!(@argv)
137
+
138
+ raise Error, "Invalid arguments: --key is required" if options[:fingerprint].nil?
139
+
140
+ input = @argv.shift
141
+ encrypted = read_input(input)
142
+ was_armored = armored?(encrypted)
143
+ blob = TresorBlob.from_bytes(encrypted)
144
+ updated = Tresor.remove_key(blob, options[:fingerprint])
145
+ output = serialize(updated, options[:armor] || was_armored)
146
+ write_output(options[:in_place] ? input : options[:output], output)
147
+ 0
148
+ end
149
+
150
+ def list_slots_command
151
+ encrypted = read_input(@argv.shift)
152
+ blob = TresorBlob.from_bytes(encrypted)
153
+ agent_keys = begin
154
+ Tresor.list_keys
155
+ rescue AgentError
156
+ []
157
+ end
158
+
159
+ puts "Tresor contains #{blob.slots.length} key slot(s):"
160
+ blob.slot_fingerprints.each_with_index do |fingerprint, index|
161
+ fingerprint_text = "SHA256:#{Base64.strict_encode64(fingerprint).delete("=")}"
162
+ key = agent_keys.find { |agent_key| agent_key.fingerprint_bytes == fingerprint }
163
+ availability = key ? " #{key.key_type} #{key.comment} [AVAILABLE]" : ""
164
+ puts " Slot #{index + 1}: #{fingerprint_text}#{availability}"
165
+ end
166
+ 0
167
+ end
168
+
169
+ def list_keys_command
170
+ options = { md5: false }
171
+ parser = OptionParser.new do |opts|
172
+ opts.on("--md5") { options[:md5] = true }
173
+ end
174
+ parser.parse!(@argv)
175
+
176
+ keys = Tresor.list_keys
177
+ raise KeyNotFound, "No keys available in SSH agent\nHint: Try running: ssh-add" if keys.empty?
178
+
179
+ keys.each do |key|
180
+ puts(options[:md5] ? "#{key.md5_fingerprint} #{key.key_type} #{key.comment}" : key.to_s)
181
+ end
182
+ 0
183
+ end
184
+
185
+ def read_input(path)
186
+ bytes = if path.nil? || path == "-"
187
+ $stdin.binmode.read
188
+ else
189
+ File.binread(path)
190
+ end
191
+
192
+ if bytes.bytesize > TresorBlob::MAX_TRESOR_SIZE
193
+ raise Error, "Invalid tresor format: input too large: #{bytes.bytesize} bytes, maximum #{TresorBlob::MAX_TRESOR_SIZE} bytes"
194
+ end
195
+
196
+ bytes
197
+ end
198
+
199
+ def write_output(path, data)
200
+ if path.nil?
201
+ $stdout.binmode.write(data)
202
+ else
203
+ File.binwrite(path, data)
204
+ end
205
+ end
206
+
207
+ def armored?(data)
208
+ data.b.strip.start_with?(TresorBlob::ARMOR_BEGIN)
209
+ end
210
+
211
+ def serialize(blob, armor)
212
+ armor ? blob.to_armored : blob.to_bytes
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ require_relative "error"
7
+
8
+ module SshTresor
9
+ module Crypto
10
+ CHALLENGE_SIZE = 32
11
+ MASTER_KEY_SIZE = 32
12
+ NONCE_SIZE = 12
13
+ AUTH_TAG_SIZE = 16
14
+
15
+ module_function
16
+
17
+ def random_challenge
18
+ SecureRandom.random_bytes(CHALLENGE_SIZE)
19
+ end
20
+
21
+ def random_master_key
22
+ SecureRandom.random_bytes(MASTER_KEY_SIZE)
23
+ end
24
+
25
+ def random_nonce
26
+ SecureRandom.random_bytes(NONCE_SIZE)
27
+ end
28
+
29
+ def derive_key(signature)
30
+ OpenSSL::KDF.hkdf(
31
+ signature,
32
+ salt: "ssh-tresor-v3",
33
+ info: "slot-key-derivation",
34
+ length: 32,
35
+ hash: "SHA256"
36
+ )
37
+ end
38
+
39
+ def encrypt(key, nonce, plaintext)
40
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
41
+ cipher.encrypt
42
+ cipher.key = key
43
+ cipher.iv = nonce
44
+ cipher.auth_data = "".b
45
+
46
+ ciphertext = cipher.update(plaintext.b) + cipher.final
47
+ ciphertext + cipher.auth_tag
48
+ rescue OpenSSL::Cipher::CipherError => e
49
+ raise Error, "Encryption failed: AES-GCM encryption failed: #{e.message}"
50
+ end
51
+
52
+ def decrypt(key, nonce, ciphertext_with_tag)
53
+ raise DecryptionError, "ciphertext too short" if ciphertext_with_tag.bytesize < AUTH_TAG_SIZE
54
+
55
+ ciphertext = ciphertext_with_tag.byteslice(0, ciphertext_with_tag.bytesize - AUTH_TAG_SIZE)
56
+ tag = ciphertext_with_tag.byteslice(-AUTH_TAG_SIZE, AUTH_TAG_SIZE)
57
+
58
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
59
+ cipher.decrypt
60
+ cipher.key = key
61
+ cipher.iv = nonce
62
+ cipher.auth_tag = tag
63
+ cipher.auth_data = "".b
64
+
65
+ cipher.update(ciphertext) + cipher.final
66
+ rescue OpenSSL::Cipher::CipherError
67
+ raise DecryptionError, "authentication failed - wrong key or corrupted data"
68
+ end
69
+ end
70
+ end
71
+
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SshTresor
4
+ class Error < StandardError
5
+ EXIT_GENERAL_ERROR = 1
6
+ EXIT_AGENT_CONNECTION_FAILED = 2
7
+ EXIT_KEY_NOT_FOUND = 3
8
+ EXIT_DECRYPTION_FAILED = 4
9
+
10
+ attr_reader :exit_code
11
+
12
+ def initialize(message, exit_code: EXIT_GENERAL_ERROR)
13
+ super(message)
14
+ @exit_code = exit_code
15
+ end
16
+ end
17
+
18
+ class AgentError < Error
19
+ def initialize(message)
20
+ super(message, exit_code: EXIT_AGENT_CONNECTION_FAILED)
21
+ end
22
+ end
23
+
24
+ class KeyNotFound < Error
25
+ def initialize(message)
26
+ super(message, exit_code: EXIT_KEY_NOT_FOUND)
27
+ end
28
+ end
29
+
30
+ class NoMatchingSlot < Error
31
+ def initialize
32
+ super(
33
+ "No matching slot found\nHint: None of the keys in your SSH agent can decrypt this tresor",
34
+ exit_code: EXIT_KEY_NOT_FOUND
35
+ )
36
+ end
37
+ end
38
+
39
+ class DecryptionError < Error
40
+ def initialize(message)
41
+ super("Decryption failed: #{message}", exit_code: EXIT_DECRYPTION_FAILED)
42
+ end
43
+ end
44
+ end
45
+
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ require_relative "crypto"
6
+ require_relative "error"
7
+
8
+ module SshTresor
9
+ Slot = Struct.new(:fingerprint, :challenge, :nonce, :encrypted_key, keyword_init: true) do
10
+ def to_bytes
11
+ fingerprint + challenge + nonce + encrypted_key
12
+ end
13
+ end
14
+
15
+ class TresorBlob
16
+ MAGIC = "SSHTRESR".b
17
+ VERSION = 0x03
18
+ FINGERPRINT_SIZE = 32
19
+ CHALLENGE_SIZE = 32
20
+ NONCE_SIZE = 12
21
+ AUTH_TAG_SIZE = 16
22
+ MASTER_KEY_SIZE = 32
23
+ ENCRYPTED_KEY_SIZE = MASTER_KEY_SIZE + AUTH_TAG_SIZE
24
+ SLOT_SIZE = FINGERPRINT_SIZE + CHALLENGE_SIZE + NONCE_SIZE + ENCRYPTED_KEY_SIZE
25
+ HEADER_SIZE = 10
26
+ MAX_TRESOR_SIZE = 100 * 1024 * 1024
27
+ ARMOR_BEGIN = "-----BEGIN SSH TRESOR-----"
28
+ ARMOR_END = "-----END SSH TRESOR-----"
29
+
30
+ attr_reader :slots, :data_nonce, :ciphertext
31
+
32
+ def self.from_bytes(data)
33
+ bytes = data.b
34
+ if bytes.valid_encoding? && bytes.strip.start_with?(ARMOR_BEGIN)
35
+ from_armored(bytes)
36
+ else
37
+ from_binary(bytes)
38
+ end
39
+ end
40
+
41
+ def self.from_armored(text)
42
+ start = text.index(ARMOR_BEGIN)
43
+ finish = text.index(ARMOR_END)
44
+ raise Error, "Invalid tresor format: missing BEGIN header" if start.nil?
45
+ raise Error, "Invalid tresor format: missing END footer" if finish.nil?
46
+ raise Error, "Invalid tresor format: invalid armor structure" if start >= finish
47
+
48
+ base64 = text[(start + ARMOR_BEGIN.length)...finish].chars.reject { |char| char =~ /\s/ }.join
49
+ from_binary(Base64.strict_decode64(base64))
50
+ rescue ArgumentError => e
51
+ raise Error, "Invalid tresor format: base64 decoding failed: #{e.message}"
52
+ end
53
+
54
+ def self.from_binary(data)
55
+ min_size = HEADER_SIZE + SLOT_SIZE + NONCE_SIZE + AUTH_TAG_SIZE
56
+ raise Error, "Invalid tresor format: data too short: #{data.bytesize} bytes, minimum #{min_size} required" if data.bytesize < min_size
57
+ raise Error, "Invalid tresor format: invalid magic header" unless data.byteslice(0, 8) == MAGIC
58
+
59
+ version = data.getbyte(8)
60
+ raise Error, "Invalid tresor format: unsupported version: #{version}, expected #{VERSION}" unless version == VERSION
61
+
62
+ slot_count = data.getbyte(9)
63
+ raise Error, "Invalid tresor format: tresor has no key slots" if slot_count.zero?
64
+
65
+ slots_end = HEADER_SIZE + (slot_count * SLOT_SIZE)
66
+ raise Error, "Invalid tresor format: data too short for #{slot_count} slots" if data.bytesize < slots_end + NONCE_SIZE + AUTH_TAG_SIZE
67
+
68
+ slots = Array.new(slot_count) do |index|
69
+ offset = HEADER_SIZE + (index * SLOT_SIZE)
70
+ parse_slot(data.byteslice(offset, SLOT_SIZE))
71
+ end
72
+
73
+ data_nonce = data.byteslice(slots_end, NONCE_SIZE)
74
+ ciphertext = data.byteslice(slots_end + NONCE_SIZE, data.bytesize - slots_end - NONCE_SIZE)
75
+
76
+ new(slots: slots, data_nonce: data_nonce, ciphertext: ciphertext)
77
+ end
78
+
79
+ def self.parse_slot(bytes)
80
+ raise Error, "Invalid tresor format: slot data too short" if bytes.bytesize < SLOT_SIZE
81
+
82
+ offset = 0
83
+ fingerprint = bytes.byteslice(offset, FINGERPRINT_SIZE)
84
+ offset += FINGERPRINT_SIZE
85
+ challenge = bytes.byteslice(offset, CHALLENGE_SIZE)
86
+ offset += CHALLENGE_SIZE
87
+ nonce = bytes.byteslice(offset, NONCE_SIZE)
88
+ offset += NONCE_SIZE
89
+ encrypted_key = bytes.byteslice(offset, ENCRYPTED_KEY_SIZE)
90
+
91
+ Slot.new(
92
+ fingerprint: fingerprint,
93
+ challenge: challenge,
94
+ nonce: nonce,
95
+ encrypted_key: encrypted_key
96
+ )
97
+ end
98
+
99
+ def initialize(slots:, data_nonce:, ciphertext:)
100
+ @slots = slots
101
+ @data_nonce = data_nonce
102
+ @ciphertext = ciphertext
103
+ end
104
+
105
+ def to_bytes
106
+ raise Error, "Invalid tresor format: tresor has no key slots" if slots.empty?
107
+ raise Error, "Invalid tresor format: tresor has too many slots (max 255)" if slots.length > 255
108
+
109
+ MAGIC + [VERSION, slots.length].pack("CC") + slots.map(&:to_bytes).join.b + data_nonce + ciphertext
110
+ end
111
+
112
+ def to_armored
113
+ encoded = Base64.strict_encode64(to_bytes)
114
+ wrapped = encoded.scan(/.{1,64}/).join("\n")
115
+ "#{ARMOR_BEGIN}\n#{wrapped}\n#{ARMOR_END}\n"
116
+ end
117
+
118
+ def find_slot(fingerprint)
119
+ slots.find { |slot| slot.fingerprint == fingerprint }
120
+ end
121
+
122
+ def slot_fingerprints
123
+ slots.map(&:fingerprint)
124
+ end
125
+ end
126
+ end
127
+
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SshTresor
4
+ module SSHEncoding
5
+ module_function
6
+
7
+ def byte(value)
8
+ value.chr.b
9
+ end
10
+
11
+ def uint32(value)
12
+ [value].pack("N")
13
+ end
14
+
15
+ def string(value)
16
+ bytes = value.b
17
+ uint32(bytes.bytesize) + bytes
18
+ end
19
+
20
+ class Reader
21
+ def initialize(data)
22
+ @data = data.b
23
+ @offset = 0
24
+ end
25
+
26
+ def byte
27
+ read(1).getbyte(0)
28
+ end
29
+
30
+ def uint32
31
+ read(4).unpack1("N")
32
+ end
33
+
34
+ def string
35
+ length = uint32
36
+ read(length)
37
+ end
38
+
39
+ def eof?
40
+ @offset == @data.bytesize
41
+ end
42
+
43
+ private
44
+
45
+ def read(length)
46
+ raise Error, "Invalid SSH wire data: short read" if @offset + length > @data.bytesize
47
+
48
+ bytes = @data.byteslice(@offset, length)
49
+ @offset += length
50
+ bytes
51
+ end
52
+ end
53
+ end
54
+ end
55
+
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent"
4
+ require_relative "crypto"
5
+ require_relative "format"
6
+ require "base64"
7
+
8
+ module SshTresor
9
+ module Tresor
10
+ module_function
11
+
12
+ def encrypt(plaintext, fingerprints: [])
13
+ encrypt_with_agent(Agent.connect, plaintext, fingerprints: fingerprints)
14
+ end
15
+
16
+ def encrypt_with_agent(agent, plaintext, fingerprints: [])
17
+ keys = if fingerprints.empty?
18
+ [agent.first_key]
19
+ else
20
+ fingerprints.map { |fingerprint| agent.find_key(fingerprint) }
21
+ end
22
+
23
+ encrypt_with_keys(agent, keys, plaintext)
24
+ end
25
+
26
+ def decrypt(blob)
27
+ decrypt_with_agent(Agent.connect, blob)
28
+ end
29
+
30
+ def decrypt_with_agent(agent, blob)
31
+ keys = agent.list_keys.sort_by(&:security_key?)
32
+
33
+ keys.each do |key|
34
+ slot = blob.find_slot(key.fingerprint_bytes)
35
+ next if slot.nil?
36
+
37
+ begin
38
+ return decrypt_with_slot(agent, key, slot, blob)
39
+ rescue DecryptionError
40
+ next
41
+ end
42
+ end
43
+
44
+ raise NoMatchingSlot
45
+ end
46
+
47
+ def add_key(blob, fingerprint)
48
+ add_key_with_agent(Agent.connect, blob, fingerprint)
49
+ end
50
+
51
+ def add_key_with_agent(agent, blob, fingerprint)
52
+ master_key = recover_master_key(agent, blob)
53
+ new_key = agent.find_key(fingerprint)
54
+
55
+ raise Error, "Invalid tresor format: key already exists in tresor" if blob.find_slot(new_key.fingerprint_bytes)
56
+
57
+ TresorBlob.new(
58
+ slots: blob.slots + [create_slot(agent, new_key, master_key)],
59
+ data_nonce: blob.data_nonce,
60
+ ciphertext: blob.ciphertext
61
+ )
62
+ end
63
+
64
+ def add_all_keys(blob)
65
+ add_all_keys_with_agent(Agent.connect, blob)
66
+ end
67
+
68
+ def add_all_keys_with_agent(agent, blob)
69
+ master_key = recover_master_key(agent, blob)
70
+ new_slots = blob.slots.dup
71
+ added = 0
72
+
73
+ agent.list_keys.each do |key|
74
+ next if blob.find_slot(key.fingerprint_bytes)
75
+
76
+ begin
77
+ new_slots << create_slot(agent, key, master_key)
78
+ added += 1
79
+ rescue Error
80
+ next
81
+ end
82
+ end
83
+
84
+ [TresorBlob.new(slots: new_slots, data_nonce: blob.data_nonce, ciphertext: blob.ciphertext), added]
85
+ end
86
+
87
+ def remove_key(blob, fingerprint)
88
+ raise Error, "Invalid tresor format: cannot remove the last key from tresor" if blob.slots.length == 1
89
+
90
+ fingerprint_bytes = resolve_slot_fingerprint(blob, fingerprint)
91
+ new_slots = blob.slots.reject { |slot| slot.fingerprint == fingerprint_bytes }
92
+
93
+ raise KeyNotFound, "Key not found: #{fingerprint}" if new_slots.length == blob.slots.length
94
+
95
+ TresorBlob.new(slots: new_slots, data_nonce: blob.data_nonce, ciphertext: blob.ciphertext)
96
+ end
97
+
98
+ def list_keys
99
+ Agent.connect.list_keys
100
+ end
101
+
102
+ def list_slots(blob)
103
+ blob.slot_fingerprints
104
+ end
105
+
106
+ def encrypt_with_keys(agent, keys, plaintext)
107
+ master_key = Crypto.random_master_key
108
+ slots = keys.map { |key| create_slot(agent, key, master_key) }
109
+ data_nonce = Crypto.random_nonce
110
+ ciphertext = Crypto.encrypt(master_key, data_nonce, plaintext)
111
+
112
+ TresorBlob.new(slots: slots, data_nonce: data_nonce, ciphertext: ciphertext)
113
+ end
114
+
115
+ def create_slot(agent, key, master_key)
116
+ challenge = Crypto.random_challenge
117
+ signature = agent.sign(key, challenge)
118
+ slot_key = Crypto.derive_key(signature)
119
+ nonce = Crypto.random_nonce
120
+ encrypted_key = Crypto.encrypt(slot_key, nonce, master_key)
121
+
122
+ Slot.new(
123
+ fingerprint: key.fingerprint_bytes,
124
+ challenge: challenge,
125
+ nonce: nonce,
126
+ encrypted_key: encrypted_key
127
+ )
128
+ end
129
+
130
+ def decrypt_with_slot(agent, key, slot, blob)
131
+ signature = agent.sign(key, slot.challenge)
132
+ slot_key = Crypto.derive_key(signature)
133
+ master_key = Crypto.decrypt(slot_key, slot.nonce, slot.encrypted_key)
134
+ Crypto.decrypt(master_key, blob.data_nonce, blob.ciphertext)
135
+ end
136
+
137
+ def recover_master_key(agent, blob)
138
+ agent.list_keys.each do |key|
139
+ slot = blob.find_slot(key.fingerprint_bytes)
140
+ next if slot.nil?
141
+
142
+ begin
143
+ signature = agent.sign(key, slot.challenge)
144
+ slot_key = Crypto.derive_key(signature)
145
+ return Crypto.decrypt(slot_key, slot.nonce, slot.encrypted_key)
146
+ rescue DecryptionError
147
+ next
148
+ end
149
+ end
150
+
151
+ raise NoMatchingSlot
152
+ end
153
+
154
+ def resolve_slot_fingerprint(blob, fingerprint)
155
+ normalized = fingerprint.delete_prefix("SHA256:")
156
+ matches = blob.slot_fingerprints.select do |slot_fingerprint|
157
+ Base64.strict_encode64(slot_fingerprint).delete("=").start_with?(normalized)
158
+ end
159
+
160
+ case matches.length
161
+ when 0
162
+ raise KeyNotFound, "Key not found: #{fingerprint}"
163
+ when 1
164
+ matches.first
165
+ else
166
+ raise KeyNotFound, "Key not found: #{fingerprint} (ambiguous: #{matches.length} slots match this prefix, please be more specific)"
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "format"
4
+ require_relative "tresor"
5
+
6
+ module SshTresor
7
+ # Public high-level API for encrypting and decrypting tresors from another Ruby
8
+ # application or gem.
9
+ #
10
+ # `Vault` is intentionally a small facade over the lower-level SSH agent,
11
+ # crypto, and wire-format objects. It connects to `SSH_AUTH_SOCK` by default,
12
+ # but accepts an injected agent object for tests or alternate transports.
13
+ #
14
+ # @example Encrypt and decrypt using the current SSH agent
15
+ # vault = SshTresor::Vault.new
16
+ # encrypted = vault.encrypt("secret", armor: true)
17
+ # plaintext = vault.decrypt(encrypted)
18
+ #
19
+ # @example Inject a custom agent implementation
20
+ # vault = SshTresor::Vault.new(agent: my_agent)
21
+ #
22
+ # @see SshTresor::Tresor
23
+ # @see SshTresor::TresorBlob
24
+ class Vault
25
+ # Creates a vault bound to an SSH agent.
26
+ #
27
+ # The default agent is opened from `ENV["SSH_AUTH_SOCK"]`. The injected agent
28
+ # must implement the subset of {SshTresor::Agent} used by the high-level
29
+ # operations: `first_key`, `find_key`, `list_keys`, and `sign`.
30
+ #
31
+ # @param agent [#first_key, #find_key, #list_keys, #sign] SSH agent-like object.
32
+ # @raise [SshTresor::AgentError] when the default SSH agent cannot be reached.
33
+ # @return [SshTresor::Vault]
34
+ def initialize(agent: Agent.connect)
35
+ @agent = agent
36
+ end
37
+
38
+ # Encrypts plaintext for one or more keys available in the SSH agent.
39
+ #
40
+ # When no fingerprints are given, the first key returned by the agent is
41
+ # used. Fingerprints may be full `SHA256:...` values or unambiguous prefixes.
42
+ #
43
+ # @param plaintext [String] Plaintext bytes to encrypt.
44
+ # @param fingerprints [Array<String>] SSH key fingerprints to encrypt for.
45
+ # @param armor [Boolean] Whether to return base64 armor instead of binary format.
46
+ # @return [String] Encrypted tresor bytes or armored text.
47
+ # @raise [SshTresor::KeyNotFound] when a requested key is unavailable.
48
+ # @raise [SshTresor::AgentError] when agent signing fails.
49
+ def encrypt(plaintext, fingerprints: [], armor: false)
50
+ blob = Tresor.encrypt_with_agent(@agent, plaintext, fingerprints: fingerprints)
51
+ armor ? blob.to_armored : blob.to_bytes
52
+ end
53
+
54
+ # Decrypts an encrypted tresor using any matching key in the SSH agent.
55
+ #
56
+ # The input may be binary `SSHTRESR` v3 data or armored text. The agent is
57
+ # asked to sign the stored slot challenge for matching key fingerprints.
58
+ #
59
+ # @param encrypted [String] Binary or armored tresor content.
60
+ # @return [String] Decrypted plaintext bytes.
61
+ # @raise [SshTresor::NoMatchingSlot] when no loaded agent key can decrypt it.
62
+ # @raise [SshTresor::DecryptionError] when authentication/decryption fails.
63
+ # @raise [SshTresor::Error] when the tresor format is invalid.
64
+ def decrypt(encrypted)
65
+ Tresor.decrypt_with_agent(@agent, TresorBlob.from_bytes(encrypted))
66
+ end
67
+
68
+ # Adds one SSH key slot to an existing tresor.
69
+ #
70
+ # The current agent must be able to decrypt an existing slot before adding a
71
+ # new one, because the master key must be recovered and re-wrapped for the
72
+ # new key.
73
+ #
74
+ # @param encrypted [String] Binary or armored tresor content.
75
+ # @param fingerprint [String] Fingerprint or unambiguous prefix of the key to add.
76
+ # @param armor [Boolean, nil] Output armor mode. `nil` preserves input format.
77
+ # @return [String] Updated encrypted tresor content.
78
+ # @raise [SshTresor::NoMatchingSlot] when the current agent cannot unlock the tresor.
79
+ # @raise [SshTresor::KeyNotFound] when the new key is unavailable.
80
+ def add_key(encrypted, fingerprint:, armor: nil)
81
+ input_was_armored = armored?(encrypted)
82
+ blob = TresorBlob.from_bytes(encrypted)
83
+ updated = Tresor.add_key_with_agent(@agent, blob, fingerprint)
84
+ serialize(updated, armor.nil? ? input_was_armored : armor)
85
+ end
86
+
87
+ # Adds slots for all available SSH agent keys not already present.
88
+ #
89
+ # Keys that are already present or cannot sign are skipped.
90
+ #
91
+ # @param encrypted [String] Binary or armored tresor content.
92
+ # @param armor [Boolean, nil] Output armor mode. `nil` preserves input format.
93
+ # @return [Array(String, Integer)] Updated tresor content and number of slots added.
94
+ # @raise [SshTresor::NoMatchingSlot] when the current agent cannot unlock the tresor.
95
+ def add_all_keys(encrypted, armor: nil)
96
+ input_was_armored = armored?(encrypted)
97
+ blob = TresorBlob.from_bytes(encrypted)
98
+ updated, added = Tresor.add_all_keys_with_agent(@agent, blob)
99
+ [serialize(updated, armor.nil? ? input_was_armored : armor), added]
100
+ end
101
+
102
+ # Removes one key slot from an existing tresor.
103
+ #
104
+ # This operation only edits metadata and does not require the SSH agent to
105
+ # hold the removed key. Removing the final slot is rejected.
106
+ #
107
+ # @param encrypted [String] Binary or armored tresor content.
108
+ # @param fingerprint [String] Fingerprint or unambiguous prefix of the slot to remove.
109
+ # @param armor [Boolean, nil] Output armor mode. `nil` preserves input format.
110
+ # @return [String] Updated encrypted tresor content.
111
+ # @raise [SshTresor::KeyNotFound] when no slot matches the fingerprint.
112
+ # @raise [SshTresor::Error] when attempting to remove the last slot.
113
+ def remove_key(encrypted, fingerprint:, armor: nil)
114
+ input_was_armored = armored?(encrypted)
115
+ blob = TresorBlob.from_bytes(encrypted)
116
+ updated = Tresor.remove_key(blob, fingerprint)
117
+ serialize(updated, armor.nil? ? input_was_armored : armor)
118
+ end
119
+
120
+ # Lists keys currently available through the configured SSH agent.
121
+ #
122
+ # @return [Array<SshTresor::AgentKey>] Agent keys with fingerprints, type, and comments.
123
+ # @raise [SshTresor::AgentError] when the SSH agent cannot be queried.
124
+ def list_keys
125
+ @agent.list_keys
126
+ end
127
+
128
+ # Lists key slot fingerprints present in encrypted tresor content.
129
+ #
130
+ # This does not require access to an SSH agent because slot fingerprints are
131
+ # stored in the tresor header.
132
+ #
133
+ # @param encrypted [String] Binary or armored tresor content.
134
+ # @return [Array<String>] Raw 32-byte SHA-256 fingerprint bytes for each slot.
135
+ # @raise [SshTresor::Error] when the tresor format is invalid.
136
+ def list_slots(encrypted)
137
+ TresorBlob.from_bytes(encrypted).slot_fingerprints
138
+ end
139
+
140
+ private
141
+
142
+ def armored?(data)
143
+ data.b.strip.start_with?(TresorBlob::ARMOR_BEGIN)
144
+ end
145
+
146
+ def serialize(blob, armor)
147
+ armor ? blob.to_armored : blob.to_bytes
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SshTresor
4
+ VERSION = "0.1.0"
5
+ end
6
+
data/lib/ssh_tresor.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ssh_tresor/agent"
4
+ require_relative "ssh_tresor/crypto"
5
+ require_relative "ssh_tresor/error"
6
+ require_relative "ssh_tresor/format"
7
+ require_relative "ssh_tresor/tresor"
8
+ require_relative "ssh_tresor/vault"
9
+ require_relative "ssh_tresor/version"
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ssh-tresor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ronan Potage
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-05-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.3'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.9'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.9'
69
+ description: Independent Ruby implementation of ssh-tresor using ssh-agent signatures,
70
+ HKDF-SHA256, and AES-256-GCM.
71
+ email:
72
+ executables:
73
+ - ssh-tresor
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE.txt
78
+ - README.md
79
+ - exe/ssh-tresor
80
+ - lib/ssh_tresor.rb
81
+ - lib/ssh_tresor/agent.rb
82
+ - lib/ssh_tresor/cli.rb
83
+ - lib/ssh_tresor/crypto.rb
84
+ - lib/ssh_tresor/error.rb
85
+ - lib/ssh_tresor/format.rb
86
+ - lib/ssh_tresor/ssh_encoding.rb
87
+ - lib/ssh_tresor/tresor.rb
88
+ - lib/ssh_tresor/vault.rb
89
+ - lib/ssh_tresor/version.rb
90
+ homepage: https://github.com/capripot/ssh-tresor-ruby
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ rubygems_mfa_required: 'true'
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '3.1'
104
+ required_rubygems_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '0'
109
+ requirements: []
110
+ rubygems_version: 3.1.6
111
+ signing_key:
112
+ specification_version: 4
113
+ summary: Encrypt and decrypt secrets using SSH agent keys
114
+ test_files: []