src/agreement.rs:66-68:
pub fn agree(&self, their_public: &PublicKey) -> SharedSecret {
SharedSecret(&self.0 * &their_public.0)
}
PublicKey::from accepts any 32 bytes; there is no explicit on-curve
check, no low-order-point rejection, and no twist-point rejection.
This is consistent with RFC 7748, which defines X25519 as accepting
any 32-byte input. The security argument relies on:
- X25519 clamping (src/agreement.rs:93-95) forces the secret scalar
to s ≡ 0 (mod 8). Therefore s · P = identity (all-zero) for any
point P of order dividing 8. An adversary providing a low-order
public key gets an all-zero shared secret and learns nothing
about the device's private scalar.
- Curve25519's twist has its smallest non-cofactor factor at
approximately 2^125, blocking practical twist attacks within the
threat model's adversary capabilities.
The lack of explicit checks is therefore not a key-recovery
vulnerability. However, an adversary submitting low-order public
keys receives a deterministic all-zero shared secret. A host
application that uses the shared secret directly as an encryption
key (without HKDF mixing in the public keys) would produce
ciphertexts with the same key as everyone else — a functional
security failure at the application level.
In the Nitrokey 3 firmware, all current downstream uses of X25519
feed the shared secret through HKDF or HPKE key_schedule that
binds the public keys into the KDF input, providing structural
resistance to this concern. The finding is therefore defense-in-
depth only: the salty layer provides no protection of its own;
resistance depends on the caller doing the right thing.
Recommended remediation.
Add an optional reject_zero or reject_low_order method to
SharedSecret that returns an error rather than the all-zero
value. Document the existing behavior and the new API in the
crate README.
src/agreement.rs:66-68:
PublicKey::from accepts any 32 bytes; there is no explicit on-curve
check, no low-order-point rejection, and no twist-point rejection.
This is consistent with RFC 7748, which defines X25519 as accepting
any 32-byte input. The security argument relies on:
to s ≡ 0 (mod 8). Therefore s · P = identity (all-zero) for any
point P of order dividing 8. An adversary providing a low-order
public key gets an all-zero shared secret and learns nothing
about the device's private scalar.
approximately 2^125, blocking practical twist attacks within the
threat model's adversary capabilities.
The lack of explicit checks is therefore not a key-recovery
vulnerability. However, an adversary submitting low-order public
keys receives a deterministic all-zero shared secret. A host
application that uses the shared secret directly as an encryption
key (without HKDF mixing in the public keys) would produce
ciphertexts with the same key as everyone else — a functional
security failure at the application level.
In the Nitrokey 3 firmware, all current downstream uses of X25519
feed the shared secret through HKDF or HPKE key_schedule that
binds the public keys into the KDF input, providing structural
resistance to this concern. The finding is therefore defense-in-
depth only: the salty layer provides no protection of its own;
resistance depends on the caller doing the right thing.
Recommended remediation.
Add an optional reject_zero or reject_low_order method to
SharedSecret that returns an error rather than the all-zero
value. Document the existing behavior and the new API in the
crate README.