1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
|
# Auth-Key Fast-Reconnect for DTail
## Problem
When using a YubiKey for SSH authentication, each DTail connection requires a
physical touch of the YubiKey during the SSH handshake. This is slow and becomes
painful when connecting to many servers concurrently — the YubiKey serialises
all signing requests, turning parallel connections into sequential ones.
## Solution
Allow the DTail client to register a local SSH public key with the DTail server
over an already-authenticated SSH session. The server caches this key
**in-memory only** (never written to disk). On subsequent connections the client
offers that local key first — a pure in-memory RSA verify with no YubiKey
interaction — and falls back to the original auth method if the server does not
recognise the key.
## Design Principles
1. **Transparent fallback** — Go's `golang.org/x/crypto/ssh` tries each
`AuthMethod` in order; if the fast key is rejected the client silently falls
back to the SSH agent / YubiKey. No user interaction required.
2. **Server keys are ephemeral** — the in-memory store is lost on server
restart. No file I/O, no persistence.
3. **Trust chain preserved** — an auth-key can only be registered over a session
that was already authenticated via the normal (YubiKey) path.
4. **Minimal protocol addition** — a single `AUTHKEY <base64-pubkey>` command
sent over the existing SSH session text protocol.
## Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ DTail Client │
│ │
│ Auth methods (tried in order): │
│ 1. Local private key (~/.ssh/id_rsa) ← FAST │
│ 2. SSH Agent / YubiKey ← SLOW fallback │
│ │
│ After slow-path auth: │
│ → sends AUTHKEY <~/.ssh/id_rsa.pub> to server │
└────────────────────────┬────────────────────────────────┘
│ SSH
┌────────────────────────▼────────────────────────────────┐
│ DTail Server (dserver) │
│ │
│ PublicKeyCallback: │
│ 1. Check in-memory authkeystore ← FAST │
│ 2. Check authorized_keys file ← existing path │
│ │
│ AUTHKEY command handler: │
│ → authkeystore.Add(user, pubkey) │
│ → responds AUTHKEY OK / AUTHKEY ERR │
│ │
│ authkeystore (in-memory only): │
│ map[username] → []PublicKey (with TTL, max per user) │
└─────────────────────────────────────────────────────────┘
```
## Sequence of Events
### First Connection (slow path — YubiKey)
1. Client checks for local private key at `~/.ssh/id_rsa` (or `--auth-key-path`).
2. Client builds auth methods list: `[localKey, sshAgent]`.
3. SSH handshake begins; server's `PublicKeyCallback` is called with local key.
4. Server checks in-memory authkeystore → not found.
5. Server checks `authorized_keys` file → not found (this key isn't in there).
6. Server rejects the key.
7. Go SSH client automatically tries next auth method: SSH agent (YubiKey).
8. YubiKey signs the challenge; server finds the YubiKey pubkey in
`authorized_keys` → auth succeeds.
9. Session is established; client sends DTail commands as usual.
10. Client reads `~/.ssh/id_rsa.pub` and sends `AUTHKEY <base64-pubkey>`.
11. Server's handler parses the command, calls `authkeystore.Add(user, pubkey)`.
12. Server responds `AUTHKEY OK`.
### Subsequent Connections (fast path — no YubiKey)
1. Client builds auth methods list: `[localKey, sshAgent]`.
2. SSH handshake begins; server's `PublicKeyCallback` is called with local key.
3. Server checks in-memory authkeystore → **found** → auth succeeds immediately.
4. No YubiKey touch needed. Session is established instantly.
### Fallback (server restarted, key expired)
1. Client offers local key → server's authkeystore is empty → rejected.
2. Client falls back to SSH agent → YubiKey auth succeeds.
3. Client re-registers local pubkey via `AUTHKEY` command.
## Components
### 1. Server: In-Memory Auth-Key Store
**New file:** `internal/ssh/server/authkeystore.go`
- Thread-safe store using `sync.RWMutex`.
- Data structure: `map[string][]authKeyEntry` where key is username.
- Each `authKeyEntry` holds `gossh.PublicKey` + `time.Time` (registered at).
- Methods: `Add(user, pubkey)`, `Has(user, pubkey) bool`, `Remove(user, pubkey)`.
- Per-user max key limit (default 5, configurable via `AuthKeyMaxPerUser`).
- TTL-based expiry (default 24h, configurable via `AuthKeyTTLSeconds`).
- Lazy expiry: check TTL on `Has()` calls; optionally a background reaper.
- Package-level singleton or passed via dependency injection.
### 2. Server: Extend PublicKeyCallback
**Modified file:** `internal/ssh/server/publickeycallback.go`
- Before the existing `authorizedKeysFile` lookup, check `authkeystore.Has(user, offeredPubKey)`.
- If found → return success immediately (fast path).
- If not found → fall through to existing file-based logic (no behaviour change).
### 3. Server: AUTHKEY Command Handler
**Modified file:** `internal/server/handlers/serverhandler.go` (or relevant handler)
- Parse incoming line for `AUTHKEY <base64-pubkey>` prefix.
- Decode the base64 public key using `gossh.ParsePublicKey()`.
- Call `authkeystore.Add(user, pubkey)`.
- Write `AUTHKEY OK\n` or `AUTHKEY ERR <reason>\n` back to the client.
- Guard: only accept if `AuthKeyEnabled` is true in server config.
### 4. Client: Auth Method Ordering (Multi-Method Support)
**Modified file:** `internal/ssh/client/authmethods.go`
- Change `initKnownHostsAuthMethods` to **collect multiple auth methods**
instead of returning after the first successful one.
- Order: local private key first (from `--auth-key-path`, default `~/.ssh/id_rsa`),
then SSH agent, then other default keys.
- This ensures Go's SSH client tries the fast key before the YubiKey.
### 5. Client: Auth-Key Registration After Slow-Path Connection
**Modified file:** `internal/clients/connectors/serverconnection.go` (or handler layer)
- After session is established and DTail commands are sent, determine whether
the connection used the fast path or slow path.
- If slow path (YubiKey was used): read the public key file
(`--auth-key-path` + `.pub`), send `AUTHKEY <base64-pubkey>` command.
- Parse `AUTHKEY OK` / `AUTHKEY ERR` response.
- A simple heuristic: if the auth-key-path private key exists and we have a
corresponding `.pub` file, always send the registration — sending it again is
idempotent and cheap.
### 6. Configuration
**Modified files:** `internal/config/server.go`, `internal/config/client.go`, `internal/config/args.go`
Server config (`dtail.json`):
- `AuthKeyEnabled` (bool, default `true`)
- `AuthKeyTTLSeconds` (int, default `86400` = 24h)
- `AuthKeyMaxPerUser` (int, default `5`)
Client config / CLI flags:
- `--auth-key-path` (string, default `~/.ssh/id_rsa`) — path to the local
private key to try first and whose `.pub` counterpart is registered
- `--no-auth-key` (bool, default `false`) — disable auth-key feature entirely
### 7. Integration Tests
**Modified/new files in:** `integrationtests/`
- Test that auth-key registration works end-to-end.
- Test that fast-path auth succeeds after registration.
- Test fallback when server has no cached key (simulating restart).
- Test TTL expiry and max-keys-per-user limits.
- Test `--no-auth-key` disables the feature.
### 8. Documentation
- Update `README.md` with auth-key feature description.
- Update `AGENTS.md` / `CLAUDE.md` with new config options and architecture notes.
## Security Considerations
- **No server-side disk persistence** — keys exist only in memory, lost on restart.
- **Trust chain** — auth-keys can only be registered over an already-authenticated
session. An attacker cannot register a key without first proving identity.
- **TTL expiry** — keys auto-expire (default 24h), limiting exposure window.
- **Per-user limits** — max 5 keys per user prevents memory exhaustion.
- **Same security model as `~/.ssh/id_rsa`** — the local key is protected by
filesystem permissions (0600). If an attacker has access to `~/.ssh/id_rsa`,
they already have SSH access anyway.
- **No new attack surface** — the `AUTHKEY` command is only processed inside an
authenticated session. The `PublicKeyCallback` fast-path is equivalent to
having the key in `authorized_keys`.
## Implementation Order
1. Auth-key store (server, standalone, unit-testable)
2. Extend `PublicKeyCallback` (server, minimal change)
3. `AUTHKEY` command handler (server handler)
4. Client auth method ordering (multi-method collection)
5. Client auth-key registration (send pubkey after slow-path)
6. Configuration and CLI flags
7. Integration tests
8. Documentation
|