Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ concurrency:
permissions:
contents: write
pull-requests: read
# Keyless signing: cosign exchanges this for a short-lived Fulcio certificate
# bound to the workflow identity, so there is no private key for anyone to
# lose and a verifier can ask "was this built by this repository's release
# workflow" rather than "do I recognise this key".
id-token: write

jobs:
changes:
Expand Down Expand Up @@ -119,14 +124,25 @@ jobs:
done
echo "go mod download failed after retries" >&2
exit 1
# -race on every platform, because the bugs it finds do not reproduce
# reliably and the suite had never been run under it. The first run found
# a real data race; a suite that only ever runs without it is a suite that
# cannot see the class of bug most likely to reach production.
- name: Test
run: go test ./...
run: go test -race ./...
- name: Build
shell: bash
run: |
out=contextd
[[ "${RUNNER_OS}" == "Windows" ]] && out=contextd.exe
go build -o "$out" ./cmd/contextd
# Cheap and catches a category tests do not: printf verbs that do not
# match their arguments, unreachable code, lost struct tags.
- name: Vet
run: go vet ./...
- name: Vet integration-tagged code
if: runner.os == 'Linux'
run: go vet -tags integration ./...
- name: Shellcheck install.sh
if: runner.os == 'Linux'
run: |
Expand All @@ -137,6 +153,28 @@ jobs:
if: runner.os == 'Linux'
run: bash scripts/ci/next-version_test.sh

analysis:
needs: [changes]
if: needs.changes.outputs.tests == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25.x"
# Known vulnerabilities in what we actually call, not merely in what we
# depend on: govulncheck reports a module only when a reachable path
# exists, so it stays quiet enough to be worth reading.
- name: Vulnerability check
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
"$(go env GOPATH)/bin/govulncheck" ./...
- name: Lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.62
args: --timeout=5m

integration:
needs: [changes]
if: needs.changes.outputs.tests == 'true'
Expand Down Expand Up @@ -168,12 +206,13 @@ jobs:
always() &&
needs.changes.outputs.product == 'true' &&
needs.test.result == 'success' &&
needs.analysis.result == 'success' &&
needs.integration.result == 'success' &&
(
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
)
needs: [changes, test, integration]
needs: [changes, test, analysis, integration]
runs-on: ubuntu-latest
concurrency:
group: release-main
Expand All @@ -188,6 +227,11 @@ jobs:
with:
go-version: "1.25.x"

# Signing and the bill of materials. Both are GoReleaser steps; these put
# the tools on the runner.
- uses: sigstore/cosign-installer@v3
- uses: anchore/sbom-action/download-syft@v0

- name: Compute next minor version
id: ver
run: |
Expand Down
43 changes: 43 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Linters that must pass, chosen so the gate means something on the day it is
# switched on.
#
# The full set was run first, and it reports 489 findings — 409 of them errcheck
# on deliberate discards like `defer f.Close()`, 43 noctx, 15 errorlint. Turning
# all of that on at once produces a gate that is red from the first commit, and a
# gate nobody can get green is a gate somebody deletes. Those three are tracked
# as their own piece of work, with the numbers, rather than being enabled and
# then permanently ignored.
#
# What is here catches defects rather than taste, and passes today — so a new
# finding is a new mistake, which is the only way a linter earns its place in CI.

run:
timeout: 5m
tests: true

linters:
disable-all: true
enable:
- govet
# staticcheck found the real one: an `if` with an empty body where backend
# migration was supposed to verify that what arrived matched what was sent.
- staticcheck
- ineffassign
- unused

linters-settings:
staticcheck:
checks: ["all"]

issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
# SA1019 on the TUI is bubbles renaming its viewport scrolling methods. Real,
# but it is a rename to follow deliberately in one change, not a reason to
# block every unrelated commit in the meantime.
- path: internal/tui/
linters:
- staticcheck
text: "SA1019"
40 changes: 40 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,46 @@ archives:
checksum:
name_template: checksums.txt

# Signatures and a bill of materials, because a checksum file signs nothing.
#
# Whoever can replace the binary on the release page can replace checksums.txt
# beside it, and the install script downloads both from the same place. The
# checksum proves the download was not corrupted in transit; it says nothing
# about who produced it. That matters more here than for most projects, because
# the documented way to install this is `curl … | bash`.
#
# Keyless signing: cosign gets a short-lived certificate from Fulcio bound to
# the GitHub Actions identity that ran the release, and the fact is recorded in
# the Rekor transparency log. There is no private key for anybody to lose, and
# the question a verifier asks is answerable — "was this built by the release
# workflow of this repository", rather than "do I recognise this key".
#
# Verify a download with:
# cosign verify-blob --signature checksums.txt.sig \
# --certificate checksums.txt.pem \
# --certificate-identity-regexp 'https://github.com/orkcom-tech/contextverse/.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# checksums.txt
signs:
- cmd: cosign
certificate: "${artifact}.pem"
args:
- sign-blob
- "--output-certificate=${certificate}"
- "--output-signature=${signature}"
- "${artifact}"
- "--yes"
# The checksum file covers every artifact, so signing it signs the release.
artifacts: checksum
output: true

# What is actually inside each archive, in a format a scanner can read. Without
# one, answering "is this release affected by CVE-x" means rebuilding it and
# hoping the dependency graph has not moved.
sboms:
- id: archive
artifacts: archive

changelog:
sort: asc
filters:
Expand Down
4 changes: 3 additions & 1 deletion internal/auth/auth_hardening_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ func TestLoginFailuresAreIndistinguishableAndLockOut(t *testing.T) {
t.Fatalf("failure reasons must not differ: %q vs %q", missing, wrong)
}

for i := 0; i < MaxLoginFailures; i++ {
// Enough failures from anywhere to reach the account's own budget, which is
// deliberately looser than the per-address one — see lockout.go.
for i := 0; i < MaxAccountFailures; i++ {
_, _, _ = s.LoginUserpass("kim", "wrong-password")
}
if !s.LoginLocked("kim") {
Expand Down
47 changes: 40 additions & 7 deletions internal/auth/lockout.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,40 @@ import (
"time"
)

// Lockout thresholds for password login. Single-node, in-process: a restart
// clears the counters, which is acceptable because the window is short.
// Lockout thresholds for password login. In-process: a restart clears the
// counters, which is acceptable because the window is short.
//
// # Who gets locked out
//
// Counting failures against the username alone means anyone who knows a name can
// lock its owner out by failing five times — a denial of service delivered by
// typing the wrong password, and one that a support desk cannot distinguish from
// a real attack. That is worse than the attack it prevents, because the attacker
// pays nothing and the victim loses access.
//
// So failures count twice: against the account, and against the address they
// came from. An account lock still exists, because an attacker spread across
// many addresses has to be stopped somewhere, but it is deliberately the looser
// of the two — a wider budget, so a single hostile client hits its own limit
// long before it can spend the account's.
//
// # What this does not solve
//
// The counters live in one process. A fleet behind a load balancer gives an
// attacker a fresh budget per replica, and a restart clears everything. Fixing
// that needs shared state, which the OSS server deliberately does not have —
// contextverse-server.md calls its HA "stateless, no clustering". An operator
// running more than one replica should rate-limit authentication at the router;
// the values here are a floor, not a fleet-wide guarantee.
const (
// MaxLoginFailures is the per-address budget: the one an attacker spends.
MaxLoginFailures = 5
LockoutWindow = 15 * time.Minute
LockoutDuration = 15 * time.Minute
// MaxAccountFailures is the per-account budget. Larger on purpose: reaching
// it means failures arrived from several addresses, which is the case the
// account lock exists for.
MaxAccountFailures = 25
LockoutWindow = 15 * time.Minute
LockoutDuration = 15 * time.Minute
)

// BootstrapTokenTTL bounds the first-run admin token, which is written to disk
Expand Down Expand Up @@ -48,7 +76,7 @@ func (l *loginFailures) locked(key string, now time.Time) bool {
return false
}

func (l *loginFailures) fail(key string, now time.Time) {
func (l *loginFailures) fail(key string, now time.Time, max int) {
l.mu.Lock()
defer l.mu.Unlock()
if l.by == nil {
Expand All @@ -60,7 +88,7 @@ func (l *loginFailures) fail(key string, now time.Time) {
return
}
st.count++
if st.count >= MaxLoginFailures {
if st.count >= max {
st.blocked = now.Add(LockoutDuration)
}
// Opportunistic eviction so a scripted attacker cannot grow the map without
Expand All @@ -82,5 +110,10 @@ func (l *loginFailures) reset(key string) {

// LoginLocked reports whether password login for username is currently refused.
func (s *Store) LoginLocked(username string) bool {
return s.failures.locked(username, time.Now())
return s.failures.locked(accountKey(username), time.Now())
}

// Namespaced so a username can never collide with an address — "10.0.0.1" is a
// legal username.
func accountKey(username string) string { return "user:" + username }
func addressKey(addr string) string { return "addr:" + addr }
Loading
Loading