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 +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +63 -0
- data/exe/ssh-tresor +7 -0
- data/lib/ssh_tresor/agent.rb +181 -0
- data/lib/ssh_tresor/cli.rb +215 -0
- data/lib/ssh_tresor/crypto.rb +71 -0
- data/lib/ssh_tresor/error.rb +45 -0
- data/lib/ssh_tresor/format.rb +127 -0
- data/lib/ssh_tresor/ssh_encoding.rb +55 -0
- data/lib/ssh_tresor/tresor.rb +170 -0
- data/lib/ssh_tresor/vault.rb +150 -0
- data/lib/ssh_tresor/version.rb +6 -0
- data/lib/ssh_tresor.rb +9 -0
- metadata +114 -0
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,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
|
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: []
|