[GH-ISSUE #227] AWG 3.x timer ranges are applied inconsistently: RejectAfterTime is ignored on data paths #131

Open
opened 2026-10-13 21:06:51 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @2Grey on GitHub (Aug 31, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/227

Summary

The configurable AWG 3.x timing parameters are not applied consistently in the kernel module.

The current code has at least three related problems:

  1. RejectAfterTime is ignored when validating sending and receiving keypairs. Both paths still use the fixed WireGuard constant REJECT_AFTER_TIME.
  2. After handshake retries are exhausted, the zero-key-material timer is derived from RekeyAfterTime instead of RejectAfterTime.
  3. The receive-side key refresh calculation can become negative and is passed to a function accepting an unsigned timeout, while amneziawg-go explicitly clamps the equivalent value to zero.

These inconsistencies can cause the kernel implementation to expire keys earlier or later than configured and to behave differently from amneziawg-go.

Affected source

  • Repository: amnezia-vpn/amneziawg-linux-kernel-module
  • Confirmed in tag: v3.1.20260828
  • The relevant configurable timers were introduced with AWG 3.x.
  • Earlier affected tags have not been bisected.

This report is based on source review. A target-router runtime reproduction has not yet been completed.

Problem 1: data paths ignore RejectAfterTime

The sending path always uses the fixed REJECT_AFTER_TIME constant:

https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/send.c#L464-L475

if (unlikely(wg_birthdate_has_expired(keypair->sending.birthdate, REJECT_AFTER_TIME)))
	goto out_invalid;

The receiving path does the same:

https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/receive.c#L331-L346

if (unlikely(!READ_ONCE(keypair->receiving.is_valid) ||
		wg_birthdate_has_expired(keypair->receiving.birthdate,
			REJECT_AFTER_TIME) ||
		READ_ONCE(keypair->receiving_counter.counter) >=
			REJECT_AFTER_MESSAGES))
{
	WRITE_ONCE(keypair->receiving.is_valid, false);
	return false;
}

Neither path reads:

peer->device->reject_after_time

This means, for example:

RejectAfterTime = 300

still causes the data paths to reject a key after the fixed 180-second default.

Conversely:

RejectAfterTime = 60

does not make those checks reject the key after 60 seconds.

This also differs from amneziawg-go, where both send and receive use keychainExpireTime():

The Go helper uses the upper bound of the configured RejectAfterTime range:

github.com/amnezia-vpn/amneziawg-go@b5928efb6c/device/timers.go (L290-L297)

func (device *Device) keychainExpireTime() time.Duration {
	rejectAfterTime := RejectAfterTime

	if t := device.timings.rejectAfterTimeSec.Load(); !t.IsZero() {
		rejectAfterTime = time.Duration(t.Hi()) * time.Second
	}

	return rejectAfterTime
}

Problem 2: failed-handshake cleanup uses RekeyAfterTime

After the maximum number of handshake attempts is exceeded, the kernel schedules destruction of residual key material.

The timeout is currently selected from rekey_after_time:

https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/timers.c#L43-L66

u16 timeout = !u16_range_is_zero(peer->device->rekey_after_time) ?
	u16_range_pick_one(peer->device->rekey_after_time) :
	REJECT_AFTER_TIME;

/* ... */

if (!timer_pending(&peer->timer_zero_key_material))
	mod_peer_timer(peer, &peer->timer_zero_key_material,
	jiffies + timeout * 3 * HZ);

This is inconsistent with wg_timers_session_derived(), which correctly derives the same zero-key-material timer from reject_after_time:

https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/timers.c#L221-L230

u16 timeout = !u16_range_is_zero(peer->device->reject_after_time) ?
	u16_range_pick_one(peer->device->reject_after_time) :
	REJECT_AFTER_TIME;

mod_peer_timer(peer, &peer->timer_zero_key_material, jiffies + timeout * 3 * HZ);

For example:

RekeyAfterTime = 10
RejectAfterTime = 600

can schedule cleanup after approximately:

10 × 3 = 30 seconds

instead of a value derived from:

600 × 3 = 1800 seconds

The fallback is also REJECT_AFTER_TIME, which makes the use of rekey_after_time in the configured branch look unintended.

Problem 3: negative receive refresh timeout is not clamped

The receive-side refresh timeout is calculated as:

https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/receive.c#L302-L307

static int key_fresh_timeout(struct wg_peer *peer)
{
	return
		(!u16_range_is_zero(peer->device->reject_after_time) ?
		 u16_range_pick_one(peer->device->reject_after_time) :
		 REJECT_AFTER_TIME) -
		(!u16_range_is_zero(peer->device->keepalive_timeout) ?
		 u16_range_lo(peer->device->keepalive_timeout) :
		 KEEPALIVE_TIMEOUT) -
		(!u16_range_is_zero(peer->device->rekey_timeout) ?
		 u16_range_lo(peer->device->rekey_timeout) :
		 REKEY_TIMEOUT);
}

A configuration such as:

RejectAfterTime = 20
KeepaliveTimeout = 15
RekeyTimeout = 10

produces:

20 - 15 - 10 = -5

The result is an int, but it is passed to:

https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/timers.h#L24-L28

static inline bool wg_birthdate_has_expired(
	u64 birthday_nanoseconds,
	u64 expiration_seconds)

The negative value is therefore converted to u64, and the subsequent nanosecond multiplication can wrap. This does not represent the intended immediate refresh behavior.

The equivalent amneziawg-go implementation explicitly clamps the result:

return max(0, rejectAfterTime-keepaliveTimeout-rekeyTimeout)

github.com/amnezia-vpn/amneziawg-go@b5928efb6c/device/timers.go (L272-L287)

Expected behavior

  • Sending and receiving keypair expiration should honor configured RejectAfterTime.
  • Kernel and Go implementations should use compatible range semantics.
  • Destruction of residual key material after failed handshakes should be based on RejectAfterTime, not RekeyAfterTime.
  • A negative receive refresh timeout should be clamped to zero before it is passed to wg_birthdate_has_expired().

Actual behavior

  • Sending and receiving always use the fixed 180-second constant.
  • Failed-handshake cleanup uses RekeyAfterTime × 3.
  • Negative receive refresh values are implicitly converted to unsigned timeouts.
  • Kernel behavior can diverge from amneziawg-go for the same configuration.

Suggested direction

A shared helper could provide the effective keychain expiration time, matching the Go implementation:

static inline u16 keychain_expire_time(const struct wg_device *wg)
{
	return !u16_range_is_zero(wg->reject_after_time) ?
		u16_range_hi(wg->reject_after_time) :
		REJECT_AFTER_TIME;
}

It could then be used by:

  • the sending keypair expiry check;
  • the receiving keypair expiry check;
  • the zero-key-material cleanup path.

The exact range semantics should be confirmed by the maintainers. If a random value is intended per session, it should be selected once and stored with the keypair rather than selected independently during packet checks.

For the receive refresh calculation, the result should be clamped before conversion to u64, for example:

return max_t(int, 0, reject_after - keepalive - rekey_timeout);

Suggested tests

  1. Configure RejectAfterTime above 180 seconds and verify that send and receive keys remain valid beyond 180 seconds.

  2. Configure RejectAfterTime below 180 seconds and verify expiration at the configured boundary.

  3. Use different RekeyAfterTime and RejectAfterTime values, force handshake retries to fail, and verify that key cleanup follows RejectAfterTime.

  4. Verify that a configuration where:

    RejectAfterTime <= KeepaliveTimeout + RekeyTimeout
    

    produces a zero receive-refresh delay rather than unsigned wraparound.

  5. Run the same timing configurations against amneziawg-go and the kernel module and compare rekey/expiration behavior.

Originally created by @2Grey on GitHub (Aug 31, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/issues/227 ## Summary The configurable AWG 3.x timing parameters are not applied consistently in the kernel module. The current code has at least three related problems: 1. `RejectAfterTime` is ignored when validating sending and receiving keypairs. Both paths still use the fixed WireGuard constant `REJECT_AFTER_TIME`. 2. After handshake retries are exhausted, the zero-key-material timer is derived from `RekeyAfterTime` instead of `RejectAfterTime`. 3. The receive-side key refresh calculation can become negative and is passed to a function accepting an unsigned timeout, while `amneziawg-go` explicitly clamps the equivalent value to zero. These inconsistencies can cause the kernel implementation to expire keys earlier or later than configured and to behave differently from `amneziawg-go`. ## Affected source - Repository: `amnezia-vpn/amneziawg-linux-kernel-module` - Confirmed in tag: `v3.1.20260828` - The relevant configurable timers were introduced with AWG 3.x. - Earlier affected tags have not been bisected. This report is based on source review. A target-router runtime reproduction has not yet been completed. ## Problem 1: data paths ignore RejectAfterTime The sending path always uses the fixed `REJECT_AFTER_TIME` constant: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/send.c#L464-L475 ```c if (unlikely(wg_birthdate_has_expired(keypair->sending.birthdate, REJECT_AFTER_TIME))) goto out_invalid; ``` The receiving path does the same: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/receive.c#L331-L346 ```c if (unlikely(!READ_ONCE(keypair->receiving.is_valid) || wg_birthdate_has_expired(keypair->receiving.birthdate, REJECT_AFTER_TIME) || READ_ONCE(keypair->receiving_counter.counter) >= REJECT_AFTER_MESSAGES)) { WRITE_ONCE(keypair->receiving.is_valid, false); return false; } ``` Neither path reads: ```c peer->device->reject_after_time ``` This means, for example: ```ini RejectAfterTime = 300 ``` still causes the data paths to reject a key after the fixed 180-second default. Conversely: ```ini RejectAfterTime = 60 ``` does not make those checks reject the key after 60 seconds. This also differs from `amneziawg-go`, where both send and receive use `keychainExpireTime()`: - https://github.com/amnezia-vpn/amneziawg-go/blob/b5928efb6ca19f0153958460c3d141f04abc5c2e/device/send.go#L431-L440 - https://github.com/amnezia-vpn/amneziawg-go/blob/b5928efb6ca19f0153958460c3d141f04abc5c2e/device/receive.go#L194-L198 The Go helper uses the upper bound of the configured `RejectAfterTime` range: https://github.com/amnezia-vpn/amneziawg-go/blob/b5928efb6ca19f0153958460c3d141f04abc5c2e/device/timers.go#L290-L297 ```go func (device *Device) keychainExpireTime() time.Duration { rejectAfterTime := RejectAfterTime if t := device.timings.rejectAfterTimeSec.Load(); !t.IsZero() { rejectAfterTime = time.Duration(t.Hi()) * time.Second } return rejectAfterTime } ``` ## Problem 2: failed-handshake cleanup uses RekeyAfterTime After the maximum number of handshake attempts is exceeded, the kernel schedules destruction of residual key material. The timeout is currently selected from `rekey_after_time`: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/timers.c#L43-L66 ```c u16 timeout = !u16_range_is_zero(peer->device->rekey_after_time) ? u16_range_pick_one(peer->device->rekey_after_time) : REJECT_AFTER_TIME; /* ... */ if (!timer_pending(&peer->timer_zero_key_material)) mod_peer_timer(peer, &peer->timer_zero_key_material, jiffies + timeout * 3 * HZ); ``` This is inconsistent with `wg_timers_session_derived()`, which correctly derives the same zero-key-material timer from `reject_after_time`: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/timers.c#L221-L230 ```c u16 timeout = !u16_range_is_zero(peer->device->reject_after_time) ? u16_range_pick_one(peer->device->reject_after_time) : REJECT_AFTER_TIME; mod_peer_timer(peer, &peer->timer_zero_key_material, jiffies + timeout * 3 * HZ); ``` For example: ```ini RekeyAfterTime = 10 RejectAfterTime = 600 ``` can schedule cleanup after approximately: ```text 10 × 3 = 30 seconds ``` instead of a value derived from: ```text 600 × 3 = 1800 seconds ``` The fallback is also `REJECT_AFTER_TIME`, which makes the use of `rekey_after_time` in the configured branch look unintended. ## Problem 3: negative receive refresh timeout is not clamped The receive-side refresh timeout is calculated as: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/receive.c#L302-L307 ```c static int key_fresh_timeout(struct wg_peer *peer) { return (!u16_range_is_zero(peer->device->reject_after_time) ? u16_range_pick_one(peer->device->reject_after_time) : REJECT_AFTER_TIME) - (!u16_range_is_zero(peer->device->keepalive_timeout) ? u16_range_lo(peer->device->keepalive_timeout) : KEEPALIVE_TIMEOUT) - (!u16_range_is_zero(peer->device->rekey_timeout) ? u16_range_lo(peer->device->rekey_timeout) : REKEY_TIMEOUT); } ``` A configuration such as: ```ini RejectAfterTime = 20 KeepaliveTimeout = 15 RekeyTimeout = 10 ``` produces: ```text 20 - 15 - 10 = -5 ``` The result is an `int`, but it is passed to: https://github.com/amnezia-vpn/amneziawg-linux-kernel-module/blob/v3.1.20260828/src/timers.h#L24-L28 ```c static inline bool wg_birthdate_has_expired( u64 birthday_nanoseconds, u64 expiration_seconds) ``` The negative value is therefore converted to `u64`, and the subsequent nanosecond multiplication can wrap. This does not represent the intended immediate refresh behavior. The equivalent `amneziawg-go` implementation explicitly clamps the result: ```go return max(0, rejectAfterTime-keepaliveTimeout-rekeyTimeout) ``` https://github.com/amnezia-vpn/amneziawg-go/blob/b5928efb6ca19f0153958460c3d141f04abc5c2e/device/timers.go#L272-L287 ## Expected behavior - Sending and receiving keypair expiration should honor configured `RejectAfterTime`. - Kernel and Go implementations should use compatible range semantics. - Destruction of residual key material after failed handshakes should be based on `RejectAfterTime`, not `RekeyAfterTime`. - A negative receive refresh timeout should be clamped to zero before it is passed to `wg_birthdate_has_expired()`. ## Actual behavior - Sending and receiving always use the fixed 180-second constant. - Failed-handshake cleanup uses `RekeyAfterTime × 3`. - Negative receive refresh values are implicitly converted to unsigned timeouts. - Kernel behavior can diverge from `amneziawg-go` for the same configuration. ## Suggested direction A shared helper could provide the effective keychain expiration time, matching the Go implementation: ```c static inline u16 keychain_expire_time(const struct wg_device *wg) { return !u16_range_is_zero(wg->reject_after_time) ? u16_range_hi(wg->reject_after_time) : REJECT_AFTER_TIME; } ``` It could then be used by: - the sending keypair expiry check; - the receiving keypair expiry check; - the zero-key-material cleanup path. The exact range semantics should be confirmed by the maintainers. If a random value is intended per session, it should be selected once and stored with the keypair rather than selected independently during packet checks. For the receive refresh calculation, the result should be clamped before conversion to `u64`, for example: ```c return max_t(int, 0, reject_after - keepalive - rekey_timeout); ``` ## Suggested tests 1. Configure `RejectAfterTime` above 180 seconds and verify that send and receive keys remain valid beyond 180 seconds. 2. Configure `RejectAfterTime` below 180 seconds and verify expiration at the configured boundary. 3. Use different `RekeyAfterTime` and `RejectAfterTime` values, force handshake retries to fail, and verify that key cleanup follows `RejectAfterTime`. 4. Verify that a configuration where: ```text RejectAfterTime <= KeepaliveTimeout + RekeyTimeout ``` produces a zero receive-refresh delay rather than unsigned wraparound. 5. Run the same timing configurations against `amneziawg-go` and the kernel module and compare rekey/expiration behavior.
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amneziawg-linux-kernel-module#131
No description provided.