Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Base32 Crockford for recovery code generation #1253

Merged
merged 1 commit into from
Mar 22, 2017

Conversation

pkarman
Copy link
Contributor

@pkarman pkarman commented Mar 21, 2017

Why: We were stripping out O but then reintroducing it, randomly.

@jessieay
Copy link
Contributor

I think you might have an idea of what my comment here is going to be.... 🎓

@zachmargolis
Copy link
Contributor

I'd like to take this opportunity to offer up base32/crockford encoding again: http://www.crockford.com/wrmg/base32.html

The reason is that with a limited alphabet and code that normalizes ambiguous characters, it's much friendlier about transcription errors.

There's even a crockford-base32 gem we could use: https://github.com/levinalex/base32

require 'base32/crockford'
require 'securerandom'
# 5 bits per character * 16 characters = 16 character result
# :length adds zero padding in case it's a smaller number
orig = Base32::Crockford.encode(SecureRandom.random_number(2**(16*5)), length: 16)
=> "D8VR0KPN6WZ6YV0Y"
typoed = orig.tr('0', 'o')
=> "D8VRoKPN6WZ6YVoY"
Base32::Crockford.encode(Base32::Crockford.decode(typoed))) == orig
=> true

@pkarman
Copy link
Contributor Author

pkarman commented Mar 21, 2017

thanks for that suggestion @zachmargolis -- that seems very reasonable. I will revisit this PR.

@pkarman pkarman force-pushed the pek-recovery-code-zeros branch 2 times, most recently from a68ce0d to ad4dfbf Compare March 21, 2017 19:23
@pkarman pkarman changed the title Fix bug in recovery code generation to skip O Use Base32 Crockford for recovery code generation Mar 21, 2017
# 5 bits per character means we must multiply what we want by 5
# :length adds zero padding in case it's a smaller number
random_bytes = SecureRandom.random_number(2**(str_size * 5))
random_string = Base32::Crockford.encode(random_bytes, length: 16)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want length: str_size here

Copy link
Contributor

@zachmargolis zachmargolis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

img

# :length adds zero padding in case it's a smaller number
random_bytes = SecureRandom.random_number(2**(str_size * 5))
random_string = Base32::Crockford.encode(random_bytes, length: 16)
random_string.upcase.chars.last(str_size).each_slice(@word_length).map(&:join)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upcase and .last(str_size) are redundant right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upcase is definitely a belt-and-suspenders redundancy because I wanted to be sure we were handing back UPPER case strings No Matter What.

The last(str_size) will likely be resolved if we set length: str_size instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please let's use the length: str_size

@@ -1,6 +1,8 @@
class RecoveryCodeGenerator
attr_reader :user_access_key

INVALID_CODE = 'this is an invalid recovery code but a valid string'.freeze
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: "but not a valid string"?

Should we i18n this?

Copy link
Contributor Author

@pkarman pkarman Mar 21, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not a typo.

I was cheating a bit. If you hand Base32::Crockford.decode an un-decodable string like this string has U in it then it will return nil, which the subsequent encode will throw an exception on. But I wanted normalize_code to always return a string, even if it was a bad string. So I created a constant that could never be a recovery code but was a valid string. The point is to trigger the decryption exception in verify so that the return value of verify is false.

So no need to i18n because this value is never seen by a human.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I get it. The value didn't not seem super clear to me, got any other alternatives?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about do not change this string because things will break ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or this is a meaningless string but change it at your peril

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or a meaningless string that the RandomPhrase class will never generate

split_length = normed.length / length
decoded = Base32::Crockford.decode(normed)
Base32::Crockford.encode(decoded, length: 16, split: split_length).tr('-', ' ')
rescue
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rescue ArgumentError to be specific?

@pkarman
Copy link
Contributor Author

pkarman commented Mar 21, 2017

@zachmargolis took my best shot - PTAL

Copy link
Contributor

@zachmargolis zachmargolis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😍 awesome thank you!

@@ -21,5 +21,13 @@

expect(phrase.to_s.length).to eq 24 # 20 chars + 4 spaces
end

it 'does not contain the letters I L O U' do
100.times do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to do this fewer than 100 times and still have an effective test? I imagine 100x is kinda slow but maybe I am wrong

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on my machine the entire test file runs under a second, so that seems fast enough to me.

Copy link
Contributor

@jessieay jessieay Mar 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps if there is some significance to this number it would be good to name it? I just know that the minute I got in here and some a loop going through 100 times, I'd be tempted to lower it to something like 5. And if doing that means that the test is less effective, it would be great to know why.

Copy link
Contributor Author

@pkarman pkarman Mar 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100 is an arbitrary number reasonably big enough to "randomly" trigger the presence of one of the suspect letters.

Maybe we should just remove this test, since we can rely on the Base32 algorithm to avoid those letters.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am fine with removing or naming it so it's clear that it is arbitrary!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

se #1258

let(:recovery_code) { 'four score and seven years' }
let(:recovery_code) { Base32::Crockford.encode(100**10, length: 16, split: 4).tr('-', ' ') }
let(:bad_code) { Base32::Crockford.encode(100**9, length: 16, split: 4).tr('-', ' ') }
let(:invalid_base32_code) { 'four score has letter U in it' }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😻

allow(Figaro.env).to receive(:recovery_code_length).and_return('14')
generator = RecoveryCodeGenerator.new(user)

expect(generator.create).to match(/\A(\w+\ ){13}\w+\z/)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be possible to name this regex so mere humans can know know what it means at a glance? 🐰

expect(generator.verify(recovery_code)).to eq true
end

it 'forgives user mistaking O for 0' do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well isn't that lovely

**Why**: Fixes case sensitivity and oft-confused characters.
@pkarman pkarman merged commit d47290b into master Mar 22, 2017
@pkarman pkarman deleted the pek-recovery-code-zeros branch March 22, 2017 15:03
pkarman added a commit that referenced this pull request Mar 22, 2017
**Why**: Fixes case sensitivity and oft-confused characters.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants