Compare commits
2
Commits
1ef44fd760
...
fba2ab960e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fba2ab960e | ||
|
|
0e6d06a7fc |
@@ -180,7 +180,7 @@ For users who want to access the same session from multiple devices:
|
|||||||
- **Set password via IRC PASS command**: the authenticated client sends
|
- **Set password via IRC PASS command**: the authenticated client sends
|
||||||
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The
|
`POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The
|
||||||
server hashes the password with bcrypt and stores it on the session. Password
|
server hashes the password with bcrypt and stores it on the session. Password
|
||||||
must be at least 8 characters.
|
must be 8 to 72 bytes.
|
||||||
- **Login from another client**: `POST /api/v1/login` with nick and password →
|
- **Login from another client**: `POST /api/v1/login` with nick and password →
|
||||||
server verifies the password, creates a new client for the existing session,
|
server verifies the password, creates a new client for the existing session,
|
||||||
and sets an auth cookie. Channel memberships are shared; the new client gets
|
and sets an auth cookie. Channel memberships are shared; the new client gets
|
||||||
@@ -835,10 +835,11 @@ Set a password on the current session, enabling multi-client login via
|
|||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|
||||||
- `body[0]` is the password. Must be at least 8 characters.
|
- `body[0]` is the password. Must be 8 to 72 bytes; bcrypt cannot hash a longer
|
||||||
|
one.
|
||||||
- On success, the server responds with `{"status": "ok"}`.
|
- On success, the server responds with `{"status": "ok"}`.
|
||||||
- If the password is too short or missing, the server sends ERR_NEEDMOREPARAMS
|
- If the password is too short, too long or missing, the server sends
|
||||||
(461) via the message queue.
|
ERR_NEEDMOREPARAMS (461) via the message queue.
|
||||||
- Calling PASS again overwrites the previous password.
|
- Calling PASS again overwrites the previous password.
|
||||||
- Once a password is set, `POST /api/v1/login` can be used with the nick and
|
- Once a password is set, `POST /api/v1/login` can be used with the nick and
|
||||||
password to create additional clients on the same session.
|
password to create additional clients on the same session.
|
||||||
@@ -1404,7 +1405,8 @@ Include the hashcash stamp in the `meta` field:
|
|||||||
**Server validation:** The server checks that the stamp is well-formed, meets
|
**Server validation:** The server checks that the stamp is well-formed, meets
|
||||||
the required difficulty, is bound to the correct channel and message body, has a
|
the required difficulty, is bound to the correct channel and message body, has a
|
||||||
recent date, and has not been previously used. Spent stamps are cached for 1
|
recent date, and has not been previously used. Spent stamps are cached for 1
|
||||||
year to prevent replay attacks.
|
year to prevent replay attacks. The stamp is checked after membership, bans and
|
||||||
|
`+m`, so a message refused for one of those does not spend it.
|
||||||
|
|
||||||
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
**Error responses:** If the channel requires hashcash and the stamp is missing,
|
||||||
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a
|
||||||
@@ -2677,9 +2679,11 @@ IRC_LISTEN_ADDR: ""
|
|||||||
|
|
||||||
- **Wire format**: CR-LF delimited lines, max 512 bytes per line
|
- **Wire format**: CR-LF delimited lines, max 512 bytes per line
|
||||||
- **Connection registration**: Clients must send `NICK` and `USER` to register.
|
- **Connection registration**: Clients must send `NICK` and `USER` to register.
|
||||||
An optional `PASS` before registration sets the session password (minimum 8
|
An optional `PASS` before registration sets the session password (8 to 72
|
||||||
characters). Registering creates a session without a hashcash stamp; closing
|
bytes); one outside that range is refused with a `NOTICE` once registration
|
||||||
the connection ends the session, as `QUIT` does.
|
completes, as `PASS` after registration is. Registering creates a session
|
||||||
|
without a hashcash stamp; closing the connection ends the session, as `QUIT`
|
||||||
|
does.
|
||||||
- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and
|
- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and
|
||||||
is closed if it sends nothing in the 30 seconds after that; any line, such as
|
is closed if it sends nothing in the 30 seconds after that; any line, such as
|
||||||
the client's `PONG`, starts both waits again.
|
the client's `PONG`, starts both waits again.
|
||||||
@@ -3084,6 +3088,10 @@ limits that punish shared networks.
|
|||||||
- Stamp has not been used before (replay prevention; the spent stamps are
|
- Stamp has not been used before (replay prevention; the spent stamps are
|
||||||
kept in memory, so a restart forgets them)
|
kept in memory, so a restart forgets them)
|
||||||
|
|
||||||
|
The stamp is spent only when the session is created. A request refused with
|
||||||
|
400 or 409 leaves it unspent, and the client can send the same stamp again
|
||||||
|
with another nick or username.
|
||||||
|
|
||||||
### Stamp Format
|
### Stamp Format
|
||||||
|
|
||||||
Standard hashcash format:
|
Standard hashcash format:
|
||||||
@@ -3156,12 +3164,13 @@ creating one session pays once and keeps their session.
|
|||||||
### Why Hashcash and Not Rate Limits?
|
### Why Hashcash and Not Rate Limits?
|
||||||
|
|
||||||
- **Little state to track**: No IP tables, no token buckets, no sliding windows.
|
- **Little state to track**: No IP tables, no token buckets, no sliding windows.
|
||||||
The server verifies a single hash and remembers the stamps already spent.
|
The server hashes the stamp and remembers the stamps already spent.
|
||||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||||
campuses, corporate networks, Tor exits). Every client computes their own
|
campuses, corporate networks, Tor exits). Every client computes their own
|
||||||
proof independently.
|
proof independently.
|
||||||
- **Cost falls on the requester**: The server's verification cost is constant
|
- **Cost falls on the requester**: The server's verification cost is constant
|
||||||
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
regardless of difficulty: at most two SHA-256 hashes of the stamp per session
|
||||||
|
request, one to check it and one to spend it. Only the client does more work.
|
||||||
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No
|
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No
|
||||||
registration, no email, no phone number, no CAPTCHA. Just compute.
|
registration, no email, no phone number, no CAPTCHA. Just compute.
|
||||||
- **Language-agnostic**: SHA-256 is available in every programming language. The
|
- **Language-agnostic**: SHA-256 is available in every programming language. The
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ func TestCreateSessionSendsNickAndStamp(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validator.Validate(payload.Hashcash, stampBits)
|
err = validator.Claim(payload.Hashcash, stampBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writer.WriteHeader(http.StatusPaymentRequired)
|
writer.WriteHeader(http.StatusPaymentRequired)
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hdlr.validateHashcash(
|
if !hdlr.checkHashcash(
|
||||||
writer, request, payload.Hashcash,
|
writer, request, payload.Hashcash,
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
@@ -232,14 +232,15 @@ func (hdlr *Handlers) handleCreateSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
hdlr.executeCreateSession(
|
hdlr.executeCreateSession(
|
||||||
writer, request, payload.Nick, username,
|
writer, request,
|
||||||
|
payload.Nick, username, payload.Hashcash,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hdlr *Handlers) executeCreateSession(
|
func (hdlr *Handlers) executeCreateSession(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
nick, username string,
|
nick, username, stamp string,
|
||||||
) {
|
) {
|
||||||
remoteIP := clientIP(request)
|
remoteIP := clientIP(request)
|
||||||
|
|
||||||
@@ -247,12 +248,17 @@ func (hdlr *Handlers) executeCreateSession(
|
|||||||
request.Context(), remoteIP,
|
request.Context(), remoteIP,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if !hdlr.claimHashcash(writer, request, stamp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sessionID, clientID, token, err :=
|
sessionID, clientID, token, err :=
|
||||||
hdlr.params.Database.CreateSession(
|
hdlr.params.Database.CreateSession(
|
||||||
request.Context(),
|
request.Context(),
|
||||||
nick, username, hostname, remoteIP,
|
nick, username, hostname, remoteIP,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
hdlr.hashcashVal.Release(stamp)
|
||||||
hdlr.handleCreateSessionError(
|
hdlr.handleCreateSessionError(
|
||||||
writer, request, err,
|
writer, request, err,
|
||||||
)
|
)
|
||||||
@@ -273,10 +279,10 @@ func (hdlr *Handlers) executeCreateSession(
|
|||||||
}, http.StatusCreated)
|
}, http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateHashcash validates a hashcash stamp if required.
|
// checkHashcash checks a hashcash stamp if required,
|
||||||
// Returns false if validation failed and a response was
|
// without spending it. Returns false if the check failed
|
||||||
// already sent.
|
// and a response was already sent.
|
||||||
func (hdlr *Handlers) validateHashcash(
|
func (hdlr *Handlers) checkHashcash(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
stamp string,
|
stamp string,
|
||||||
@@ -295,7 +301,31 @@ func (hdlr *Handlers) validateHashcash(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
err := hdlr.hashcashVal.Validate(
|
err := hdlr.hashcashVal.Check(
|
||||||
|
stamp, hdlr.params.Config.HashcashBits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
hdlr.respondError(
|
||||||
|
writer, request,
|
||||||
|
"invalid hashcash stamp: "+err.Error(),
|
||||||
|
http.StatusPaymentRequired,
|
||||||
|
)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimHashcash spends a stamp that checkHashcash accepted.
|
||||||
|
// Returns false if Claim refused the stamp, normally because
|
||||||
|
// it was already spent, and a response was already sent.
|
||||||
|
func (hdlr *Handlers) claimHashcash(
|
||||||
|
writer http.ResponseWriter,
|
||||||
|
request *http.Request,
|
||||||
|
stamp string,
|
||||||
|
) bool {
|
||||||
|
err := hdlr.hashcashVal.Claim(
|
||||||
stamp, hdlr.params.Config.HashcashBits,
|
stamp, hdlr.params.Config.HashcashBits,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+179
-22
@@ -29,6 +29,7 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"modernc.org/sqlite"
|
"modernc.org/sqlite"
|
||||||
"sneak.berlin/go/neoirc/internal/broker"
|
"sneak.berlin/go/neoirc/internal/broker"
|
||||||
|
api "sneak.berlin/go/neoirc/internal/cli/api"
|
||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"sneak.berlin/go/neoirc/internal/globals"
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
@@ -2905,32 +2906,73 @@ func TestPassCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPassCommandShortPassword(t *testing.T) {
|
// The shortest and longest password PASS accepts; bcrypt
|
||||||
tserver := newTestServer(t)
|
// cannot hash more than 72 bytes.
|
||||||
token := tserver.createSession("shortpw")
|
const (
|
||||||
|
minPasswordBytes = 8
|
||||||
|
maxPasswordBytes = 72
|
||||||
|
)
|
||||||
|
|
||||||
// Drain initial messages.
|
// TestPassCommandLength checks that PASS stores a password of
|
||||||
_, lastID := tserver.pollMessages(token, 0)
|
// 8 to 72 bytes and refuses any other with 461. The limit
|
||||||
|
// counts bytes: "é" is two bytes in UTF-8, so 72 of them are
|
||||||
// Try short password — should fail.
|
// refused.
|
||||||
status, _ := tserver.sendCommand(
|
func TestPassCommandLength(t *testing.T) {
|
||||||
token,
|
cases := []struct {
|
||||||
map[string]any{
|
name string
|
||||||
commandKey: irc.CmdPass,
|
password string
|
||||||
bodyKey: []string{"short"},
|
stored bool
|
||||||
},
|
}{
|
||||||
)
|
{"empty", "", false},
|
||||||
if status != http.StatusOK {
|
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false},
|
||||||
t.Fatalf("expected 200, got %d", status)
|
{"8 bytes", strings.Repeat("a", minPasswordBytes), true},
|
||||||
|
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
|
||||||
|
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
|
||||||
|
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, _ := tserver.pollMessages(token, lastID)
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession("pwlength")
|
||||||
|
|
||||||
if !findNumeric(msgs, "461") {
|
_, lastID := tserver.pollMessages(token, 0)
|
||||||
t.Fatalf(
|
|
||||||
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
status, result := tserver.sendCommand(
|
||||||
msgs,
|
token,
|
||||||
)
|
map[string]any{
|
||||||
|
commandKey: irc.CmdPass,
|
||||||
|
bodyKey: []string{tc.password},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %v", status, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tc.stored {
|
||||||
|
msgs, _ := tserver.pollMessages(token, lastID)
|
||||||
|
if !findNumeric(msgs, "461") {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
||||||
|
msgs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[statusKey] != "ok" {
|
||||||
|
t.Fatalf("expected ok, got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
postJSONExpectStatus(
|
||||||
|
t, tserver, "/api/v1/login",
|
||||||
|
map[string]string{
|
||||||
|
nickKey: "pwlength", passwordKey: tc.password,
|
||||||
|
},
|
||||||
|
http.StatusOK,
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4073,6 +4115,121 @@ func TestChannelHashcashMissingBitsArg(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postSessionWithStamp posts a session request for nick and
|
||||||
|
// username carrying stamp and returns the response status.
|
||||||
|
// An empty username defaults to the nick.
|
||||||
|
func postSessionWithStamp(
|
||||||
|
t *testing.T,
|
||||||
|
tserver *testServer,
|
||||||
|
nick, username, stamp string,
|
||||||
|
) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
nickKey: nick, "username": username, "pow_token": stamp,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequest(
|
||||||
|
t, http.MethodPost, tserver.url(apiSession),
|
||||||
|
bytes.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionCount returns the number of sessions in tserver's
|
||||||
|
// database.
|
||||||
|
func sessionCount(t *testing.T, tserver *testServer) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
count, err := tserver.database.GetUserCount(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count sessions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefusedSessionLeavesStampUnspent checks that a
|
||||||
|
// session request is refused with 402 for an invalid stamp
|
||||||
|
// before its nick is looked at, that a request refused with
|
||||||
|
// 400 or 409 does not spend its stamp, so the same stamp
|
||||||
|
// then creates a session, that the stamp stays spent after
|
||||||
|
// that, that a spent stamp with an invalid nick is refused
|
||||||
|
// for its nick, and that no refused request creates a
|
||||||
|
// session.
|
||||||
|
func TestRefusedSessionLeavesStampUnspent(t *testing.T) {
|
||||||
|
const (
|
||||||
|
bits = 2
|
||||||
|
badNick = "bad nick"
|
||||||
|
)
|
||||||
|
|
||||||
|
tserver := newTestServerWith(t, bits, "", "")
|
||||||
|
|
||||||
|
// A server with no SERVER_NAME takes stamps for "neoirc".
|
||||||
|
status := postSessionWithStamp(
|
||||||
|
t, tserver, "taken", "", api.MintHashcash(bits, "neoirc"),
|
||||||
|
)
|
||||||
|
if status != http.StatusCreated {
|
||||||
|
t.Fatalf("create taken: want 201, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := api.MintHashcash(bits, "neoirc")
|
||||||
|
otherServerStamp := api.MintHashcash(bits, "elsewhere")
|
||||||
|
|
||||||
|
steps := []struct {
|
||||||
|
nick, username, stamp string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{badNick, "", otherServerStamp, http.StatusPaymentRequired},
|
||||||
|
{badNick, "", stamp, http.StatusBadRequest},
|
||||||
|
{"fresh", "bad username", stamp, http.StatusBadRequest},
|
||||||
|
{"taken", "", stamp, http.StatusConflict},
|
||||||
|
{"fresh", "", stamp, http.StatusCreated},
|
||||||
|
{"another", "", stamp, http.StatusPaymentRequired},
|
||||||
|
// Refused as already spent, the stamp is not given back.
|
||||||
|
{"later", "", stamp, http.StatusPaymentRequired},
|
||||||
|
// Whether the stamp is spent is looked at after the nick.
|
||||||
|
{badNick, "", stamp, http.StatusBadRequest},
|
||||||
|
// The refused request for "another" left the nick free.
|
||||||
|
{"another", "", api.MintHashcash(bits, "neoirc"), http.StatusCreated},
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions := sessionCount(t, tserver)
|
||||||
|
|
||||||
|
for _, step := range steps {
|
||||||
|
status = postSessionWithStamp(
|
||||||
|
t, tserver, step.nick, step.username, step.stamp,
|
||||||
|
)
|
||||||
|
if status != step.want {
|
||||||
|
t.Fatalf(
|
||||||
|
"nick %q, username %q: want %d, got %d",
|
||||||
|
step.nick, step.username, step.want, status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == http.StatusCreated {
|
||||||
|
sessions++
|
||||||
|
}
|
||||||
|
|
||||||
|
count := sessionCount(t, tserver)
|
||||||
|
if count != sessions {
|
||||||
|
t.Fatalf(
|
||||||
|
"nick %q, username %q: want %d sessions, got %d",
|
||||||
|
step.nick, step.username, sessions, count,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNamesShowsHostmask(t *testing.T) {
|
func TestNamesShowsHostmask(t *testing.T) {
|
||||||
tserver := newTestServer(t)
|
tserver := newTestServer(t)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import (
|
|||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minPasswordLength = 8
|
// A session password is 8 to 72 bytes; bcrypt cannot hash
|
||||||
|
// a longer one.
|
||||||
|
const (
|
||||||
|
minPasswordLength = 8
|
||||||
|
maxPasswordLength = 72
|
||||||
|
)
|
||||||
|
|
||||||
// HandleLogin authenticates a user with nick and password.
|
// HandleLogin authenticates a user with nick and password.
|
||||||
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
func (hdlr *Handlers) HandleLogin() http.HandlerFunc {
|
||||||
@@ -149,12 +154,13 @@ func (hdlr *Handlers) handlePass(
|
|||||||
|
|
||||||
password := lines[0]
|
password := lines[0]
|
||||||
|
|
||||||
if len(password) < minPasswordLength {
|
if len(password) < minPasswordLength ||
|
||||||
|
len(password) > maxPasswordLength {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
irc.ErrNeedMoreParams, nick,
|
irc.ErrNeedMoreParams, nick,
|
||||||
[]string{irc.CmdPass},
|
[]string{irc.CmdPass},
|
||||||
"Password must be at least 8 characters",
|
"Password must be 8 to 72 bytes",
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -77,9 +77,13 @@ func NewValidator(resource string) *Validator {
|
|||||||
return validator
|
return validator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks a hashcash stamp. It returns nil if the
|
// Check returns nil if requiredBits is not positive, or if
|
||||||
// stamp is valid and has not been seen before.
|
// the stamp is a well-formed, recently dated proof of work
|
||||||
func (v *Validator) Validate(
|
// for this resource with at least requiredBits. It does not
|
||||||
|
// look at or change the set of spent stamps, so a request
|
||||||
|
// can check its stamp first and Claim it after its other
|
||||||
|
// checks.
|
||||||
|
func (v *Validator) Check(
|
||||||
stamp string,
|
stamp string,
|
||||||
requiredBits int,
|
requiredBits int,
|
||||||
) error {
|
) error {
|
||||||
@@ -87,9 +91,49 @@ func (v *Validator) Validate(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err := v.check(stamp, requiredBits)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim checks the stamp as Check does and, when
|
||||||
|
// requiredBits is positive, records it as spent until it
|
||||||
|
// expires. It returns an error if the stamp is already
|
||||||
|
// spent. Release gives a claimed stamp back.
|
||||||
|
func (v *Validator) Claim(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
) error {
|
||||||
|
if requiredBits <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stampTime, err := v.check(stamp, requiredBits)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.checkAndRecordStamp(stamp, stampTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release forgets a claimed stamp so that it can be claimed
|
||||||
|
// again, for a request that failed after its Claim.
|
||||||
|
// Releasing a stamp that is not claimed does nothing.
|
||||||
|
func (v *Validator) Release(stamp string) {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
|
||||||
|
delete(v.spent, stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check returns the stamp's date if Check accepts it.
|
||||||
|
func (v *Validator) check(
|
||||||
|
stamp string,
|
||||||
|
requiredBits int,
|
||||||
|
) (time.Time, error) {
|
||||||
parts := strings.Split(stamp, ":")
|
parts := strings.Split(stamp, ":")
|
||||||
if len(parts) != stampFields {
|
if len(parts) != stampFields {
|
||||||
return fmt.Errorf(
|
return time.Time{}, fmt.Errorf(
|
||||||
"%w: expected %d, got %d",
|
"%w: expected %d, got %d",
|
||||||
errInvalidFields, stampFields, len(parts),
|
errInvalidFields, stampFields, len(parts),
|
||||||
)
|
)
|
||||||
@@ -104,25 +148,25 @@ func (v *Validator) Validate(
|
|||||||
version, bitsStr, resource, requiredBits,
|
version, bitsStr, resource, requiredBits,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
stampTime, err := parseStampDate(dateStr)
|
stampTime, err := parseStampDate(dateStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateTime(stampTime)
|
err = validateTime(stampTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validateProof(stamp, requiredBits)
|
err = validateProof(stamp, requiredBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return v.checkAndRecordStamp(stamp, stampTime)
|
return stampTime, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) validateHeader(
|
func (v *Validator) validateHeader(
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func TestMintAndValidate(t *testing.T) {
|
|||||||
t, testBits, "test-resource", todayDate(),
|
t, testBits, "test-resource", todayDate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("valid stamp rejected: %v", err)
|
t.Fatalf("valid stamp rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -99,17 +99,74 @@ func TestReplayDetection(t *testing.T) {
|
|||||||
t, testBits, "test-resource", todayDate(),
|
t, testBits, "test-resource", todayDate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first use failed: %v", err)
|
t.Fatalf("first use failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = validator.Validate(stamp, testBits)
|
err = validator.Claim(stamp, testBits)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("replay not detected")
|
t.Fatal("replay not detected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCheckDoesNotSpendStamp checks that a stamp Check
|
||||||
|
// accepts can still be claimed, and only once, and that
|
||||||
|
// Check still accepts it once it is spent.
|
||||||
|
func TestCheckDoesNotSpendStamp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
err := validator.Check(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid stamp rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := validator.Claim(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim after check failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validator.Claim(stamp, testBits)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("replay not detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validator.Check(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("spent stamp rejected by Check: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReleasedStampCanBeClaimedAgain checks that Release
|
||||||
|
// gives a claimed stamp back.
|
||||||
|
func TestReleasedStampCanBeClaimedAgain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
stamp := mintStampWithDate(
|
||||||
|
t, testBits, "test-resource", todayDate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
err := validator.Claim(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first claim failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
validator.Release(stamp)
|
||||||
|
|
||||||
|
err = validator.Claim(stamp, testBits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim after release failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResourceMismatch(t *testing.T) {
|
func TestResourceMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -118,7 +175,7 @@ func TestResourceMismatch(t *testing.T) {
|
|||||||
t, testBits, "wrong-resource", todayDate(),
|
t, testBits, "wrong-resource", todayDate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected resource mismatch error")
|
t.Fatal("expected resource mismatch error")
|
||||||
}
|
}
|
||||||
@@ -129,7 +186,7 @@ func TestInvalidStampFormat(t *testing.T) {
|
|||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
err := validator.Validate(
|
err := validator.Claim(
|
||||||
"not:a:valid:stamp", testBits,
|
"not:a:valid:stamp", testBits,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -146,7 +203,7 @@ func TestBadVersion(t *testing.T) {
|
|||||||
testBits, todayDate(), "test-resource",
|
testBits, todayDate(), "test-resource",
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected bad version error")
|
t.Fatal("expected bad version error")
|
||||||
}
|
}
|
||||||
@@ -162,7 +219,7 @@ func TestInsufficientDifficulty(t *testing.T) {
|
|||||||
todayDate(), "test-resource",
|
todayDate(), "test-resource",
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected insufficient bits error")
|
t.Fatal("expected insufficient bits error")
|
||||||
}
|
}
|
||||||
@@ -178,7 +235,7 @@ func TestExpiredStamp(t *testing.T) {
|
|||||||
t, testBits, "test-resource", oldDate,
|
t, testBits, "test-resource", oldDate,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected expired stamp error")
|
t.Fatal("expected expired stamp error")
|
||||||
}
|
}
|
||||||
@@ -189,9 +246,14 @@ func TestZeroBitsSkipsValidation(t *testing.T) {
|
|||||||
|
|
||||||
validator := hashcash.NewValidator("test-resource")
|
validator := hashcash.NewValidator("test-resource")
|
||||||
|
|
||||||
err := validator.Validate("garbage", 0)
|
err := validator.Check("garbage", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("zero bits should skip: %v", err)
|
t.Fatalf("zero bits should skip the check: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validator.Claim("garbage", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zero bits should skip the claim: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +266,7 @@ func TestLongDateFormat(t *testing.T) {
|
|||||||
t, testBits, "test-resource", longDate,
|
t, testBits, "test-resource", longDate,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("long date stamp rejected: %v", err)
|
t.Fatalf("long date stamp rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -219,7 +281,7 @@ func TestBadDateFormat(t *testing.T) {
|
|||||||
testBits, "test-resource",
|
testBits, "test-resource",
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected bad date error")
|
t.Fatal("expected bad date error")
|
||||||
}
|
}
|
||||||
@@ -235,7 +297,7 @@ func TestMultipleUniqueStamps(t *testing.T) {
|
|||||||
t, testBits, "test-resource", todayDate(),
|
t, testBits, "test-resource", todayDate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unique stamp rejected: %v", err)
|
t.Fatalf("unique stamp rejected: %v", err)
|
||||||
}
|
}
|
||||||
@@ -251,7 +313,7 @@ func TestHigherBitsStillValid(t *testing.T) {
|
|||||||
t, 4, "test-resource", todayDate(),
|
t, 4, "test-resource", todayDate(),
|
||||||
)
|
)
|
||||||
|
|
||||||
err := validator.Validate(stamp, testBits)
|
err := validator.Claim(stamp, testBits)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf(
|
t.Fatalf(
|
||||||
"higher-difficulty stamp rejected: %v",
|
"higher-difficulty stamp rejected: %v",
|
||||||
|
|||||||
@@ -1216,15 +1216,14 @@ func (c *Conn) handlePassPostReg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
password := msg.Params[0]
|
password := msg.Params[0]
|
||||||
if len(password) < minPasswordLen {
|
if !c.checkPasswordLength(password) {
|
||||||
c.sendFromServer("NOTICE", c.nick,
|
|
||||||
"Password must be at least 8 characters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.setPassword(ctx, password)
|
err := c.setPassword(ctx, password)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.sendFromServer("NOTICE", c.nick,
|
c.sendFromServer("NOTICE", c.nick,
|
||||||
"Password set. You can reconnect using "+
|
"Password set. You can reconnect using "+
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const (
|
|||||||
pingInterval = 90 * time.Second
|
pingInterval = 90 * time.Second
|
||||||
pongDeadline = 30 * time.Second
|
pongDeadline = 30 * time.Second
|
||||||
minPasswordLen = 8
|
minPasswordLen = 8
|
||||||
|
maxPasswordLen = 72 // bcrypt cannot hash a longer one
|
||||||
maxHashcashBits = 40
|
maxHashcashBits = 40
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ type Conn struct {
|
|||||||
registered bool
|
registered bool
|
||||||
gotNick bool
|
gotNick bool
|
||||||
gotUser bool
|
gotUser bool
|
||||||
|
gotPass bool
|
||||||
passWord string
|
passWord string
|
||||||
|
|
||||||
lastQueueID int64
|
lastQueueID int64
|
||||||
@@ -436,6 +438,7 @@ func (c *Conn) handlePreRegistration(
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.passWord = msg.Params[0]
|
c.passWord = msg.Params[0]
|
||||||
|
c.gotPass = true
|
||||||
case irc.CmdNick:
|
case irc.CmdNick:
|
||||||
if len(msg.Params) < 1 {
|
if len(msg.Params) < 1 {
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
@@ -550,17 +553,20 @@ func (c *Conn) completeRegistration(ctx context.Context) {
|
|||||||
// So that KILL, from either transport, can close it.
|
// So that KILL, from either transport, can close it.
|
||||||
c.svc.RegisterWireConn(sessionID, c)
|
c.svc.RegisterWireConn(sessionID, c)
|
||||||
|
|
||||||
// If PASS was provided before registration, set the
|
|
||||||
// session password.
|
|
||||||
if c.passWord != "" && len(c.passWord) >= minPasswordLen {
|
|
||||||
c.setPassword(ctx, c.passWord)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send welcome burst.
|
// Send welcome burst.
|
||||||
c.deliverWelcome()
|
c.deliverWelcome()
|
||||||
c.deliverLusers(ctx)
|
c.deliverLusers(ctx)
|
||||||
c.deliverMOTD()
|
c.deliverMOTD()
|
||||||
|
|
||||||
|
// If PASS was given before registration, store its
|
||||||
|
// password now, after the welcome burst, so that a
|
||||||
|
// NOTICE refusing it reaches a registered client. A
|
||||||
|
// failure to store it is only logged, as it is for PASS
|
||||||
|
// after registration.
|
||||||
|
if c.gotPass && c.checkPasswordLength(c.passWord) {
|
||||||
|
_ = c.setPassword(ctx, c.passWord)
|
||||||
|
}
|
||||||
|
|
||||||
// Start the message relay goroutine.
|
// Start the message relay goroutine.
|
||||||
go c.relayMessages(ctx)
|
go c.relayMessages(ctx)
|
||||||
}
|
}
|
||||||
@@ -643,13 +649,33 @@ func (c *Conn) deliverMOTD() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setPassword sets a bcrypt password on the session.
|
// checkPasswordLength reports whether pw is 8 to 72 bytes
|
||||||
func (c *Conn) setPassword(ctx context.Context, pw string) {
|
// long, and sends the client a NOTICE saying so when it is
|
||||||
|
// not.
|
||||||
|
func (c *Conn) checkPasswordLength(pw string) bool {
|
||||||
|
if len(pw) >= minPasswordLen && len(pw) <= maxPasswordLen {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendFromServer("NOTICE", c.nick,
|
||||||
|
"Password must be 8 to 72 bytes",
|
||||||
|
)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPassword sets a bcrypt password on the session, and
|
||||||
|
// logs and returns the error when it was not stored.
|
||||||
|
func (c *Conn) setPassword(ctx context.Context, pw string) error {
|
||||||
// Use the database's auth module to hash and store.
|
// Use the database's auth module to hash and store.
|
||||||
err := c.database.SetPassword(ctx, c.sessionID, pw)
|
err := c.database.SetPassword(ctx, c.sessionID, pw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.log.Error(
|
c.log.Error(
|
||||||
"failed to set password", "error", err,
|
"failed to set password", "error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1158,6 +1158,165 @@ func TestHandlePassPostRegistration(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shortest and longest password PASS accepts; bcrypt
|
||||||
|
// cannot hash more than 72 bytes.
|
||||||
|
const (
|
||||||
|
minPasswordBytes = 8
|
||||||
|
maxPasswordBytes = 72
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordLengthNotice is the NOTICE text for a password that
|
||||||
|
// is too short or too long.
|
||||||
|
const passwordLengthNotice = "Password must be 8 to 72 bytes"
|
||||||
|
|
||||||
|
// passwordCase is a password a PASS test sends, and whether
|
||||||
|
// the server should store it.
|
||||||
|
type passwordCase struct {
|
||||||
|
name string
|
||||||
|
password string
|
||||||
|
stored bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordCases returns the passwords the PASS length tests
|
||||||
|
// send: one of 8 to 72 bytes is stored and any other is
|
||||||
|
// refused. The limit counts bytes: "é" is two bytes in UTF-8,
|
||||||
|
// so 72 of them are refused.
|
||||||
|
func passwordCases() []passwordCase {
|
||||||
|
return []passwordCase{
|
||||||
|
{"empty", "", false},
|
||||||
|
{"7 bytes", strings.Repeat("a", minPasswordBytes-1), false},
|
||||||
|
{"8 bytes", strings.Repeat("a", minPasswordBytes), true},
|
||||||
|
{"72 bytes", strings.Repeat("a", maxPasswordBytes), true},
|
||||||
|
{"73 bytes", strings.Repeat("a", maxPasswordBytes+1), false},
|
||||||
|
{"72 characters", strings.Repeat("é", maxPasswordBytes), false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertStored fails the test unless nick can log in with
|
||||||
|
// password exactly when stored is true.
|
||||||
|
func assertStored(
|
||||||
|
t *testing.T, env *testEnv, nick, password string, stored bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, _, token, err := env.database.LoginUser(
|
||||||
|
t.Context(), nick, password, "127.0.0.1", "localhost",
|
||||||
|
)
|
||||||
|
|
||||||
|
loggedIn := err == nil && token != ""
|
||||||
|
if loggedIn != stored {
|
||||||
|
t.Errorf(
|
||||||
|
"log in as %s: logged in %v, want %v (error: %v)",
|
||||||
|
nick, loggedIn, stored, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassPostRegistrationLength checks that PASS after
|
||||||
|
// registration stores and confirms a password of 8 to 72
|
||||||
|
// bytes, and refuses any other with a NOTICE.
|
||||||
|
func TestPassPostRegistrationLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
for i, tc := range passwordCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nick := fmt.Sprintf("postpass%d", i)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register(nick)
|
||||||
|
client.send("PASS :" + tc.password)
|
||||||
|
|
||||||
|
// The server answers commands in order, so its
|
||||||
|
// reply to PASS comes before the PONG.
|
||||||
|
lines := client.sendAndExpect("PING :after", "PONG")
|
||||||
|
|
||||||
|
reply := passwordLengthNotice
|
||||||
|
if tc.stored {
|
||||||
|
reply = "Password set"
|
||||||
|
}
|
||||||
|
|
||||||
|
assertContains(t, lines, reply, "reply to PASS")
|
||||||
|
assertStored(t, env, nick, tc.password, tc.stored)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassPostRegistrationNotStored checks that PASS is not
|
||||||
|
// confirmed when the password could not be stored.
|
||||||
|
func TestPassPostRegistrationNotStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.register("failpass")
|
||||||
|
|
||||||
|
_, err := env.database.GetDB().ExecContext(t.Context(),
|
||||||
|
`CREATE TRIGGER refuse_password
|
||||||
|
BEFORE UPDATE OF password_hash ON sessions
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create trigger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send("PASS :mypassword123")
|
||||||
|
|
||||||
|
// The server answers commands in order, so anything it
|
||||||
|
// sent about PASS comes before the PONG.
|
||||||
|
lines := client.sendAndExpect("PING :after", "PONG")
|
||||||
|
|
||||||
|
assertNotContains(
|
||||||
|
t, lines, "Password set",
|
||||||
|
"confirmation of a password that was not stored",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassBeforeRegistrationLength checks that a PASS before
|
||||||
|
// registration stores a password of 8 to 72 bytes, and that
|
||||||
|
// any other is refused with a NOTICE once registration
|
||||||
|
// completes.
|
||||||
|
func TestPassBeforeRegistrationLength(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
for i, tc := range passwordCases() {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nick := fmt.Sprintf("prepass%d", i)
|
||||||
|
client := env.dial(t)
|
||||||
|
|
||||||
|
client.send("PASS :" + tc.password)
|
||||||
|
client.register(nick)
|
||||||
|
|
||||||
|
// The password is checked after the welcome
|
||||||
|
// burst, which register reads; the PONG comes
|
||||||
|
// after that.
|
||||||
|
lines := client.sendAndExpect("PING :after", "PONG")
|
||||||
|
|
||||||
|
if tc.stored {
|
||||||
|
assertNotContains(
|
||||||
|
t, lines, passwordLengthNotice,
|
||||||
|
"refusal of a valid password",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
assertContains(
|
||||||
|
t, lines, passwordLengthNotice,
|
||||||
|
"refusal after registration",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertStored(t, env, nick, tc.password, tc.stored)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreRegistrationNotRegistered(t *testing.T) {
|
func TestPreRegistrationNotRegistered(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -387,13 +387,14 @@ func excludeSession(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendChannelMessage validates the hashcash stamp of a
|
// SendChannelMessage checks membership, bans and
|
||||||
// PRIVMSG to a +H channel, membership and moderation, then
|
// moderation, then the hashcash stamp of a PRIVMSG to a +H
|
||||||
// fans out a message to all channel members except the
|
// channel, then fans out a message to all channel members
|
||||||
// sender. Returns the database row ID, message UUID, and
|
// except the sender. The stamp is checked after the other
|
||||||
// any error. The dbID lets callers enqueue the same
|
// checks so that a message they refuse does not spend it.
|
||||||
// message to the sender when echo is needed (HTTP
|
// Returns the database row ID, message UUID, and any error.
|
||||||
// transport).
|
// The dbID lets callers enqueue the same message to the
|
||||||
|
// sender when echo is needed (HTTP transport).
|
||||||
func (s *Service) SendChannelMessage(
|
func (s *Service) SendChannelMessage(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
@@ -409,15 +410,6 @@ func (s *Service) SendChannelMessage(
|
|||||||
return 0, "", noSuchChannel
|
return 0, "", noSuchChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
if command == irc.CmdPrivmsg {
|
|
||||||
err = s.checkChannelHashcash(
|
|
||||||
ctx, chID, channel, body, meta,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isMember, _ := s.db.IsChannelMember(
|
isMember, _ := s.db.IsChannelMember(
|
||||||
ctx, chID, sessionID,
|
ctx, chID, sessionID,
|
||||||
)
|
)
|
||||||
@@ -459,6 +451,15 @@ func (s *Service) SendChannelMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if command == irc.CmdPrivmsg {
|
||||||
|
err = s.checkChannelHashcash(
|
||||||
|
ctx, chID, channel, body, meta,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
memberIDs, _ := s.db.GetChannelMemberIDs(ctx, chID)
|
||||||
recipients := excludeSession(memberIDs, sessionID)
|
recipients := excludeSession(memberIDs, sessionID)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"sneak.berlin/go/neoirc/internal/config"
|
"sneak.berlin/go/neoirc/internal/config"
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"sneak.berlin/go/neoirc/internal/globals"
|
"sneak.berlin/go/neoirc/internal/globals"
|
||||||
|
"sneak.berlin/go/neoirc/internal/hashcash"
|
||||||
"sneak.berlin/go/neoirc/internal/logger"
|
"sneak.berlin/go/neoirc/internal/logger"
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
@@ -1056,6 +1057,88 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRefusedChannelMessageLeavesStampUnspent checks that a
|
||||||
|
// PRIVMSG to a +H channel refused because the sender is not
|
||||||
|
// a member, is banned, or has no voice on a +m channel does
|
||||||
|
// not spend its hashcash stamp: the operator can then send
|
||||||
|
// the same message with it.
|
||||||
|
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
|
||||||
|
const (
|
||||||
|
channel = "#paid"
|
||||||
|
bits = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
refusals := []struct {
|
||||||
|
name string
|
||||||
|
// refuse stops bob from sending to the channel.
|
||||||
|
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
|
||||||
|
}{
|
||||||
|
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
|
||||||
|
return env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
|
||||||
|
}},
|
||||||
|
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
|
||||||
|
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
|
||||||
|
}},
|
||||||
|
{"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error {
|
||||||
|
return env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
||||||
|
|
||||||
|
for _, refusal := range refusals {
|
||||||
|
t.Run(refusal.name, func(t *testing.T) {
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
aliceSID := createSession(ctx, t, env.db, "alice")
|
||||||
|
bobSID := createSession(ctx, t, env.db, "bob")
|
||||||
|
|
||||||
|
joined, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join alice: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("join bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = env.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("set +H: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = refusal.refuse(ctx, env, joined.ChannelID, bobSID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refuse bob: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stamp := hashcash.MintChannelStamp(
|
||||||
|
bits, channel, hashcash.BodyHash(body),
|
||||||
|
)
|
||||||
|
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
|
||||||
|
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
var ircErr *service.IRCError
|
||||||
|
if !errors.As(err, &ircErr) ||
|
||||||
|
ircErr.Code != irc.ErrCannotSendToChan {
|
||||||
|
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, err = env.svc.SendChannelMessage(
|
||||||
|
ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alice with the same stamp: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryUserMode(t *testing.T) {
|
func TestQueryUserMode(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
Reference in New Issue
Block a user