darrrr 0.0.3 → 0.1.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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -5
  3. data/lib/darrrr.rb +144 -2
  4. data/lib/darrrr/account_provider.rb +150 -0
  5. data/lib/darrrr/constants.rb +17 -0
  6. data/lib/darrrr/crypto_helper.rb +55 -0
  7. data/lib/darrrr/cryptors/default/default_encryptor.rb +68 -0
  8. data/lib/darrrr/cryptors/default/encrypted_data.rb +90 -0
  9. data/lib/darrrr/cryptors/default/encrypted_data_io.rb +10 -0
  10. data/lib/darrrr/provider.rb +157 -0
  11. data/lib/darrrr/recovery_provider.rb +129 -0
  12. data/lib/darrrr/recovery_token.rb +117 -0
  13. data/lib/darrrr/serialization/recovery_token_reader.rb +20 -0
  14. data/lib/darrrr/serialization/recovery_token_writer.rb +20 -0
  15. metadata +17 -18
  16. data/lib/github/delegated_account_recovery.rb +0 -178
  17. data/lib/github/delegated_account_recovery/account_provider.rb +0 -140
  18. data/lib/github/delegated_account_recovery/constants.rb +0 -19
  19. data/lib/github/delegated_account_recovery/crypto_helper.rb +0 -57
  20. data/lib/github/delegated_account_recovery/cryptors/default/default_encryptor.rb +0 -66
  21. data/lib/github/delegated_account_recovery/cryptors/default/encrypted_data.rb +0 -92
  22. data/lib/github/delegated_account_recovery/cryptors/default/encrypted_data_io.rb +0 -12
  23. data/lib/github/delegated_account_recovery/provider.rb +0 -125
  24. data/lib/github/delegated_account_recovery/recovery_provider.rb +0 -118
  25. data/lib/github/delegated_account_recovery/recovery_token.rb +0 -113
  26. data/lib/github/delegated_account_recovery/serialization/recovery_token_reader.rb +0 -22
  27. data/lib/github/delegated_account_recovery/serialization/recovery_token_writer.rb +0 -22
  28. data/lib/github/delegated_account_recovery/version.rb +0 -5
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Darrrr
4
+ class EncryptedData
5
+ extend Forwardable
6
+ CIPHER_OPTIONS = [:encrypt, :decrypt].freeze
7
+ CIPHER = "aes-256-gcm".freeze
8
+ CIPHER_VERSION = 0
9
+ # This is the NIST recommended minimum: http://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
10
+ IV_LENGTH = 12
11
+ AUTH_TAG_LENGTH = 16
12
+ PROTOCOL_VERSION = 0
13
+
14
+ attr_reader :token_object
15
+
16
+ def_delegators :@token_object, :version, :iv, :auth_tag, :ciphertext,
17
+ :to_binary_s, :num_bytes
18
+
19
+ # token_object: an EncryptedDataIO instance
20
+ # instance.
21
+ def initialize(token_object)
22
+ raise TokenFormatError, "Version must be #{PROTOCOL_VERSION}. Supplied: #{token_object.version}" unless token_object.version == CIPHER_VERSION
23
+ raise TokenFormatError, "Auth Tag must be 16 bytes" unless token_object.auth_tag.length == AUTH_TAG_LENGTH
24
+ raise TokenFormatError, "IV must be 12 bytes" unless token_object.iv.length == IV_LENGTH
25
+ @token_object = token_object
26
+ end
27
+ private_class_method :new
28
+
29
+ def decrypt
30
+ cipher = self.class.cipher(:decrypt)
31
+ cipher.iv = self.iv.to_binary_s
32
+ cipher.auth_tag = self.auth_tag.to_binary_s
33
+ cipher.auth_data = ""
34
+ cipher.update(self.ciphertext.to_binary_s) + cipher.final
35
+ rescue OpenSSL::Cipher::CipherError => e
36
+ raise CryptoError, "Unable to decrypt data: #{e}"
37
+ end
38
+
39
+ class << self
40
+ # data: the value to encrypt.
41
+ #
42
+ # returns an EncryptedData instance.
43
+ def build(data)
44
+ cipher = cipher(:encrypt)
45
+ iv = SecureRandom.random_bytes(EncryptedData::IV_LENGTH)
46
+ cipher.iv = iv
47
+ cipher.auth_data = ""
48
+
49
+ ciphertext = cipher.update(data.to_s) + cipher.final
50
+
51
+ token = EncryptedDataIO.new.tap do |edata|
52
+ edata.version = CIPHER_VERSION
53
+ edata.auth_tag = cipher.auth_tag.bytes
54
+ edata.iv = iv.bytes
55
+ edata.ciphertext = ciphertext.bytes
56
+ end
57
+
58
+ new(token)
59
+ end
60
+
61
+ # serialized_data: the binary representation of a token.
62
+ #
63
+ # returns an EncryptedData instance.
64
+ def parse(serialized_data)
65
+ data = new(EncryptedDataIO.new.read(serialized_data))
66
+
67
+ # be extra paranoid, oracles and stuff
68
+ if data.num_bytes != serialized_data.bytesize
69
+ raise CryptoError, "Encypted data field includes unexpected extra bytes"
70
+ end
71
+
72
+ data
73
+ rescue IOError => e
74
+ raise RecoveryTokenSerializationError, e.message
75
+ end
76
+
77
+ # DRY helper for generating cipher objects
78
+ def cipher(mode)
79
+ unless CIPHER_OPTIONS.include?(mode)
80
+ raise ArgumentError, "mode must be `encrypt` or `decrypt`"
81
+ end
82
+
83
+ OpenSSL::Cipher.new(EncryptedData::CIPHER).tap do |cipher|
84
+ cipher.send(mode)
85
+ cipher.key = [Darrrr.this_account_provider.instance_variable_get(:@symmetric_key)].pack("H*")
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Darrrr
4
+ class EncryptedDataIO < BinData::Record
5
+ uint8 :version
6
+ array :auth_tag, :type => :uint8, :initial_length => EncryptedData::AUTH_TAG_LENGTH
7
+ array :iv, :type => :uint8, :initial_length => EncryptedData::IV_LENGTH
8
+ array :ciphertext, :type => :uint8, :read_until => :eof
9
+ end
10
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Darrrr
4
+ module Provider
5
+ RECOVERY_PROVIDER_CACHE_LENGTH = 60.seconds
6
+ MAX_RECOVERY_PROVIDER_CACHE_LENGTH = 5.minutes
7
+ REQUIRED_CRYPTO_OPS = [:sign, :verify, :encrypt, :decrypt].freeze
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 = Darrrr.privacy_policy
21
+ self.this.icon_152px = Darrrr.icon_152px
22
+ self.this.issuer = Darrrr.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
+ # Returns the crypto API to be used. A thread local instance overrides the
34
+ # globally configured value which overrides the default encryptor.
35
+ def encryptor
36
+ Thread.current[encryptor_key()] || @encryptor || DefaultEncryptor
37
+ end
38
+
39
+ # Overrides the global `encryptor` API to use
40
+ #
41
+ # encryptor: a class/module that responds to all +REQUIRED_CRYPTO_OPS+.
42
+ def custom_encryptor=(encryptor)
43
+ if valid_encryptor?(encryptor)
44
+ @encryptor = encryptor
45
+ else
46
+ raise ArgumentError, "custom encryption class must respond to all of #{REQUIRED_CRYPTO_OPS}"
47
+ end
48
+ end
49
+
50
+ def with_encryptor(encryptor)
51
+ raise ArgumentError, "A block must be supplied" unless block_given?
52
+ unless valid_encryptor?(encryptor)
53
+ raise ArgumentError, "custom encryption class must respond to all of #{REQUIRED_CRYPTO_OPS}"
54
+ end
55
+
56
+ Thread.current[encryptor_key()] = encryptor
57
+ yield
58
+ ensure
59
+ Thread.current[encryptor_key()] = nil
60
+ end
61
+
62
+ private def valid_encryptor?(encryptor)
63
+ REQUIRED_CRYPTO_OPS.all? {|m| encryptor.respond_to?(m)}
64
+ end
65
+
66
+ # Lazily loads attributes if attrs is nil. It makes an http call to the
67
+ # recovery provider's well-known config location and caches the response
68
+ # if it's valid json.
69
+ #
70
+ # attrs: optional way of building the provider without making an http call.
71
+ def load(attrs = nil)
72
+ body = attrs || fetch_config!
73
+ set_attrs!(body)
74
+ self
75
+ end
76
+
77
+ private def faraday
78
+ Faraday.new do |f|
79
+ f.adapter(Faraday.default_adapter)
80
+ end
81
+ end
82
+
83
+ private def cache_config(response)
84
+ match = /max-age=(\d+)/.match(response.headers["cache-control"])
85
+ cache_age = if match
86
+ [match[1].to_i, MAX_RECOVERY_PROVIDER_CACHE_LENGTH].min
87
+ else
88
+ RECOVERY_PROVIDER_CACHE_LENGTH
89
+ end
90
+ Darrrr.cache.try(:set, cache_key, response.body, cache_age)
91
+ end
92
+
93
+ private def cache_key
94
+ "recovery_provider_config:#{self.origin}:configuration"
95
+ end
96
+
97
+ private def fetch_config!
98
+ unless body = Darrrr.cache.try(:get, cache_key)
99
+ response = faraday.get([self.origin, Darrrr::WELL_KNOWN_CONFIG_PATH].join("/"))
100
+ if response.success?
101
+ cache_config(response)
102
+ else
103
+ raise ProviderConfigError.new("Unable to retrieve recovery provider config for #{self.origin}: #{response.status}: #{response.body[0..100]}")
104
+ end
105
+
106
+ body = response.body
107
+ end
108
+
109
+ JSON.parse(body)
110
+ rescue ::JSON::ParserError
111
+ raise ProviderConfigError.new("Unable to parse recovery provider config for #{self.origin}:#{body[0..100]}")
112
+ end
113
+
114
+ private def set_attrs!(context)
115
+ self.class::REQUIRED_FIELDS.each do |attr|
116
+ value = context[attr.to_s.tr("_", "-")]
117
+ self.instance_variable_set("@#{attr}", value)
118
+ end
119
+
120
+ if errors.any?
121
+ raise ProviderConfigError.new("Unable to parse recovery provider config for #{self.origin}: #{errors.join(", ")}")
122
+ end
123
+ end
124
+
125
+ private def errors
126
+ errors = []
127
+ self.class::REQUIRED_FIELDS.each do |field|
128
+ unless self.instance_variable_get("@#{field}")
129
+ errors << "#{field} not set"
130
+ end
131
+ end
132
+
133
+ self.class::URL_FIELDS.each do |field|
134
+ begin
135
+ uri = Addressable::URI.parse(self.instance_variable_get("@#{field}"))
136
+ if !Darrrr.allow_unsafe_urls && uri.try(:scheme) != "https"
137
+ errors << "#{field} must be an https URL"
138
+ end
139
+ rescue Addressable::URI::InvalidURIError
140
+ errors << "#{field} must be a valid URL"
141
+ end
142
+ end
143
+
144
+ if self.is_a? RecoveryProvider
145
+ unless self.token_max_size.to_i > 0
146
+ errors << "token max size must be an integer"
147
+ end
148
+ end
149
+
150
+ unless self.unseal_keys.try(:any?)
151
+ errors << "No public key provided"
152
+ end
153
+
154
+ errors
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Darrrr
4
+ class RecoveryProvider
5
+ include Provider
6
+ include CryptoHelper
7
+
8
+ INTEGER_FIELDS = [:token_max_size]
9
+ BASE64_FIELDS = [:countersign_pubkeys_secp256r1]
10
+ URL_FIELDS = [
11
+ :issuer, :save_token,
12
+ :recover_account, :privacy_policy
13
+ ]
14
+ REQUIRED_FIELDS = URL_FIELDS + INTEGER_FIELDS + BASE64_FIELDS
15
+
16
+ attr_reader *(REQUIRED_FIELDS - [:countersign_pubkeys_secp256r1])
17
+ attr_writer *REQUIRED_FIELDS
18
+ attr_writer :signing_private_key, :token_max_size
19
+ attr_accessor :save_token_async_api_iframe # optional
20
+
21
+ alias :origin :issuer
22
+
23
+ # optional field
24
+ attr_accessor :icon_152px
25
+
26
+ # Used to serve content at /.well-known/delegated-account-recovery/configuration
27
+ def to_h
28
+ {
29
+ "issuer" => self.issuer,
30
+ "countersign-pubkeys-secp256r1" => self.unseal_keys.dup,
31
+ "token-max-size" => self.token_max_size,
32
+ "save-token" => self.save_token,
33
+ "recover-account" => self.recover_account,
34
+ "save-token-async-api-iframe" => self.save_token_async_api_iframe,
35
+ "privacy-policy" => self.privacy_policy
36
+ }
37
+ end
38
+
39
+ # The CryptoHelper defines an `unseal` method that requires us to define
40
+ # a `unseal_keys` method that will return the set of keys that are valid
41
+ # when verifying the signature on a sealed key.
42
+ #
43
+ # returns the value of `countersign_pubkeys_secp256r1` or executes a proc
44
+ # passing `self` as the first argument.
45
+ def unseal_keys(context = nil)
46
+ if @countersign_pubkeys_secp256r1.respond_to?(:call)
47
+ @countersign_pubkeys_secp256r1.call(context)
48
+ else
49
+ @countersign_pubkeys_secp256r1
50
+ end
51
+ end
52
+
53
+ # The URL representing the location of the token. Used to initiate a recovery.
54
+ #
55
+ # token_id: the shared ID representing a token.
56
+ def recovery_url(token_id)
57
+ [self.recover_account, "?token_id=", URI.escape(token_id)].join
58
+ end
59
+
60
+ def encryptor_key
61
+ :darrrr_recovery_provider_encryptor
62
+ end
63
+
64
+ # Takes a binary representation of a token and signs if for a given
65
+ # account provider. Do not pass in a RecoveryToken object. The wrapping
66
+ # data structure is identical to the structure it's wrapping in format.
67
+ #
68
+ # token: the to_binary_s or binary representation of the recovery token
69
+ #
70
+ # returns a Base64 encoded representation of the countersigned token
71
+ # and the signature over the token.
72
+ def countersign_token(token, context = nil)
73
+ begin
74
+ account_provider = RecoveryToken.account_provider_issuer(token)
75
+ rescue RecoveryTokenSerializationError, UnknownProviderError
76
+ raise TokenFormatError, "Could not determine provider"
77
+ end
78
+
79
+ counter_recovery_token = RecoveryToken.build(
80
+ issuer: self,
81
+ audience: account_provider,
82
+ type: COUNTERSIGNED_RECOVERY_TOKEN_TYPE
83
+ )
84
+
85
+ counter_recovery_token.data = token
86
+ seal(counter_recovery_token, context)
87
+ end
88
+
89
+ # Validate the token according to the processing instructions for the
90
+ # save-token endpoint.
91
+ #
92
+ # Returns a validated token
93
+ def validate_recovery_token!(token, context = {})
94
+ errors = []
95
+
96
+ # 1. Authenticate the User. The exact nature of how the Recovery Provider authenticates the User is beyond the scope of this specification.
97
+ # handled in before_filter
98
+
99
+ # 4. Retrieve the Account Provider configuration as described in Section 2 using the issuer field of the token as the subject.
100
+ begin
101
+ account_provider = RecoveryToken.account_provider_issuer(token)
102
+ rescue RecoveryTokenSerializationError, UnknownProviderError, TokenFormatError => e
103
+ raise RecoveryTokenError, "Could not determine provider: #{e.message}"
104
+ end
105
+
106
+ # 2. Parse the token.
107
+ # 3. Validate that the version value is 0.
108
+ # 5. Validate the signature over the token according to processing rules for the algorithm implied by the version.
109
+ begin
110
+ recovery_token = account_provider.unseal(token, context)
111
+ rescue CryptoError => e
112
+ raise RecoveryTokenError.new("Unable to verify signature of token")
113
+ rescue TokenFormatError => e
114
+ raise RecoveryTokenError.new(e.message)
115
+ end
116
+
117
+ # 6. Validate that the audience field of the token identifies an origin which the provider considers itself authoritative for. (Often the audience will be same-origin with the Recovery Provider, but other values may be acceptable, e.g. "https://mail.example.com" and "https://social.example.com" may be acceptable audiences for "https://recovery.example.com".)
118
+ unless self.origin == recovery_token.audience
119
+ raise RecoveryTokenError.new("Unnacceptable audience")
120
+ end
121
+
122
+ if DateTime.parse(recovery_token.issued_time).utc < (Time.now - CLOCK_SKEW).utc
123
+ raise RecoveryTokenError.new("Issued at time is too far in the past")
124
+ end
125
+
126
+ recovery_token
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Handles binary serialization/deserialization of recovery token data. It does
4
+ # not manage signing/verification of tokens.
5
+ # Only account providers will ever call the decode function
6
+ module Darrrr
7
+ class RecoveryToken
8
+ extend Forwardable
9
+
10
+ attr_reader :token_object
11
+
12
+ def_delegators :@token_object, :token_id, :issuer, :issued_time, :options,
13
+ :audience, :binding_data, :data, :version, :to_binary_s, :num_bytes, :data=, :token_type=, :token_type
14
+
15
+ BASE64_CHARACTERS = /\A[0-9a-zA-Z+\/=]+\z/
16
+
17
+ # Typically, you would not call `new` directly but instead use `build`
18
+ # and `parse`
19
+ #
20
+ # token_object: a RecoveryTokenWriter/RecoveryTokenReader instance
21
+ def initialize(token_object)
22
+ @token_object = token_object
23
+ end
24
+ private_class_method :new
25
+
26
+ def decode(context = nil)
27
+ Darrrr.this_account_provider.encryptor.decrypt(self.data, Darrrr.this_account_provider, context)
28
+ end
29
+
30
+ # A globally known location of the token, used to initiate a recovery
31
+ def state_url
32
+ [Darrrr.recovery_provider(self.audience).recover_account, "id=#{CGI::escape(token_id.to_hex)}"].join("?")
33
+ end
34
+
35
+ class << self
36
+ # data: the value that will be encrypted by EncryptedData.
37
+ # recovery_provider: the provider for which we are building the token.
38
+ # binding_data: a value retrieved from the recovery provider for this
39
+ # token.
40
+ #
41
+ # returns a RecoveryToken.
42
+ def build(issuer:, audience:, type:)
43
+ token = RecoveryTokenWriter.new.tap do |token|
44
+ token.token_id = token_id
45
+ token.issuer = issuer.origin
46
+ token.issued_time = Time.now.utc.iso8601
47
+ token.options = 0 # when the token-status endpoint is implemented, change this to 1
48
+ token.audience = audience.origin
49
+ token.version = Darrrr::PROTOCOL_VERSION
50
+ token.token_type = type
51
+ end
52
+ new(token)
53
+ end
54
+
55
+ # token ID generates a random array of bytes.
56
+ # this method only exists so that it can be stubbed.
57
+ def token_id
58
+ SecureRandom.random_bytes(16).bytes.to_a
59
+ end
60
+
61
+ # serialized_data: a binary string representation of a RecoveryToken.
62
+ #
63
+ # returns a RecoveryToken.
64
+ def parse(serialized_data)
65
+ new RecoveryTokenReader.new.read(serialized_data)
66
+ rescue IOError => e
67
+ message = e.message
68
+ if serialized_data =~ BASE64_CHARACTERS
69
+ message = "#{message}: did you forget to Base64.strict_decode64 this value?"
70
+ end
71
+ raise RecoveryTokenSerializationError, message
72
+ end
73
+
74
+ # Extract a recovery provider from a token based on the token type.
75
+ #
76
+ # serialized_data: a binary string representation of a RecoveryToken.
77
+ #
78
+ # returns the recovery provider for the coutnersigned token or raises an
79
+ # error if the token is a recovery token
80
+ def recovery_provider_issuer(serialized_data)
81
+ issuer(serialized_data, Darrrr::COUNTERSIGNED_RECOVERY_TOKEN_TYPE)
82
+ end
83
+
84
+ # Extract an account provider from a token based on the token type.
85
+ #
86
+ # serialized_data: a binary string representation of a RecoveryToken.
87
+ #
88
+ # returns the account provider for the recovery token or raises an error
89
+ # if the token is a countersigned token
90
+ def account_provider_issuer(serialized_data)
91
+ issuer(serialized_data, Darrrr::RECOVERY_TOKEN_TYPE)
92
+ end
93
+
94
+ # Convenience method to find the issuer of the token
95
+ #
96
+ # serialized_data: a binary string representation of a RecoveryToken.
97
+ #
98
+ # raises an error if the token is the not the expected type
99
+ # returns the account provider or recovery provider instance based on the
100
+ # token type
101
+ private def issuer(serialized_data, token_type)
102
+ parsed_token = parse(serialized_data)
103
+ raise TokenFormatError, "Token type must be #{token_type}" unless parsed_token.token_type == token_type
104
+
105
+ issuer = parsed_token.issuer
106
+ case token_type
107
+ when Darrrr::RECOVERY_TOKEN_TYPE
108
+ Darrrr.account_provider(issuer)
109
+ when Darrrr::COUNTERSIGNED_RECOVERY_TOKEN_TYPE
110
+ Darrrr.recovery_provider(issuer)
111
+ else
112
+ raise RecoveryTokenError, "Could not determine provider"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end