v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f192c26fa2
commit
9ab8c356d2
60 files changed
+6791
-747
No files matched your search
+88
-27
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/core"
|
||||
"logwisp/internal/plugin"
|
||||
@@ -75,6 +77,7 @@ type HTTPSink struct {
|
||||
|
||||
// Authorization
|
||||
auth *authz.Policy
|
||||
web map[string]http.Handler // proxy mode: GET /auth/... browser files
|
||||
|
||||
// Runtime
|
||||
done chan struct{}
|
||||
@@ -126,6 +129,11 @@ func NewHTTPSinkPlugin(
|
||||
if opts.StreamPath == opts.StatusPath {
|
||||
return nil, fmt.Errorf("stream_path and status_path must differ")
|
||||
}
|
||||
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
|
||||
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
|
||||
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
|
||||
}
|
||||
}
|
||||
if opts.BufferSize <= 0 {
|
||||
opts.BufferSize = DefaultHTTPBufferSize
|
||||
}
|
||||
@@ -136,10 +144,22 @@ func NewHTTPSinkPlugin(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
|
||||
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.HTTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
|
||||
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
|
||||
case opts.ViewerPage && !opts.LoginPage:
|
||||
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
|
||||
}
|
||||
var web map[string]http.Handler
|
||||
if authPolicy.BehindProxy() {
|
||||
if web, err = webHandlers(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
h := &HTTPSink{
|
||||
id: id,
|
||||
@@ -154,6 +174,7 @@ func NewHTTPSinkPlugin(
|
||||
keepalive: core.StreamKeepaliveInterval,
|
||||
tlsConfig: tlsCfg,
|
||||
auth: authPolicy,
|
||||
web: web,
|
||||
}
|
||||
h.lastProcessed.Store(time.Time{})
|
||||
|
||||
@@ -166,7 +187,9 @@ func NewHTTPSinkPlugin(
|
||||
"status_path", opts.StatusPath,
|
||||
"tls", tlsCfg != nil,
|
||||
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
|
||||
"auth", authPolicy.Describe())
|
||||
"auth", authPolicy.Describe(),
|
||||
"login_page", opts.LoginPage,
|
||||
"viewer_page", opts.ViewerPage)
|
||||
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
|
||||
authPolicy.LogStartup(logger, "http_sink", id, false)
|
||||
return h, nil
|
||||
@@ -178,6 +201,9 @@ func (h *HTTPSink) Capabilities() []core.Capability {
|
||||
if h.tlsConfig != nil {
|
||||
caps = append(caps, core.CapTLS)
|
||||
}
|
||||
if h.auth.BehindProxy() {
|
||||
caps = append(caps, core.CapProxyTLS)
|
||||
}
|
||||
if h.auth.Enabled() {
|
||||
caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
|
||||
}
|
||||
@@ -204,6 +230,10 @@ func (h *HTTPSink) Start(ctx context.Context) error {
|
||||
// serve owns an already-bound listener, allowing tests to reserve an ephemeral
|
||||
// port and exercise the same routing and worker lifecycle as Start.
|
||||
func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
|
||||
if err := h.auth.Start(); err != nil {
|
||||
ln.Close()
|
||||
return err
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
|
||||
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
|
||||
@@ -214,10 +244,20 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
|
||||
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
|
||||
|
||||
// One wrapper covers stream and status, and keeps the handlers themselves
|
||||
// unaware of authorization
|
||||
// unaware of authorization. Login and its browser files sit outside it;
|
||||
// in proxy mode everything sits behind the proxy gate.
|
||||
var handler http.Handler = mux
|
||||
if h.auth.Enabled() {
|
||||
handler = h.authMiddleware(handler)
|
||||
outer := http.NewServeMux()
|
||||
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
|
||||
for p, file := range h.web {
|
||||
outer.Handle(http.MethodGet+" "+p, file)
|
||||
}
|
||||
outer.Handle("/", h.authMiddleware(mux))
|
||||
handler = outer
|
||||
if h.auth.BehindProxy() {
|
||||
handler = h.proxyGate(outer)
|
||||
}
|
||||
}
|
||||
|
||||
h.server = &http.Server{
|
||||
@@ -271,6 +311,7 @@ func (h *HTTPSink) Stop() {
|
||||
|
||||
h.shutdown()
|
||||
h.wg.Wait()
|
||||
h.auth.Close()
|
||||
|
||||
h.logger.Info("msg", " HTTP sink stopped",
|
||||
"component", "http_sink",
|
||||
@@ -358,7 +399,7 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
remote := r.RemoteAddr
|
||||
remote := clientAddr(r)
|
||||
|
||||
meta := map[string]any{
|
||||
"type": "http_client",
|
||||
@@ -549,34 +590,53 @@ func (h *HTTPSink) GetStats() sink.SinkStats {
|
||||
// handlers; absent when auth is disabled
|
||||
type identityKey struct{}
|
||||
|
||||
// authMiddleware gates every endpoint on the client certificate policy.
|
||||
// The rejection carries no detail: the status endpoint already exposes host,
|
||||
// port, and throughput counters, so a 403 should not add the shape of the
|
||||
// policy on top of that.
|
||||
// authMiddleware gates every endpoint on the policy: a client certificate
|
||||
// or a bearer token. The rejection carries no detail: the status endpoint
|
||||
// already exposes host, port, and throughput counters, so a refusal should
|
||||
// not add the shape of the policy on top of that.
|
||||
func (h *HTTPSink) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ident, err := h.auth.Authorize(r.TLS)
|
||||
ident, status, err := h.auth.AuthorizeRequest(r)
|
||||
if err != nil {
|
||||
h.logger.Warn("msg", "Request rejected by auth policy",
|
||||
"component", "http_sink",
|
||||
"instance_id", h.id,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"remote_addr", clientAddr(r),
|
||||
"path", r.URL.Path,
|
||||
"error", err)
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
authz.Refuse(w, status)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, ident)))
|
||||
})
|
||||
}
|
||||
|
||||
// handleAuth runs one step of a SCRAM login and logs its outcome
|
||||
func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
ident, err := h.auth.ServeAuth(w, r)
|
||||
switch {
|
||||
case err != nil:
|
||||
h.logger.Warn("msg", "Login rejected",
|
||||
"component", "http_sink",
|
||||
"instance_id", h.id,
|
||||
"remote_addr", clientAddr(r),
|
||||
"error", err)
|
||||
case ident.Name != "":
|
||||
h.logger.Info("msg", "Login accepted",
|
||||
"component", "http_sink",
|
||||
"instance_id", h.id,
|
||||
"remote_addr", clientAddr(r),
|
||||
"auth_identity", ident.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
|
||||
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
// writeSSE frames a payload per the W3C SSE spec (multi-line safe)
|
||||
// writeSSE frames a payload per the W3C SSE spec, one data: line per line
|
||||
func writeSSE(w http.ResponseWriter, payload []byte) error {
|
||||
for _, line := range splitLines(payload) {
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
|
||||
@@ -587,27 +647,28 @@ func writeSSE(w http.ResponseWriter, payload []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// splitLines splits payload by newlines, trimming a single trailing newline
|
||||
// splitLines splits on every break SSE recognises (CRLF, LF and a lone CR),
|
||||
// so no payload byte can end a data: line and start an event: or retry:
|
||||
// field. One trailing break is dropped.
|
||||
func splitLines(data []byte) [][]byte {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if data[len(data)-1] == '\n' {
|
||||
if bytes.HasSuffix(data, []byte("\r\n")) {
|
||||
data = data[:len(data)-2]
|
||||
} else if data[len(data)-1] == '\n' || data[len(data)-1] == '\r' {
|
||||
data = data[:len(data)-1]
|
||||
}
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i] == '\n' {
|
||||
lines = append(lines, data[start:i])
|
||||
start = i + 1
|
||||
for {
|
||||
i := bytes.IndexAny(data, "\r\n")
|
||||
if i < 0 {
|
||||
return append(lines, data)
|
||||
}
|
||||
lines = append(lines, data[:i])
|
||||
if data[i] == '\r' && i+1 < len(data) && data[i+1] == '\n' {
|
||||
i++
|
||||
}
|
||||
data = data[i+1:]
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, data[start:])
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return [][]byte{data}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -2,18 +2,26 @@ package http
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/core"
|
||||
"logwisp/internal/sink"
|
||||
"logwisp/internal/testutil"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
"github.com/lixenwraith/log"
|
||||
)
|
||||
|
||||
func TestStatusReportsQueueAndConnectionBounds(t *testing.T) {
|
||||
@@ -169,3 +177,145 @@ func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Under scram the login endpoint sits outside the gate it opens: a hello
|
||||
// earns a challenge without a token, while stream and status demand one.
|
||||
func TestLoginEndpointBypassesTheGate(t *testing.T) {
|
||||
pki := testutil.NewPKI(t, "viewer-01")
|
||||
creds := scramCredentials(t)
|
||||
gated, _ := newTestHTTPSink(t, map[string]any{
|
||||
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
|
||||
"auth": map[string]any{"type": "scram", "credentials_file": creds},
|
||||
})
|
||||
client, baseURL := serveTestHTTPSink(t, gated)
|
||||
caPEM, err := os.ReadFile(pki.CA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
roots.AppendCertsFromPEM(caPEM)
|
||||
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}
|
||||
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
|
||||
|
||||
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
|
||||
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var step struct {
|
||||
Challenge *auth.ServerFirstMessage `json:"challenge"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&step)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || step.Challenge == nil {
|
||||
t.Fatalf("POST /auth without a token = %d, challenge %v", resp.StatusCode, step.Challenge)
|
||||
}
|
||||
for _, path := range []string{"/status", "/stream"} {
|
||||
resp, err := client.Get(baseURL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("GET %s without a token = %d, want 401", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxied sends what a TLS-terminating proxy would: the client and https
|
||||
func proxied(req *http.Request) *http.Request {
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.7")
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
return req
|
||||
}
|
||||
|
||||
func proxySink(t *testing.T, proxies []any, overrides map[string]any) *HTTPSink {
|
||||
t.Helper()
|
||||
opts := map[string]any{"auth": map[string]any{"type": "scram", "credentials_file": scramCredentials(t), "trusted_proxies": proxies}}
|
||||
maps.Copy(opts, overrides)
|
||||
h, _ := newTestHTTPSink(t, opts)
|
||||
return h
|
||||
}
|
||||
|
||||
// Behind a proxy the sink serves the client library and the enabled pages
|
||||
// under /auth/, each with its type and the pages' CSP; the viewer learns a
|
||||
// custom status path from its meta tag.
|
||||
func TestProxyModeServesBrowserFiles(t *testing.T) {
|
||||
h := proxySink(t, []any{"127.0.0.1"}, map[string]any{"login_page": true, "viewer_page": true, "status_path": "/api/status"})
|
||||
if !slices.Contains(h.Capabilities(), core.CapProxyTLS) {
|
||||
t.Fatal("a proxy-mode sink does not report proxy_tls")
|
||||
}
|
||||
client, baseURL := serveTestHTTPSink(t, h)
|
||||
for file, ctype := range map[string]string{
|
||||
"scram.js": "text/javascript", "login": "text/html", "login.js": "text/javascript",
|
||||
"style.css": "text/css", "view": "text/html", "view.js": "text/javascript",
|
||||
} {
|
||||
req, _ := http.NewRequest(http.MethodGet, baseURL+"/auth/"+file, nil)
|
||||
resp, err := client.Do(proxied(req))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), ctype) ||
|
||||
!strings.Contains(resp.Header.Get("Content-Security-Policy"), "script-src 'self'") {
|
||||
t.Errorf("/auth/%s: %d %q %q", file, resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Security-Policy"))
|
||||
}
|
||||
if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) {
|
||||
t.Error("the viewer page does not carry the custom status path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a proxy nothing answers a peer outside trusted_proxies, not even the
|
||||
// login page or the challenge.
|
||||
func TestProxyModeRefusesDirectPeers(t *testing.T) {
|
||||
h := proxySink(t, []any{"192.0.2.1"}, map[string]any{"login_page": true})
|
||||
client, baseURL := serveTestHTTPSink(t, h)
|
||||
for _, target := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/auth/login"}, {http.MethodGet, "/status"}, {http.MethodPost, "/auth"},
|
||||
} {
|
||||
req, _ := http.NewRequest(target.method, baseURL+target.path, strings.NewReader(`{"logwisp":1}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(proxied(req))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s from an untrusted peer: %d, want 403", target.method, target.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pages exist only behind a proxy, where browsers can log in; the viewer
|
||||
// needs the login page; /auth and the paths under it are reserved.
|
||||
func TestPagesNeedProxyMode(t *testing.T) {
|
||||
scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
|
||||
pki := testutil.NewPKI(t, "viewer-01")
|
||||
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
|
||||
proxy := map[string]any{"type": "scram", "credentials_file": scram["credentials_file"], "trusted_proxies": []any{"127.0.0.1"}}
|
||||
for name, opts := range map[string]map[string]any{
|
||||
"login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
|
||||
"viewer without login": {"auth": proxy, "viewer_page": true},
|
||||
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"},
|
||||
} {
|
||||
maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
|
||||
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every line break SSE recognises, a lone CR included, starts another data:
|
||||
// line: an entry cannot inject an event:, retry: or id: field.
|
||||
func TestSSEFramesEveryLineAsData(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
if err := writeSSE(rec, []byte("a\revent: disconnect\r\nretry: 99999999\nid: x\r")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "data: a\ndata: event: disconnect\ndata: retry: 99999999\ndata: id: x\n\n"
|
||||
if got := rec.Body.String(); got != want {
|
||||
t.Fatalf("framed %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import (
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/session"
|
||||
"logwisp/internal/testutil"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
"github.com/lixenwraith/log"
|
||||
)
|
||||
|
||||
@@ -46,3 +50,20 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
|
||||
t.Cleanup(client.CloseIdleConnections)
|
||||
return client, "http://" + listener.Addr().String()
|
||||
}
|
||||
|
||||
// scramCredentials writes a credentials file holding viewer-01 under a cheap
|
||||
// Argon2 profile
|
||||
func scramCredentials(t *testing.T) string {
|
||||
t.Helper()
|
||||
cred, err := auth.NewCredential("viewer-01", "viewer-01-secret", auth.WithTime(1), auth.WithMemory(64), auth.WithThreads(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := (&authz.Credentials{DecoyKey: make([]byte, 32), Users: []*auth.Credential{cred}}).Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "users.toml")
|
||||
testutil.WriteFile(t, path, string(data))
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"html"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"logwisp/internal/authz"
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
)
|
||||
|
||||
//go:embed web/*.js web/*.html web/*.css
|
||||
var webFiles embed.FS
|
||||
|
||||
// pageCSP confines the pages to their own scripts and styles: no inline code,
|
||||
// no other origin, no framing
|
||||
const pageCSP = "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; " +
|
||||
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
||||
|
||||
var webTypes = map[string]string{
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
}
|
||||
|
||||
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage }
|
||||
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage }
|
||||
|
||||
// webRoutes are served under /auth/ in proxy mode: the client library always,
|
||||
// so a site can load it, and the pages when enabled
|
||||
var webRoutes = []struct {
|
||||
name, file string
|
||||
on func(*config.HTTPSinkOptions) bool
|
||||
}{
|
||||
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }},
|
||||
{"style.css", "style.css", loginOn},
|
||||
{"login", "login.html", loginOn},
|
||||
{"login.js", "login.js", loginOn},
|
||||
{"view", "view.html", viewerOn},
|
||||
{"view.js", "view.js", viewerOn},
|
||||
}
|
||||
|
||||
// webHandlers maps each enabled GET path to its file. The viewer learns a
|
||||
// custom status path from a meta tag, as the CSP allows no inline script.
|
||||
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
|
||||
handlers := make(map[string]http.Handler)
|
||||
for _, r := range webRoutes {
|
||||
if !r.on(o) {
|
||||
continue
|
||||
}
|
||||
data, err := webFiles.ReadFile("web/" + r.file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.file == "view.html" {
|
||||
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/"))
|
||||
data = bytes.Replace(data, []byte(`content="status"`), []byte(`content="`+status+`"`), 1)
|
||||
}
|
||||
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
|
||||
}
|
||||
return handlers, nil
|
||||
}
|
||||
|
||||
func serveWebFile(data []byte, contentType string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hd := w.Header()
|
||||
hd.Set("Content-Type", contentType)
|
||||
hd.Set("Content-Security-Policy", pageCSP)
|
||||
hd.Set("X-Content-Type-Options", "nosniff")
|
||||
hd.Set("Referrer-Policy", "no-referrer")
|
||||
hd.Set("Cache-Control", "no-cache")
|
||||
w.Write(data)
|
||||
})
|
||||
}
|
||||
|
||||
// clientKey carries the forwarded client address from proxyGate
|
||||
type clientKey struct{}
|
||||
|
||||
// proxyGate admits only the trusted proxies, and records the client they
|
||||
// forward for logs and sessions. ServeAuth and AuthorizeRequest check again.
|
||||
func (h *HTTPSink) proxyGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
addr, err := h.auth.ClientAddr(r)
|
||||
if err != nil {
|
||||
h.logger.Warn("msg", "Request refused: not through a trusted proxy over https",
|
||||
"component", "http_sink",
|
||||
"instance_id", h.id,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"path", r.URL.Path,
|
||||
"error", err)
|
||||
authz.Refuse(w, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientKey{}, addr)))
|
||||
})
|
||||
}
|
||||
|
||||
// clientAddr names the client in logs and sessions: the forwarded client in
|
||||
// proxy mode, else the socket peer
|
||||
func clientAddr(r *http.Request) string {
|
||||
if addr, ok := r.Context().Value(clientKey{}).(string); ok {
|
||||
return addr
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LogWisp sign-in</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="login.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>LogWisp</h1>
|
||||
<form id="login" method="post">
|
||||
<label>Username <input name="username" autocomplete="username" required></label>
|
||||
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit" disabled>Sign in</button>
|
||||
</form>
|
||||
<progress id="progress" max="1" hidden></progress>
|
||||
<p id="status" role="status"></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { login } from "./scram.js";
|
||||
|
||||
const base = new URL("..", location.href);
|
||||
const form = document.getElementById("login");
|
||||
const button = form.querySelector("button");
|
||||
const progress = document.getElementById("progress");
|
||||
const status = document.getElementById("status");
|
||||
|
||||
// Same origin only: an open redirect would lend this page to phishing
|
||||
function nextTarget() {
|
||||
const next = new URLSearchParams(location.search).get("next");
|
||||
if (!next) return null;
|
||||
try {
|
||||
const url = new URL(next, location.href);
|
||||
return url.origin === location.origin ? url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const fields = new FormData(form);
|
||||
button.disabled = true;
|
||||
progress.removeAttribute("value");
|
||||
progress.hidden = false;
|
||||
status.textContent = "Deriving the key, this takes a few seconds…";
|
||||
try {
|
||||
const session = await login(base, fields.get("username"), fields.get("password"), {
|
||||
onProgress: (fraction) => { progress.value = fraction; },
|
||||
});
|
||||
form.elements.password.value = "";
|
||||
const target = nextTarget();
|
||||
if (target) {
|
||||
location.replace(target.href);
|
||||
return;
|
||||
}
|
||||
status.textContent = `Signed in as ${session.username}.`;
|
||||
} catch (err) {
|
||||
status.textContent = err.message;
|
||||
} finally {
|
||||
progress.hidden = true;
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Enabled only once this script runs, so a failed load cannot submit the form natively
|
||||
button.disabled = false;
|
||||
@@ -0,0 +1,484 @@
|
||||
const MAX_USERNAME = 256;
|
||||
const MAX_PASSWORD = 1024;
|
||||
const MAX_FULL_NONCE = 512;
|
||||
const MIN_SALT = 16;
|
||||
const MAX_SALT = 64;
|
||||
// lixenwraith/auth's budget for an Argon2 profile it did not choose
|
||||
const MAX_TIME = 16;
|
||||
const MAX_MEMORY = 256 * 1024;
|
||||
const MAX_THREADS = 16;
|
||||
const MAX_WORK = 4 * 64 * 1024 * 3;
|
||||
// The Go dialer's floor: a hostile server must not obtain a cheaply guessable proof
|
||||
const MIN_TIME = 3;
|
||||
const MIN_MEMORY = 64 * 1024;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Logs in to the logwisp http sink mounted at base (a URL ending in "/"), per
|
||||
* the wire protocol in logwisp's doc/scram-auth-plan.md. The session arrives as
|
||||
* an HttpOnly cookie; the promise resolves only once the server has proved it
|
||||
* holds this user's verifier. onProgress(fraction) follows the Argon2 pass.
|
||||
*/
|
||||
export async function login(base, username, password, { onProgress } = {}) {
|
||||
const pw = checkCredentials(username, password);
|
||||
subtle(); // an insecure page fails before anything is sent
|
||||
const url = new URL("auth", base);
|
||||
const clientNonce = base64(crypto.getRandomValues(new Uint8Array(24)))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_");
|
||||
const first = await post(url, { logwisp: 1, scram: { username, client_nonce: clientNonce } });
|
||||
const { proof, serverSignature } = await prove(username, pw, clientNonce, first.challenge,
|
||||
MIN_TIME, MIN_MEMORY, onProgress);
|
||||
const step = await post(url, { proof, session: "cookie" });
|
||||
const final = step.final;
|
||||
if (!final || typeof final !== "object") {
|
||||
throw new Error("server sent no final message");
|
||||
}
|
||||
const name = final.username ?? "";
|
||||
const signature = decodeBase64(final.server_signature, 32);
|
||||
if ((name !== "" && name !== username) || !signature || !equal(signature, serverSignature)) {
|
||||
throw new Error("server could not prove it holds this user's verifier");
|
||||
}
|
||||
const expiresIn = Number.isSafeInteger(step.expires_in) && step.expires_in > 0 ? step.expires_in : 0;
|
||||
return { username, expiresIn };
|
||||
}
|
||||
|
||||
// Logout posts to auth itself: a clearing cookie from any other path would
|
||||
// get another default path and miss the session cookie
|
||||
export async function logout(base) {
|
||||
const res = await send(new URL("auth", base), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ logout: true }),
|
||||
});
|
||||
if (res.status !== 200 && res.status !== 204) {
|
||||
throw new Error(`logout failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook: the proof for a fixed client nonce, under an explicit Argon2 floor
|
||||
export async function _proofForTest(username, password, clientNonce, challenge, minTime, minMemory) {
|
||||
const { proof, serverSignature } = await prove(username, checkCredentials(username, password),
|
||||
clientNonce, challenge, minTime, minMemory);
|
||||
return { client_proof: proof.client_proof, server_signature: base64(serverSignature) };
|
||||
}
|
||||
|
||||
function checkCredentials(username, password) {
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
throw new Error("username and password must be text");
|
||||
}
|
||||
const name = encoder.encode(username);
|
||||
if (name.length === 0 || name.length > MAX_USERNAME || /[,=\p{Cc}\p{Cs}]/u.test(username)) {
|
||||
throw new Error("invalid username");
|
||||
}
|
||||
// TextEncoder would silently turn a lone surrogate into U+FFFD
|
||||
if (/\p{Cs}/u.test(password)) {
|
||||
throw new Error("password contains invalid characters");
|
||||
}
|
||||
const pw = encoder.encode(password);
|
||||
if (pw.length === 0 || pw.length > MAX_PASSWORD) {
|
||||
throw new Error(`password must be 1-${MAX_PASSWORD} bytes`);
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
|
||||
// Everything the challenge says is checked before Argon2 runs on it
|
||||
async function prove(username, pw, clientNonce, challenge, minTime, minMemory, onProgress) {
|
||||
if (!challenge || typeof challenge !== "object") {
|
||||
throw new Error("server sent no challenge");
|
||||
}
|
||||
const { full_nonce: fullNonce, salt, argon_time: t, argon_memory: m, argon_threads: p } = challenge;
|
||||
if (typeof fullNonce !== "string" || fullNonce.length > MAX_FULL_NONCE || !/^[\x21-\x2b\x2d-\x7e]+$/.test(fullNonce) ||
|
||||
!fullNonce.startsWith(clientNonce) || fullNonce.length <= clientNonce.length) {
|
||||
throw new Error("invalid server nonce");
|
||||
}
|
||||
const saltBytes = decodeBase64(salt, MAX_SALT);
|
||||
if (!saltBytes || saltBytes.length < MIN_SALT) {
|
||||
throw new Error("invalid salt");
|
||||
}
|
||||
if (![t, m, p].every(Number.isSafeInteger) || t < 1 || p < 1 || m < 8 * p ||
|
||||
t > MAX_TIME || m > MAX_MEMORY || p > MAX_THREADS || m * t > MAX_WORK) {
|
||||
throw new Error("invalid Argon2 parameters");
|
||||
}
|
||||
if (t < minTime || m < minMemory) {
|
||||
throw new Error("server asked for a weaker Argon2 cost than allowed");
|
||||
}
|
||||
const salted = await argon2id(pw, saltBytes, t, m, p, 32, { onProgress });
|
||||
const clientKey = await hmac(salted, "Client Key");
|
||||
const serverKey = await hmac(salted, "Server Key");
|
||||
const storedKey = new Uint8Array(await subtle().digest("SHA-256", clientKey));
|
||||
salted.fill(0);
|
||||
// Unbound: the browser cannot see the certificate, so no c= part
|
||||
const authMessage = `u=${username},n=${clientNonce},r=${fullNonce},s=${salt},t=${t},m=${m},p=${p},r=${fullNonce}`;
|
||||
const proof = await hmac(storedKey, authMessage);
|
||||
for (let i = 0; i < proof.length; i++) proof[i] ^= clientKey[i];
|
||||
clientKey.fill(0);
|
||||
return {
|
||||
proof: { full_nonce: fullNonce, client_proof: base64(proof) },
|
||||
serverSignature: await hmac(serverKey, authMessage),
|
||||
};
|
||||
}
|
||||
|
||||
async function post(url, body) {
|
||||
const res = await send(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const step = (await res.json().catch(() => null)) ?? {};
|
||||
if (res.status === 404 || res.status === 405) {
|
||||
throw new Error("no SCRAM login at this address");
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`login refused: ${typeof step.error === "string" ? step.error : res.status}`);
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
// A redirect would replay the proof to wherever it points
|
||||
async function send(url, init) {
|
||||
try {
|
||||
return await fetch(url, { ...init, credentials: "same-origin", cache: "no-store", redirect: "error" });
|
||||
} catch {
|
||||
throw new Error(`cannot reach ${url.pathname}`);
|
||||
}
|
||||
}
|
||||
|
||||
function subtle() {
|
||||
const s = globalThis.crypto?.subtle;
|
||||
if (!s) throw new Error("WebCrypto unavailable: the page must be served over HTTPS");
|
||||
return s;
|
||||
}
|
||||
|
||||
async function hmac(key, message) {
|
||||
const k = await subtle().importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
||||
return new Uint8Array(await subtle().sign("HMAC", k, encoder.encode(message)));
|
||||
}
|
||||
|
||||
function equal(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let d = 0;
|
||||
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
|
||||
return d === 0;
|
||||
}
|
||||
|
||||
function base64(bytes) {
|
||||
let s = "";
|
||||
for (const b of bytes) s += String.fromCharCode(b);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
// Strict like Go's base64.StdEncoding.Strict(): the round trip rejects stray padding bits
|
||||
function decodeBase64(s, maxBytes) {
|
||||
if (typeof s !== "string" || s.length > 4 * Math.ceil(maxBytes / 3) ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
|
||||
return null;
|
||||
}
|
||||
const out = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
return out.length <= maxBytes && base64(out) === s ? out : null;
|
||||
}
|
||||
|
||||
// --- Argon2id (RFC 9106, version 0x13) ---
|
||||
// Uint64s are (lo, hi) pairs of uint32 words; a 1 KiB block is 256 words.
|
||||
|
||||
const BLAKE2B_IV = new Uint32Array([
|
||||
0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a,
|
||||
0xade682d1, 0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19,
|
||||
]);
|
||||
|
||||
const SIGMA = [
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
|
||||
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
|
||||
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
|
||||
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
|
||||
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
|
||||
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
|
||||
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
|
||||
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
|
||||
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
|
||||
].map((row) => row.map((i) => 2 * i));
|
||||
|
||||
/**
|
||||
* Argon2id over a password (string or bytes) and salt, as Uint8Array of keyLen
|
||||
* bytes. Yields to the event loop every ~50 ms, so a page stays responsive.
|
||||
*/
|
||||
export async function argon2id(password, salt, time, memoryKiB, threads, keyLen,
|
||||
{ secret = new Uint8Array(0), data = new Uint8Array(0), onProgress } = {}) {
|
||||
const pw = typeof password === "string" ? encoder.encode(password) : password;
|
||||
if (![time, memoryKiB, threads, keyLen].every(Number.isSafeInteger) || time < 1 || threads < 1 ||
|
||||
threads > 0xffffff || memoryKiB < 8 * threads || memoryKiB > 0xffffffff || keyLen < 4 || keyLen > 0xffffffff) {
|
||||
throw new Error("invalid Argon2 parameters");
|
||||
}
|
||||
const h0 = new Uint8Array(72);
|
||||
h0.set(blake2b(concat(le32(threads), le32(keyLen), le32(memoryKiB), le32(time), le32(0x13), le32(2),
|
||||
le32(pw.length), pw, le32(salt.length), salt, le32(secret.length), secret, le32(data.length), data), 64));
|
||||
|
||||
const laneLen = Math.floor(memoryKiB / (4 * threads)) * 4;
|
||||
const st = {
|
||||
mem: new Uint32Array(laneLen * threads * 256), lanes: threads, laneLen, segLen: laneLen / 4,
|
||||
blocks: laneLen * threads, time, r: new Uint32Array(256), q: new Uint32Array(256),
|
||||
input: new Uint32Array(256), addr: new Uint32Array(256),
|
||||
};
|
||||
for (let lane = 0; lane < threads; lane++) {
|
||||
h0.set(le32(lane), 68);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
h0.set(le32(i), 64);
|
||||
const block = hprime(h0, 1024);
|
||||
for (let w = 0; w < 256; w++) st.mem[(lane * laneLen + i) * 256 + w] = readLE32(block, 4 * w);
|
||||
}
|
||||
}
|
||||
let done = 0, yielded = Date.now();
|
||||
for (let pass = 0; pass < time; pass++) {
|
||||
for (let slice = 0; slice < 4; slice++) {
|
||||
for (let lane = 0; lane < threads; lane++) {
|
||||
fillSegment(st, pass, slice, lane);
|
||||
done++;
|
||||
if (Date.now() - yielded > 50) {
|
||||
onProgress?.(done / (time * 4 * threads));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
yielded = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const last = st.mem.slice((laneLen - 1) * 256, laneLen * 256);
|
||||
for (let lane = 1; lane < threads; lane++) {
|
||||
const o = (lane * laneLen + laneLen - 1) * 256;
|
||||
for (let w = 0; w < 256; w++) last[w] ^= st.mem[o + w];
|
||||
}
|
||||
const bytes = new Uint8Array(1024);
|
||||
for (let w = 0; w < 256; w++) writeLE32(bytes, 4 * w, last[w]);
|
||||
return hprime(bytes, keyLen);
|
||||
}
|
||||
|
||||
function fillSegment(st, pass, slice, lane) {
|
||||
const { mem, lanes, laneLen, segLen, r, q, input, addr } = st;
|
||||
const independent = pass === 0 && slice < 2;
|
||||
let index = pass === 0 && slice === 0 ? 2 : 0;
|
||||
if (independent) {
|
||||
input.fill(0);
|
||||
input[0] = pass;
|
||||
input[2] = lane;
|
||||
input[4] = slice;
|
||||
input[6] = st.blocks;
|
||||
input[8] = st.time;
|
||||
input[10] = 2;
|
||||
if (index !== 0) nextAddresses(input, addr, q);
|
||||
}
|
||||
for (let cur = lane * laneLen + slice * segLen + index; index < segLen; index++, cur++) {
|
||||
const prev = index === 0 && slice === 0 ? cur + laneLen - 1 : cur - 1;
|
||||
let j1, j2;
|
||||
if (independent) {
|
||||
if (index % 128 === 0) nextAddresses(input, addr, q);
|
||||
j1 = addr[2 * (index % 128)];
|
||||
j2 = addr[2 * (index % 128) + 1];
|
||||
} else {
|
||||
j1 = mem[prev * 256];
|
||||
j2 = mem[prev * 256 + 1];
|
||||
}
|
||||
const refLane = pass === 0 && slice === 0 ? lane : j2 % lanes;
|
||||
const same = refLane === lane;
|
||||
let area, start = 0;
|
||||
if (pass === 0) {
|
||||
area = slice * segLen + (slice === 0 || same ? index : 0);
|
||||
} else {
|
||||
area = 3 * segLen + (same ? index : 0);
|
||||
start = ((slice + 1) % 4) * segLen;
|
||||
}
|
||||
if (index === 0 || same) area--;
|
||||
const ref = refLane * laneLen + (start + area - 1 - mulHi(mulHi(j1, j1), area)) % laneLen;
|
||||
|
||||
const c = cur * 256, x = prev * 256, y = ref * 256;
|
||||
for (let i = 0; i < 256; i++) r[i] = mem[x + i] ^ mem[y + i];
|
||||
q.set(r);
|
||||
permute(q);
|
||||
for (let i = 0; i < 256; i++) mem[c + i] ^= r[i] ^ q[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Data-independent addressing: two compressions of the counter block against zero
|
||||
function nextAddresses(input, addr, q) {
|
||||
input[12]++;
|
||||
for (const src of [input, addr]) {
|
||||
q.set(src);
|
||||
permute(q);
|
||||
for (let i = 0; i < 256; i++) addr[i] = src[i] ^ q[i];
|
||||
}
|
||||
}
|
||||
|
||||
function permute(v) {
|
||||
for (let x = 0; x < 256; x += 32) {
|
||||
blamka(v, x, x + 8, x + 16, x + 24);
|
||||
blamka(v, x + 2, x + 10, x + 18, x + 26);
|
||||
blamka(v, x + 4, x + 12, x + 20, x + 28);
|
||||
blamka(v, x + 6, x + 14, x + 22, x + 30);
|
||||
blamka(v, x, x + 10, x + 20, x + 30);
|
||||
blamka(v, x + 2, x + 12, x + 22, x + 24);
|
||||
blamka(v, x + 4, x + 14, x + 16, x + 26);
|
||||
blamka(v, x + 6, x + 8, x + 18, x + 28);
|
||||
}
|
||||
for (let w = 0; w < 32; w += 4) {
|
||||
blamka(v, w, w + 64, w + 128, w + 192);
|
||||
blamka(v, w + 2, w + 66, w + 130, w + 194);
|
||||
blamka(v, w + 32, w + 96, w + 160, w + 224);
|
||||
blamka(v, w + 34, w + 98, w + 162, w + 226);
|
||||
blamka(v, w, w + 66, w + 160, w + 226);
|
||||
blamka(v, w + 2, w + 96, w + 162, w + 192);
|
||||
blamka(v, w + 32, w + 98, w + 128, w + 194);
|
||||
blamka(v, w + 34, w + 64, w + 130, w + 224);
|
||||
}
|
||||
}
|
||||
|
||||
// G of Argon2's P on four uint64s, in locals; the products are split so
|
||||
// every intermediate stays exact in a double
|
||||
function blamka(v, a, b, c, d) {
|
||||
let al = v[a], ah = v[a + 1], bl = v[b], bh = v[b + 1];
|
||||
let cl = v[c], ch = v[c + 1], dl = v[d], dh = v[d + 1];
|
||||
let pl, ph, s, xl, xh;
|
||||
|
||||
pl = Math.imul(al, bl); ph = mulHi(al, bl);
|
||||
s = al + bl + ((pl << 1) >>> 0);
|
||||
ah = (ah + bh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; al = s >>> 0;
|
||||
xl = dl ^ al; dl = (dh ^ ah) >>> 0; dh = xl >>> 0;
|
||||
|
||||
pl = Math.imul(cl, dl); ph = mulHi(cl, dl);
|
||||
s = cl + dl + ((pl << 1) >>> 0);
|
||||
ch = (ch + dh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; cl = s >>> 0;
|
||||
xl = bl ^ cl; xh = bh ^ ch; bl = ((xl >>> 24) | (xh << 8)) >>> 0; bh = ((xh >>> 24) | (xl << 8)) >>> 0;
|
||||
|
||||
pl = Math.imul(al, bl); ph = mulHi(al, bl);
|
||||
s = al + bl + ((pl << 1) >>> 0);
|
||||
ah = (ah + bh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; al = s >>> 0;
|
||||
xl = dl ^ al; xh = dh ^ ah; dl = ((xl >>> 16) | (xh << 16)) >>> 0; dh = ((xh >>> 16) | (xl << 16)) >>> 0;
|
||||
|
||||
pl = Math.imul(cl, dl); ph = mulHi(cl, dl);
|
||||
s = cl + dl + ((pl << 1) >>> 0);
|
||||
ch = (ch + dh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; cl = s >>> 0;
|
||||
xl = bl ^ cl; xh = bh ^ ch; bl = (xl << 1) | (xh >>> 31); bh = (xh << 1) | (xl >>> 31);
|
||||
|
||||
v[a] = al; v[a + 1] = ah; v[b] = bl; v[b + 1] = bh;
|
||||
v[c] = cl; v[c + 1] = ch; v[d] = dl; v[d + 1] = dh;
|
||||
}
|
||||
|
||||
// High word of the 64-bit product of two uint32s
|
||||
function mulHi(a, b) {
|
||||
const a0 = a & 0xffff, a1 = a >>> 16, b0 = b & 0xffff, b1 = b >>> 16;
|
||||
const m01 = a0 * b1, m10 = a1 * b0;
|
||||
const carry = ((a0 * b0) >>> 16) + (m01 & 0xffff) + (m10 & 0xffff);
|
||||
return a1 * b1 + (m01 >>> 16) + (m10 >>> 16) + (carry >>> 16);
|
||||
}
|
||||
|
||||
function xorRotr(v, d, s, n) {
|
||||
const lo = v[d] ^ v[s], hi = v[d + 1] ^ v[s + 1];
|
||||
if (n === 32) {
|
||||
v[d] = hi;
|
||||
v[d + 1] = lo;
|
||||
} else if (n === 63) {
|
||||
v[d] = (lo << 1) | (hi >>> 31);
|
||||
v[d + 1] = (hi << 1) | (lo >>> 31);
|
||||
} else {
|
||||
v[d] = (lo >>> n) | (hi << (32 - n));
|
||||
v[d + 1] = (hi >>> n) | (lo << (32 - n));
|
||||
}
|
||||
}
|
||||
|
||||
// H' of RFC 9106: BLAKE2b stretched to any length
|
||||
function hprime(input, outLen) {
|
||||
const x = concat(le32(outLen), input);
|
||||
if (outLen <= 64) return blake2b(x, outLen);
|
||||
const out = new Uint8Array(outLen);
|
||||
let v = blake2b(x, 64), pos = 32;
|
||||
out.set(v.subarray(0, 32));
|
||||
for (; outLen - pos > 64; pos += 32) {
|
||||
v = blake2b(v, 64);
|
||||
out.set(v.subarray(0, 32), pos);
|
||||
}
|
||||
out.set(blake2b(v, outLen - pos), pos);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Unkeyed BLAKE2b (RFC 7693) of a short input
|
||||
function blake2b(input, outLen) {
|
||||
const h = BLAKE2B_IV.slice();
|
||||
h[0] ^= 0x01010000 ^ outLen;
|
||||
const v = new Uint32Array(32), m = new Uint32Array(32), block = new Uint8Array(128);
|
||||
const blocks = Math.max(1, Math.ceil(input.length / 128));
|
||||
for (let b = 0; b < blocks; b++) {
|
||||
const last = b === blocks - 1;
|
||||
block.fill(0);
|
||||
block.set(input.subarray(b * 128, b * 128 + 128));
|
||||
for (let w = 0; w < 32; w++) m[w] = readLE32(block, 4 * w);
|
||||
const count = last ? input.length : (b + 1) * 128;
|
||||
v.set(h);
|
||||
v.set(BLAKE2B_IV, 16);
|
||||
v[24] ^= count;
|
||||
v[25] ^= count / 4294967296;
|
||||
if (last) {
|
||||
v[28] = ~v[28];
|
||||
v[29] = ~v[29];
|
||||
}
|
||||
for (let round = 0; round < 12; round++) {
|
||||
const s = SIGMA[round % 10];
|
||||
b2mix(v, m, 0, 8, 16, 24, s[0], s[1]);
|
||||
b2mix(v, m, 2, 10, 18, 26, s[2], s[3]);
|
||||
b2mix(v, m, 4, 12, 20, 28, s[4], s[5]);
|
||||
b2mix(v, m, 6, 14, 22, 30, s[6], s[7]);
|
||||
b2mix(v, m, 0, 10, 20, 30, s[8], s[9]);
|
||||
b2mix(v, m, 2, 12, 22, 24, s[10], s[11]);
|
||||
b2mix(v, m, 4, 14, 16, 26, s[12], s[13]);
|
||||
b2mix(v, m, 6, 8, 18, 28, s[14], s[15]);
|
||||
}
|
||||
for (let w = 0; w < 16; w++) h[w] ^= v[w] ^ v[w + 16];
|
||||
}
|
||||
const out = new Uint8Array(64);
|
||||
for (let w = 0; w < 16; w++) writeLE32(out, 4 * w, h[w]);
|
||||
return out.slice(0, outLen);
|
||||
}
|
||||
|
||||
function b2mix(v, m, a, b, c, d, x, y) {
|
||||
add64(v, a, v, b);
|
||||
add64(v, a, m, x);
|
||||
xorRotr(v, d, a, 32);
|
||||
add64(v, c, v, d);
|
||||
xorRotr(v, b, c, 24);
|
||||
add64(v, a, v, b);
|
||||
add64(v, a, m, y);
|
||||
xorRotr(v, d, a, 16);
|
||||
add64(v, c, v, d);
|
||||
xorRotr(v, b, c, 63);
|
||||
}
|
||||
|
||||
function add64(v, x, w, y) {
|
||||
const lo = v[x] + w[y];
|
||||
v[x + 1] = v[x + 1] + w[y + 1] + (lo > 0xffffffff ? 1 : 0);
|
||||
v[x] = lo;
|
||||
}
|
||||
|
||||
function concat(...parts) {
|
||||
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
||||
let o = 0;
|
||||
for (const p of parts) {
|
||||
out.set(p, o);
|
||||
o += p.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function le32(n) {
|
||||
const b = new Uint8Array(4);
|
||||
writeLE32(b, 0, n);
|
||||
return b;
|
||||
}
|
||||
|
||||
function readLE32(b, i) {
|
||||
return (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0;
|
||||
}
|
||||
|
||||
function writeLE32(b, i, n) {
|
||||
b[i] = n;
|
||||
b[i + 1] = n >>> 8;
|
||||
b[i + 2] = n >>> 16;
|
||||
b[i + 3] = n >>> 24;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { argon2id, _proofForTest } from "./scram.js";
|
||||
|
||||
const hex = (b) => Buffer.from(b).toString("hex");
|
||||
const unhex = (s) => new Uint8Array(Buffer.from(s, "hex"));
|
||||
|
||||
test("Argon2id matches RFC 9106 section 5.3", async () => {
|
||||
const tag = await argon2id(new Uint8Array(32).fill(1), new Uint8Array(16).fill(2), 3, 32, 4, 32,
|
||||
{ secret: new Uint8Array(8).fill(3), data: new Uint8Array(12).fill(4) });
|
||||
assert.equal(hex(tag), "0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659");
|
||||
});
|
||||
|
||||
// From golang.org/x/crypto/argon2.IDKey; m=1020 and m=37 exercise lane rounding,
|
||||
// keyLen 100 the long form of H'.
|
||||
test("Argon2id matches Go's argon2.IDKey", async () => {
|
||||
const vectors = [
|
||||
{ pw: "70617373776f7264", salt: "736f6d6573616c74", t: 1, m: 8, p: 1, keyLen: 32, want: "f137f8e186a403a679ccd0606e5ab5dcdafe43c1640855ac8c6e33e9bd63eeb3" },
|
||||
{ pw: "", salt: "01080f161d242b323940474e555c636a", t: 2, m: 64, p: 2, keyLen: 32, want: "044879f042c4211727f27afa4e8fd18f95c199373cd03402f1f3dc4f07622d28" },
|
||||
{ pw: "030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dce3eaf1f8ff060d141b222930373e454c535a61686f767d848b9299a0a7aeb5bcc3cad1d8dfe6edf4fb020910171e252c333a41484f565d646b727980878e959ca3aab1b8", salt: "050c131a21282f363d444b525960676e757c838a91989fa6adb4bbc2c9d0d7de", t: 3, m: 256, p: 4, keyLen: 32, want: "a71f8b4acd16b613a2706aafb7ec23b1ddf1790b3aec1911f52152083dda79c9" },
|
||||
{ pw: "70c3a4737377c3b6726420e29c93", salt: "0910171e252c333a41484f565d646b727980878e959ca3aab1b8bfc6cdd4dbe2e9f0f7fe050c131a21282f363d444b525960676e757c838a91989fa6adb4bbc2", t: 1, m: 1024, p: 3, keyLen: 32, want: "b069833fc8b42b81ad8c0baad6fa131f498fc2787a549809afe7459847029bc9" },
|
||||
{ pw: "636f727265637420686f727365206261747465727920737461706c65", salt: "0b121920272e353c434a51585f666d74", t: 2, m: 4096, p: 1, keyLen: 32, want: "7d4729ce2bb0a9113eb50b1517b19172c47a61cf9b2c5c0bc6724997ecc52ad7" },
|
||||
{ pw: "78", salt: "0d141b222930373e454c535a61686f767d848b92", t: 3, m: 37, p: 1, keyLen: 32, want: "f98a0cfc10228157b77cac1d45816f36447239e88bb5ed37e5a4f269f470607e" },
|
||||
{ pw: "6c6f6e67206f7574707574", salt: "11181f262d343b424950575e656c737a", t: 1, m: 128, p: 2, keyLen: 100, want: "436511e64b4986f59fe6b2e86166a2cd2d543c2f221816780a7698284e0a81529fa25000a5da5fefc062ef70f0f8a94d87dbec1f292091e5fc14fdd7f155f29f4dd0073b604e3a7c5b77433e482337761a9c1dd454872e0e00cc22483b8b0513fbda54e4" },
|
||||
];
|
||||
for (const v of vectors) {
|
||||
const key = await argon2id(unhex(v.pw), unhex(v.salt), v.t, v.m, v.p, v.keyLen);
|
||||
assert.equal(hex(key), v.want, `t=${v.t} m=${v.m} p=${v.p} keyLen=${v.keyLen}`);
|
||||
}
|
||||
});
|
||||
|
||||
// lixenwraith/auth's unbound known answer; the server signature comes from the
|
||||
// same AuthMessage under Go's DeriveCredential.
|
||||
test("proof and server signature match lixenwraith/auth", async () => {
|
||||
const challenge = {
|
||||
full_nonce: "client-nonceserver-nonce", salt: Buffer.alloc(16, 7).toString("base64"),
|
||||
argon_time: 1, argon_memory: 8, argon_threads: 1,
|
||||
};
|
||||
const got = await _proofForTest("alice", "password123", "client-nonce", challenge, 1, 8);
|
||||
assert.deepEqual(got, {
|
||||
client_proof: "JCVOdKAYwVu3YZx+6nfRfBUcnsTCgydOchnQq1G7Ry0=",
|
||||
server_signature: "0HKxosIZvdoAzstS4dEkNgoYvcDpFnOgwBjyhiSsK00=",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9;
|
||||
--fg: #1d2330;
|
||||
--muted: #5b6475;
|
||||
--panel: #ffffff;
|
||||
--line: #d6dae2;
|
||||
--accent: #2f6fdb;
|
||||
font: 15px/1.45 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14171d;
|
||||
--fg: #e3e7ef;
|
||||
--muted: #9aa3b5;
|
||||
--panel: #1c2028;
|
||||
--line: #2e3440;
|
||||
--accent: #6ea0ff;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 22rem;
|
||||
margin: 12vh auto 0;
|
||||
padding: 1.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input {
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#status {
|
||||
min-height: 1.4em;
|
||||
margin: 0.8rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.viewer header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#state {
|
||||
flex: 1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#log {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 1rem;
|
||||
font: 13px/1.4 ui-monospace, monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LogWisp stream</title>
|
||||
<meta name="logwisp-status" content="status">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="view.js"></script>
|
||||
</head>
|
||||
<body class="viewer">
|
||||
<header>
|
||||
<h1>LogWisp</h1>
|
||||
<span id="state" role="status">connecting…</span>
|
||||
<button id="logout" type="button">Sign out</button>
|
||||
</header>
|
||||
<main id="log"></main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { logout } from "./scram.js";
|
||||
|
||||
const base = new URL("..", location.href);
|
||||
// The server writes its status path here, relative to the mount
|
||||
const statusPath = document.querySelector('meta[name="logwisp-status"]').content;
|
||||
const MAX_LINES = 5000;
|
||||
const log = document.getElementById("log");
|
||||
const state = document.getElementById("state");
|
||||
let source = null;
|
||||
|
||||
function append(line) {
|
||||
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
|
||||
const row = document.createElement("div");
|
||||
row.textContent = line;
|
||||
log.append(row);
|
||||
while (log.childElementCount > MAX_LINES) log.firstElementChild.remove();
|
||||
if (atBottom) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
// The status answer names the stream path; its 401 is the only way to tell an
|
||||
// expired session apart, since EventSource hides the status of a failure.
|
||||
async function connect() {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(new URL("./" + statusPath, base), { credentials: "same-origin", cache: "no-store", redirect: "error" });
|
||||
} catch {
|
||||
return retry("server unreachable");
|
||||
}
|
||||
if (res.status === 401) {
|
||||
location.replace(new URL("login?next=view", location.href).href);
|
||||
return;
|
||||
}
|
||||
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
|
||||
if (typeof path !== "string" || !path.startsWith("/")) {
|
||||
return retry(`status unavailable (${res.status})`);
|
||||
}
|
||||
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix
|
||||
source = new EventSource(new URL("./" + path.replace(/^\/+/, ""), base));
|
||||
source.addEventListener("connected", () => { state.textContent = "live"; });
|
||||
source.addEventListener("disconnect", () => {
|
||||
source.close();
|
||||
retry("server shut down");
|
||||
});
|
||||
source.onmessage = (event) => event.data.split("\n").forEach(append);
|
||||
source.onerror = () => {
|
||||
if (source.readyState === EventSource.CLOSED) retry("disconnected");
|
||||
else state.textContent = "reconnecting…";
|
||||
};
|
||||
}
|
||||
|
||||
function retry(reason) {
|
||||
state.textContent = `${reason}, retrying…`;
|
||||
setTimeout(connect, 5000);
|
||||
}
|
||||
|
||||
document.getElementById("logout").addEventListener("click", async () => {
|
||||
source?.close();
|
||||
try {
|
||||
await logout(base);
|
||||
} catch (err) {
|
||||
state.textContent = err.message;
|
||||
return;
|
||||
}
|
||||
location.replace(new URL("login", location.href).href);
|
||||
});
|
||||
|
||||
connect();
|
||||
Reference in new issue
Block a user