Summary
remote.CheckPushPermission resolves the credential against ref.Context().Registry, while every other credential resolution in remote resolves against the repository. With a config.json that has a registry-level entry next to a more specific repository-level one, the two pick different credentials, and the preflight rejects a push that remote.Write then performs successfully.
Reproduced on main (d3bca10) and v0.21.9.
the registry-scoped resolve was spotted by @WoozyMasta while implementing path-scoped registry auth in kaniko osscontainertools/kaniko#1002
The mismatch
check.go:35 kc.Resolve(ref.Context().Registry) <- name.Registry
fetcher.go:53 authn.Resolve(ctx, o.keychain, target) <- name.Repository
write.go:85 authn.Resolve(ctx, o.keychain, repo) <- name.Repository
write.go:101 authn.Resolve(ctx, o.keychain, repo) <- name.Repository
authn.Resource carries exactly String() and RegistryStr(). For a name.Registry both return the bare host, so passing the registry does not narrow the lookup, it drops the repository from the keychain's input and no keychain can recover it. DefaultKeychain then has two identical candidates and never considers a repository-keyed auths entry.
The two halves of the function also disagree with each other:
auth, err := kc.Resolve(ref.Context().Registry) // check.go:35 registry
scopes := []string{ref.Scope(transport.PushScope)} // check.go:40 repository:org-a/project/image:push,pull
If registry-scope credential selection were deliberate the scope would be registry:catalog:*, which is what name.Registry.Scope returns. check.go is from 2019 and predates #510, which made the Keychain API repository-aware.
What happens
A config.json with a wrong host-level credential next to a correct repository-level one, against a registry where only gooduser is valid:
{
"auths": {
"localhost:5555": {"auth": "<base64 wronguser:wrongpass>"},
"localhost:5555/org-a/project/image": {"auth": "<base64 gooduser:goodpass>"}
}
}
Pushing localhost:5555/org-a/project/image:latest with authn.DefaultKeychain:
Resolve(localhost:5555) -> user="wronguser"
Resolve(localhost:5555/org-a/project/image) -> user="gooduser"
CheckPushPermission: POST http://localhost:5555/v2/org-a/project/image/blobs/uploads/: UNAUTHORIZED: authentication required
Write: <nil>
The check refuses the push, remote.Write to the same ref then succeeds.
Impact
Any caller with a repository-aware keychain gets a preflight that tests a different credential than the push uses. That is both false negatives, where a build is refused although the push would have worked, and false positives, where the build runs to completion and then 401s on push, which is the exact failure the preflight exists to prevent. For plain DefaultKeychain it needs the combination above, which is the normal reason to write a repository-keyed entry at all, a general credential for the host and a narrower one for one repository.
Worth noting why this has gone unnoticed. When the host lookup misses, docker/cli's fileStore.Get falls back to iterating every auths key and comparing ConvertToHostname(key), which cuts at the first /. A repository key therefore fuzzy-matches a host lookup, and that is what rescues the registry-scoped resolve in the simpler configs:
| config |
host lookup |
result |
| host entry present |
direct hit |
deterministic, fallback never runs |
| one repository entry, no host entry |
direct miss, one key matches |
deterministic, and it happens to work |
| several repository entries, no host entry |
direct miss, several keys match |
non-deterministic, map iteration order decides |
remote.Write never reaches that fallback, because the exact repository key hits the map directly.
Fix direction
auth, err := authn.Resolve(context.TODO(), kc, ref.Context())
The check then resolves the same credential as the remote.Write it precedes.
It does not drop any credential that resolves today. A keychain handed a repository still sees the host through RegistryStr(), and DefaultKeychain already tries the host as its second candidate. A keychain handed a registry cannot see the repository at all.
Using authn.Resolve rather than kc.Resolve also honours ContextKeychain, which check.go:35 is the only credential resolution in the package to skip.
Summary
remote.CheckPushPermissionresolves the credential againstref.Context().Registry, while every other credential resolution inremoteresolves against the repository. With aconfig.jsonthat has a registry-level entry next to a more specific repository-level one, the two pick different credentials, and the preflight rejects a push thatremote.Writethen performs successfully.Reproduced on
main(d3bca10) andv0.21.9.the registry-scoped resolve was spotted by @WoozyMasta while implementing path-scoped registry auth in kaniko osscontainertools/kaniko#1002
The mismatch
authn.Resourcecarries exactlyString()andRegistryStr(). For aname.Registryboth return the bare host, so passing the registry does not narrow the lookup, it drops the repository from the keychain's input and no keychain can recover it.DefaultKeychainthen has two identical candidates and never considers a repository-keyedauthsentry.The two halves of the function also disagree with each other:
If registry-scope credential selection were deliberate the scope would be
registry:catalog:*, which is whatname.Registry.Scopereturns.check.gois from 2019 and predates #510, which made theKeychainAPI repository-aware.What happens
A
config.jsonwith a wrong host-level credential next to a correct repository-level one, against a registry where onlygooduseris valid:{ "auths": { "localhost:5555": {"auth": "<base64 wronguser:wrongpass>"}, "localhost:5555/org-a/project/image": {"auth": "<base64 gooduser:goodpass>"} } }Pushing
localhost:5555/org-a/project/image:latestwithauthn.DefaultKeychain:The check refuses the push,
remote.Writeto the same ref then succeeds.Impact
Any caller with a repository-aware keychain gets a preflight that tests a different credential than the push uses. That is both false negatives, where a build is refused although the push would have worked, and false positives, where the build runs to completion and then 401s on push, which is the exact failure the preflight exists to prevent. For plain
DefaultKeychainit needs the combination above, which is the normal reason to write a repository-keyed entry at all, a general credential for the host and a narrower one for one repository.Worth noting why this has gone unnoticed. When the host lookup misses, docker/cli's
fileStore.Getfalls back to iterating everyauthskey and comparingConvertToHostname(key), which cuts at the first/. A repository key therefore fuzzy-matches a host lookup, and that is what rescues the registry-scoped resolve in the simpler configs:remote.Writenever reaches that fallback, because the exact repository key hits the map directly.Fix direction
The check then resolves the same credential as the
remote.Writeit precedes.It does not drop any credential that resolves today. A keychain handed a repository still sees the host through
RegistryStr(), andDefaultKeychainalready tries the host as its second candidate. A keychain handed a registry cannot see the repository at all.Using
authn.Resolverather thankc.Resolvealso honoursContextKeychain, which check.go:35 is the only credential resolution in the package to skip.