slosilo 1.0.0 → 2.2.1

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.
@@ -1,3 +1,4 @@
1
+ require "slosilo/jwt"
1
2
  require "slosilo/version"
2
3
  require "slosilo/keystore"
3
4
  require "slosilo/symmetric"
@@ -14,7 +14,7 @@ module Slosilo
14
14
  def create_model
15
15
  model = Sequel::Model(:slosilo_keystore)
16
16
  model.unrestrict_primary_key
17
- model.attr_encrypted :key if secure?
17
+ model.attr_encrypted(:key, aad: :id) if secure?
18
18
  model
19
19
  end
20
20
 
@@ -5,21 +5,44 @@ module Slosilo
5
5
  # so we encrypt sensitive attributes before storing them
6
6
  module EncryptedAttributes
7
7
  module ClassMethods
8
+
9
+ # @param options [Hash]
10
+ # @option :aad [#to_proc, #to_s] Provide additional authenticated data for
11
+ # encryption. This should be something unique to the instance having
12
+ # this attribute, such as a primary key; this will ensure that an attacker can't swap
13
+ # values around -- trying to decrypt value with a different auth data will fail.
14
+ # This means you have to be able to recover it in order to decrypt attributes.
15
+ # The following values are accepted:
16
+ #
17
+ # * Something proc-ish: will be called with self each time auth data is needed.
18
+ # * Something stringish: will be to_s-d and used for all instances as auth data.
19
+ # Note that this will only prevent swapping in data using another string.
20
+ #
21
+ # The recommended way to use this option is to pass a proc-ish that identifies the record.
22
+ # Note the proc-ish can be a simple method name; for example in case of a Sequel::Model:
23
+ # attr_encrypted :secret, aad: :pk
8
24
  def attr_encrypted *a
25
+ options = a.last.is_a?(Hash) ? a.pop : {}
26
+ aad = options[:aad]
27
+ # note nil.to_s is "", which is exactly the right thing
28
+ auth_data = aad.respond_to?(:to_proc) ? aad.to_proc : proc{ |_| aad.to_s }
29
+ raise ":aad proc must take one argument" unless auth_data.arity.abs == 1 # take abs to allow *args arity, -1
30
+
9
31
  # push a module onto the inheritance hierarchy
10
32
  # this allows calling super in classes
11
33
  include(accessors = Module.new)
12
34
  accessors.module_eval do
13
35
  a.each do |attr|
14
36
  define_method "#{attr}=" do |value|
15
- super(EncryptedAttributes.encrypt value)
37
+ super(EncryptedAttributes.encrypt(value, aad: auth_data[self]))
16
38
  end
17
39
  define_method attr do
18
- EncryptedAttributes.decrypt(super())
40
+ EncryptedAttributes.decrypt(super(), aad: auth_data[self])
19
41
  end
20
42
  end
21
43
  end
22
44
  end
45
+
23
46
  end
24
47
 
25
48
  def self.included base
@@ -27,14 +50,14 @@ module Slosilo
27
50
  end
28
51
 
29
52
  class << self
30
- def encrypt value
53
+ def encrypt value, opts={}
31
54
  return nil unless value
32
- cipher.encrypt value, key: key
55
+ cipher.encrypt value, key: key, aad: opts[:aad]
33
56
  end
34
57
 
35
- def decrypt ctxt
58
+ def decrypt ctxt, opts={}
36
59
  return nil unless ctxt
37
- cipher.decrypt ctxt, key: key
60
+ cipher.decrypt ctxt, key: key, aad: opts[:aad]
38
61
  end
39
62
 
40
63
  def key
@@ -8,5 +8,8 @@ module Slosilo
8
8
  super
9
9
  end
10
10
  end
11
+
12
+ class TokenValidationError < Error
13
+ end
11
14
  end
12
15
  end
@@ -0,0 +1,122 @@
1
+ require 'json'
2
+
3
+ module Slosilo
4
+ # A JWT-formatted Slosilo token.
5
+ # @note This is not intended to be a general-purpose JWT implementation.
6
+ class JWT
7
+ # Create a new unsigned token with the given claims.
8
+ # @param claims [#to_h] claims to embed in this token.
9
+ def initialize claims = {}
10
+ @claims = JSONHash[claims]
11
+ end
12
+
13
+ # Parse a token in compact representation
14
+ def self.parse_compact raw
15
+ load *raw.split('.', 3).map(&Base64.method(:urlsafe_decode64))
16
+ end
17
+
18
+ # Parse a token in JSON representation.
19
+ # @note only single signature is currently supported.
20
+ def self.parse_json raw
21
+ raw = JSON.load raw unless raw.respond_to? :to_h
22
+ parts = raw.to_h.values_at(*%w(protected payload signature))
23
+ fail ArgumentError, "input not a complete JWT" unless parts.all?
24
+ load *parts.map(&Base64.method(:urlsafe_decode64))
25
+ end
26
+
27
+ # Add a signature.
28
+ # @note currently only a single signature is handled;
29
+ # the token will be frozen after this operation.
30
+ def add_signature header, &sign
31
+ @claims = canonicalize_claims.freeze
32
+ @header = JSONHash[header].freeze
33
+ @signature = sign[string_to_sign].freeze
34
+ freeze
35
+ end
36
+
37
+ def string_to_sign
38
+ [header, claims].map(&method(:encode)).join '.'
39
+ end
40
+
41
+ # Returns the JSON serialization of this JWT.
42
+ def to_json *a
43
+ {
44
+ protected: encode(header),
45
+ payload: encode(claims),
46
+ signature: encode(signature)
47
+ }.to_json *a
48
+ end
49
+
50
+ # Returns the compact serialization of this JWT.
51
+ def to_s
52
+ [header, claims, signature].map(&method(:encode)).join('.')
53
+ end
54
+
55
+ attr_accessor :claims, :header, :signature
56
+
57
+ private
58
+
59
+ # Create a JWT token object from existing header, payload, and signature strings.
60
+ # @param header [#to_s] URLbase64-encoded representation of the protected header
61
+ # @param payload [#to_s] URLbase64-encoded representation of the token payload
62
+ # @param signature [#to_s] URLbase64-encoded representation of the signature
63
+ def self.load header, payload, signature
64
+ self.new(JSONHash.load payload).tap do |token|
65
+ token.header = JSONHash.load header
66
+ token.signature = signature.to_s.freeze
67
+ token.freeze
68
+ end
69
+ end
70
+
71
+ def canonicalize_claims
72
+ claims[:iat] = Time.now unless claims.include? :iat
73
+ claims[:iat] = claims[:iat].to_time.to_i
74
+ claims[:exp] = claims[:exp].to_time.to_i if claims.include? :exp
75
+ JSONHash[claims.to_a]
76
+ end
77
+
78
+ # Convenience method to make the above code clearer.
79
+ # Converts to string and urlbase64-encodes.
80
+ def encode s
81
+ Base64.urlsafe_encode64 s.to_s
82
+ end
83
+
84
+ # a hash with a possibly frozen JSON stringification
85
+ class JSONHash < Hash
86
+ def to_s
87
+ @repr || to_json
88
+ end
89
+
90
+ def freeze
91
+ @repr = to_json.freeze
92
+ super
93
+ end
94
+
95
+ def self.load raw
96
+ self[JSON.load raw.to_s].tap do |h|
97
+ h.send :repr=, raw
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def repr= raw
104
+ @repr = raw.freeze
105
+ freeze
106
+ end
107
+ end
108
+ end
109
+
110
+ # Try to convert by detecting token representation and parsing
111
+ def self.JWT raw
112
+ if raw.is_a? JWT
113
+ raw
114
+ elsif raw.respond_to?(:to_h) || raw =~ /\A\s*\{/
115
+ JWT.parse_json raw
116
+ else
117
+ JWT.parse_compact raw
118
+ end
119
+ rescue
120
+ raise ArgumentError, "invalid value for JWT(): #{raw.inspect}"
121
+ end
122
+ end
@@ -3,6 +3,8 @@ require 'json'
3
3
  require 'base64'
4
4
  require 'time'
5
5
 
6
+ require 'slosilo/errors'
7
+
6
8
  module Slosilo
7
9
  class Key
8
10
  def initialize raw_key = nil
@@ -13,6 +15,10 @@ module Slosilo
13
15
  else
14
16
  OpenSSL::PKey::RSA.new 2048
15
17
  end
18
+ rescue OpenSSL::PKey::PKeyError => e
19
+ # old openssl versions used to report ArgumentError
20
+ # which arguably makes more sense here, so reraise as that
21
+ raise ArgumentError, e, e.backtrace
16
22
  end
17
23
 
18
24
  attr_reader :key
@@ -71,14 +77,80 @@ module Slosilo
71
77
  token["key"] = fingerprint
72
78
  token
73
79
  end
80
+
81
+ JWT_ALGORITHM = 'conjur.org/slosilo/v2'.freeze
82
+
83
+ # Issue a JWT with the given claims.
84
+ # `iat` (issued at) claim is automatically added.
85
+ # Other interesting claims you can give are:
86
+ # - `sub` - token subject, for example a user name;
87
+ # - `exp` - expiration time (absolute);
88
+ # - `cidr` (Conjur extension) - array of CIDR masks that are accepted to
89
+ # make requests that bear this token
90
+ def issue_jwt claims
91
+ token = Slosilo::JWT.new claims
92
+ token.add_signature \
93
+ alg: JWT_ALGORITHM,
94
+ kid: fingerprint,
95
+ &method(:sign)
96
+ token.freeze
97
+ end
98
+
99
+ DEFAULT_EXPIRATION = 8 * 60
74
100
 
75
- def token_valid? token, expiry = 8 * 60
101
+ def token_valid? token, expiry = DEFAULT_EXPIRATION
102
+ return jwt_valid? token if token.respond_to? :header
76
103
  token = token.clone
77
104
  expected_key = token.delete "key"
78
105
  return false if (expected_key and (expected_key != fingerprint))
79
106
  signature = Base64::urlsafe_decode64(token.delete "signature")
80
107
  (Time.parse(token["timestamp"]) + expiry > Time.now) && verify_signature(token, signature)
81
108
  end
109
+
110
+ # Validate a JWT.
111
+ #
112
+ # Convenience method calling #validate_jwt and returning false if an
113
+ # exception is raised.
114
+ #
115
+ # @param token [JWT] pre-parsed token to verify
116
+ # @return [Boolean]
117
+ def jwt_valid? token
118
+ validate_jwt token
119
+ true
120
+ rescue
121
+ false
122
+ end
123
+
124
+ # Validate a JWT.
125
+ #
126
+ # First checks whether algorithm is 'conjur.org/slosilo/v2' and the key id
127
+ # matches this key's fingerprint. Then verifies if the token is not expired,
128
+ # as indicated by the `exp` claim; in its absence tokens are assumed to
129
+ # expire in `iat` + 8 minutes.
130
+ #
131
+ # If those checks pass, finally the signature is verified.
132
+ #
133
+ # @raises TokenValidationError if any of the checks fail.
134
+ #
135
+ # @note It's the responsibility of the caller to examine other claims
136
+ # included in the token; consideration needs to be given to handling
137
+ # unrecognized claims.
138
+ #
139
+ # @param token [JWT] pre-parsed token to verify
140
+ def validate_jwt token
141
+ def err msg
142
+ raise Error::TokenValidationError, msg, caller
143
+ end
144
+
145
+ header = token.header
146
+ err 'unrecognized algorithm' unless header['alg'] == JWT_ALGORITHM
147
+ err 'mismatched key' if (kid = header['kid']) && kid != fingerprint
148
+ iat = Time.at token.claims['iat'] || err('unknown issuing time')
149
+ exp = Time.at token.claims['exp'] || (iat + DEFAULT_EXPIRATION)
150
+ err 'token expired' if exp <= Time.now
151
+ err 'invalid signature' unless verify_signature token.string_to_sign, token.signature
152
+ true
153
+ end
82
154
 
83
155
  def sign_string value
84
156
  salt = shake_salt
@@ -86,7 +158,7 @@ module Slosilo
86
158
  end
87
159
 
88
160
  def fingerprint
89
- @fingerprint ||= OpenSSL::Digest::MD5.hexdigest key.public_key.to_der
161
+ @fingerprint ||= OpenSSL::Digest::SHA256.hexdigest key.public_key.to_der
90
162
  end
91
163
 
92
164
  def == other
@@ -114,7 +186,7 @@ module Slosilo
114
186
  # Note that this is currently somewhat shallow stringification --
115
187
  # to implement originating tokens we may need to make it deeper.
116
188
  def stringify value
117
- case value
189
+ string = case value
118
190
  when Hash
119
191
  value.to_a.sort.to_json
120
192
  when String
@@ -122,6 +194,17 @@ module Slosilo
122
194
  else
123
195
  value.to_json
124
196
  end
197
+
198
+ # Make sure that the string is ascii_8bit (i.e. raw bytes), and represents
199
+ # the utf-8 encoding of the string. This accomplishes two things: it normalizes
200
+ # the representation of the string at the byte level (so we don't have an error if
201
+ # one username is submitted as ISO-whatever, and the next as UTF-16), and it prevents
202
+ # an incompatible encoding error when we concatenate it with the salt.
203
+ if string.encoding != Encoding::ASCII_8BIT
204
+ string.encode(Encoding::UTF_8).force_encoding(Encoding::ASCII_8BIT)
205
+ else
206
+ string
207
+ end
125
208
  end
126
209
 
127
210
  def shake_salt
@@ -59,15 +59,26 @@ module Slosilo
59
59
  keystore.any? { |k| k.token_valid? token }
60
60
  end
61
61
 
62
+ # Looks up the signer by public key fingerprint and checks the validity
63
+ # of the signature. If the token is JWT, exp and/or iat claims are also
64
+ # verified; the caller is responsible for validating any other claims.
62
65
  def token_signer token
63
- key, id = keystore.get_by_fingerprint token['key']
66
+ begin
67
+ # see if maybe it's a JWT
68
+ token = JWT token
69
+ fingerprint = token.header['kid']
70
+ rescue ArgumentError
71
+ fingerprint = token['key']
72
+ end
73
+
74
+ key, id = keystore.get_by_fingerprint fingerprint
64
75
  if key && key.token_valid?(token)
65
76
  return id
66
77
  else
67
78
  return nil
68
79
  end
69
80
  end
70
-
81
+
71
82
  attr_accessor :adapter
72
83
 
73
84
  private
@@ -1,25 +1,40 @@
1
1
  module Slosilo
2
2
  class Symmetric
3
+ VERSION_MAGIC = 'G'
4
+ TAG_LENGTH = 16
5
+
3
6
  def initialize
4
- @cipher = OpenSSL::Cipher.new 'AES-256-CBC'
7
+ @cipher = OpenSSL::Cipher.new 'aes-256-gcm' # NB: has to be lower case for whatever reason.
5
8
  end
6
-
9
+
10
+ # This lets us do a final sanity check in migrations from older encryption versions
11
+ def cipher_name
12
+ @cipher.name
13
+ end
14
+
7
15
  def encrypt plaintext, opts = {}
8
16
  @cipher.reset
9
17
  @cipher.encrypt
10
- @cipher.key = opts[:key]
18
+ @cipher.key = (opts[:key] or raise("missing :key option"))
11
19
  @cipher.iv = iv = random_iv
12
- ctxt = @cipher.update(plaintext)
13
- iv + ctxt + @cipher.final
20
+ @cipher.auth_data = opts[:aad] || "" # Nothing good happens if you set this to nil, or don't set it at all
21
+ ctext = @cipher.update(plaintext) + @cipher.final
22
+ tag = @cipher.auth_tag(TAG_LENGTH)
23
+ "#{VERSION_MAGIC}#{tag}#{iv}#{ctext}"
14
24
  end
15
-
25
+
16
26
  def decrypt ciphertext, opts = {}
27
+ version, tag, iv, ctext = unpack ciphertext
28
+
29
+ raise "Invalid version magic: expected #{VERSION_MAGIC} but was #{version}" unless version == VERSION_MAGIC
30
+
17
31
  @cipher.reset
18
32
  @cipher.decrypt
19
33
  @cipher.key = opts[:key]
20
- @cipher.iv, ctxt = ciphertext.unpack("a#{@cipher.iv_len}a*")
21
- ptxt = @cipher.update(ctxt)
22
- ptxt + @cipher.final
34
+ @cipher.iv = iv
35
+ @cipher.auth_tag = tag
36
+ @cipher.auth_data = opts[:aad] || ""
37
+ @cipher.update(ctext) + @cipher.final
23
38
  end
24
39
 
25
40
  def random_iv
@@ -29,5 +44,11 @@ module Slosilo
29
44
  def random_key
30
45
  @cipher.random_key
31
46
  end
47
+
48
+ private
49
+ # return tag, iv, ctext
50
+ def unpack msg
51
+ msg.unpack "aa#{TAG_LENGTH}a#{@cipher.iv_len}a*"
52
+ end
32
53
  end
33
54
  end
@@ -1,3 +1,3 @@
1
1
  module Slosilo
2
- VERSION = "1.0.0"
2
+ VERSION = "2.2.1"
3
3
  end
@@ -0,0 +1,11 @@
1
+ #!/bin/bash -e
2
+
3
+ docker pull registry.tld/conjurinc/publish-rubygem
4
+
5
+ docker run -i --rm -v $PWD:/src -w /src alpine/git clean -fxd
6
+
7
+ summon --yaml "RUBYGEMS_API_KEY: !var rubygems/api-key" \
8
+ docker run --rm --env-file @SUMMONENVFILE -v "$(pwd)":/opt/src \
9
+ registry.tld/conjurinc/publish-rubygem slosilo
10
+
11
+ docker run -i --rm -v $PWD:/src -w /src alpine/git clean -fxd