Summary
The Linux keychain backend (Secret Service over D-Bus, using the dh-ietf1024-sha256-aes128-cbc-pkcs7 session encryption mode) intermittently fails to create items with:
save secret: failed to create item: Couldn't create item: The secret was transferred or encrypted in an invalid way
This surfaces as flaky CI on the Linux keychain tests (roughly 1 run in 256).
Affected code
store/keychain/internal/go-keychain/secretservice/dh_ietf1024_sha256_aes128_cbc_pkcs7.go, function keygenHKDFSHA256AES128:
sharedSecret, err := group.diffieHellman(theirPublic, myPrivate)
if err != nil {
return nil, err
}
sharedSecretBytes := sharedSecret.Bytes() // <-- strips leading zero bytes
defer clear(sharedSecretBytes)
r := hkdf.New(sha256.New, sharedSecretBytes, nil, nil)
Root cause
The Secret Service DH key exchange uses the 1024-bit RFC 2409 Second Oakley Group. After computing the Diffie-Hellman shared secret s = peerPublic^myPrivate mod p, both peers must derive the AES key with HKDF-SHA256 over the shared secret encoded as a fixed-length, 128-byte big-endian value (the group's prime size).
math/big.Int.Bytes() returns the minimal big-endian encoding — it drops leading zero bytes. When the shared secret happens to be numerically small enough to have one or more leading zero bytes, our HKDF input is shorter than 128 bytes, while gnome-keyring / libsecret feeds the full zero-padded 128 bytes. The two sides therefore derive different AES keys.
With a mismatched key, the item we encrypt cannot be decrypted by the keyring daemon, which rejects it with The secret was transferred or encrypted in an invalid way.
The top byte of the shared secret is zero with probability ≈ 1/256, which matches the observed intermittent (~0.4%) failure rate; each additional leading zero byte adds another ~1/256 factor. This is a well-known class of bug in Secret Service client implementations — the same leading-zero issue has been found and fixed in other language bindings.
Proposed fix
Encode the shared secret into a fixed-length buffer sized to the group prime, using big.Int.FillBytes (which left-pads with zeros):
sharedSecret, err := group.diffieHellman(theirPublic, myPrivate)
if err != nil {
return nil, err
}
// The peer derives the key over the shared secret encoded as a fixed-length
// big-endian value matching the group prime size (128 bytes for the 1024-bit
// Second Oakley Group). big.Int.Bytes() strips leading zeros, so pad to the
// prime length to keep both sides in agreement.
sharedSecretBytes := make([]byte, (group.p.BitLen()+7)/8)
sharedSecret.FillBytes(sharedSecretBytes)
defer clear(sharedSecretBytes)
r := hkdf.New(sha256.New, sharedSecretBytes, nil, nil)
FillBytes is safe here because the shared secret is always < p, so it always fits in ceil(bitLen(p)/8) = 128 bytes.
Related observation (separate, lower priority)
SecretService.GetSecret in secretservice.go swallows a decryption error and returns nil, nil:
plaintext, err := unauthenticatedAESCBCDecrypt(secret.Parameters, secret.Value, session.AESKey)
if err != nil {
return nil, nil // should return the error
}
The same key mismatch would corrupt the read path too, but here the error is hidden and the caller receives a silent empty secret. Worth returning the error alongside the fix above so future key issues surface instead of being masked.
Suggested validation
- Add a unit test asserting the HKDF input (shared secret encoding) is always 128 bytes, or assert key agreement against a vector whose shared secret has a leading zero byte.
- Note that the existing
TestKeygen passes today but does not catch this case: it compares two calls into the same implementation, so both sides share the bug and still agree. A regression test must pin the encoding length or use a known vector.
Summary
The Linux keychain backend (Secret Service over D-Bus, using the
dh-ietf1024-sha256-aes128-cbc-pkcs7session encryption mode) intermittently fails to create items with:This surfaces as flaky CI on the Linux keychain tests (roughly 1 run in 256).
Affected code
store/keychain/internal/go-keychain/secretservice/dh_ietf1024_sha256_aes128_cbc_pkcs7.go, functionkeygenHKDFSHA256AES128:Root cause
The Secret Service DH key exchange uses the 1024-bit RFC 2409 Second Oakley Group. After computing the Diffie-Hellman shared secret
s = peerPublic^myPrivate mod p, both peers must derive the AES key withHKDF-SHA256over the shared secret encoded as a fixed-length, 128-byte big-endian value (the group's prime size).math/big.Int.Bytes()returns the minimal big-endian encoding — it drops leading zero bytes. When the shared secret happens to be numerically small enough to have one or more leading zero bytes, our HKDF input is shorter than 128 bytes, while gnome-keyring / libsecret feeds the full zero-padded 128 bytes. The two sides therefore derive different AES keys.With a mismatched key, the item we encrypt cannot be decrypted by the keyring daemon, which rejects it with
The secret was transferred or encrypted in an invalid way.The top byte of the shared secret is zero with probability ≈ 1/256, which matches the observed intermittent (~0.4%) failure rate; each additional leading zero byte adds another ~1/256 factor. This is a well-known class of bug in Secret Service client implementations — the same leading-zero issue has been found and fixed in other language bindings.
Proposed fix
Encode the shared secret into a fixed-length buffer sized to the group prime, using
big.Int.FillBytes(which left-pads with zeros):FillBytesis safe here because the shared secret is always< p, so it always fits inceil(bitLen(p)/8) = 128bytes.Related observation (separate, lower priority)
SecretService.GetSecretinsecretservice.goswallows a decryption error and returnsnil, nil:The same key mismatch would corrupt the read path too, but here the error is hidden and the caller receives a silent empty secret. Worth returning the error alongside the fix above so future key issues surface instead of being masked.
Suggested validation
TestKeygenpasses today but does not catch this case: it compares two calls into the same implementation, so both sides share the bug and still agree. A regression test must pin the encoding length or use a known vector.