BenFenner

BenFenner

Most performant way to search a file for a string?

I’m still working on password validation, and part of that is making sure a user doesn’t try to use a common password. I’ve downloaded a list of common passwords.

I’m trying to compare the user’s desired password to the text file containing 1 million common passwords (one password per line).

The code below works great, and is relatively fast. I’m wondering if I can get it to perform any better though? Preferably without loading the entire file into memory at once, but if that will give a large performance boost, I could be convinced…

  #
  # Password rarity is checked against the largest list of common passwords
  # found here: https://github.com/danielmiessler/SecLists/tree/master/Passwords/Common-Credentials
  #
  defp validate_password_rarity(changeset, field) do
    proposed_password = get_field(changeset, field)
    proposed_password = "#{proposed_password}\n" # Append line break here to avoid needing to remove 1M line breaks from the stream.

    common_password_stream = File.stream!(Path.join(:code.priv_dir(:my_app), "static/10-million-password-list-top-1,000,000.txt"))
    |> Stream.filter(fn common_password -> proposed_password == common_password end)
    |> Enum.to_list

    if length(common_password_stream) == 0 do
      changeset
    else
      add_error(changeset, field, "The given password is too common. Please choose a less common password.")
    end
  end

Marked As Solved

idi527

idi527

You’d probably want to create the ets table right after the app starts.

In your application.ex:

# in start/2
children = [
  BadPasswords,
  # ...
]
# ...
defmodule BadPasswords do
  use GenServer

  @table __MODULE__

  @spec contains?(String.t()) :: boolean
  def contains?(password) do
    :ets.member(@table, password)
  end

  @doc false  
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts)
  end

  @doc false
  def init(_opts) do
    @table = :ets.new(@table, [:named_table])
    send(self(), :populate)
    {:ok, []}
  end

  @doc false
  def handle_info(:populate, state) do
    :code.priv_dir(:rums)
    |> Path.join("static/10-million-password-list-top-1,000,000.txt")
    |> File.stream!()
    |> Enum.each(fn common_password ->
      # note that you can also do batch inserts
      :ets.insert(@table, {String.trim(common_password)})
    end)

    {:noreply, state}
  end
end
# in your changeset validation:
defp validate_password_rarity(changeset, field) do
  proposed_password = get_field(changeset, field)
  if BadPassowrds.contains?(proposed_password) do
    add_error(changeset, field, "The given password is too common. Please choose a less common password.")
  else
    changeset
  end
end

But I don’t think this is what Joe suggested … You might want to hash the strings before inserting them into ets.

Also Liked

joeerl

joeerl

Creator of Erlang - Fondly Remembered

Why don’t you want all the password entries in memory - 1M entries is probably only a few MB’s and most machines have GBytes of memory.

If all the entries are not in memory then getting the file content into your program will be an I/O bottleneck.

If you do this on a modern machine the OS will cache the files anyway, so they will be in memory - but it would be better to do build you own hash table that repeated thash the I/O system and garbage collector.

Put the password entries in a hash table (say ets) and do a hash lookup no search is involved - this is O(1) and independent of the number of entries in the table.

And yes the hash table takes space (a few MB) but not using a hash table takes space (in memory buffers in the OS) and puts repeated pressure on the I/O and GC

The best way is to read the entire file atomically, split it into lines and inject them into a resident hash table.

I guess I’ve assumed that you want to check passwords often and read the big table infrequently. If you only onto to check one password every few hours then it’s irrelevant how you do it since anything you write will be sufficiently fast.

If you want to check thousands of passwords per second then you must build an in-memory hash table.

It all depends upon what you want to do

23
Post #3
joeerl

joeerl

Creator of Erlang - Fondly Remembered

I’ve just tried sqlite on 1M passwords (not 10M) and can now directly compare with ets.

Name       Build (sec)   FileSize (MB)  Lookup Time (micro sec) 
ets        0.68          24             0.455 
sqlite3    719           41             53

Build is the time to build the ets table (or sqlite3) database the FileSize is the on-disk size of the ets-table/database Time is the lookup time (microseconds)

I suspect that the differences are due to the fact that ets is in memory and I understand what I’m doing, whereas the sqlite table is on disk and I haven’t a clue what I’m doing - it’s my first attempt at using sqlite so I’m probably doing it all wrong. Even if sqlite is in memory for the lookups there are an awful lot of round-trips between Erlang and sqlite and there is a semantic mismatch between the internal representations of data in Erlang and sqlite and conversion always takes time.

aside: First time I got sqlite working as well, so I’m quite pleased.

It’s basically (in SQL)

 create table test (pwd text primary key); 
 insert into test values ('password1);
 insert into test values ('password2);
 insert into test values ('password3);
  ...
 insert into test values ('password1000000');

then to lookup

 select * from test where pwd='something'

Seems to work - but rather slow.

What is more disturbing is the memory usage - 30 MB for 1M passwords would indicate 300MB for 10M passwords - now 300MB is getting to the point where I worry about space efficiency. So while a hash table is fastest it’s not space efficient - I suspect that bloom filters might be the way to go.

joeerl

joeerl

Creator of Erlang - Fondly Remembered

Just for fun I did this in Erlang - the code and arguments are pretty much the same for Elixir

First I read the password file, split it into lines and insert into an ets table and dump the ets table. Note: these file-at-a-time operations are by far the most efficient way to get data into and out of the system.

build() ->
    {ok, B} = file:read_file("10-million-password-list-top-1000000.txt"),
    Parts = binary:split(B,<<"\n">>,[global]),
    Tab = ets:new(passwords,[]),
    [ets:insert(Tab,{I,[]}) || I<- Parts],
    ets:tab2file(Tab, "passwords.tab").

It doesn’t matter how long this takes since I only do it once.

To check a password we read the table back into memory and do a lookup

lookup(Password) ->    
    {ok, Tab} = ets:file2tab("passwords.tmp"),
    case ets:lookup(Tab, Password) of
    [] -> false;
    _  -> true
    end.

I can’t think of a faster way to do this.

Now for some timings - I measured the time to do ets:file2tab this was 0.75 seconds
I timed the lookups by looking up every password in the list - this took 0.4 microseconds per lookup. (this is on a 3.1GHz Intel Core i7 macbook pro) so you could check 2.2 Million passwords/second.

Reading the password file takes 0.75 seconds so the “read file” + single check could do 4800 passwords per hour and you’d be transferring about 40 GBytes of data per hour into your program - That might be acceptable - it depends what else you are doing. It will use a fair bit of CPU reading the ets file.

Keeping your ets table in memory costs virtually nothing (in terms of GC) - you don’t need a gen server to access it since ets tables are shared anyway.

alukasz

alukasz

Hi, I had similar problem and after some benchmarking I found out that the fastest way for me was to store sorted data in ETS and use binary search. Here is an example https://gist.github.com/alukasz/7dcf1dc097e321565c372dc0de5ea1fe

Name                          ips        average  deviation         median         99th %
ETS + binary search       24.14 K      0.0414 ms    ±15.77%      0.0400 ms      0.0690 ms
stream + filter         0.00162 K      616.14 ms     ±5.05%      622.06 ms      641.31 ms

Comparison: 
ETS + binary search       24.14 K
stream + filter         0.00162 K - 14876.58x slower
ckampfe

ckampfe

I mentioned Postgres being a reasonable choice for a broad variety of problems, so I figured I’d back it up. Results first:

# these benchmarks made with Benchee

clark$> PORT=4000 MIX_ENV=prod mix run bench.exs
mixwarning: found quoted keyword "test" but the quotes are not required. Note that keywords are always atoms, even when quoted, and quotes should only be used to introduce keywords with foreign characters in them
  mix.exs:58

Operating System: macOS"
CPU Information: Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz
Number of Available Cores: 12
Available memory: 16 GB
Elixir 1.7.3
Erlang 21.1

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 μs
parallel: 1
inputs: none specified
Estimated total run time: 14 s


Benchmarking check password ecto query builder...
Benchmarking check password postgrex prepared...

Name                                        ips        average  deviation         median         99th %
check password postgrex prepared        10.12 K       98.79 μs    ±10.25%          96 μs         139 μs
check password ecto query builder        8.47 K      118.04 μs    ±11.38%         114 μs         169 μs

Comparison: 
check password postgrex prepared        10.12 K
check password ecto query builder        8.47 K - 1.19x slower
warning: unused import Ecto.Query
  bench.exs:1

Next, benchmark setup:

import Ecto.Query                                                                                       
                                                                                                        
query_prepared = "SELECT id FROM passwords WHERE password = $1"                                         
                                                                                                        
{:ok, _pid} =                                                                                           
  Postgrex.start_link(hostname: "localhost", username: "clark", password: "", database: "passtest_dev", 
pool: DBConnection.Poolboy, pool_size: 20, name: DB)                                                    
                                                                                                        
{:ok, prepared} = Postgrex.prepare(DB, "get_password", query_prepared, pool: DBConnection.Poolboy)      
                                                                                                        
Benchee.run(%{                                                                                          
  "check password ecto query builder" => fn ->                                                          
    Ecto.Query.select("passwords", [p], p.id)                                                           
    |> Ecto.Query.where([p], p.password == "austin")                                                    
    |> Passtest.Repo.one!                                                                               
  end,                                                                                                  
  "check password postgrex prepared" => fn ->                                                           
     Postgrex.execute!(DB, prepared, ["austin"], pool: DBConnection.Poolboy)                            
  end                                                                                                   
})

I generated a standard phoenix app, created a database, and then ran this migration:

defmodule Passtest.Repo.Migrations.CreatePasswords do                                                   
  use Ecto.Migration                                                                                    
                                                                                                        
  def change do                                                                                         
    create table("passwords") do                                                                        
      add :password, :string, size: 50                                                                  
    end                                                                                                 
                                                                                                        
    create index("passwords", [:password])                                                              
  end                                                                                                   
end  

The data file I used was the 1M passwords one from the link posted earlier in the thread:

clark$> wc -l ~/code/pass.txt
999999 /Users/clark/code/pass.txt

clark$> head -10 ~/code/pass.txt
123456
password
12345678
qwerty
123456789
12345
1234
111111
1234567
dragon

To be transparent, the above is with BEAM and the Postgres server instance running on my local machine. This decreases latency vs what you would experience in a production setting where databases and app servers live on their own physical/virtual hardware separated by at least one network hop. Even so, starting from a baseline 99%ile of 139 μs is encouraging. For something that isn’t a hot loop, 139 μs is blisteringly low latency. Especially something where network time is going to dominate computation time by one or two orders of magnitude.

I’m going to paraphrase a comment I read elsewhere that was put forward in a similar context: performance is rarely about what is theoretically possible. It’s almost always a function of what you can achieve with the time and resources you have available. Further, it’s a function of what is necessary for the situation. With enough time, money, and enough smart people, could we theoretically optimize this down to the fewest possible machine instructions and the least number of main memory hits? We could. Is that useful? I doubt it. The performance of common password checking is never going to be the thing that makes or sinks your product or application. For this problem, it’s likely that there is a threshold that when hit is “fast enough”, and anything else is unnoticeable by the end user and/or adds code or architectural complexity wildly disproportionate to its benefits.

Getting these passwords into Postgres and queryable by Ecto took about 15 minutes total from my first reading of the post. As I’m already going to be using both in my webapp, I add almost no code and zero dependencies I wasn’t already using. Then, I’ll deploy and monitor. Then, optimize. I hope this is useful to understanding my approach.

Where Next?

Popular in Questions Top

bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
jerry
Good day to you all. I have been struggling to get a query involving like and ilike to work. Can anyone assist me on this, please? pro...
New
itssasanka
Hi all, Trying to get some more clarity over utc_datetime and naive_datetime for Ecto: https://hexdocs.pm/ecto/Ecto.Schema.html#module-...
New
Werner
Hi, I’m using Ubuntu 18.04 and after updating to OTP-24.0 yesterday i have this warning when I run “mix local.hex”: 14:57:30.512 [warn] ...
New
dokuzbir
Hello, I am trying to convert my lists to string without losing brackets.For start i have 3 map. They look like these buyer = %{ id: ...
New
script
If I have a string “1000 cfu/ml” . I want to remove the characters and / and space . So the string is like this "1000" What is the ...
New
makeitrein
Hey all, just started picking up Elixir last week and am writing a scraper as a learning project. Baby step #1 is extracting the number ...
New
fayddelight
I tried installing elixir 1.11.2 erlang 23.3.4 via asdf in my zsh shell. Enabled the versions locally and globally. When I list them ...
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New
sabri
Can someone explain the settings of pool_size of Ecto in config file? and what is the recommend size? Thanks
New

Other popular topics Top

JDanielMartinez
Hi! May someone helps me, please! I have two apps into an umbrella project: the first one is Database, which manages queries, and the se...
New
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
ycv005
I have followed this StackOverflow post to install the specific version of Erlang. And When I am running mix ecto.setup then getting fol...
New
malloryerik
Hi, this is for people who, like me, have had some friction using .html.heex templates in VSCode. The solution seems to be, in a hyphena...
New
Jim
As a follow up to my earlier question: I have the code compiling and running but not getting a successful login from the rest server. ...
New
nsuchy
Hi. I’ve noticed that Windows Powershell has it’s own IEX command and you cannot access Elixir’s IEX due to the conflict. This isn’t a cr...
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 27727 240
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
lucidguppy
I have a super simple question about elixir - how would I take a file like this foo bar baz and output a new file that enumerates th...
New

We're in Beta

About us Mission Statement