-
Notifications
You must be signed in to change notification settings - Fork 112
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
Conversation
I think you might have an idea of what my comment here is going to be.... 🎓 |
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 |
ec4e452
to
ed53049
Compare
thanks for that suggestion @zachmargolis -- that seems very reasonable. I will revisit this PR. |
a68ce0d
to
ad4dfbf
Compare
app/services/random_phrase.rb
Outdated
# 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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
app/services/random_phrase.rb
Outdated
# :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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
ad4dfbf
to
8be4a0b
Compare
@zachmargolis took my best shot - PTAL |
There was a problem hiding this 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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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' } |
There was a problem hiding this comment.
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/) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
8be4a0b
to
9df7d8b
Compare
**Why**: Fixes case sensitivity and oft-confused characters.
Why: We were stripping out O but then reintroducing it, randomly.