mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
Follow-up to 8a556a5. v3 keys accounts by DID and, unlike v2, rewrites the
config during ordinary `docker pull` traffic (migration on first read, DID
backfill after a successful get) rather than only on explicit login. That moved
three latent ways to lose a device.json onto the hot path. A user cannot recover
from any of them: once the file is gone they no longer know which accounts they
had.
save() is now atomic. It writes a temp file in the same directory, fsyncs, and
renames, so the config always holds either the previous contents or the complete
new contents. The truncate-then-write it replaced left a zero-length window that
Ctrl-C, Docker reaping the helper, or a suspend could land in. The fsync matters
on its own: on ext4/XFS a rename can become durable while the new file's data
blocks are not, which resurrects the empty-file case. The rename also replaces
the destination's mode, which incidentally repairs a config restored from a
backup as world-readable (os.WriteFile's perm argument only applies at creation).
An unreadable config no longer degrades into an empty one that the next write
commits. loadConfig returns a usable empty config so read paths can still print
something helpful, but it now marks the unrecoverable cases with
errConfigUnusable, and loadConfigForWrite refuses on that sentinel. This is the
downgrade path: the previous binary hard-gates on Version == 2, so without the
guard a reinstall of an older helper would wipe a v3 file on the first
`docker login`. loadConfig also stops explicitly on a version newer than it
understands instead of falling through the legacy probes to the same empty
config. All six write paths take the guard — get, store, erase, login, logout,
switch. login had the same warn-and-continue-then-save shape as store.
The guard keys on the sentinel rather than on any error, deliberately. The
v2 -> v3 migration returns a fully populated config alongside a "saving migrated
config" error when the directory is not writable, and every pre-v3 user passes
through that path on their next invocation; refusing there would hard-break
`docker pull` for exactly the population that is migrating.
migrateV2toV3 merges DID collisions deterministically. Two v2 entries collapse
onto one v3 key when a handle was renamed and the old entry was never cleaned up,
which is the case v3 exists to fix. The previous loop wrote both into the same
map slot in randomized iteration order, so which account survived varied run to
run, and when the stale one won, get would fail validation and remove it — the
user ended up with no account at all. Iteration is now sorted and collisions
resolve through v2EntryBeats (active entry, then the one holding a secret, then
the smaller key), with the loser donating its secret if the winner has none. The
"No account is dropped" comment was false and is now accurate.
Also deterministic: find() and upsert() resolve their scans through a shared
scanFor helper that prefers DID matches and breaks ties on the smallest key, so
two entries sharing a handle can no longer hand Docker a different secret on each
invocation. upsert additionally refuses to match a handle that already belongs to
a different DID, which previously let it overwrite an unrelated account's DID and
destroy that account's credentials. Nil map entries are tolerated throughout
rather than panicking on Docker's credential path.
resolveHandleDNS is bounded at 2s. It runs synchronously on every store and on
every get for a DID-less account, and net.LookupTXT applies no deadline of its
own, so a blackholing resolver (captive portal, split-horizon VPN) stalled
docker login and docker pull on each invocation.
Tests cover the paths that can lose credentials. Each was checked against the
pre-fix code: the collision, null-entry, DID-theft, find-determinism,
permissions, and out-of-place-write tests all fail or panic without their fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
3.6 KiB
Go
114 lines
3.6 KiB
Go
package credhelper
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// dnsResolveTimeout bounds the _atproto TXT lookup. Handle resolution runs on
|
|
// Docker's synchronous credential path (every `store`, and every `get` for an
|
|
// account that has no DID yet), and the default resolver applies no deadline of
|
|
// its own. Without this, a blackholing DNS setup — captive portal, split-horizon
|
|
// VPN — stalls `docker login` and `docker pull` for the resolver's full retry
|
|
// budget on every invocation. Resolution is best-effort, so giving up early just
|
|
// leaves the account on its provisional handle key.
|
|
const dnsResolveTimeout = 2 * time.Second
|
|
|
|
// resolveHandleDID resolves an AT-proto handle to its DID using the two
|
|
// standard resolution methods, no server or auth required:
|
|
//
|
|
// 1. DNS: a TXT record at _atproto.<handle> whose value is "did=<did>".
|
|
// 2. HTTPS: GET https://<handle>/.well-known/atproto-did returning the bare DID.
|
|
//
|
|
// DNS is tried first (cheaper, no TLS), HTTPS second. If the input already
|
|
// looks like a DID it is returned as-is. Returns an error if neither method
|
|
// yields a DID — callers treat resolution as best-effort (a failure just means
|
|
// the account keeps its handle key until the next attempt).
|
|
func resolveHandleDID(handle string) (string, error) {
|
|
h := normalizeHandle(handle)
|
|
if h == "" {
|
|
return "", fmt.Errorf("empty handle")
|
|
}
|
|
if looksLikeDID(h) {
|
|
return h, nil
|
|
}
|
|
|
|
if did := resolveHandleDNS(h); did != "" {
|
|
return did, nil
|
|
}
|
|
if did := resolveHandleHTTPS(h); did != "" {
|
|
return did, nil
|
|
}
|
|
return "", fmt.Errorf("could not resolve handle %q to a DID", handle)
|
|
}
|
|
|
|
// normalizeHandle lowercases and strips an optional leading "@" and any scheme
|
|
// or trailing slash a caller may have passed through.
|
|
func normalizeHandle(handle string) string {
|
|
h := strings.TrimSpace(handle)
|
|
h = strings.TrimPrefix(h, "@")
|
|
h = strings.TrimPrefix(h, "https://")
|
|
h = strings.TrimPrefix(h, "http://")
|
|
h = strings.TrimSuffix(h, "/")
|
|
return strings.ToLower(h)
|
|
}
|
|
|
|
// looksLikeDID reports whether s is syntactically a DID (did:method:id).
|
|
func looksLikeDID(s string) bool {
|
|
return strings.HasPrefix(s, "did:") && strings.Count(s, ":") >= 2
|
|
}
|
|
|
|
// resolveHandleDNS looks up the _atproto.<handle> TXT record and extracts the
|
|
// DID. Returns "" on any failure.
|
|
func resolveHandleDNS(handle string) string {
|
|
ctx, cancel := context.WithTimeout(context.Background(), dnsResolveTimeout)
|
|
defer cancel()
|
|
|
|
records, err := net.DefaultResolver.LookupTXT(ctx, "_atproto."+handle)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return parseDIDFromTXT(records)
|
|
}
|
|
|
|
// parseDIDFromTXT returns the first "did=<did>" value found in a set of TXT
|
|
// records, or "" if none is a valid DID.
|
|
func parseDIDFromTXT(records []string) string {
|
|
for _, rec := range records {
|
|
val := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(rec), "did="))
|
|
if val != rec && looksLikeDID(val) {
|
|
return val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveHandleHTTPS fetches https://<handle>/.well-known/atproto-did and
|
|
// returns the DID in the body. Returns "" on any failure.
|
|
func resolveHandleHTTPS(handle string) string {
|
|
client := httpClientWithTimeout(5*time.Second, nil)
|
|
resp, err := client.Get("https://" + handle + "/.well-known/atproto-did")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return ""
|
|
}
|
|
// The well-known document is a single line containing just the DID; cap the
|
|
// read so a misbehaving host can't stream us an unbounded body.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
did := strings.TrimSpace(string(body))
|
|
if !looksLikeDID(did) {
|
|
return ""
|
|
}
|
|
return did
|
|
}
|