tep 0.11.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- metadata +264 -0
data/lib/tep/parallel.rb
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Tep::Parallel -- grosser/parallel-shaped process fan-out.
|
|
2
|
+
#
|
|
3
|
+
# Why
|
|
4
|
+
# ---
|
|
5
|
+
# Spinel doesn't ship Ractors, doesn't expose the GVL'd threading
|
|
6
|
+
# story, and the `parallel` gem (heavy use of `Marshal`,
|
|
7
|
+
# `IO.pipe`, dynamic `Proc` invocation) doesn't lower. Fork is
|
|
8
|
+
# however a perfectly cheap C call here, so the smallest useful
|
|
9
|
+
# slice of `parallel` -- "run this worker over a list of items,
|
|
10
|
+
# one child per item, collect the results" -- is implementable
|
|
11
|
+
# directly on top of sphttp's `sphttp_fork` + a tiny file-based
|
|
12
|
+
# IPC channel.
|
|
13
|
+
#
|
|
14
|
+
# API
|
|
15
|
+
# ---
|
|
16
|
+
# results = Tep::Parallel.map_processes(items, worker)
|
|
17
|
+
# #=> [String, String, ...] -- one entry per input, in order
|
|
18
|
+
#
|
|
19
|
+
# `worker.run(item)` must return a String. Each child runs the
|
|
20
|
+
# worker once, writes its return value to a per-index file under
|
|
21
|
+
# /tmp, exits; the parent reaps everyone and reads the files
|
|
22
|
+
# back. The String constraint exists because passing structured
|
|
23
|
+
# data across fork would need Marshal, which spinel doesn't
|
|
24
|
+
# emit -- and HTTP-shaped APIs (the dashboard) round-trip
|
|
25
|
+
# strings naturally.
|
|
26
|
+
#
|
|
27
|
+
# Fire-and-forget shape:
|
|
28
|
+
#
|
|
29
|
+
# Tep::Parallel.each_process(items, worker)
|
|
30
|
+
#
|
|
31
|
+
# Forks one child per item, doesn't capture results.
|
|
32
|
+
#
|
|
33
|
+
# Scope (v1)
|
|
34
|
+
# ----------
|
|
35
|
+
# * One child per item -- no fixed-size pool. Fine up to a few
|
|
36
|
+
# dozen items; for larger fan-outs the caller should chunk
|
|
37
|
+
# beforehand or write the round-trip into Tep::Job.
|
|
38
|
+
# * String return values only.
|
|
39
|
+
# * No thread mode -- spinel doesn't lower MRI's Thread reliably.
|
|
40
|
+
#
|
|
41
|
+
# Closeness to grosser/parallel
|
|
42
|
+
# -----------------------------
|
|
43
|
+
# `parallel`'s top-level API is
|
|
44
|
+
#
|
|
45
|
+
# Parallel.map(items, in_processes: N) { |x| ... }
|
|
46
|
+
#
|
|
47
|
+
# spinel can't take a block as a value, so we lift the body into
|
|
48
|
+
# a Worker class instead. Spinel also can't auto-cast subclass
|
|
49
|
+
# pointers at cmeth call sites (#429-shaped), which means cmeth
|
|
50
|
+
# args typed as a worker base class widen to poly at the call
|
|
51
|
+
# site and the C compile fails. The fix: store the worker in an
|
|
52
|
+
# instance field of `Tep::Parallel` -- typed-slot imeth dispatch
|
|
53
|
+
# works the same way `@before_filter.before(req, res)` does for
|
|
54
|
+
# `Tep::Filter`. Resulting shape:
|
|
55
|
+
#
|
|
56
|
+
# p = Tep::Parallel.new(MyWorker.new)
|
|
57
|
+
# results = p.map_processes(items)
|
|
58
|
+
#
|
|
59
|
+
# Worker base class
|
|
60
|
+
# -----------------
|
|
61
|
+
# Real workers subclass `Tep::ParallelWorker` and override `run(item)`.
|
|
62
|
+
# Two spinel landings made this name viable: matz/spinel#531 (270eceb)
|
|
63
|
+
# narrowed the poly-receiver dispatch table by ivar observed-class set
|
|
64
|
+
# (so `Tep::Server#run` no longer leaks into `@worker.run`'s switch),
|
|
65
|
+
# and matz/spinel#549 (1d561ad) collapsed the dispatch result to a
|
|
66
|
+
# scalar when all reachable arms agree on the return type (so the
|
|
67
|
+
# result lands as `const char *` instead of sp_RbVal).
|
|
68
|
+
module Tep
|
|
69
|
+
# Base class for Tep::Parallel workers. Override `run(item)` in
|
|
70
|
+
# subclasses; the default emits "" so a base-class instance used
|
|
71
|
+
# for seeding stays type-safe.
|
|
72
|
+
class ParallelWorker
|
|
73
|
+
def run(item)
|
|
74
|
+
""
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Parallel
|
|
79
|
+
attr_accessor :worker
|
|
80
|
+
|
|
81
|
+
def initialize(worker)
|
|
82
|
+
@worker = worker
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Result-collecting fan-out. Returns an Array of Strings in
|
|
86
|
+
# input order; one fork per item. See module doc for the
|
|
87
|
+
# constraints (Strings only, no fixed pool).
|
|
88
|
+
def map_processes(items)
|
|
89
|
+
job_dir = Parallel.scratch_dir
|
|
90
|
+
Tep::Shell.run("mkdir -p " + job_dir)
|
|
91
|
+
|
|
92
|
+
n = items.length
|
|
93
|
+
i = 0
|
|
94
|
+
while i < n
|
|
95
|
+
# Pull each fork into its own stack frame -- spinel's
|
|
96
|
+
# codegen for the in-line fork-and-exec pattern was
|
|
97
|
+
# observed to share locals across the parent loop and
|
|
98
|
+
# the child body, so all children ended up processing
|
|
99
|
+
# the same (last) item. Method-call boundary gives each
|
|
100
|
+
# child a clean local snapshot.
|
|
101
|
+
spawn_one(items[i], i, job_dir)
|
|
102
|
+
i += 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
reaped = 0
|
|
106
|
+
while reaped < n
|
|
107
|
+
Sock.sphttp_wait_any
|
|
108
|
+
reaped += 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
out = [""]
|
|
112
|
+
out.delete_at(0)
|
|
113
|
+
k = 0
|
|
114
|
+
while k < n
|
|
115
|
+
out.push(Tep::Shell.read(job_dir + "/" + k.to_s))
|
|
116
|
+
k += 1
|
|
117
|
+
end
|
|
118
|
+
Tep::Shell.run("rm -rf " + job_dir)
|
|
119
|
+
out
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Fork one child to process `item`. When `job_dir` is non-empty,
|
|
123
|
+
# the child writes the worker's String result to `job_dir/idx`
|
|
124
|
+
# (consumed by map_processes); otherwise the result is discarded
|
|
125
|
+
# (fire-and-forget shape used by each_process). Returns the child
|
|
126
|
+
# pid in the parent; the child never returns (exits when done).
|
|
127
|
+
#
|
|
128
|
+
# The method-call boundary is load-bearing: an inline fork-and-
|
|
129
|
+
# exec loop body shared locals across iterations under spinel's
|
|
130
|
+
# codegen, so every child processed the same (last) item. A
|
|
131
|
+
# separate def gives each fork a clean local frame.
|
|
132
|
+
def spawn_one(item, idx, job_dir)
|
|
133
|
+
pid = Sock.sphttp_fork
|
|
134
|
+
if pid == 0
|
|
135
|
+
result = @worker.run(item)
|
|
136
|
+
if job_dir.length > 0
|
|
137
|
+
path = job_dir + "/" + idx.to_s
|
|
138
|
+
File.write(path, result)
|
|
139
|
+
end
|
|
140
|
+
Sock.sphttp_exit(0)
|
|
141
|
+
end
|
|
142
|
+
pid
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Fire-and-forget version. Returns 0 once every child exits.
|
|
146
|
+
def each_process(items)
|
|
147
|
+
n = items.length
|
|
148
|
+
i = 0
|
|
149
|
+
while i < n
|
|
150
|
+
spawn_one(items[i], 0, "")
|
|
151
|
+
i += 1
|
|
152
|
+
end
|
|
153
|
+
reaped = 0
|
|
154
|
+
while reaped < n
|
|
155
|
+
Sock.sphttp_wait_any
|
|
156
|
+
reaped += 1
|
|
157
|
+
end
|
|
158
|
+
0
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Per-invocation scratch directory. Uses pid + monotonic
|
|
162
|
+
# timestamp so concurrent map_processes calls in different
|
|
163
|
+
# workers don't trample each other.
|
|
164
|
+
def self.scratch_dir
|
|
165
|
+
"/tmp/tep_par_" + Sock.sphttp_getpid.to_s + "_" + Time.now.to_i.to_s
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/tep/parser.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# HTTP/1.x request parser. Produces a Tep::Request from the raw
|
|
2
|
+
# byte blob the C helper read off the wire (headers, possibly a
|
|
3
|
+
# prefix of the body).
|
|
4
|
+
module Tep
|
|
5
|
+
class Parser
|
|
6
|
+
# Returns a fully-populated Request, or nil if the blob is malformed.
|
|
7
|
+
def self.parse(blob)
|
|
8
|
+
# Note: Spinel's String#index returns -1 (not nil) when not found.
|
|
9
|
+
end_of_headers = Tep.str_find(blob, "\r\n\r\n", 0)
|
|
10
|
+
if end_of_headers < 0
|
|
11
|
+
return nil
|
|
12
|
+
end
|
|
13
|
+
headers_blob = blob[0, end_of_headers]
|
|
14
|
+
lines = headers_blob.split("\r\n")
|
|
15
|
+
if lines.length == 0
|
|
16
|
+
return nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
first = lines[0]
|
|
20
|
+
first_parts = first.split(" ")
|
|
21
|
+
if first_parts.length < 3
|
|
22
|
+
return nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
req = Request.new
|
|
26
|
+
req.verb = first_parts[0]
|
|
27
|
+
req.raw_path = first_parts[1]
|
|
28
|
+
req.http_version = first_parts[2]
|
|
29
|
+
|
|
30
|
+
qmark = Tep.str_find(req.raw_path, "?", 0)
|
|
31
|
+
if qmark < 0
|
|
32
|
+
req.path = req.raw_path
|
|
33
|
+
else
|
|
34
|
+
req.path = req.raw_path[0, qmark]
|
|
35
|
+
qstring = req.raw_path[qmark + 1, req.raw_path.length - qmark - 1]
|
|
36
|
+
req.query = Url.parse_query(qstring)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
i = 1
|
|
40
|
+
while i < lines.length
|
|
41
|
+
line = lines[i]
|
|
42
|
+
colon = Tep.str_find(line, ":", 0)
|
|
43
|
+
if colon >= 0
|
|
44
|
+
name = line[0, colon].downcase
|
|
45
|
+
value = line[colon + 1, line.length - colon - 1].strip
|
|
46
|
+
req.req_headers[name] = value
|
|
47
|
+
end
|
|
48
|
+
i += 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Pre-merge query into params; path captures will be folded in
|
|
52
|
+
# by the router on a successful match.
|
|
53
|
+
req.query.each do |k, v|
|
|
54
|
+
req.params[k] = v
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parse Cookie header into req.cookies. Format: "k=v; k2=v2; ...".
|
|
58
|
+
# Whitespace around `;` is allowed and stripped.
|
|
59
|
+
cookie_blob = req.req_headers["cookie"]
|
|
60
|
+
if cookie_blob.length > 0
|
|
61
|
+
cookie_blob.split(";").each do |pair|
|
|
62
|
+
eq = Tep.str_find(pair, "=", 0)
|
|
63
|
+
if eq > 0
|
|
64
|
+
cname = pair[0, eq].strip
|
|
65
|
+
cvalue = pair[eq + 1, pair.length - eq - 1].strip
|
|
66
|
+
req.cookies[cname] = Url.unescape(cvalue)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Carry over any body bytes already in the blob (the C helper
|
|
72
|
+
# may have read more than just the headers in one recv()).
|
|
73
|
+
body_start = end_of_headers + 4
|
|
74
|
+
if body_start < blob.length
|
|
75
|
+
req.raw_body = blob[body_start, blob.length - body_start]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
req
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/tep/password.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Tep::Password -- password hashing for spinel-AOT'd apps.
|
|
2
|
+
#
|
|
3
|
+
# Uses PBKDF2-HMAC-SHA256 with a 16-byte CSPRNG salt and a default
|
|
4
|
+
# of 200,000 iterations -- sits in the OWASP-recommended ballpark
|
|
5
|
+
# (200k for SHA256 as of 2023). Backed by a small C helper in
|
|
6
|
+
# tep_crypto.c; no libcrypt / OpenSSL / bcrypt-gem dependency.
|
|
7
|
+
#
|
|
8
|
+
# Why PBKDF2 instead of bcrypt?
|
|
9
|
+
# -----------------------------
|
|
10
|
+
# Bcrypt is the textbook choice but its canonical impls are either
|
|
11
|
+
# the system `crypt(3)` (not portable: macOS doesn't ship $2b$, Linux
|
|
12
|
+
# needs libxcrypt) or the bcrypt-ruby gem (a CRuby C extension that
|
|
13
|
+
# spinel can't load). PBKDF2-SHA256 is in NIST SP 800-132 and OWASP
|
|
14
|
+
# acceptable, builds on the HMAC-SHA256 we already ship for the
|
|
15
|
+
# session store, and adds zero new system dependencies.
|
|
16
|
+
#
|
|
17
|
+
# scrypt / argon2 would be stronger but require linking libsodium or
|
|
18
|
+
# vendoring ~2k lines of C. Defer until callers need them.
|
|
19
|
+
#
|
|
20
|
+
# Format
|
|
21
|
+
# ------
|
|
22
|
+
# Stored hash is `pbkdf2-sha256$<iters>$<salt_b64>$<derived_b64>`.
|
|
23
|
+
# All segments are base64url, no padding. Self-describing so a
|
|
24
|
+
# future rotation to higher iter counts (or a different scheme) can
|
|
25
|
+
# coexist with old hashes -- `verify` honours the embedded iter
|
|
26
|
+
# count.
|
|
27
|
+
#
|
|
28
|
+
# Usage
|
|
29
|
+
# -----
|
|
30
|
+
#
|
|
31
|
+
# stored = Tep::Password.hash("user-input")
|
|
32
|
+
# # store `stored` in the DB
|
|
33
|
+
#
|
|
34
|
+
# # On login:
|
|
35
|
+
# if Tep::Password.verify("user-input", stored)
|
|
36
|
+
# # session.set("uid", row_id)
|
|
37
|
+
# end
|
|
38
|
+
module Tep
|
|
39
|
+
class Password
|
|
40
|
+
DEFAULT_ITERS = 200000
|
|
41
|
+
SALT_BYTES = 16
|
|
42
|
+
|
|
43
|
+
# Derive a stored hash from a plain password. Generates a
|
|
44
|
+
# fresh CSPRNG salt and runs PBKDF2-SHA256 at the default
|
|
45
|
+
# iter count. Returns the self-describing storage string.
|
|
46
|
+
# Named `hash` to match the bcrypt-gem-style factory shape.
|
|
47
|
+
def self.hash(plain)
|
|
48
|
+
salt = Crypto.sp_crypto_random_b64url(SALT_BYTES)
|
|
49
|
+
derived = Crypto.sp_crypto_pbkdf2_sha256_b64url(plain, salt, DEFAULT_ITERS)
|
|
50
|
+
"pbkdf2-sha256$" + DEFAULT_ITERS.to_s + "$" + salt + "$" + derived
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Verify `plain` against a stored hash. Re-runs PBKDF2 with the
|
|
54
|
+
# same salt + iter count embedded in the stored string and
|
|
55
|
+
# constant-time compares. Rejects malformed stored hashes by
|
|
56
|
+
# returning false.
|
|
57
|
+
def self.verify(plain, stored)
|
|
58
|
+
parts = Password.split4(stored)
|
|
59
|
+
if parts[0] != "pbkdf2-sha256"
|
|
60
|
+
return false
|
|
61
|
+
end
|
|
62
|
+
iters_s = parts[1]
|
|
63
|
+
salt = parts[2]
|
|
64
|
+
derived = parts[3]
|
|
65
|
+
if iters_s.length == 0 || salt.length == 0 || derived.length == 0
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
iters = iters_s.to_i
|
|
69
|
+
if iters < 1
|
|
70
|
+
return false
|
|
71
|
+
end
|
|
72
|
+
candidate = Crypto.sp_crypto_pbkdf2_sha256_b64url(plain, salt, iters)
|
|
73
|
+
Tep::Jwt.timing_safe_eq(candidate, derived)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Split a 4-segment "$"-delimited stored hash into its four
|
|
77
|
+
# parts. spinel's `String#split` exists but its behaviour on
|
|
78
|
+
# complex inputs has tripped us before; the explicit walker
|
|
79
|
+
# is small and obviously correct.
|
|
80
|
+
def self.split4(s)
|
|
81
|
+
out = ["", "", "", ""]
|
|
82
|
+
n = s.length
|
|
83
|
+
seg = 0
|
|
84
|
+
start = 0
|
|
85
|
+
i = 0
|
|
86
|
+
while i < n
|
|
87
|
+
if s[i] == "$"
|
|
88
|
+
if seg < 4
|
|
89
|
+
out[seg] = s[start, i - start]
|
|
90
|
+
end
|
|
91
|
+
seg += 1
|
|
92
|
+
start = i + 1
|
|
93
|
+
end
|
|
94
|
+
i += 1
|
|
95
|
+
end
|
|
96
|
+
if seg < 4
|
|
97
|
+
out[seg] = s[start, n - start]
|
|
98
|
+
end
|
|
99
|
+
out
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|