darrrr 0.0.2

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