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.
- 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
|