Skip to content

Commit 63ffbca

Browse files
committed
Correct Base32 implementation to exactly match Google Authenticator
- Correctly pad extra bits - Update tests - Fixes #86
1 parent 2698c91 commit 63ffbca

File tree

3 files changed

+86
-51
lines changed

3 files changed

+86
-51
lines changed

lib/rotp/base32.rb

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,67 @@
11
module ROTP
22
class Base32
33
class Base32Error < RuntimeError; end
4-
CHARS = 'abcdefghijklmnopqrstuvwxyz234567'.each_char.to_a
4+
CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.each_char.to_a
5+
SHIFT = 5
6+
MASK = 31
57

68
class << self
9+
710
def decode(str)
8-
str = str.tr('=', '')
9-
output = []
10-
str.scan(/.{1,8}/).each do |block|
11-
char_array = decode_block(block).map(&:chr)
12-
output << char_array
11+
buffer = 0
12+
idx = 0
13+
bits_left = 0
14+
str = str.tr('=', '').upcase
15+
result = []
16+
str.split('').each do |char|
17+
buffer = buffer << SHIFT
18+
buffer = buffer | (decode_quint(char) & MASK)
19+
bits_left = bits_left + SHIFT
20+
if bits_left >= 8
21+
result[idx] = (buffer >> (bits_left - 8)) & 255
22+
idx = idx + 1
23+
bits_left = bits_left - 8
24+
end
1325
end
14-
output.join
26+
result.pack('c*')
1527
end
1628

17-
def random_base32(length = 32)
18-
b32 = ''
19-
SecureRandom.random_bytes(length).each_byte do |b|
20-
b32 << CHARS[b % 32]
29+
def encode(b)
30+
data = b.unpack('c*')
31+
out = ''
32+
buffer = data[0]
33+
idx = 1
34+
bits_left = 8
35+
while bits_left > 0 || idx < data.length
36+
if bits_left < SHIFT
37+
if idx < data.length
38+
buffer = buffer << 8
39+
buffer = buffer | (data[idx] & 255)
40+
bits_left = bits_left + 8
41+
idx = idx + 1
42+
else
43+
pad = SHIFT - bits_left
44+
buffer = buffer << pad
45+
bits_left = bits_left + pad
46+
end
47+
end
48+
val = MASK & (buffer >> (bits_left - SHIFT))
49+
bits_left = bits_left - SHIFT
50+
out.concat(CHARS[val])
2151
end
22-
b32
52+
return out
2353
end
2454

25-
private
26-
27-
def decode_block(block)
28-
length = block.scan(/[^=]/).length
29-
quints = block.each_char.map { |c| decode_quint(c) }
30-
bytes = []
31-
bytes[0] = (quints[0] << 3) + (quints[1] ? quints[1] >> 2 : 0)
32-
return bytes if length < 3
33-
34-
bytes[1] = ((quints[1] & 3) << 6) + (quints[2] << 1) + (quints[3] ? quints[3] >> 4 : 0)
35-
return bytes if length < 4
36-
37-
bytes[2] = ((quints[3] & 15) << 4) + (quints[4] ? quints[4] >> 1 : 0)
38-
return bytes if length < 6
39-
40-
bytes[3] = ((quints[4] & 1) << 7) + (quints[5] << 2) + (quints[6] ? quints[6] >> 3 : 0)
41-
return bytes if length < 7
42-
43-
bytes[4] = ((quints[6] & 7) << 5) + (quints[7] || 0)
44-
bytes
55+
# Defaults to 160 bit long secret (meaning a 32 character long base32 secret)
56+
def random(byte_length = 20)
57+
rand_bytes = SecureRandom.random_bytes(byte_length)
58+
self.encode(rand_bytes)
4559
end
4660

61+
private
62+
4763
def decode_quint(q)
48-
CHARS.index(q.downcase) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
64+
CHARS.index(q) || raise(Base32Error, "Invalid Base32 Character - '#{q}'")
4965
end
5066
end
5167
end

lib/rotp/cli.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def errors
1919
if %i[time hmac].include?(options.mode)
2020
if options.secret.to_s == ''
2121
red 'You must also specify a --secret. Try --help for help.'
22-
elsif options.secret.to_s.chars.any? { |c| ROTP::Base32::CHARS.index(c.downcase).nil? }
22+
elsif options.secret.to_s.chars.any? { |c| ROTP::Base32::CHARS.index(c.upcase).nil? }
2323
red 'Secret must be in RFC4648 Base32 format - http://en.wikipedia.org/wiki/Base32#RFC_4648_Base32_alphabet'
2424
end
2525
end

spec/lib/rotp/base32_spec.rb

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
require 'spec_helper'
22

33
RSpec.describe ROTP::Base32 do
4-
describe '.random_base32' do
4+
describe '.random' do
55
context 'without arguments' do
6-
let(:base32) { ROTP::Base32.random_base32 }
6+
let(:base32) { ROTP::Base32.random }
77

8-
it 'is 32 characters long' do
8+
it 'is 20 bytes (160 bits) long (resulting in a 32 character base32 code)' do
9+
expect(ROTP::Base32.decode(base32).length).to eq 20
910
expect(base32.length).to eq 32
1011
end
1112

1213
it 'is base32 charset' do
13-
expect(base32).to match(/\A[a-z2-7]+\z/)
14+
expect(base32).to match(/\A[A-Z2-7]+\z/)
1415
end
1516
end
1617

1718
context 'with arguments' do
18-
let(:base32) { ROTP::Base32.random_base32 32 }
19+
let(:base32) { ROTP::Base32.random 48 }
1920

20-
it 'allows a specific length' do
21-
expect(base32.length).to eq 32
21+
it 'returns the appropriate byte length code' do
22+
expect(ROTP::Base32.decode(base32).length).to eq 48
2223
end
2324
end
2425
end
@@ -33,22 +34,40 @@
3334

3435
context 'valid input data' do
3536
it 'correctly decodes a string' do
36-
expect(ROTP::Base32.decode('F').unpack('H*').first).to eq '28'
37-
expect(ROTP::Base32.decode('23').unpack('H*').first).to eq 'd6'
38-
expect(ROTP::Base32.decode('234').unpack('H*').first).to eq 'd6f8'
39-
expect(ROTP::Base32.decode('234A').unpack('H*').first).to eq 'd6f800'
40-
expect(ROTP::Base32.decode('234B').unpack('H*').first).to eq 'd6f810'
41-
expect(ROTP::Base32.decode('234BCD').unpack('H*').first).to eq 'd6f8110c'
42-
expect(ROTP::Base32.decode('234BCDE').unpack('H*').first).to eq 'd6f8110c80'
43-
expect(ROTP::Base32.decode('234BCDEFG').unpack('H*').first).to eq 'd6f8110c8530'
44-
expect(ROTP::Base32.decode('234BCDEFG234BCDEFG').unpack('H*').first).to eq 'd6f8110c8536b7c0886429'
37+
expect(ROTP::Base32.decode('2EB7C66WC5TSO').unpack('H*').first).to eq 'd103f17bd6176727'
38+
expect(ROTP::Base32.decode('Y6Y5ZCAC7NABCHSJ').unpack('H*').first).to eq 'c7b1dc8802fb40111e49'
39+
end
40+
41+
it 'correctly decode strings with trailing bits (not a multiple of 8)' do
42+
# Dropbox style 26 characters (26*5==130 bits or 16.25 bytes, but chopped to 128)
43+
# Matches the behavior of Google Authenticator, drops extra 2 empty bits
44+
expect(ROTP::Base32.decode('YVT6Z2XF4BQJNBMTD7M6QBQCEM').unpack('H*').first).to eq 'c567eceae5e0609685931fd9e8060223'
45+
46+
# For completeness, test all the possibilities allowed by Google Authenticator
47+
# Drop the incomplete empty extra 4 bits (28*5==140bits or 17.5 bytes, chopped to 136 bits)
48+
expect(ROTP::Base32.decode('5GGZQB3WN6LD7V3L5HPDYTQUANEQ').unpack('H*').first).to eq 'e98d9807766f963fd76be9de3c4e140349'
49+
4550
end
4651

4752
context 'with padding' do
4853
it 'correctly decodes a string' do
49-
expect(ROTP::Base32.decode('F==').unpack('H*').first).to eq '28'
54+
expect(ROTP::Base32.decode('234A===').unpack('H*').first).to eq 'd6f8'
5055
end
5156
end
57+
5258
end
5359
end
60+
61+
describe '.encode' do
62+
context 'encode input data' do
63+
it 'correctly encodes data' do
64+
expect(ROTP::Base32.encode(hex_to_bin('3c204da94294ff82103ee34e96f74b48'))).to eq 'HQQE3KKCST7YEEB64NHJN52LJA'
65+
end
66+
end
67+
end
68+
69+
end
70+
71+
def hex_to_bin(s)
72+
s.scan(/../).map { |x| x.hex }.pack('c*')
5473
end

0 commit comments

Comments
 (0)