slosilo 1.0.0 → 2.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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