Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions iam/authmethods/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import json
import time
import hmac as hmac_module
from datetime import datetime
from api import test_data
from api.tests import JClient, flush_db_load_fixture
Expand Down Expand Up @@ -303,6 +304,34 @@ def test_authenticate_valid_auth_token_tilde(self):
self.assertEqual(r['username'], self.user2.username)
self.assertTrue(r['auth-token'].startswith('khmac:///sha-256'))

def test_authenticate_future_timestamp_rejected(self):
'''
A SmartLink token whose timestamp is in the future should be rejected.
This reproduces the bug where check_expiration only verified the upper
bound (token not yet expired) but never checked that the timestamp was
not in the future, allowing pre-generated SmartLinks to be used early.
'''
c = JClient()
user_id = self.user.userdata.metadata['user_id']
auth_event_id = str(self.auth_event.id)

# Create a timestamp 1 hour in the future
future_timestamp = int(time.time()) + 3600

# Build the message in genhmac format (same as the reported URL token)
msg = f"{user_id}:AuthEvent:{auth_event_id}:vote:{future_timestamp}"

# Sign with shared secret
shared_secret = settings.SHARED_SECRET
h = hmac_module.new(shared_secret, msg.encode('utf-8'), 'sha256')
auth_token = f"khmac:///sha-256;{h.hexdigest()}/{msg}"

data = {'auth-token': auth_token}
response = c.authenticate(self.auth_event.id, data)
self.assertEqual(response.status_code, 400)
r = json.loads(response.content.decode('utf-8'))
self.assertEqual(r['error_codename'], 'EXPIRED_AUTH_TOKEN')


class AuthMethodSmsTestCase(TestCase):
def setUpTestData():
Expand Down
146 changes: 146 additions & 0 deletions iam/test_future_timestamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Standalone test to demonstrate the SmartLink future-timestamp bug.

The bug: HMACToken.check_expiration() only checks if a token has expired
(upper bound) but never validates that the token's timestamp is not in the
future (lower bound). This allows pre-generated SmartLinks with future
timestamps to be used immediately.

Comment on lines +2 to +9
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module’s documentation/assertion text is misleading after the fix: it says it “demonstrate[s] … bug” and the failure message claims there is “no lower-bound check (BUG)”, but the implementation under test does include the lower-bound check. Please update the module docstring/test name/assertion message to reflect that this is a regression test for the fix (or remove the duplicate re-implementation and test the real utils.HMACToken).

Copilot uses AI. Check for mistakes.
Reported token example:
khmac:///sha-256;d7a498...a7e43/10057:AuthEvent:10000:vote:1770992400
timestamp 1770992400 = 2026-02-13 15:20 CET (4+ hours in the future)

Run: python3 test_future_timestamp.py
"""

import hmac
import datetime
import time
import unittest
from unittest.mock import patch, MagicMock

Comment on lines +17 to +22
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unittest.mock.patch and MagicMock are imported but never used. Either remove them or (preferably) use patch to freeze time.time()/datetime.now() so the timestamp-based tests are deterministic.

Copilot uses AI. Check for mistakes.
# ---------------------------------------------------------------------------
# Minimal re-implementation of HMACToken.check_expiration from iam/utils.py
# (lines 223-260) so we can test without a full Django stack.
# ---------------------------------------------------------------------------

TIMEOUT_TOKEN_STR = 'timeout-token'
SMARTLINK_TIMEOUT = 90 # seconds, from settings

class HMACToken:
"""Extracted from iam/utils.py with Django timezone calls replaced."""

def __init__(self, token):
self.token = token
l = len('khmac:///')
self.head = token[0:l]
tails = token[l:]
self.digest, data = tails.split(';', 1)
self.hash, self.msg = data.split('/', 1)
msg_split = self.msg.split(':')
self.timestamp = msg_split[-1]

has_expiry = (
len(msg_split) >= 4
and TIMEOUT_TOKEN_STR == msg_split[-2]
)
self.expiry_timestamp = msg_split[-3] if has_expiry else False

def check_expiration(self, seconds=300):
"""Returns True iff the token hasn't expired (FIXED)."""
now = datetime.datetime.now(tz=datetime.timezone.utc)

# Reject tokens whose creation timestamp is in the future
token_date = datetime.datetime.fromtimestamp(
int(self.timestamp),
tz=datetime.timezone.utc,
)
if token_date > now:
return False

if self.expiry_timestamp is not False:
expiry_date = datetime.datetime.fromtimestamp(
int(self.expiry_timestamp),
tz=datetime.timezone.utc,
)
else:
expiry_date = token_date + datetime.timedelta(seconds=seconds)
return expiry_date > now


def _build_smartlink_token(shared_secret, user_id, event_id, timestamp):
"""Build a genhmac-style SmartLink token with the given timestamp."""
msg = f"{user_id}:AuthEvent:{event_id}:vote:{timestamp}"
h = hmac.new(shared_secret, msg.encode('utf-8'), 'sha256')
return f"khmac:///sha-256;{h.hexdigest()}/{msg}"


class TestFutureTimestampBug(unittest.TestCase):
"""Demonstrate the future-timestamp bug in check_expiration."""

SECRET = b'test-shared-secret'

# -- token with current timestamp (should always be valid) ---------------
def test_current_timestamp_is_valid(self):
now_ts = int(time.time())
token_str = _build_smartlink_token(
self.SECRET, 'user1', '10000', now_ts
)
token = HMACToken(token_str)
self.assertTrue(
token.check_expiration(SMARTLINK_TIMEOUT),
"A token with the current timestamp should be valid",
)

# -- token with past timestamp beyond timeout (should be expired) --------
def test_expired_token_is_rejected(self):
old_ts = int(time.time()) - SMARTLINK_TIMEOUT - 10
token_str = _build_smartlink_token(
self.SECRET, 'user1', '10000', old_ts
)
token = HMACToken(token_str)
self.assertFalse(
token.check_expiration(SMARTLINK_TIMEOUT),
"A token older than SMARTLINK_TIMEOUT should be rejected",
)

# -- THE BUG: token with a FUTURE timestamp (should be rejected) ---------
def test_future_timestamp_is_rejected(self):
future_ts = int(time.time()) + 3600 # 1 hour in the future
token_str = _build_smartlink_token(
self.SECRET, 'user1', '10000', future_ts
)
token = HMACToken(token_str)
self.assertFalse(
token.check_expiration(SMARTLINK_TIMEOUT),
"A token with a FUTURE timestamp should be rejected, "
"but check_expiration has no lower-bound check (BUG)",
)

# -- reproduce the exact reported token timestamp ------------------------
def test_reported_token_1770992400(self):
"""
The reported token has timestamp 1770992400 = 2026-02-13 14:20 UTC.
If the current time is before that, the token should be rejected.
"""
reported_ts = 1770992400
now_ts = int(time.time())
if reported_ts > now_ts:
token_str = _build_smartlink_token(
self.SECRET, '10057', '10000', reported_ts
)
token = HMACToken(token_str)
self.assertFalse(
token.check_expiration(SMARTLINK_TIMEOUT),
f"Token timestamp {reported_ts} is in the future "
f"(now={now_ts}), should be rejected",
)
else:
self.skipTest(
"Reported timestamp is no longer in the future"
)
Comment on lines +125 to +142
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_reported_token_1770992400 is time-dependent and will now always skip (the hard-coded timestamp is 2026-02-13, which is already in the past). Consider removing this test, or make it deterministic by freezing “now” to a fixed value and/or generating the “reported” timestamp relative to that fixed baseline.

Suggested change
If the current time is before that, the token should be rejected.
"""
reported_ts = 1770992400
now_ts = int(time.time())
if reported_ts > now_ts:
token_str = _build_smartlink_token(
self.SECRET, '10057', '10000', reported_ts
)
token = HMACToken(token_str)
self.assertFalse(
token.check_expiration(SMARTLINK_TIMEOUT),
f"Token timestamp {reported_ts} is in the future "
f"(now={now_ts}), should be rejected",
)
else:
self.skipTest(
"Reported timestamp is no longer in the future"
)
Freeze "now" to a fixed earlier time so this test remains deterministic.
"""
reported_ts = 1770992400
frozen_now = datetime.datetime(
2026, 2, 13, 13, 20, tzinfo=datetime.timezone.utc
)
class FrozenDateTime(datetime.datetime):
@classmethod
def now(cls, tz=None):
if tz is None:
return frozen_now.replace(tzinfo=None)
return frozen_now.astimezone(tz)
token_str = _build_smartlink_token(
self.SECRET, '10057', '10000', reported_ts
)
token = HMACToken(token_str)
with patch.object(datetime, 'datetime', FrozenDateTime):
self.assertFalse(
token.check_expiration(SMARTLINK_TIMEOUT),
f"Token timestamp {reported_ts} is in the future "
f"relative to frozen now={int(frozen_now.timestamp())}, "
"should be rejected",
)

Copilot uses AI. Check for mistakes.


if __name__ == '__main__':
unittest.main(verbosity=2)
15 changes: 10 additions & 5 deletions iam/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,17 +246,22 @@ def check_expiration(self, seconds=300):
returns true iff the token hasn't expired
'''
now = timezone.now()

# Reject tokens whose creation timestamp is in the future
token_date = datetime.datetime.fromtimestamp(
int(self.timestamp),
tz=timezone.get_current_timezone()
)
if token_date > now:
return False

if False != self.expiry_timestamp:
expiry_date = datetime.datetime.fromtimestamp(
int(self.expiry_timestamp),
tz=timezone.get_current_timezone()
)
else:
expiry_date = datetime.datetime.fromtimestamp(
int(self.timestamp),
tz=timezone.get_current_timezone()
)
expiry_date = expiry_date + datetime.timedelta(seconds=seconds)
expiry_date = token_date + datetime.timedelta(seconds=seconds)
return expiry_date > now

def get_userid(self):
Expand Down
Loading