Skip to content

Commit dea0b6d

Browse files
fix(security): known-hosts Put fails closed on concurrent first-use key conflict (#668)
* fix(security): known-hosts Put fails closed on concurrent first-use key conflict internal/knownhosts.Store.Put discarded the command tag, so a conflicting-key INSERT that updated 0 rows returned nil. On a concurrent first-use race (two first-ever dials to the same hostname — scheduler + manual scan, or scan + liveness), the first connection records key A; a second arriving with a DIFFERENT key B (an attacker's MITM key on one path) hits ON CONFLICT, the `WHERE public_key = EXCLUDED.public_key` predicate is false, 0 rows update, and Put returned nil — so the TOFU callback accepted the unverified key B and presented the real credential to the attacker. Steady state was already safe (later dials Get-hit A and reject B); the gap was strictly the concurrent first-use window. Fix: check tag.RowsAffected(); 0 rows means a different key is already stored, so return ssh.ErrHostKeyMismatch and let the dial fail closed. Same key still refreshes last_seen (1 row) and stays idempotent. Regression test: Put A, then Put B for the same host returns ErrHostKeyMismatch and leaves A stored. Found by the rc.13 security review. * docs: use standard 'Fixed' category for the Unreleased docs entry The docs-accuracy entry (#667) used a '### Docs' heading under [Unreleased], which TestChangelog_UnreleasedCategoriesAreStandard rejects (only Added/Changed/ Deprecated/Removed/Fixed/Security are allowed there). Renamed to '### Fixed'. Released sections may keep their '### Docs' heading; the test only gates [Unreleased].
1 parent 51dad74 commit dea0b6d

3 files changed

Lines changed: 50 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010

1111
## [Unreleased]
1212

13-
### Docs
13+
### Fixed
1414
- Operator-guide truthfulness + accuracy pass (`docs/guides/`): verified every
1515
documented `openwatch` CLI subcommand, REST endpoint, file path, env var, and
1616
systemd unit against the binary, `api/openapi.yaml`, and `packaging/`. Fixed

internal/knownhosts/store.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"time"
1515

1616
"github.com/jackc/pgx/v5/pgxpool"
17+
18+
owssh "github.com/Hanalyx/openwatch/internal/ssh"
1719
)
1820

1921
// Store implements ssh.KnownHostsStore against the ssh_known_hosts table.
@@ -47,11 +49,25 @@ func (s *Store) Get(hostname string) ([]byte, bool) {
4749
func (s *Store) Put(hostname string, marshalled []byte) error {
4850
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
4951
defer cancel()
50-
_, err := s.pool.Exec(ctx,
52+
tag, err := s.pool.Exec(ctx,
5153
`INSERT INTO ssh_known_hosts (hostname, public_key)
5254
VALUES ($1, $2)
5355
ON CONFLICT (hostname) DO UPDATE SET last_seen = now()
5456
WHERE ssh_known_hosts.public_key = EXCLUDED.public_key`,
5557
hostname, marshalled)
56-
return err
58+
if err != nil {
59+
return err
60+
}
61+
// Zero rows affected means a row already existed for this hostname with a
62+
// DIFFERENT key: a concurrent first-use race lost to another connection
63+
// that recorded a different key (or a changed key slipped past Get). The
64+
// INSERT conflicted and the UPDATE's key-equality predicate was false, so
65+
// nothing was written. Fail closed — never let Put report success for a key
66+
// that does not match what is now stored, which would otherwise let the
67+
// TOFU callback accept an unverified (possibly MITM) host key on this
68+
// connection.
69+
if tag.RowsAffected() == 0 {
70+
return owssh.ErrHostKeyMismatch
71+
}
72+
return nil
5773
}

internal/knownhosts/store_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
package knownhosts
66

77
import (
8+
"errors"
89
"testing"
910

1011
"github.com/Hanalyx/openwatch/internal/db/dbtest"
12+
owssh "github.com/Hanalyx/openwatch/internal/ssh"
1113
)
1214

1315
// @ac AC-22
@@ -49,3 +51,32 @@ func TestStore_DurableTOFU(t *testing.T) {
4951
}
5052
})
5153
}
54+
55+
// @ac AC-22
56+
// Regression (known-hosts TOCTOU): when a row already exists for the hostname
57+
// with a DIFFERENT key — the concurrent first-use race where another connection
58+
// recorded a different key first — Put MUST fail closed with ErrHostKeyMismatch.
59+
// Before the fix the conflicting UPDATE touched 0 rows but Exec returned nil, so
60+
// Put reported success and the TOFU callback accepted an unverified key.
61+
func TestStore_Put_ConflictingKeyFailsClosed(t *testing.T) {
62+
t.Run("system-ssh-connectivity/AC-22", func(t *testing.T) {
63+
pool := dbtest.Pool(t)
64+
s := NewStore(pool)
65+
66+
// First connection records key A.
67+
if err := s.Put("host-x", []byte("first-key-A")); err != nil {
68+
t.Fatalf("Put A: %v", err)
69+
}
70+
// A racing first-use connection presents a DIFFERENT key B for the same
71+
// host. Put must NOT report success.
72+
err := s.Put("host-x", []byte("racing-different-key-B"))
73+
if !errors.Is(err, owssh.ErrHostKeyMismatch) {
74+
t.Fatalf("Put with conflicting key = %v, want ErrHostKeyMismatch", err)
75+
}
76+
// The originally-stored key still wins; the conflicting key was rejected.
77+
got, ok := s.Get("host-x")
78+
if !ok || string(got) != "first-key-A" {
79+
t.Fatalf("stored key = (%q, %v), want first-key-A unchanged", got, ok)
80+
}
81+
})
82+
}

0 commit comments

Comments
 (0)