saltpack 0.1.0.pre.20200506181314
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/.document +5 -0
- data/.rdoc_options +16 -0
- data/.simplecov +9 -0
- data/ChangeLog +55 -0
- data/History.md +4 -0
- data/LICENSE.txt +19 -0
- data/Manifest.txt +25 -0
- data/README.md +89 -0
- data/Rakefile +9 -0
- data/examples/post_signed_message.rb +21 -0
- data/lib/saltpack.rb +112 -0
- data/lib/saltpack/armor.rb +211 -0
- data/lib/saltpack/errors.rb +23 -0
- data/lib/saltpack/header.rb +299 -0
- data/lib/saltpack/message.rb +101 -0
- data/lib/saltpack/payload.rb +24 -0
- data/lib/saltpack/recipient.rb +33 -0
- data/lib/saltpack/refinements.rb +38 -0
- data/spec/data/msg1-ciphertext.txt +1 -0
- data/spec/data/msg1.txt +16 -0
- data/spec/saltpack/armor_spec.rb +57 -0
- data/spec/saltpack/header_spec.rb +87 -0
- data/spec/saltpack/recipient_spec.rb +25 -0
- data/spec/saltpack_spec.rb +86 -0
- data/spec/spec_helper.rb +43 -0
- metadata +193 -0
@@ -0,0 +1,23 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'e2mmap'
|
5
|
+
|
6
|
+
require 'saltpack' unless defined?( Saltpack )
|
7
|
+
|
8
|
+
|
9
|
+
module Saltpack
|
10
|
+
extend Exception2MessageMapper
|
11
|
+
|
12
|
+
def_exception :Error, "saltpack error"
|
13
|
+
|
14
|
+
def_exception :KeyError, "missing/malformed key error"
|
15
|
+
|
16
|
+
def_exception :MalformedMessage, "malformed saltpack message", Saltpack::Error
|
17
|
+
def_exception :HMACError, "HMAC mismatch", Saltpack::Error
|
18
|
+
|
19
|
+
def_exception :UnsupportedFormat, "unrecognized format name", Saltpack::Error
|
20
|
+
def_exception :UnsupportedVersion, "incompatible version", Saltpack::Error
|
21
|
+
|
22
|
+
end # module Saltpack
|
23
|
+
|
@@ -0,0 +1,299 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'msgpack'
|
5
|
+
require 'rbnacl'
|
6
|
+
require 'loggability'
|
7
|
+
|
8
|
+
require 'saltpack' unless defined?( Saltpack )
|
9
|
+
require 'saltpack/refinements'
|
10
|
+
|
11
|
+
|
12
|
+
using Saltpack::Refinements
|
13
|
+
|
14
|
+
|
15
|
+
# Header for a saltpack message.
|
16
|
+
#
|
17
|
+
# Refs:
|
18
|
+
# - https://saltpack.org/encryption-format-v2
|
19
|
+
class Saltpack::Header
|
20
|
+
extend Loggability
|
21
|
+
|
22
|
+
|
23
|
+
# The header format name field value
|
24
|
+
FORMAT_NAME = 'saltpack'
|
25
|
+
|
26
|
+
# The header format major version field value
|
27
|
+
FORMAT_MAJOR_VERSION = 2
|
28
|
+
|
29
|
+
# The header format minor version field value
|
30
|
+
FORMAT_MINOR_VERSION = 0
|
31
|
+
|
32
|
+
# Version header Array
|
33
|
+
FORMAT_VERSION = [ FORMAT_MAJOR_VERSION, FORMAT_MINOR_VERSION ].freeze
|
34
|
+
|
35
|
+
# Mode names to numeric values
|
36
|
+
MODES = {
|
37
|
+
encryption: 0,
|
38
|
+
attached_signing: 1,
|
39
|
+
detached_signing: 2,
|
40
|
+
signcryption: 4
|
41
|
+
}.freeze
|
42
|
+
MODE_NAMES = MODES.invert.freeze
|
43
|
+
|
44
|
+
# The nonce used to create the sender key secret box
|
45
|
+
SENDER_KEY_SECRETBOX_NONCE = "saltpack_sender_key_sbox".b
|
46
|
+
|
47
|
+
# The nonce prefix used to create the recipients list
|
48
|
+
PAYLOAD_KEY_BOX_NONCE_PREFIX = "saltpack_recipsb".b
|
49
|
+
|
50
|
+
# The nonce prefix used for the payload packets
|
51
|
+
PAYLOAD_NONCE_PREFIX = "saltpack_ploadsb".b
|
52
|
+
|
53
|
+
|
54
|
+
# Log to the Saltpack logger
|
55
|
+
log_to :saltpack
|
56
|
+
|
57
|
+
|
58
|
+
# [
|
59
|
+
# format name,
|
60
|
+
# version,
|
61
|
+
# mode,
|
62
|
+
# ephemeral public key,
|
63
|
+
# sender secretbox,
|
64
|
+
# recipients list,
|
65
|
+
# ]
|
66
|
+
|
67
|
+
### Parse the (already once-decoded) data in the specified +source+ as a
|
68
|
+
### Saltpack header and return it as a Saltpack::Header. Raises a
|
69
|
+
### Saltpack::Error if the +source+ cannot be parsed.
|
70
|
+
def self::parse( source, recipient_key )
|
71
|
+
source = StringIO.new( source ) unless
|
72
|
+
source.respond_to?( :read ) || source.respond_to?( :readpartial )
|
73
|
+
unpacker = MessagePack::Unpacker.new( source )
|
74
|
+
self.log.debug "Unpacker is: %p" % [ unpacker ]
|
75
|
+
|
76
|
+
encoded_header = unpacker.read
|
77
|
+
header_hash = RbNaCl::Hash.sha512( encoded_header )
|
78
|
+
parts = MessagePack.unpack( encoded_header )
|
79
|
+
|
80
|
+
raise Saltpack::MalformedMessage, "header is not an Array" unless parts.is_a?( Array )
|
81
|
+
raise Saltpack::UnsupportedFormat, parts[0] unless
|
82
|
+
parts[0] == FORMAT_NAME
|
83
|
+
raise Saltpack::UnsupportedVersion, parts[1] unless
|
84
|
+
parts[1] == FORMAT_VERSION
|
85
|
+
|
86
|
+
return new( *parts, header_hash: header_hash )
|
87
|
+
rescue MessagePack::MalformedFormatError => err
|
88
|
+
self.log.error "%p while parsing the header: %s" % [ err.class, err.message ]
|
89
|
+
raise Saltpack::MalformedMessage, "malformed msgpack data: %s" % [ err.message ]
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
### Generate a header
|
94
|
+
def self::generate( sender_key, *recipient_public_keys, hide_recipients: false )
|
95
|
+
|
96
|
+
|
97
|
+
end
|
98
|
+
|
99
|
+
|
100
|
+
### Create a new header with the given
|
101
|
+
def initialize( **fields )
|
102
|
+
@format_name = FORMAT_NAME
|
103
|
+
@format_version = FORMAT_VERSION
|
104
|
+
|
105
|
+
@mode = :encryption
|
106
|
+
|
107
|
+
@payload_key = RbNaCl::Random.random_bytes( RbNaCl::SecretBox.key_bytes )
|
108
|
+
@ephemeral_key = RbNaCl::PrivateKey.generate
|
109
|
+
@sender_key = nil
|
110
|
+
|
111
|
+
@recipients = []
|
112
|
+
@hide_recipients = true
|
113
|
+
|
114
|
+
@recipient_mac_keys = nil
|
115
|
+
@hash = nil
|
116
|
+
@data = nil
|
117
|
+
|
118
|
+
fields.each do |name, value|
|
119
|
+
self.public_send( "#{name}=", value )
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
|
124
|
+
######
|
125
|
+
public
|
126
|
+
######
|
127
|
+
|
128
|
+
##
|
129
|
+
# The format name used in the header
|
130
|
+
attr_reader :format_name
|
131
|
+
|
132
|
+
##
|
133
|
+
# The [major, minor] version tuple used in the header
|
134
|
+
attr_reader :format_version
|
135
|
+
|
136
|
+
##
|
137
|
+
# The mode being used; one of the keys of MODES
|
138
|
+
attr_reader :mode
|
139
|
+
|
140
|
+
##
|
141
|
+
# The random payload key
|
142
|
+
attr_accessor :payload_key
|
143
|
+
|
144
|
+
##
|
145
|
+
# The RbNaCl::PrivateKey used only for this message to encrypt/sign its
|
146
|
+
# internals
|
147
|
+
attr_accessor :ephemeral_key
|
148
|
+
|
149
|
+
##
|
150
|
+
# The RbNaCl::PrivateKey/PublicKey of the sender
|
151
|
+
attr_accessor :sender_key
|
152
|
+
|
153
|
+
##
|
154
|
+
# The public keys of each of the message's recipients.
|
155
|
+
attr_reader :recipients
|
156
|
+
|
157
|
+
##
|
158
|
+
# Whether to include the recipients' public key in the recipients tuples of the
|
159
|
+
# message.
|
160
|
+
attr_accessor :hide_recipients
|
161
|
+
|
162
|
+
|
163
|
+
### Set the mode as either a Symbol or as an Integer.
|
164
|
+
def mode=( new_mode )
|
165
|
+
if MODES.key?( new_mode )
|
166
|
+
@mode = new_mode
|
167
|
+
elsif MODE_NAMES.key?( new_mode )
|
168
|
+
@mode = MODE_NAMES[ new_mode ]
|
169
|
+
else
|
170
|
+
raise ArgumentError, "invalid mode %p" % [ new_mode ]
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
|
175
|
+
### Return the mode as an Integer.
|
176
|
+
def numeric_mode
|
177
|
+
return MODES[ self.mode ]
|
178
|
+
end
|
179
|
+
|
180
|
+
|
181
|
+
### Return the #sender_key after checking to be sure the PrivateKey is
|
182
|
+
### available.
|
183
|
+
def sender_private_key
|
184
|
+
key = self.sender_key or raise Saltpack::KeyError, "sender key is not set"
|
185
|
+
raise Saltpack::KeyError, "sender private key not available" unless
|
186
|
+
key && key.respond_to?( :public_key )
|
187
|
+
return key
|
188
|
+
end
|
189
|
+
|
190
|
+
|
191
|
+
### Return either the #sender_key, or the public half of the #send_key if it's a
|
192
|
+
### PrivateKey.
|
193
|
+
def sender_public_key
|
194
|
+
key = self.sender_key or raise Saltpack::KeyError, "sender key is not set"
|
195
|
+
return key.public_key if key.respond_to?( :public_key )
|
196
|
+
return key
|
197
|
+
end
|
198
|
+
|
199
|
+
|
200
|
+
### Calculate all the header values and freeze it.
|
201
|
+
def finalize
|
202
|
+
return if self.frozen?
|
203
|
+
|
204
|
+
# 5. Collect the format name, version, and mode into a list, followed by the
|
205
|
+
# ephemeral public key, the sender secretbox, and the nested recipients list.
|
206
|
+
header_parts = [
|
207
|
+
self.format_name,
|
208
|
+
self.format_version,
|
209
|
+
self.numeric_mode,
|
210
|
+
self.ephemeral_key.public_key.to_bytes,
|
211
|
+
self.sender_secretbox,
|
212
|
+
self.recipient_tuples( hide_recipients: self.hide_recipients ),
|
213
|
+
]
|
214
|
+
|
215
|
+
# 6. Serialize the list from #5 into a MessagePack array object.
|
216
|
+
header_bytes = MessagePack.pack( header_parts )
|
217
|
+
|
218
|
+
# 7. Take the crypto_hash (SHA512) of the bytes from #6. This is the header hash.
|
219
|
+
@hash = RbNaCl::Hash.sha512( header_bytes )
|
220
|
+
|
221
|
+
# 8. Serialize the bytes from #6 again into a MessagePack bin object. These
|
222
|
+
# twice-encoded bytes are the header packet.
|
223
|
+
@data = MessagePack.pack( header_bytes )
|
224
|
+
|
225
|
+
# After generating the header, the sender computes each recipient's MAC key,
|
226
|
+
# which will be used below to authenticate the payload:
|
227
|
+
@recipient_mac_keys = self.recipients.map.with_index do |recipient_key, i|
|
228
|
+
Saltpack.calculate_recipient_hash(
|
229
|
+
@hash, i,
|
230
|
+
[recipient_key, self.sender_private_key],
|
231
|
+
[recipient_key, self.ephemeral_key]
|
232
|
+
)
|
233
|
+
end
|
234
|
+
|
235
|
+
self.freeze
|
236
|
+
end
|
237
|
+
|
238
|
+
|
239
|
+
### Overloaded -- also freeze the recipients when the header is frozen.
|
240
|
+
def freeze
|
241
|
+
@recipients.freeze
|
242
|
+
super
|
243
|
+
end
|
244
|
+
|
245
|
+
|
246
|
+
### Return the SHA612 hash of the single-messagepacked header.
|
247
|
+
def hash
|
248
|
+
self.finalize
|
249
|
+
return @hash
|
250
|
+
end
|
251
|
+
|
252
|
+
|
253
|
+
### Return the header as a binary String.
|
254
|
+
def data
|
255
|
+
self.finalize
|
256
|
+
return @data
|
257
|
+
end
|
258
|
+
alias_method :to_s, :data
|
259
|
+
|
260
|
+
|
261
|
+
### The MAC keys used to hash/validate message parts.
|
262
|
+
def recipient_mac_keys
|
263
|
+
self.finalize
|
264
|
+
return @recipient_mac_keys
|
265
|
+
end
|
266
|
+
|
267
|
+
|
268
|
+
### Generate an Array of header tuples from the #recipients keys and return it.
|
269
|
+
### If +hide_recipients+ is true, don't include the public keys in the tuples.
|
270
|
+
def recipient_tuples( hide_recipients: true )
|
271
|
+
# 4. For each recipient, encrypt the payload key using crypto_box with the
|
272
|
+
# recipient's public key, the ephemeral private key, and the nonce
|
273
|
+
# saltpack_recipsbXXXXXXXX. XXXXXXXX is 8-byte big-endian unsigned recipient
|
274
|
+
# index, where the first recipient is index zero. Pair these with the
|
275
|
+
# recipients' public keys, or null for anonymous recipients, and collect the
|
276
|
+
# pairs into the recipients list.
|
277
|
+
return self.recipients.map.with_index do |recipient_key, i|
|
278
|
+
box = RbNaCl::Box.new( recipient_key, self.ephemeral_key )
|
279
|
+
nonce = PAYLOAD_KEY_BOX_NONCE_PREFIX + [ i ].pack( 'Q>' )
|
280
|
+
encrypted_key = box.encrypt( nonce, self.payload_key )
|
281
|
+
|
282
|
+
[ hide_recipients ? nil : recipient_key, encrypted_key ]
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
|
287
|
+
### Return the sender secretbox
|
288
|
+
def sender_secretbox
|
289
|
+
# 1. Generate a random 32-byte payload key
|
290
|
+
# 2. Generate a random ephemeral keypair
|
291
|
+
# 3. Encrypt the sender's long-term public key using crypto_secretbox with the
|
292
|
+
# payload key and the nonce saltpack_sender_key_sbox, to create the sender
|
293
|
+
# secretbox.
|
294
|
+
box = RbNaCl::SecretBox.new( self.payload_key )
|
295
|
+
return box.encrypt( SENDER_KEY_SECRETBOX_NONCE, self.sender_public_key )
|
296
|
+
end
|
297
|
+
|
298
|
+
end # class Saltpack::Header
|
299
|
+
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'loggability'
|
5
|
+
require 'msgpack'
|
6
|
+
require 'rbnacl'
|
7
|
+
|
8
|
+
require 'saltpack' unless defined?( Saltpack )
|
9
|
+
require 'saltpack/header'
|
10
|
+
require 'saltpack/payload'
|
11
|
+
|
12
|
+
|
13
|
+
# An encrypted message.
|
14
|
+
class Saltpack::Message
|
15
|
+
extend Loggability
|
16
|
+
|
17
|
+
|
18
|
+
# Loggability -- log to the Saltpack module's logger
|
19
|
+
log_to :saltpack
|
20
|
+
|
21
|
+
|
22
|
+
### Read a Saltpack::Message from the given +source+ and +recipient_key+ and
|
23
|
+
### return it.
|
24
|
+
def self::read( source, recipient_key )
|
25
|
+
header = Saltpack::Header.parse( source, recipient_key )
|
26
|
+
self.log.debug( header )
|
27
|
+
|
28
|
+
# Try to open each of the payload key boxes in the recipients list using
|
29
|
+
# crypto_box_open_afternm, the precomputed secret from #5, and the nonce
|
30
|
+
# saltpack_recipsbXXXXXXXX. XXXXXXXX is 8-byte big-endian unsigned recipient
|
31
|
+
# index, where the first recipient is index 0. Successfully opening one gives
|
32
|
+
# the payload key.
|
33
|
+
if ( recipient = recipients.assoc(recipient_key.public_key) )
|
34
|
+
index = recipients.index( recipient ) or
|
35
|
+
raise "Recipient %p not present in the recipients list?!"
|
36
|
+
nonce = PAYLOAD_KEY_BOX_NONCE_PREFIX + [index].pack( 'Q>' )
|
37
|
+
box = RbNaCl::Box.new( ephemeral_pubkey, recipient_key )
|
38
|
+
payload_key = box.decrypt( nonce, recipient[1] )
|
39
|
+
else
|
40
|
+
ephemeral_beforenm = RbNaCl::Box.beforenm( ephemeral_pubkey, recipient_key ) or
|
41
|
+
raise "Failed to extract the ephemeral shared key."
|
42
|
+
recipients.each_with_index do |(_, encrypted_key), index|
|
43
|
+
nonce = self.payload_key_nonce( header.version, index )
|
44
|
+
payload_key = RbNaCl::Box.open_afternm( encrypted_key,
|
45
|
+
ephemeral_beforenm, nonce, RbNaCl::SecretBox.key_bytes )
|
46
|
+
break if payload_key
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
raise "Failed to extract the payload key" unless payload_key
|
51
|
+
sender_public = RbNaCl::SecretBox.new( payload_key ).
|
52
|
+
decrypt( SENDER_KEY_SECRETBOX_NONCE, sender_secretbox )
|
53
|
+
|
54
|
+
recipient_mac = Saltpack.calculate_recipient_hash( header_hash, index,
|
55
|
+
[sender_public, recipient_key],
|
56
|
+
[ephemeral_pubkey, recipient_key]
|
57
|
+
)
|
58
|
+
|
59
|
+
self.log.debug "recipient index: %p" % [ recipient_index ]
|
60
|
+
self.log.debug "sender key: %p" % [ sender_public ]
|
61
|
+
self.log.debug "payload key: %p" % [ payload_key ]
|
62
|
+
self.log.debug "mac key: %p" % [ mac_key ]
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
### (Undocumented)
|
67
|
+
def self::payload_key_nonce( version, index )
|
68
|
+
|
69
|
+
return PAYLOAD_KEY_BOX_NONCE_PREFIX + [index].pack( 'Q>' )
|
70
|
+
end
|
71
|
+
|
72
|
+
### Create a new Message for the given +recipients+ using the specified
|
73
|
+
### +sender_key+.
|
74
|
+
def initialize( data, header=nil, from: nil, to: [], **options )
|
75
|
+
@data = readable_source( data )
|
76
|
+
@header = header
|
77
|
+
@sender_key = from
|
78
|
+
@recipients = to
|
79
|
+
@options = options
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
### (Undocumented)
|
84
|
+
def decrypt
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
|
89
|
+
#######
|
90
|
+
private
|
91
|
+
#######
|
92
|
+
|
93
|
+
### Convert the given +data+ source into an object that supports #read or #readpartial if it
|
94
|
+
### isn't already one.
|
95
|
+
def readable_source( data )
|
96
|
+
return data if data.respond_to?( :read ) || data.respond_to?( :readpartial )
|
97
|
+
return StringIO.new( data ) if data.respond_to?( :to_str )
|
98
|
+
raise ArgumentError, "don't know how to read from a %p" % [ data.class ]
|
99
|
+
end
|
100
|
+
|
101
|
+
end # class Saltpack::Message
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'loggability'
|
5
|
+
require 'rbnacl'
|
6
|
+
|
7
|
+
require 'saltpack' unless defined?( Saltpack )
|
8
|
+
|
9
|
+
|
10
|
+
# A payload packet of a Saltpack message.
|
11
|
+
class Saltpack::Payload
|
12
|
+
|
13
|
+
#################################################################
|
14
|
+
### I N S T A N C E M E T H O D S
|
15
|
+
#################################################################
|
16
|
+
|
17
|
+
### Create a new Saltpack::Payload
|
18
|
+
def initialize( payload, *authenticators, final: false )
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
|
23
|
+
end # class Saltpack::Payload
|
24
|
+
|