slosilo 1.0.0 → 2.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.dockerignore +2 -0
- data/.gitleaks.toml +221 -0
- data/CHANGELOG.md +11 -0
- data/CONTRIBUTING.md +16 -0
- data/Jenkinsfile +55 -0
- data/LICENSE +2 -2
- data/README.md +125 -8
- data/lib/slosilo.rb +1 -0
- data/lib/slosilo/adapters/sequel_adapter.rb +1 -1
- data/lib/slosilo/attr_encrypted.rb +29 -6
- data/lib/slosilo/errors.rb +3 -0
- data/lib/slosilo/jwt.rb +122 -0
- data/lib/slosilo/key.rb +86 -3
- data/lib/slosilo/keystore.rb +13 -2
- data/lib/slosilo/symmetric.rb +30 -9
- data/lib/slosilo/version.rb +1 -1
- data/publish-rubygem.sh +11 -0
- data/slosilo.gemspec +11 -3
- data/spec/encrypted_attributes_spec.rb +114 -0
- data/spec/file_adapter_spec.rb +10 -10
- data/spec/jwt_spec.rb +102 -0
- data/spec/key_spec.rb +120 -41
- data/spec/keystore_spec.rb +2 -2
- data/spec/random_spec.rb +12 -2
- data/spec/sequel_adapter_spec.rb +26 -30
- data/spec/slosilo_spec.rb +47 -15
- data/spec/spec_helper.rb +2 -20
- data/spec/symmetric_spec.rb +44 -22
- data/test.sh +25 -0
- metadata +36 -11
data/lib/slosilo.rb
CHANGED
@@ -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
|
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
|
data/lib/slosilo/errors.rb
CHANGED
data/lib/slosilo/jwt.rb
ADDED
@@ -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
|
data/lib/slosilo/key.rb
CHANGED
@@ -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 =
|
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::
|
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
|
data/lib/slosilo/keystore.rb
CHANGED
@@ -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
|
-
|
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
|
data/lib/slosilo/symmetric.rb
CHANGED
@@ -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 '
|
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
|
-
|
13
|
-
|
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
|
21
|
-
|
22
|
-
|
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
|
data/lib/slosilo/version.rb
CHANGED
data/publish-rubygem.sh
ADDED
@@ -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
|