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