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