darrrr 0.0.2
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +203 -0
- data/Rakefile +42 -0
- data/lib/darrrr.rb +9 -0
- data/lib/github/delegated_account_recovery.rb +178 -0
- data/lib/github/delegated_account_recovery/account_provider.rb +140 -0
- data/lib/github/delegated_account_recovery/constants.rb +19 -0
- data/lib/github/delegated_account_recovery/crypto_helper.rb +57 -0
- data/lib/github/delegated_account_recovery/cryptors/default/default_encryptor.rb +66 -0
- data/lib/github/delegated_account_recovery/cryptors/default/encrypted_data.rb +92 -0
- data/lib/github/delegated_account_recovery/cryptors/default/encrypted_data_io.rb +12 -0
- data/lib/github/delegated_account_recovery/provider.rb +125 -0
- data/lib/github/delegated_account_recovery/recovery_provider.rb +118 -0
- data/lib/github/delegated_account_recovery/recovery_token.rb +113 -0
- data/lib/github/delegated_account_recovery/serialization/recovery_token_reader.rb +22 -0
- data/lib/github/delegated_account_recovery/serialization/recovery_token_writer.rb +22 -0
- data/lib/github/delegated_account_recovery/version.rb +5 -0
- metadata +129 -0
@@ -0,0 +1,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitHub
|
4
|
+
module DelegatedAccountRecovery
|
5
|
+
class AccountProvider
|
6
|
+
include CryptoHelper
|
7
|
+
include Provider
|
8
|
+
|
9
|
+
# Only applicable when acting as a recovery provider
|
10
|
+
PRIVATE_FIELDS = [:symmetric_key, :signing_private_key]
|
11
|
+
|
12
|
+
FIELDS = [:tokensign_pubkeys_secp256r1].freeze
|
13
|
+
URL_FIELDS = [:issuer, :save_token_return, :recover_account_return,
|
14
|
+
:privacy_policy, :icon_152px].freeze
|
15
|
+
|
16
|
+
# These are the fields required by the spec
|
17
|
+
REQUIRED_FIELDS = FIELDS + URL_FIELDS
|
18
|
+
|
19
|
+
attr_accessor(*REQUIRED_FIELDS)
|
20
|
+
attr_accessor(*PRIVATE_FIELDS)
|
21
|
+
|
22
|
+
alias :origin :issuer
|
23
|
+
|
24
|
+
# The CryptoHelper defines an `unseal` method that requires us to
|
25
|
+
# define a `unseal_keys` method that will return the set of keys that
|
26
|
+
# are valid when verifying the signature on a sealed key.
|
27
|
+
def unseal_keys
|
28
|
+
tokensign_pubkeys_secp256r1
|
29
|
+
end
|
30
|
+
|
31
|
+
# Used to serve content at /.well-known/delegated-account-recovery/configuration
|
32
|
+
def to_h
|
33
|
+
{
|
34
|
+
"issuer" => self.issuer,
|
35
|
+
"tokensign-pubkeys-secp256r1" => self.tokensign_pubkeys_secp256r1.dup,
|
36
|
+
"save-token-return" => self.save_token_return,
|
37
|
+
"recover-account-return" => self.recover_account_return,
|
38
|
+
"privacy-policy" => self.privacy_policy,
|
39
|
+
"icon-152px" => self.icon_152px
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
# Generates a binary token with an encrypted arbitrary data payload.
|
44
|
+
#
|
45
|
+
# data: value to encrypt in the token
|
46
|
+
# provider: the recovery provider/audience of the token
|
47
|
+
# binding data: binding data value retrieved from recovery provider to
|
48
|
+
# provide some assurance the same browser was used.
|
49
|
+
def generate_recovery_token(data:, audience:)
|
50
|
+
RecoveryToken.build(issuer: self, audience: audience, type: RECOVERY_TOKEN_TYPE).tap do |token|
|
51
|
+
token.data = Darrrr.encryptor.encrypt(data)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Parses a countersigned_token and returns the nested recovery token
|
56
|
+
# WITHOUT verifying any signatures. This should only be used if no user
|
57
|
+
# context can be identified or if we're extracting issuer information.
|
58
|
+
def dangerous_unverified_recovery_token(countersigned_token)
|
59
|
+
parsed_countersigned_token = RecoveryToken.parse(Base64.strict_decode64(countersigned_token))
|
60
|
+
RecoveryToken.parse(parsed_countersigned_token.data)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validates the countersigned recovery token by verifying the signature
|
64
|
+
# of the countersigned token, parsing out the origin recovery token,
|
65
|
+
# verifying the signature on the recovery token, and finally decrypting
|
66
|
+
# the data in the origin recovery token.
|
67
|
+
#
|
68
|
+
# countersigned_token: our original recovery token wrapped in recovery
|
69
|
+
# token instance that is signed by the recovery provider.
|
70
|
+
#
|
71
|
+
# returns a verified recovery token or raises
|
72
|
+
# an error if the token fails validation.
|
73
|
+
def validate_countersigned_recovery_token!(countersigned_token)
|
74
|
+
# 5. Validate the the issuer field is present in the token,
|
75
|
+
# and that it matches the audience field in the original countersigned token.
|
76
|
+
begin
|
77
|
+
recovery_provider = RecoveryToken.recovery_provider_issuer(Base64.strict_decode64(countersigned_token))
|
78
|
+
rescue RecoveryTokenSerializationError => e
|
79
|
+
raise CountersignedTokenError.new("Countersigned token is invalid: " + e.message, :countersigned_token_parse_error)
|
80
|
+
rescue UnknownProviderError => e
|
81
|
+
raise CountersignedTokenError.new(e.message, :recovery_token_invalid_issuer)
|
82
|
+
end
|
83
|
+
|
84
|
+
# 1. Parse the countersigned-token.
|
85
|
+
# 2. Validate that the version field is 0.
|
86
|
+
# 7. Retrieve the current Recovery Provider configuration as described in Section 2.
|
87
|
+
# 8. Validate that the counter-signed token signature validates with a current element of the countersign-pubkeys-secp256r1 array.
|
88
|
+
begin
|
89
|
+
parsed_countersigned_token = recovery_provider.unseal(Base64.strict_decode64(countersigned_token))
|
90
|
+
rescue TokenFormatError => e
|
91
|
+
raise CountersignedTokenError.new(e.message, :countersigned_invalid_token_version)
|
92
|
+
rescue CryptoError
|
93
|
+
raise CountersignedTokenError.new("Countersigned token has an invalid signature", :countersigned_invalid_signature)
|
94
|
+
end
|
95
|
+
|
96
|
+
# 3. De-serialize the original recovery token from the data field.
|
97
|
+
# 4. Validate the signature on the original recovery token.
|
98
|
+
begin
|
99
|
+
recovery_token = self.unseal(parsed_countersigned_token.data)
|
100
|
+
rescue RecoveryTokenSerializationError => e
|
101
|
+
raise CountersignedTokenError.new("Nested recovery token is invalid: " + e.message, :recovery_token_token_parse_error)
|
102
|
+
rescue TokenFormatError => e
|
103
|
+
raise CountersignedTokenError.new("Nested recovery token format error: #{e.message}", :recovery_token_invalid_token_type)
|
104
|
+
rescue CryptoError
|
105
|
+
raise CountersignedTokenError.new("Nested recovery token has an invalid signature", :recovery_token_invalid_signature)
|
106
|
+
end
|
107
|
+
|
108
|
+
# 5. Validate the the issuer field is present in the countersigned-token,
|
109
|
+
# and that it matches the audience field in the original token.
|
110
|
+
|
111
|
+
countersigned_token_issuer = parsed_countersigned_token.issuer
|
112
|
+
if countersigned_token_issuer.blank? || countersigned_token_issuer != recovery_token.audience || recovery_provider.origin != countersigned_token_issuer
|
113
|
+
raise CountersignedTokenError.new("Validate the the issuer field is present in the countersigned-token, and that it matches the audience field in the original token", :recovery_token_invalid_issuer)
|
114
|
+
end
|
115
|
+
|
116
|
+
# 6. Validate the token binding for the countersigned token, if present.
|
117
|
+
# (the token binding for the inner token is not relevant)
|
118
|
+
# TODO not required, to be implemented later
|
119
|
+
|
120
|
+
# 9. Decrypt the data field from the original recovery token and parse its information, if present.
|
121
|
+
begin
|
122
|
+
recovery_token.decode
|
123
|
+
rescue CryptoError
|
124
|
+
raise CountersignedTokenError.new("Recovery token data could not be decrypted", :indecipherable_opaque_data)
|
125
|
+
end
|
126
|
+
|
127
|
+
# 10. Apply any additional processing which provider-specific data in the opaque data portion may indicate is necessary.
|
128
|
+
begin
|
129
|
+
if DateTime.parse(parsed_countersigned_token.issued_time).utc < (Time.now - CLOCK_SKEW).utc
|
130
|
+
raise CountersignedTokenError.new("Countersigned recovery token issued at time is too far in the past", :stale_token)
|
131
|
+
end
|
132
|
+
rescue ArgumentError
|
133
|
+
raise CountersignedTokenError.new("Invalid countersigned token issued time", :invalid_issued_time)
|
134
|
+
end
|
135
|
+
|
136
|
+
recovery_token
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitHub
|
4
|
+
module DelegatedAccountRecovery
|
5
|
+
module Constants
|
6
|
+
PROTOCOL_VERSION = 0
|
7
|
+
PRIME_256_V1 = "prime256v1" # AKA secp256r1
|
8
|
+
GROUP = OpenSSL::PKey::EC::Group.new(PRIME_256_V1)
|
9
|
+
DIGEST = OpenSSL::Digest::SHA256
|
10
|
+
TOKEN_ID_BYTE_LENGTH = 16
|
11
|
+
RECOVERY_TOKEN_TYPE = 0
|
12
|
+
COUNTERSIGNED_RECOVERY_TOKEN_TYPE = 1
|
13
|
+
WELL_KNOWN_CONFIG_PATH = ".well-known/delegated-account-recovery/configuration"
|
14
|
+
CLOCK_SKEW = 5 * 60
|
15
|
+
end
|
16
|
+
|
17
|
+
include Constants
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitHub
|
4
|
+
module DelegatedAccountRecovery
|
5
|
+
module CryptoHelper
|
6
|
+
include Constants
|
7
|
+
# Signs the provided token and joins the data with the signature.
|
8
|
+
#
|
9
|
+
# token: a RecoveryToken instance
|
10
|
+
#
|
11
|
+
# returns a base64 value for the binary token string and the signature
|
12
|
+
# of the token.
|
13
|
+
def seal(token)
|
14
|
+
raise RuntimeError, "signing private key must be set" unless self.signing_private_key
|
15
|
+
binary_token = token.to_binary_s
|
16
|
+
signature = Darrrr.encryptor.sign(binary_token, self.signing_private_key)
|
17
|
+
Base64.strict_encode64([binary_token, signature].join)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Splits the payload by the token size, treats the remaining portion as
|
21
|
+
# the signature of the payload, and verifies the signature is valid for
|
22
|
+
# the given payload.
|
23
|
+
#
|
24
|
+
# token_and_signature: binary string consisting of [token_binary_str, signature].join
|
25
|
+
# keys - An array of public keys to use for signature verification.
|
26
|
+
#
|
27
|
+
# returns a RecoveryToken if the payload has been verified and
|
28
|
+
# deserializes correctly. Raises exceptions if any crypto fails.
|
29
|
+
# Raises an error if the token's version field is not valid.
|
30
|
+
def unseal(token_and_signature)
|
31
|
+
token = RecoveryToken.parse(token_and_signature)
|
32
|
+
|
33
|
+
unless token.version.to_i == PROTOCOL_VERSION
|
34
|
+
raise TokenFormatError, "Version field must be #{PROTOCOL_VERSION}"
|
35
|
+
end
|
36
|
+
|
37
|
+
token_data, signature = partition_signed_token(token_and_signature, token)
|
38
|
+
self.unseal_keys.each do |key|
|
39
|
+
return token if Darrrr.encryptor.verify(token_data, signature, key)
|
40
|
+
end
|
41
|
+
raise CryptoError, "Recovery token signature was invalid"
|
42
|
+
end
|
43
|
+
|
44
|
+
# Split the binary token into the token data and the signature over the
|
45
|
+
# data.
|
46
|
+
#
|
47
|
+
# token_and_signature: binary serialization of the token and signature for the token
|
48
|
+
# recovery_token: a RecoveryToken object parsed from token_and_signature
|
49
|
+
#
|
50
|
+
# returns a two element array of [token, signature]
|
51
|
+
private def partition_signed_token(token_and_signature, recovery_token)
|
52
|
+
token_length = recovery_token.num_bytes
|
53
|
+
[token_and_signature[0...token_length], token_and_signature[token_length..-1]]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module GitHub
|
2
|
+
module DelegatedAccountRecovery
|
3
|
+
module DefaultEncryptor
|
4
|
+
class << self
|
5
|
+
include Constants
|
6
|
+
|
7
|
+
# Encrypts the data in an opaque way
|
8
|
+
#
|
9
|
+
# data: the secret to be encrypted
|
10
|
+
#
|
11
|
+
# returns a byte array representation of the data
|
12
|
+
def encrypt(data)
|
13
|
+
EncryptedData.build(data).to_binary_s
|
14
|
+
end
|
15
|
+
|
16
|
+
# Decrypts the data
|
17
|
+
#
|
18
|
+
# ciphertext: the byte array to be decrypted
|
19
|
+
#
|
20
|
+
# returns a string
|
21
|
+
def decrypt(ciphertext)
|
22
|
+
EncryptedData.parse(ciphertext).decrypt
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
# payload: binary serialized recovery token (to_binary_s).
|
27
|
+
#
|
28
|
+
# key: the private EC key used to sign the token
|
29
|
+
#
|
30
|
+
# returns signature in ASN.1 DER r + s sequence
|
31
|
+
def sign(payload, key)
|
32
|
+
digest = DIGEST.new.digest(payload)
|
33
|
+
ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(key))
|
34
|
+
ec.dsa_sign_asn1(digest)
|
35
|
+
end
|
36
|
+
|
37
|
+
# payload: token in binary form
|
38
|
+
# signature: signature of the binary token
|
39
|
+
# key: the EC public key used to verify the signature
|
40
|
+
#
|
41
|
+
# returns true if signature validates the payload
|
42
|
+
def verify(payload, signature, key)
|
43
|
+
public_key_hex = format_key(key)
|
44
|
+
pkey = OpenSSL::PKey::EC.new(GROUP)
|
45
|
+
public_key_bn = OpenSSL::BN.new(public_key_hex, 16)
|
46
|
+
public_key = OpenSSL::PKey::EC::Point.new(GROUP, public_key_bn)
|
47
|
+
pkey.public_key = public_key
|
48
|
+
|
49
|
+
pkey.verify(DIGEST.new, signature, payload)
|
50
|
+
rescue OpenSSL::PKey::ECError, OpenSSL::PKey::PKeyError => e
|
51
|
+
raise CryptoError, "Unable verify recovery token"
|
52
|
+
end
|
53
|
+
|
54
|
+
private def format_key(key)
|
55
|
+
sequence, bit_string = OpenSSL::ASN1.decode(Base64.decode64(key)).value
|
56
|
+
unless bit_string.try(:tag) == OpenSSL::ASN1::BIT_STRING
|
57
|
+
raise CryptoError, "DER-encoded key did not contain a bit string"
|
58
|
+
end
|
59
|
+
bit_string.value.unpack("H*").first
|
60
|
+
rescue OpenSSL::ASN1::ASN1Error => e
|
61
|
+
raise CryptoError, "Invalid public key format. The key must be in ASN.1 format. #{e.message}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitHub
|
4
|
+
module DelegatedAccountRecovery
|
5
|
+
class EncryptedData
|
6
|
+
extend Forwardable
|
7
|
+
CIPHER_OPTIONS = [:encrypt, :decrypt].freeze
|
8
|
+
CIPHER = "aes-256-gcm".freeze
|
9
|
+
CIPHER_VERSION = 0
|
10
|
+
# This is the NIST recommended minimum: http://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
|
11
|
+
IV_LENGTH = 12
|
12
|
+
AUTH_TAG_LENGTH = 16
|
13
|
+
PROTOCOL_VERSION = 0
|
14
|
+
|
15
|
+
attr_reader :token_object
|
16
|
+
|
17
|
+
def_delegators :@token_object, :version, :iv, :auth_tag, :ciphertext,
|
18
|
+
:to_binary_s, :num_bytes
|
19
|
+
|
20
|
+
# token_object: an EncryptedDataIO instance
|
21
|
+
# instance.
|
22
|
+
def initialize(token_object)
|
23
|
+
raise ArgumentError, "Version must be #{PROTOCOL_VERSION}. Supplied: #{token_object.version}" unless token_object.version == CIPHER_VERSION
|
24
|
+
raise ArgumentError, "Auth Tag must be 16 bytes" unless token_object.auth_tag.length == AUTH_TAG_LENGTH
|
25
|
+
raise ArgumentError, "IV must be 12 bytes" unless token_object.iv.length == IV_LENGTH
|
26
|
+
@token_object = token_object
|
27
|
+
end
|
28
|
+
private_class_method :new
|
29
|
+
|
30
|
+
def decrypt
|
31
|
+
cipher = self.class.cipher(:decrypt)
|
32
|
+
cipher.iv = self.iv.to_binary_s
|
33
|
+
cipher.auth_tag = self.auth_tag.to_binary_s
|
34
|
+
cipher.auth_data = ""
|
35
|
+
cipher.update(self.ciphertext.to_binary_s) + cipher.final
|
36
|
+
rescue OpenSSL::Cipher::CipherError => e
|
37
|
+
raise CryptoError, "Unable to decrypt data: #{e}"
|
38
|
+
end
|
39
|
+
|
40
|
+
class << self
|
41
|
+
# data: the value to encrypt.
|
42
|
+
#
|
43
|
+
# returns an EncryptedData instance.
|
44
|
+
def build(data)
|
45
|
+
cipher = cipher(:encrypt)
|
46
|
+
iv = SecureRandom.random_bytes(EncryptedData::IV_LENGTH)
|
47
|
+
cipher.iv = iv
|
48
|
+
cipher.auth_data = ""
|
49
|
+
|
50
|
+
ciphertext = cipher.update(data.to_s) + cipher.final
|
51
|
+
|
52
|
+
token = EncryptedDataIO.new.tap do |edata|
|
53
|
+
edata.version = CIPHER_VERSION
|
54
|
+
edata.auth_tag = cipher.auth_tag.bytes
|
55
|
+
edata.iv = iv.bytes
|
56
|
+
edata.ciphertext = ciphertext.bytes
|
57
|
+
end
|
58
|
+
|
59
|
+
new(token)
|
60
|
+
end
|
61
|
+
|
62
|
+
# serialized_data: the binary representation of a token.
|
63
|
+
#
|
64
|
+
# returns an EncryptedData instance.
|
65
|
+
def parse(serialized_data)
|
66
|
+
data = new(EncryptedDataIO.new.read(serialized_data))
|
67
|
+
|
68
|
+
# be extra paranoid, oracles and stuff
|
69
|
+
if data.num_bytes != serialized_data.bytesize
|
70
|
+
raise CryptoError, "Encypted data field includes unexpected extra bytes"
|
71
|
+
end
|
72
|
+
|
73
|
+
data
|
74
|
+
rescue IOError => e
|
75
|
+
raise RecoveryTokenSerializationError, e.message
|
76
|
+
end
|
77
|
+
|
78
|
+
# DRY helper for generating cipher objects
|
79
|
+
def cipher(mode)
|
80
|
+
unless CIPHER_OPTIONS.include?(mode)
|
81
|
+
raise ArgumentError, "mode must be `encrypt` or `decrypt`"
|
82
|
+
end
|
83
|
+
|
84
|
+
OpenSSL::Cipher.new(EncryptedData::CIPHER).tap do |cipher|
|
85
|
+
cipher.send(mode)
|
86
|
+
cipher.key = [Darrrr.this_account_provider.symmetric_key].pack("H*")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitHub
|
4
|
+
module DelegatedAccountRecovery
|
5
|
+
class EncryptedDataIO < BinData::Record
|
6
|
+
uint8 :version
|
7
|
+
array :auth_tag, :type => :uint8, :initial_length => EncryptedData::AUTH_TAG_LENGTH
|
8
|
+
array :iv, :type => :uint8, :initial_length => EncryptedData::IV_LENGTH
|
9
|
+
array :ciphertext, :type => :uint8, :read_until => :eof
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GitHub
|
4
|
+
module DelegatedAccountRecovery
|
5
|
+
module Provider
|
6
|
+
RECOVERY_PROVIDER_CACHE_LENGTH = 60.seconds
|
7
|
+
MAX_RECOVERY_PROVIDER_CACHE_LENGTH = 5.minutes
|
8
|
+
include Constants
|
9
|
+
|
10
|
+
def self.included(base)
|
11
|
+
base.instance_eval do
|
12
|
+
# this represents the account/recovery provider on this web app
|
13
|
+
class << self
|
14
|
+
attr_accessor :this
|
15
|
+
|
16
|
+
def configure(&block)
|
17
|
+
raise ArgumentError, "Block required to configure #{self.name}" unless block_given?
|
18
|
+
raise ProviderConfigError, "#{self.name} already configured" if self.this
|
19
|
+
self.this = self.new.tap { |provider| provider.instance_eval(&block).freeze }
|
20
|
+
self.this.privacy_policy = DelegatedAccountRecovery.privacy_policy
|
21
|
+
self.this.icon_152px = DelegatedAccountRecovery.icon_152px
|
22
|
+
self.this.issuer = DelegatedAccountRecovery.authority
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize(provider_origin = nil, attrs: nil)
|
29
|
+
self.issuer = provider_origin
|
30
|
+
load(attrs) if attrs
|
31
|
+
end
|
32
|
+
|
33
|
+
# Lazily loads attributes if attrs is nil. It makes an http call to the
|
34
|
+
# recovery provider's well-known config location and caches the response
|
35
|
+
# if it's valid json.
|
36
|
+
#
|
37
|
+
# attrs: optional way of building the provider without making an http call.
|
38
|
+
def load(attrs = nil)
|
39
|
+
body = attrs || fetch_config!
|
40
|
+
set_attrs!(body)
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
private def faraday
|
45
|
+
Faraday.new do |f|
|
46
|
+
f.adapter(Faraday.default_adapter)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private def cache_config(response)
|
51
|
+
match = /max-age=(\d+)/.match(response.headers["cache-control"])
|
52
|
+
cache_age = if match
|
53
|
+
[match[1].to_i, MAX_RECOVERY_PROVIDER_CACHE_LENGTH].min
|
54
|
+
else
|
55
|
+
RECOVERY_PROVIDER_CACHE_LENGTH
|
56
|
+
end
|
57
|
+
DelegatedAccountRecovery.cache.try(:set, cache_key, response.body, cache_age)
|
58
|
+
end
|
59
|
+
|
60
|
+
private def cache_key
|
61
|
+
"recovery_provider_config:#{self.origin}:configuration"
|
62
|
+
end
|
63
|
+
|
64
|
+
private def fetch_config!
|
65
|
+
unless body = DelegatedAccountRecovery.cache.try(:get, cache_key)
|
66
|
+
response = faraday.get([self.origin, DelegatedAccountRecovery::WELL_KNOWN_CONFIG_PATH].join("/"))
|
67
|
+
if response.success?
|
68
|
+
cache_config(response)
|
69
|
+
else
|
70
|
+
raise ProviderConfigError.new("Unable to retrieve recovery provider config for #{self.origin}: #{response.status}: #{response.body[0..100]}")
|
71
|
+
end
|
72
|
+
|
73
|
+
body = response.body
|
74
|
+
end
|
75
|
+
|
76
|
+
JSON.parse(body)
|
77
|
+
rescue JSON::ParserError
|
78
|
+
raise ProviderConfigError.new("Unable to parse recovery provider config for #{self.origin}:#{body[0..100]}")
|
79
|
+
end
|
80
|
+
|
81
|
+
private def set_attrs!(context)
|
82
|
+
self.class::REQUIRED_FIELDS.each do |attr|
|
83
|
+
value = context[attr.to_s.tr("_", "-")]
|
84
|
+
self.instance_variable_set("@#{attr}", value)
|
85
|
+
end
|
86
|
+
|
87
|
+
if errors.any?
|
88
|
+
raise ProviderConfigError.new("Unable to parse recovery provider config for #{self.origin}: #{errors.join(", ")}")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
private def errors
|
93
|
+
errors = []
|
94
|
+
self.class::REQUIRED_FIELDS.each do |field|
|
95
|
+
unless self.instance_variable_get("@#{field}")
|
96
|
+
errors << "#{field} not set"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
self.class::URL_FIELDS.each do |field|
|
101
|
+
begin
|
102
|
+
uri = Addressable::URI.parse(self.instance_variable_get("@#{field}"))
|
103
|
+
if !DelegatedAccountRecovery.allow_unsafe_urls && uri.try(:scheme) != "https"
|
104
|
+
errors << "#{field} must be an https URL"
|
105
|
+
end
|
106
|
+
rescue Addressable::URI::InvalidURIError
|
107
|
+
errors << "#{field} must be a valid URL"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
if self.is_a? RecoveryProvider
|
112
|
+
unless self.token_max_size.to_i > 0
|
113
|
+
errors << "token max size must be an integer"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
unless self.unseal_keys.try(:any?)
|
118
|
+
errors << "No public key provided"
|
119
|
+
end
|
120
|
+
|
121
|
+
errors
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|