Skip to content

Commit 8dcdee6

Browse files
Derb237claude
andcommitted
Add Duo Push auto-approval and HOTP hex conversion
Implements three-priority Duo MFA authentication system: 1. OTP (highest priority) - Auto-generates HOTP codes from secret 2. Auto-approval - Uses device credentials to approve pushes via Duo API 3. Manual push (fallback) - Traditional approve-on-phone flow Database changes: - Add duo_push_akey, duo_push_pkey, duo_push_host, duo_push_rsa_key_path columns - Add duo_device column to persist user's selected device - Migration: 20522d39dc63_add_duo_push_method Duo Push auto-approval: - Integrate with Duo device API using RSA-SHA512 signed requests - Load device credentials from database and RSA key from file - Poll for pending push notifications and auto-approve - Hard fail if auto-approval is configured but broken (prevents hanging) - Auto-correct duo_device when credentials don't match selected device HOTP hex secret auto-conversion: - Auto-detect 32-char hex format (from synackDUO's hotp_secret) - Convert by treating hex string as UTF-8, then base32 encode - Based on duo-hotp reference implementation - Accepts both hex (hotp_secret) and base32 (otpauth://) formats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 3214293 commit 8dcdee6

File tree

8 files changed

+513
-21
lines changed

8 files changed

+513
-21
lines changed

docs/src/usage/index.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,8 @@ With that in mind, I would highly recommend you become familiar with the [Plugin
1717
## Authentication
1818

1919
The first time you try to do anything which requires authentication, you will be automatically prompted for your credentials.
20-
This prompt will expect the `Synack Email` and `Synack Password`, which are fairly self explanitory, but it also asks for the `Synack OTP Secret`.
20+
This prompt will expect the `Synack Email` and `Synack Password`, which are fairly self explanatory.
2121

22-
The `Synack OTP Secret` is NOT the 8 digit code you pull out of Authy.
23-
Instead, it is a string that you must extract from Authy via a method similar to the one found [here](https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93).
24-
25-
Use the above instructions at your own discression.
26-
I TAKE NO RESPONSIBILITY IF SOMETHING BAD HAPPENS AS A RESULT.
22+
For Duo MFA setup options, see the [Duo plugin documentation](./plugins/duo.md).
2723

2824
Once you complete these steps, your credentials are stored in a SQLiteDB at `~/.config/synack/synackapi.db`.

docs/src/usage/main-components/state.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,16 @@ In the event that one of the State variables is set and is **not** constantly at
5555
| api_token | str | This is the Synack Access Token used to authenticate requests
5656
| config_dir | pathlib.Path | The location of the Database and Login script
5757
| debug | bool | Used to show/hide debugging messages
58+
| duo_push_akey | str | Duo device activation key for push auto-approval
59+
| duo_push_host | str | Duo API hostname for push auto-approval
60+
| duo_push_pkey | str | Duo device private key for push auto-approval
61+
| duo_push_rsa_key_path | str | Path to RSA private key for signing Duo API requests
5862
| email | str | Your email address used to log into Synack
5963
| http_proxy | str | A Web Proxy (Burp, etc.) to intercept requests
6064
| https_proxy | str | A Web Proxy (Burp, etc.) to intercept requests
6165
| login | bool | Used to enable/disable a check of the api_token upon creation of the Handler
6266
| notifications_token | str | Token used for authentication when dealing with Synack Notifications
63-
| otp_secret | str | OTP Secret held by Authy. NOT an OTP. For more information, read the Usage page
67+
| otp_secret | str | OTP Secret held by Duo Mobile. NOT an OTP. For more information, read the Usage page
6468
| password | str | Your Synack Password
6569
| session | requests.Session | Tracks cookies and headers across various functions
6670
| template_dir | pathlib.Path | The location of your Mission Templates

docs/src/usage/plugins/duo.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,58 @@
11
# Duo
22

3+
## Duo MFA Options
4+
5+
When prompted during authentication, you can choose from three options:
6+
7+
**Option 1: Manual Push Approval (Simplest)**
8+
- Press Enter when prompted for OTP Secret
9+
- Approve push notifications on your phone each time the token is expired
10+
- No additional setup required
11+
12+
**Option 2: Automated OTP (Preferred)**
13+
- Enter your OTP Secret when prompted (accepts both hex and base32 formats)
14+
- Automatically generates OTP codes using a counter (saved in the database)
15+
- Extract the `hotp_secret` from Duo Mobile using [synackDUO](https://github.com/dinosn/synackDUO) (see `response.json`)
16+
- **Note:** This is NOT the 8-digit codes from Duo Mobile, but the HOTP secret key
17+
18+
**Option 3: Automated Duo Push**
19+
- Uses Duo credentials to auto-approve push requests
20+
- Can also approve push requests using duo.approve_pending_push(timeout)
21+
- Extract credentials using [synackDUO](https://github.com/dinosn/synackDUO) (see `response.json`) (see below)
22+
23+
24+
**Disclaimer:** Use the above instructions at your own discretion. I TAKE NO RESPONSIBILITY IF SOMETHING BAD HAPPENS AS A RESULT.
25+
26+
## Duo Push Auto-Approval Setup
27+
28+
The Duo plugin supports push notification approval using device credentials.
29+
30+
### Prerequisites
31+
32+
You must extract and configure four credentials from Duo Mobile:
33+
34+
| Credential | Description | Example
35+
| --- | --- | ---
36+
| `duo_push_akey` | Device activation key | `DAXXXXXXXXXXXXXXXXXXXX`
37+
| `duo_push_pkey` | Device private key | `DPXXXXXXXXXXXXXXXXXXXX`
38+
| `duo_push_host` | Duo API hostname | `api-xxxxxxxx.duosecurity.com`
39+
| `duo_push_rsa_key_path` | Path to RSA private key | `~/.config/synack/duo/key.pem`
40+
41+
### Configuration
42+
43+
Set credentials in the database:
44+
PP
45+
```python
46+
import synack
47+
48+
h = synack.Handler(login=False)
49+
50+
h.db.set_config('duo_push_akey', 'DAXXXXXXXXXXXXXXXXXX')
51+
h.db.set_config('duo_push_pkey', 'DPXXXXXXXXXXXXXXXXXX')
52+
h.db.set_config('duo_push_host', 'api-xxxxxxxx.duosecurity.com')
53+
h.db.set_config('duo_push_rsa_key_path', 'synackDUO/key.pem')
54+
```
55+
356
## duo.get_grant_token(auth_url)
457

558
> Handles Duo Security MFA stages and returns the grant_token used to finish logging into Synack
@@ -13,3 +66,21 @@
1366
>> >>> h.duo.get_grant_token('https:///...duosecurity.com/...')
1467
>> 'Y8....6g'
1568
>> ```
69+
70+
## duo.approve_pending_push(timeout)
71+
72+
> Wait for and approve a single Duo push notification
73+
>
74+
> Polls Duo's device API for pending push notifications and automatically approves the first one found. Useful for automated workflows that need to handle Duo MFA.
75+
>
76+
> | Argument | Type | Default | Description
77+
> | --- | --- | --- | ---
78+
> | `timeout` | int | 30 | Maximum seconds to wait for a push notification
79+
>
80+
> Returns `True` if a push was approved, `False` if timeout or error occurred.
81+
>
82+
>> Examples
83+
>> ```python3
84+
>> >>> h.duo.approve_pending_push(timeout=60)
85+
>> True
86+
>> ```

src/synack/_state.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ def __init__(self):
4141
self._use_proxies = None
4242
self._use_scratchspace = None
4343
self._user_id = None
44+
self._duo_push_akey = None
45+
self._duo_push_pkey = None
46+
self._duo_push_host = None
47+
self._duo_push_rsa_key_path = None
48+
self._duo_device = None
4449

4550
@property
4651
def smtp_email_from(self) -> str:
@@ -357,3 +362,58 @@ def user_id(self) -> str:
357362
@user_id.setter
358363
def user_id(self, value: str) -> None:
359364
self._user_id = value
365+
366+
@property
367+
def duo_push_akey(self) -> str:
368+
ret = self._duo_push_akey
369+
if ret is None:
370+
ret = self._db.duo_push_akey
371+
return ret
372+
373+
@duo_push_akey.setter
374+
def duo_push_akey(self, value: str) -> None:
375+
self._duo_push_akey = value
376+
377+
@property
378+
def duo_push_pkey(self) -> str:
379+
ret = self._duo_push_pkey
380+
if ret is None:
381+
ret = self._db.duo_push_pkey
382+
return ret
383+
384+
@duo_push_pkey.setter
385+
def duo_push_pkey(self, value: str) -> None:
386+
self._duo_push_pkey = value
387+
388+
@property
389+
def duo_push_host(self) -> str:
390+
ret = self._duo_push_host
391+
if ret is None:
392+
ret = self._db.duo_push_host
393+
return ret
394+
395+
@duo_push_host.setter
396+
def duo_push_host(self, value: str) -> None:
397+
self._duo_push_host = value
398+
399+
@property
400+
def duo_push_rsa_key_path(self) -> str:
401+
ret = self._duo_push_rsa_key_path
402+
if ret is None:
403+
ret = self._db.duo_push_rsa_key_path
404+
return ret
405+
406+
@duo_push_rsa_key_path.setter
407+
def duo_push_rsa_key_path(self, value: str) -> None:
408+
self._duo_push_rsa_key_path = value
409+
410+
@property
411+
def duo_device(self) -> str:
412+
ret = self._duo_device
413+
if ret is None:
414+
ret = self._db.duo_device
415+
return ret
416+
417+
@duo_device.setter
418+
def duo_device(self, value: str) -> None:
419+
self._duo_device = value
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""add_duo_push_method
2+
3+
Revision ID: 20522d39dc63
4+
Revises: 8b478a84c1a6
5+
Create Date: 2025-11-06 06:45:20.877380
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '20522d39dc63'
14+
down_revision = '8b478a84c1a6'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column('config', sa.Column('duo_device', sa.VARCHAR(length=50), nullable=True))
22+
op.add_column('config', sa.Column('duo_push_akey', sa.VARCHAR(length=200), nullable=True))
23+
op.add_column('config', sa.Column('duo_push_host', sa.VARCHAR(length=100), nullable=True))
24+
op.add_column('config', sa.Column('duo_push_pkey', sa.VARCHAR(length=200), nullable=True))
25+
op.add_column('config', sa.Column('duo_push_rsa_key_path', sa.VARCHAR(length=250), nullable=True))
26+
# ### end Alembic commands ###
27+
28+
29+
def downgrade():
30+
# ### commands auto generated by Alembic - please adjust! ###
31+
op.drop_column('config', 'duo_push_rsa_key_path')
32+
op.drop_column('config', 'duo_push_pkey')
33+
op.drop_column('config', 'duo_push_host')
34+
op.drop_column('config', 'duo_push_akey')
35+
op.drop_column('config', 'duo_device')
36+
# ### end Alembic commands ###

src/synack/db/models/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,10 @@ class Config(Base):
3838
user_id = sa.Column(sa.VARCHAR(20), default='')
3939
use_proxies = sa.Column(sa.BOOLEAN, default=False)
4040
use_scratchspace = sa.Column(sa.BOOLEAN, default=False)
41+
duo_push_akey = sa.Column(sa.VARCHAR(200), default='')
42+
duo_push_pkey = sa.Column(sa.VARCHAR(200), default='')
43+
duo_push_host = sa.Column(sa.VARCHAR(100), default='')
44+
duo_push_rsa_key_path = sa.Column(
45+
sa.VARCHAR(250), default=''
46+
)
47+
duo_device = sa.Column(sa.VARCHAR(50), default='')

src/synack/plugins/db.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,14 +497,49 @@ def otp_count(self, value):
497497
def otp_secret(self):
498498
ret = self.get_config('otp_secret')
499499
if not ret:
500-
ret = input('Synack OTP Secret: ')
501-
self.otp_secret = ret
500+
# Skip prompt if automated push credentials are already configured
501+
if self.duo_push_akey and self.duo_push_pkey and self.duo_push_host:
502+
ret = ''
503+
self.otp_secret = ret
504+
# Skip prompt if user has already selected a device for manual push
505+
elif self.duo_device:
506+
ret = ''
507+
else:
508+
print("\nDuo MFA Authentication Setup:")
509+
print(
510+
"1. Press Enter to use Duo Push notifications "
511+
"(you'll approve on your phone)"
512+
)
513+
print("2. OR enter your Duo OTP Secret for automated passcode generation")
514+
print(" (Accepts hex (hotp_secret) or base32 (otpauth://) format)")
515+
ret = input('\nDuo OTP Secret (or press Enter for push): ').strip()
516+
self.otp_secret = ret if ret else ''
502517
return ret
503518

504519
@otp_secret.setter
505520
def otp_secret(self, value):
521+
# Auto-detect and convert hex format to base32
522+
# Duo's hotp_secret is a hex string, but needs to be treated as UTF-8
523+
# not as hex bytes (based on duo-hotp reference implementation)
524+
if value and self._is_hex_secret(value):
525+
import base64
526+
# Encode the hex string as UTF-8 bytes, then base32
527+
value = base64.b32encode(value.encode('utf-8')).decode('ascii').rstrip('=')
506528
self.set_config('otp_secret', value)
507529

530+
def _is_hex_secret(self, value):
531+
"""Check if the secret appears to be in hex format (not base32)"""
532+
# Hex: 32 chars using only 0-9, a-f
533+
# Base32: variable length using A-Z, 2-7
534+
if len(value) != 32:
535+
return False
536+
try:
537+
# If it can be decoded as hex, it's hex
538+
bytes.fromhex(value)
539+
return True
540+
except ValueError:
541+
return False
542+
508543
@property
509544
def password(self):
510545
ret = self.get_config('password')
@@ -517,6 +552,46 @@ def password(self):
517552
def password(self, value):
518553
self.set_config('password', value)
519554

555+
@property
556+
def duo_push_akey(self):
557+
return self.get_config('duo_push_akey')
558+
559+
@duo_push_akey.setter
560+
def duo_push_akey(self, value):
561+
self.set_config('duo_push_akey', value)
562+
563+
@property
564+
def duo_push_pkey(self):
565+
return self.get_config('duo_push_pkey')
566+
567+
@duo_push_pkey.setter
568+
def duo_push_pkey(self, value):
569+
self.set_config('duo_push_pkey', value)
570+
571+
@property
572+
def duo_push_host(self):
573+
return self.get_config('duo_push_host')
574+
575+
@duo_push_host.setter
576+
def duo_push_host(self, value):
577+
self.set_config('duo_push_host', value)
578+
579+
@property
580+
def duo_push_rsa_key_path(self):
581+
return self.get_config('duo_push_rsa_key_path')
582+
583+
@duo_push_rsa_key_path.setter
584+
def duo_push_rsa_key_path(self, value):
585+
self.set_config('duo_push_rsa_key_path', value)
586+
587+
@property
588+
def duo_device(self):
589+
return self.get_config('duo_device')
590+
591+
@duo_device.setter
592+
def duo_device(self, value):
593+
self.set_config('duo_device', value)
594+
520595
@property
521596
def ports(self):
522597
session = self.Session()

0 commit comments

Comments
 (0)