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
@@ -1,66 +0,0 @@
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
@@ -1,92 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,125 +0,0 @@
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
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GitHub
4
- module DelegatedAccountRecovery
5
- class RecoveryProvider
6
- include Provider
7
- include CryptoHelper
8
-
9
- INTEGER_FIELDS = [:token_max_size]
10
- BASE64_FIELDS = [:countersign_pubkeys_secp256r1]
11
- URL_FIELDS = [
12
- :issuer, :save_token,
13
- :recover_account, :privacy_policy
14
- ]
15
- REQUIRED_FIELDS = URL_FIELDS + INTEGER_FIELDS + BASE64_FIELDS
16
-
17
- attr_accessor(*REQUIRED_FIELDS)
18
- attr_accessor :save_token_async_api_iframe # optional
19
- attr_accessor :signing_private_key
20
- alias :origin :issuer
21
-
22
- # optional field
23
- attr_accessor :icon_152px
24
-
25
- # Used to serve content at /.well-known/delegated-account-recovery/configuration
26
- def to_h
27
- {
28
- "issuer" => self.issuer,
29
- "countersign-pubkeys-secp256r1" => self.countersign_pubkeys_secp256r1.dup,
30
- "token-max-size" => self.token_max_size,
31
- "save-token" => self.save_token,
32
- "recover-account" => self.recover_account,
33
- "save-token-async-api-iframe" => self.save_token_async_api_iframe,
34
- "privacy-policy" => self.privacy_policy
35
- }
36
- end
37
-
38
- # The CryptoHelper defines an `unseal` method that requires us to define
39
- # a `unseal_keys` method that will return the set of keys that are valid
40
- # when verifying the signature on a sealed key.
41
- def unseal_keys
42
- countersign_pubkeys_secp256r1
43
- end
44
-
45
- # The URL representing the location of the token. Used to initiate a recovery.
46
- #
47
- # token_id: the shared ID representing a token.
48
- def recovery_url(token_id)
49
- [self.recover_account, "?token_id=", URI.escape(token_id)].join
50
- end
51
-
52
- # Takes a binary representation of a token and signs if for a given
53
- # account provider. Do not pass in a RecoveryToken object. The wrapping
54
- # data structure is identical to the structure it's wrapping in format.
55
- #
56
- # token: the to_binary_s or binary representation of the recovery token
57
- #
58
- # returns a Base64 encoded representation of the countersigned token
59
- # and the signature over the token.
60
- def countersign_token(token)
61
- begin
62
- account_provider = RecoveryToken.account_provider_issuer(token)
63
- rescue RecoveryTokenSerializationError, UnknownProviderError
64
- raise TokenFormatError, "Could not determine provider"
65
- end
66
-
67
- counter_recovery_token = RecoveryToken.build(
68
- issuer: self,
69
- audience: account_provider,
70
- type: COUNTERSIGNED_RECOVERY_TOKEN_TYPE
71
- )
72
-
73
- counter_recovery_token.data = token
74
- seal(counter_recovery_token)
75
- end
76
-
77
- # Validate the token according to the processing instructions for the
78
- # save-token endpoint.
79
- #
80
- # Returns a validated token
81
- def validate_recovery_token!(token)
82
- errors = []
83
-
84
- # 1. Authenticate the User. The exact nature of how the Recovery Provider authenticates the User is beyond the scope of this specification.
85
- # handled in before_filter
86
-
87
- # 4. Retrieve the Account Provider configuration as described in Section 2 using the issuer field of the token as the subject.
88
- begin
89
- account_provider = RecoveryToken.account_provider_issuer(token)
90
- rescue RecoveryTokenSerializationError, UnknownProviderError, TokenFormatError => e
91
- raise RecoveryTokenError, "Could not determine provider: #{e.message}"
92
- end
93
-
94
- # 2. Parse the token.
95
- # 3. Validate that the version value is 0.
96
- # 5. Validate the signature over the token according to processing rules for the algorithm implied by the version.
97
- begin
98
- recovery_token = account_provider.unseal(token)
99
- rescue CryptoError => e
100
- raise RecoveryTokenError.new("Unable to verify signature of token")
101
- rescue TokenFormatError => e
102
- raise RecoveryTokenError.new(e.message)
103
- end
104
-
105
- # 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".)
106
- unless self.origin == recovery_token.audience
107
- raise RecoveryTokenError.new("Unnacceptable audience")
108
- end
109
-
110
- if DateTime.parse(recovery_token.issued_time).utc < (Time.now - CLOCK_SKEW).utc
111
- raise RecoveryTokenError.new("Issued at time is too far in the past")
112
- end
113
-
114
- recovery_token
115
- end
116
- end
117
- end
118
- end
@@ -1,113 +0,0 @@
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
-
6
- module GitHub
7
- module DelegatedAccountRecovery
8
- class RecoveryToken
9
- extend Forwardable
10
-
11
- attr_reader :token_object
12
-
13
- def_delegators :@token_object, :token_id, :issuer, :issued_time, :options,
14
- :audience, :binding_data, :data, :version, :to_binary_s, :num_bytes, :data=, :token_type=, :token_type
15
-
16
- BASE64_CHARACTERS = /\A[0-9a-zA-Z+\/=]+\z/
17
-
18
- # Typically, you would not call `new` directly but instead use `build`
19
- # and `parse`
20
- #
21
- # token_object: a RecoveryTokenWriter/RecoveryTokenReader instance
22
- def initialize(token_object)
23
- @token_object = token_object
24
- end
25
- private_class_method :new
26
-
27
- def decode
28
- Darrrr.encryptor.decrypt(self.data)
29
- end
30
-
31
- # A globally known location of the token, used to initiate a recovery
32
- def state_url
33
- [DelegatedAccountRecovery.recovery_provider(self.audience).recover_account, "id=#{CGI::escape(token_id.to_hex)}"].join("?")
34
- end
35
-
36
- class << self
37
- # data: the value that will be encrypted by EncryptedData.
38
- # recovery_provider: the provider for which we are building the token.
39
- # binding_data: a value retrieved from the recovery provider for this
40
- # token.
41
- #
42
- # returns a RecoveryToken.
43
- def build(issuer:, audience:, type:)
44
- token = RecoveryTokenWriter.new.tap do |token|
45
- token.token_id = SecureRandom.random_bytes(16).bytes.to_a
46
- token.issuer = issuer.origin
47
- token.issued_time = Time.now.utc.iso8601
48
- token.options = 0 # when the token-status endpoint is implemented, change this to 1
49
- token.audience = audience.origin
50
- token.version = DelegatedAccountRecovery::PROTOCOL_VERSION
51
- token.token_type = type
52
- end
53
- new(token)
54
- end
55
-
56
- # serialized_data: a binary string representation of a RecoveryToken.
57
- #
58
- # returns a RecoveryToken.
59
- def parse(serialized_data)
60
- new RecoveryTokenReader.new.read(serialized_data)
61
- rescue IOError => e
62
- message = e.message
63
- if serialized_data =~ BASE64_CHARACTERS
64
- message = "#{message}: did you forget to Base64.strict_decode64 this value?"
65
- end
66
- raise RecoveryTokenSerializationError, message
67
- end
68
-
69
- # Extract a recovery provider from a token based on the token type.
70
- #
71
- # serialized_data: a binary string representation of a RecoveryToken.
72
- #
73
- # returns the recovery provider for the coutnersigned token or raises an
74
- # error if the token is a recovery token
75
- def recovery_provider_issuer(serialized_data)
76
- issuer(serialized_data, DelegatedAccountRecovery::COUNTERSIGNED_RECOVERY_TOKEN_TYPE)
77
- end
78
-
79
- # Extract an account provider from a token based on the token type.
80
- #
81
- # serialized_data: a binary string representation of a RecoveryToken.
82
- #
83
- # returns the account provider for the recovery token or raises an error
84
- # if the token is a countersigned token
85
- def account_provider_issuer(serialized_data)
86
- issuer(serialized_data, DelegatedAccountRecovery::RECOVERY_TOKEN_TYPE)
87
- end
88
-
89
- # Convenience method to find the issuer of the token
90
- #
91
- # serialized_data: a binary string representation of a RecoveryToken.
92
- #
93
- # raises an error if the token is the not the expected type
94
- # returns the account provider or recovery provider instance based on the
95
- # token type
96
- private def issuer(serialized_data, token_type)
97
- parsed_token = parse(serialized_data)
98
- raise TokenFormatError, "Token type must be #{token_type}" unless parsed_token.token_type == token_type
99
-
100
- issuer = parsed_token.issuer
101
- case token_type
102
- when DelegatedAccountRecovery::RECOVERY_TOKEN_TYPE
103
- DelegatedAccountRecovery.account_provider(issuer)
104
- when DelegatedAccountRecovery::COUNTERSIGNED_RECOVERY_TOKEN_TYPE
105
- DelegatedAccountRecovery.recovery_provider(issuer)
106
- else
107
- raise RecoveryTokenError, "Could not determine provider"
108
- end
109
- end
110
- end
111
- end
112
- end
113
- end