sandal 0.4.0 → 0.5.0
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 +4 -4
- data/.travis.yml +0 -1
- data/CHANGELOG.md +14 -0
- data/README.md +1 -1
- data/lib/sandal.rb +77 -76
- data/lib/sandal/claims.rb +13 -13
- data/lib/sandal/enc.rb +15 -49
- data/lib/sandal/enc/acbc_hs.rb +97 -52
- data/lib/sandal/enc/agcm.rb +64 -26
- data/lib/sandal/enc/alg.rb +2 -3
- data/lib/sandal/enc/alg/direct.rb +27 -25
- data/lib/sandal/enc/alg/rsa.rb +82 -0
- data/lib/sandal/sig.rb +12 -12
- data/lib/sandal/sig/es.rb +43 -25
- data/lib/sandal/sig/hs.rb +21 -8
- data/lib/sandal/sig/rs.rb +34 -23
- data/lib/sandal/util.rb +7 -7
- data/lib/sandal/version.rb +1 -1
- data/spec/helper.rb +1 -0
- data/spec/sample_keys.rb +28 -0
- data/spec/sandal/claims_spec.rb +4 -4
- data/spec/sandal/enc/a128cbc_hs256_spec.rb +15 -39
- data/spec/sandal/enc/a128gcm_spec.rb +13 -6
- data/spec/sandal/enc/a256cbc_hs512_spec.rb +13 -4
- data/spec/sandal/enc/a256gcm_spec.rb +15 -37
- data/spec/sandal/enc/alg/direct_spec.rb +27 -33
- data/spec/sandal/enc/alg/rsa_spec.rb +100 -0
- data/spec/sandal/enc/shared_examples.rb +93 -21
- data/spec/sandal/sig/es_spec.rb +145 -188
- data/spec/sandal/sig/hs_spec.rb +73 -18
- data/spec/sandal/sig/rs_spec.rb +81 -78
- metadata +7 -6
- data/lib/sandal/enc/alg/rsa1_5.rb +0 -47
- data/lib/sandal/enc/alg/rsa_oaep.rb +0 -48
- data/spec/sandal/enc/alg/rsa1_5_spec.rb +0 -40
data/lib/sandal/enc/acbc_hs.rb
CHANGED
@@ -1,103 +1,148 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "openssl"
|
2
|
+
require "sandal/util"
|
3
3
|
|
4
4
|
module Sandal
|
5
5
|
module Enc
|
6
6
|
|
7
|
-
# Base implementation of the
|
8
|
-
# algorithms.
|
7
|
+
# Base implementation of the A*CBC-HS* family of encryption methods.
|
9
8
|
class ACBC_HS
|
10
9
|
include Sandal::Util
|
11
10
|
|
12
|
-
# The JWA name of the encryption.
|
11
|
+
# The JWA name of the encryption method.
|
13
12
|
attr_reader :name
|
14
13
|
|
15
14
|
# The JWA algorithm used to encrypt the content master key.
|
16
15
|
attr_reader :alg
|
17
16
|
|
18
|
-
#
|
19
|
-
# constructors.
|
17
|
+
# Initialises a new instance; it's probably easier to use one of the subclass constructors.
|
20
18
|
#
|
21
|
-
# @param
|
22
|
-
# @param
|
23
|
-
# @param
|
24
|
-
#
|
25
|
-
def initialize(aes_size, sha_size, alg)
|
19
|
+
# @param name [String] The JWA name of the encryption method.
|
20
|
+
# @param aes_size [Integer] The size of the AES algorithm, in bits.
|
21
|
+
# @param sha_size [Integer] The size of the SHA algorithm, in bits.
|
22
|
+
# @param alg [#name, #encrypt_key, #decrypt_key] The algorithm to use to encrypt and/or decrypt the AES key.
|
23
|
+
def initialize(name, aes_size, sha_size, alg)
|
24
|
+
@name = name
|
26
25
|
@aes_size = aes_size
|
27
26
|
@sha_size = sha_size
|
28
|
-
@name = "A#{aes_size}CBC+HS#{@sha_size}"
|
29
27
|
@cipher_name = "aes-#{aes_size}-cbc"
|
30
28
|
@alg = alg
|
31
29
|
@digest = OpenSSL::Digest.new("sha#{@sha_size}")
|
32
30
|
end
|
33
31
|
|
32
|
+
# Encrypts a token payload.
|
33
|
+
#
|
34
|
+
# @param header [String] The header string.
|
35
|
+
# @param payload [String] The payload.
|
36
|
+
# @return [String] An encrypted JSON Web Token.
|
34
37
|
def encrypt(header, payload)
|
35
|
-
|
36
|
-
|
37
|
-
encrypted_key = @alg.
|
38
|
+
key = get_encryption_key
|
39
|
+
mac_key, enc_key = derive_keys(key)
|
40
|
+
encrypted_key = @alg.encrypt_key(key)
|
38
41
|
|
39
|
-
cipher
|
40
|
-
|
42
|
+
cipher = OpenSSL::Cipher.new(@cipher_name).encrypt
|
43
|
+
cipher.key = enc_key
|
44
|
+
cipher.iv = iv = SecureRandom.random_bytes(16)
|
41
45
|
ciphertext = cipher.update(payload) + cipher.final
|
42
46
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
+
auth_data = jwt_base64_encode(header)
|
48
|
+
auth_data_length = [auth_data.length * 8].pack("Q>")
|
49
|
+
mac_input = [auth_data, iv, ciphertext, auth_data_length].join
|
50
|
+
mac = OpenSSL::HMAC.digest(@digest, mac_key, mac_input)
|
51
|
+
auth_tag = mac[0...(mac.length / 2)]
|
47
52
|
|
48
|
-
|
53
|
+
remainder = [encrypted_key, iv, ciphertext, auth_tag].map { |part| jwt_base64_encode(part) }
|
54
|
+
[auth_data, *remainder].join(".")
|
49
55
|
end
|
50
56
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
57
|
+
# Decrypts an encrypted JSON Web Token.
|
58
|
+
#
|
59
|
+
# @param token [String or Array] The token, or token parts, to decrypt.
|
60
|
+
# @return [String] The token payload.
|
61
|
+
def decrypt(token)
|
62
|
+
parts, decoded_parts = Sandal::Enc.token_parts(token)
|
63
|
+
header, encrypted_key, iv, ciphertext, auth_tag = *decoded_parts
|
64
|
+
|
65
|
+
key = @alg.decrypt_key(encrypted_key)
|
66
|
+
mac_key, enc_key = derive_keys(key)
|
67
|
+
|
68
|
+
auth_data = parts[0]
|
69
|
+
auth_data_length = [auth_data.length * 8].pack("Q>")
|
70
|
+
mac_input = [auth_data, iv, ciphertext, auth_data_length].join
|
71
|
+
mac = OpenSSL::HMAC.digest(@digest, mac_key, mac_input)
|
72
|
+
unless auth_tag == mac[0...(mac.length / 2)]
|
73
|
+
raise Sandal::InvalidTokenError, "Invalid authentication tag."
|
58
74
|
end
|
59
75
|
|
60
76
|
cipher = OpenSSL::Cipher.new(@cipher_name).decrypt
|
61
77
|
begin
|
62
|
-
cipher.key =
|
78
|
+
cipher.key = enc_key
|
63
79
|
cipher.iv = decoded_parts[2]
|
64
80
|
cipher.update(decoded_parts[3]) + cipher.final
|
65
|
-
rescue OpenSSL::Cipher::CipherError
|
66
|
-
raise Sandal::
|
81
|
+
rescue OpenSSL::Cipher::CipherError => e
|
82
|
+
raise Sandal::InvalidTokenError, "Cannot decrypt token: #{e.message}"
|
67
83
|
end
|
68
84
|
end
|
69
85
|
|
70
|
-
|
71
|
-
|
72
|
-
#
|
73
|
-
def
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
86
|
+
private
|
87
|
+
|
88
|
+
# Gets the key to use for mac and encryption
|
89
|
+
def get_encryption_key
|
90
|
+
key_bytes = @sha_size / 8
|
91
|
+
if @alg.respond_to?(:preshared_key)
|
92
|
+
key = @alg.preshared_key
|
93
|
+
unless key.size == key_bytes
|
94
|
+
raise Sandal::KeyError, "The pre-shared content key must be #{@sha_size} bits."
|
95
|
+
end
|
96
|
+
key
|
97
|
+
else
|
98
|
+
SecureRandom.random_bytes(key_bytes)
|
99
|
+
end
|
80
100
|
end
|
81
101
|
|
82
|
-
# Derives the
|
83
|
-
def
|
84
|
-
|
102
|
+
# Derives the mac key and encryption key
|
103
|
+
def derive_keys(key)
|
104
|
+
derived_key_size = key.size / 2
|
105
|
+
mac_key = key[0...derived_key_size]
|
106
|
+
enc_key = key[derived_key_size..-1]
|
107
|
+
return mac_key, enc_key
|
85
108
|
end
|
86
109
|
|
87
110
|
end
|
88
111
|
|
89
|
-
# The
|
112
|
+
# The A128CBC-HS256 encryption method.
|
90
113
|
class A128CBC_HS256 < Sandal::Enc::ACBC_HS
|
91
|
-
|
92
|
-
|
114
|
+
|
115
|
+
# The JWA name of the algorithm.
|
116
|
+
NAME = "A128CBC-HS256"
|
117
|
+
|
118
|
+
# The size of key that is required, in bits.
|
119
|
+
KEY_SIZE = 256
|
120
|
+
|
121
|
+
# Initialises a new instance.
|
122
|
+
#
|
123
|
+
# @param alg [#name, #encrypt_key, #decrypt_key] The algorithm to use to encrypt and/or decrypt the AES key.
|
124
|
+
def initialize(alg)
|
125
|
+
super(NAME, KEY_SIZE / 2, KEY_SIZE, alg)
|
93
126
|
end
|
127
|
+
|
94
128
|
end
|
95
129
|
|
96
|
-
# The
|
130
|
+
# The A256CBC-HS512 encryption method.
|
97
131
|
class A256CBC_HS512 < Sandal::Enc::ACBC_HS
|
98
|
-
|
99
|
-
|
132
|
+
|
133
|
+
# The JWA name of the algorithm.
|
134
|
+
NAME = "A256CBC-HS512"
|
135
|
+
|
136
|
+
# The size of key that is required, in bits.
|
137
|
+
KEY_SIZE = 512
|
138
|
+
|
139
|
+
# Initialises a new instance.
|
140
|
+
#
|
141
|
+
# @param alg [#name, #encrypt_key, #decrypt_key] The algorithm to use to encrypt and/or decrypt the AES key.
|
142
|
+
def initialize(alg)
|
143
|
+
super(NAME, KEY_SIZE / 2, KEY_SIZE, alg)
|
100
144
|
end
|
145
|
+
|
101
146
|
end
|
102
147
|
|
103
148
|
end
|
data/lib/sandal/enc/agcm.rb
CHANGED
@@ -1,71 +1,109 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require "openssl"
|
2
|
+
require "sandal/util"
|
3
3
|
|
4
4
|
module Sandal
|
5
5
|
module Enc
|
6
6
|
|
7
|
-
# Base implementation of the
|
7
|
+
# Base implementation of the A*GCM family of encryption methods.
|
8
8
|
class AGCM
|
9
9
|
include Sandal::Util
|
10
10
|
|
11
|
-
|
11
|
+
@@iv_size = 96
|
12
|
+
@@auth_tag_size = 128
|
13
|
+
|
14
|
+
# The JWA name of the encryption method.
|
12
15
|
attr_reader :name
|
13
16
|
|
14
|
-
# The JWA algorithm used to encrypt the content
|
17
|
+
# The JWA algorithm used to encrypt the content encryption key.
|
15
18
|
attr_reader :alg
|
16
19
|
|
17
|
-
|
20
|
+
# Initialises a new instance; it's probably easier to use one of the subclass constructors.
|
21
|
+
#
|
22
|
+
# @param aes_size [Integer] The size of the AES algorithm, in bits.
|
23
|
+
# @param alg [#name, #encrypt_key, #decrypt_key] The algorithm to use to encrypt and/or decrypt the AES key.
|
24
|
+
def initialize(name, aes_size, alg)
|
25
|
+
@name = name
|
18
26
|
@aes_size = aes_size
|
19
|
-
@name = "A#{aes_size}GCM"
|
20
27
|
@cipher_name = "aes-#{aes_size}-gcm"
|
21
28
|
@alg = alg
|
22
29
|
end
|
23
30
|
|
31
|
+
# Encrypts a token payload.
|
32
|
+
#
|
33
|
+
# @param header [String] The header string.
|
34
|
+
# @param payload [String] The payload.
|
35
|
+
# @return [String] An encrypted JSON Web Token.
|
24
36
|
def encrypt(header, payload)
|
25
37
|
cipher = OpenSSL::Cipher.new(@cipher_name).encrypt
|
26
|
-
|
27
|
-
encrypted_key = @alg.
|
38
|
+
key = @alg.respond_to?(:preshared_key) ? @alg.preshared_key : cipher.random_key
|
39
|
+
encrypted_key = @alg.encrypt_key(key)
|
28
40
|
|
29
|
-
cipher.key =
|
30
|
-
iv =
|
41
|
+
cipher.key = key
|
42
|
+
cipher.iv = iv = SecureRandom.random_bytes(@@iv_size / 8)
|
31
43
|
|
32
|
-
|
33
|
-
auth_data = auth_parts.map { |part| jwt_base64_encode(part) }.join('.')
|
44
|
+
auth_data = jwt_base64_encode(header)
|
34
45
|
cipher.auth_data = auth_data
|
35
46
|
|
36
47
|
ciphertext = cipher.update(payload) + cipher.final
|
37
|
-
remaining_parts = [ciphertext, cipher.auth_tag]
|
48
|
+
remaining_parts = [encrypted_key, iv, ciphertext, cipher.auth_tag(@@auth_tag_size / 8)]
|
38
49
|
remaining_parts.map! { |part| jwt_base64_encode(part) }
|
39
|
-
[auth_data, *remaining_parts].join(
|
50
|
+
[auth_data, *remaining_parts].join(".")
|
40
51
|
end
|
41
52
|
|
42
|
-
|
53
|
+
# Decrypts an encrypted JSON Web Token.
|
54
|
+
#
|
55
|
+
# @param token [String or Array] The token, or token parts, to decrypt.
|
56
|
+
# @return [String] The token payload.
|
57
|
+
def decrypt(token)
|
58
|
+
parts, decoded_parts = Sandal::Enc.token_parts(token)
|
43
59
|
cipher = OpenSSL::Cipher.new(@cipher_name).decrypt
|
44
60
|
begin
|
45
|
-
cipher.key = @alg.
|
61
|
+
cipher.key = @alg.decrypt_key(decoded_parts[1])
|
46
62
|
cipher.iv = decoded_parts[2]
|
47
63
|
cipher.auth_tag = decoded_parts[4]
|
48
|
-
cipher.auth_data = parts
|
64
|
+
cipher.auth_data = parts[0]
|
49
65
|
cipher.update(decoded_parts[3]) + cipher.final
|
50
|
-
rescue OpenSSL::Cipher::CipherError
|
51
|
-
raise Sandal::
|
66
|
+
rescue OpenSSL::Cipher::CipherError => e
|
67
|
+
raise Sandal::InvalidTokenError, "Cannot decrypt token: #{e.message}"
|
52
68
|
end
|
53
69
|
end
|
54
70
|
|
55
71
|
end
|
56
72
|
|
57
|
-
# The
|
73
|
+
# The A128GCM encryption method.
|
58
74
|
class A128GCM < Sandal::Enc::AGCM
|
59
|
-
|
60
|
-
|
75
|
+
|
76
|
+
# The JWA name of the algorithm.
|
77
|
+
NAME = "A128GCM"
|
78
|
+
|
79
|
+
# The size of key that is required, in bits.
|
80
|
+
KEY_SIZE = 128
|
81
|
+
|
82
|
+
# Initialises a new instance.
|
83
|
+
#
|
84
|
+
# @param alg [#name, #encrypt_key, #decrypt_key] The algorithm to use to encrypt and/or decrypt the AES key.
|
85
|
+
def initialize(alg)
|
86
|
+
super(NAME, KEY_SIZE, alg)
|
61
87
|
end
|
88
|
+
|
62
89
|
end
|
63
90
|
|
64
|
-
# The
|
91
|
+
# The A256GCM encryption method.
|
65
92
|
class A256GCM < Sandal::Enc::AGCM
|
66
|
-
|
67
|
-
|
93
|
+
|
94
|
+
# The JWA name of the algorithm.
|
95
|
+
NAME = "A256GCM"
|
96
|
+
|
97
|
+
# The size of key that is required, in bits.
|
98
|
+
KEY_SIZE = 256
|
99
|
+
|
100
|
+
# Initialises a new instance.
|
101
|
+
#
|
102
|
+
# @param alg [#name, #encrypt_key, #decrypt_key] The algorithm to use to encrypt and/or decrypt the AES key.
|
103
|
+
def initialize(alg)
|
104
|
+
super(NAME, KEY_SIZE, alg)
|
68
105
|
end
|
106
|
+
|
69
107
|
end
|
70
108
|
|
71
109
|
end
|
data/lib/sandal/enc/alg.rb
CHANGED
@@ -1,46 +1,48 @@
|
|
1
|
-
require
|
1
|
+
require "openssl"
|
2
2
|
|
3
3
|
module Sandal
|
4
4
|
module Enc
|
5
5
|
module Alg
|
6
6
|
|
7
|
-
# The direct ("dir") key encryption
|
8
|
-
# content master key.
|
7
|
+
# The direct ("dir") key encryption algorithm, which uses a pre-shared symmetric key.
|
9
8
|
class Direct
|
10
9
|
|
11
|
-
#
|
12
|
-
|
10
|
+
# The JWA name of the algorithm.
|
11
|
+
NAME = "dir"
|
13
12
|
|
14
|
-
# @return [String] The pre-shared
|
15
|
-
attr_reader :
|
13
|
+
# @return [String] The pre-shared symmetric key.
|
14
|
+
attr_reader :preshared_key
|
16
15
|
|
17
|
-
#
|
16
|
+
# Initialises a new instance.
|
18
17
|
#
|
19
|
-
# @param
|
20
|
-
def initialize(
|
21
|
-
@
|
22
|
-
@cmk = cmk
|
18
|
+
# @param preshared_key [String] The pre-shared symmetric key.
|
19
|
+
def initialize(preshared_key)
|
20
|
+
@preshared_key = preshared_key
|
23
21
|
end
|
24
22
|
|
25
|
-
#
|
26
|
-
|
23
|
+
# The JWA name of the algorithm.
|
24
|
+
def name
|
25
|
+
NAME
|
26
|
+
end
|
27
|
+
|
28
|
+
# Returns an empty string as the key is not included in JWE tokens using direct key exchange.
|
27
29
|
#
|
28
|
-
# @param
|
30
|
+
# @param key [String] This parameter is ignored.
|
29
31
|
# @return [String] An empty string.
|
30
|
-
def
|
31
|
-
|
32
|
+
def encrypt_key(key)
|
33
|
+
""
|
32
34
|
end
|
33
35
|
|
34
|
-
# Returns the pre-shared content
|
36
|
+
# Returns the pre-shared content key.
|
35
37
|
#
|
36
|
-
# @param
|
37
|
-
# @return [String] The pre-shared
|
38
|
-
# @raise [Sandal::
|
39
|
-
def
|
40
|
-
unless
|
41
|
-
raise Sandal::
|
38
|
+
# @param encrypted_key [String] The encrypted key.
|
39
|
+
# @return [String] The pre-shared symmetric key.
|
40
|
+
# @raise [Sandal::InvalidTokenError] encrypted_key is not nil or empty.
|
41
|
+
def decrypt_key(encrypted_key)
|
42
|
+
unless encrypted_key.nil? || encrypted_key.empty?
|
43
|
+
raise Sandal::InvalidTokenError, "Tokens using direct key exchange must not include a content key."
|
42
44
|
end
|
43
|
-
@
|
45
|
+
@preshared_key
|
44
46
|
end
|
45
47
|
|
46
48
|
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require "openssl"
|
2
|
+
|
3
|
+
module Sandal
|
4
|
+
module Enc
|
5
|
+
module Alg
|
6
|
+
|
7
|
+
# Base class for RSA key encryption algorithm.
|
8
|
+
class RSA
|
9
|
+
|
10
|
+
# The JWA name of the algorithm.
|
11
|
+
attr_reader :name
|
12
|
+
|
13
|
+
# Initialises a new instance.
|
14
|
+
#
|
15
|
+
# @param name [String] The JWA name of the algorithm.
|
16
|
+
# @param rsa_key [OpenSSL::PKey::RSA or String] The RSA key to use for key encryption (public) or decryption
|
17
|
+
# (private). If the value is a String then it will be passed to the constructor of the RSA class. This must
|
18
|
+
# be at least 2048 bits to be compliant with the JWA specification.
|
19
|
+
def initialize(name, rsa_key, padding)
|
20
|
+
@name = name
|
21
|
+
@rsa_key = rsa_key.is_a?(String) ? OpenSSL::PKey::RSA.new(rsa_key) : rsa_key
|
22
|
+
@padding = padding
|
23
|
+
end
|
24
|
+
|
25
|
+
# Encrypts the content key.
|
26
|
+
#
|
27
|
+
# @param key [String] The content key.
|
28
|
+
# @return [String] The encrypted content key.
|
29
|
+
def encrypt_key(key)
|
30
|
+
@rsa_key.public_encrypt(key, @padding)
|
31
|
+
end
|
32
|
+
|
33
|
+
# Decrypts the content key.
|
34
|
+
#
|
35
|
+
# @param encrypted_key [String] The encrypted content key.
|
36
|
+
# @return [String] The pre-shared content key.
|
37
|
+
# @raise [Sandal::TokenError] The content key can't be decrypted.
|
38
|
+
def decrypt_key(encrypted_key)
|
39
|
+
@rsa_key.private_decrypt(encrypted_key, @padding)
|
40
|
+
rescue => e
|
41
|
+
raise Sandal::InvalidTokenError, "Cannot decrypt content key: #{e.message}"
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
|
46
|
+
# The RSA1_5 key encryption algorithm.
|
47
|
+
class RSA1_5 < RSA
|
48
|
+
|
49
|
+
# The JWA name of the algorithm.
|
50
|
+
NAME = "RSA1_5"
|
51
|
+
|
52
|
+
# Initialises a new instance.
|
53
|
+
#
|
54
|
+
# @param rsa_key [OpenSSL::PKey::RSA or String] The RSA key to use for key encryption (public) or decryption
|
55
|
+
# (private). If the value is a String then it will be passed to the constructor of the RSA class. This must
|
56
|
+
# be at least 2048 bits to be compliant with the JWA specification.
|
57
|
+
def initialize(rsa_key)
|
58
|
+
super(NAME, rsa_key, OpenSSL::PKey::RSA::PKCS1_PADDING)
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
|
63
|
+
# The RSA-OAEP key encryption algorithm.
|
64
|
+
class RSA_OAEP < RSA
|
65
|
+
|
66
|
+
# The JWA name of the algorithm.
|
67
|
+
NAME = "RSA-OAEP"
|
68
|
+
|
69
|
+
# Initialises a new instance.
|
70
|
+
#
|
71
|
+
# @param rsa_key [OpenSSL::PKey::RSA or String] The RSA key to use for key encryption (public) or decryption
|
72
|
+
# (private). If the value is a String then it will be passed to the constructor of the RSA class. This must
|
73
|
+
# be at least 2048 bits to be compliant with the JWA specification.
|
74
|
+
def initialize(rsa_key)
|
75
|
+
super(NAME, rsa_key, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|