mrjackyliang / homebridge-adt-pulse

Homebridge security system platform for ADT Pulse
MIT License
36 stars 7 forks source link

[Feature]: Add 2FA workflow #131

Closed rlippmann closed 3 months ago

rlippmann commented 10 months ago

Pre-check confirmation

Your email address

robert.lippmann.development@gmail.com

Describe the new feature requested

Automatically generating the fingerprint for 2fa

Legal Agreements

rlippmann commented 10 months ago

Hi Jacky,

As promised, here is what I've discovered about the process to do 2fa. I've omitted all the https://portal.adtpulse/myhome/version from all the URLs for brevity. first, when an invalid fingerprint is received, the browser gets a redirect to /mfa/mfaSignIn.jsp?workflow=challenge

In that page, there is some js source as follows:

<script type="text/javascript">
  const workflow = "challenge";
  window.g.mfa = {
    xToken: '',
    xPreAuthToken: '7987E5DCAFC24CD01C6F9D7598D329C7636194118616B100574ACA358067E0C3D1D84145769F6EBC51FBB9E557CCB56DFECA4749EF0AAB616E7CCABF8B37C2178F12995C5312A30722439450A7F33749A29DA9AA478B17FAE213805912B0AA604BBD8978E42092B81BF2D52ABF5450FDC492368326088AAD3B122AE664B6B278',
    xLogin: "rml.adtpulse@gmail.com",
    userEmail: "rml.adtpulse@gmail.com",
    xClientType: "web",
    env: "prod",
    locale: "en_US",
    partner: "adt",
    sat: '3ac1a3c2-e771-4d28-82ef-2725a4a270fa',
    proxyServletUrl: "/nga/serv/RunRRAProxy",
    rootAddress: "",
    workflow: workflow,
    shouldForceMfaSetup: null,
    supportPhoneNumber: "1 (800) 251-9581",
    mockApi: false,
    testCase: 0,
    returnToPortalCb: returnToPortalCb
  };

  if (workflow === 'challenge' || workflow === 'initialSetup') {
    // callback only for mfa sign in
    window.g.mfa.returnToSignInCb = returnToSignInCb;
  } else {
    // callbacks only for mfa settings hosted in dialog
    window.g.mfa.isDataLoadedCb = resizeAndRecenterDialog;
    window.g.mfa.resizeDialogCb = resizeDialog;
    window.g.mfa.registerDismissCallback = window.parent.registerDismissCallback;
  }
</script>

you'll need the variables defined in g.mfa to set http headers:

{
                "name": "X-clientType",
                "value": "web"
            },
            {
                "name": "x-dtpc",
                "value": "9$392570027_372h2vPKSEFMFGKUWAMVLCGWMCWRFOJDUMFROR-0e0"
            },
            {
                "name": "X-format",
                "value": "json"
            },
            {
                "name": "X-locale",
                "value": "en-US"
            },
            {
                "name": "X-login",
                "value": "rlippmann@hotmail.com"
            },
            {
                "name": "X-preAuthToken",
                "value": "D07734ACAC6C9F4139B16B4453ECCFAE9600B51FF1D74A932FDFFAFFF1727252BA986CC361FE20BFD29A01E9426011D19A0CA8A6A4DE96A8A969F2313DEBB1BC374C99D953986488DDC115729B64E49354B51F0705DA4E0337005A23C4288D73BDA7726B6E4DDF91727F5EE8C644D76C422786F9AC426405AF21501C64C9F8F4"
            },
            {
                "name": "X-version",
                "value": "7.0"
            }

I don't think the x-dtpc is necessary (it's some tracing code on ADT's side), and I'm not sure about X-version then, when you make the following request, you'll get some json back

request: /nga/serv/RunRRAProxy?href=rest/icontrol/ui/client/multiFactorAuth&sat=3ac1a3c2-e771-4d28-82ef-2725a4a270fa

(where sat is the value of the sat variable above)

response:

"state": {
        "mfaEnabled": true,
        "label": "Enabled (2 verification methods)",
        "mfaProperties": [
            {
                "id": "SMS",
                "type": "SMS",
                "label": "(***) ***-5549",
                "caption": "Text message to <span class=\"ic_strong\">(***) ***-5549</span>"
            },
            {
                "id": "EMAIL",
                "type": "EMAIL",
                "label": "r***@h***.com",
                "caption": "Email to <span class=\"ic_strong\">r***@h***.com</span>"
            }
        ]
    },
    "commands": {
        "requestOtpForRegisteredProperty": {
            "params": {
                "id": {
                    "label": "Select a verification method",
                    "options": [
                        {
                            "value": "SMS",
                            "type": "SMS",
                            "label": "by text to <span class=\"ic_strong\">(***) ***-5549</span>",
                            "caption": "Verify with text message"
                        },
                        {
                            "value": "EMAIL",
                            "type": "EMAIL",
                            "label": "by email to <span class=\"ic_strong\">r***@h***.com</span>",
                            "caption": "Verify with email"
                        }
                    ],
                    "type": "select"
                }
            },
            "method": "POST",
            "action": "rest/adt/ui/client/multiFactorAuth/requestOtpForRegisteredProperty",
            "label": "Request a verification code"
        },
        "validateOtp": {
            "params": {
                "otp": {
                    "type": "textInput",
                    "label": "Enter verification code"
                }
            },
            "method": "POST",
            "action": "rest/adt/ui/client/multiFactorAuth/validateOtp",
            "label": "Validate verification code"
        }
    }
}

the commands give you the http requests and the parameters, so to request the otp, you would do: POST /nga/serv/RunRRAProxy with parameter href=rest/adt/ui/client/multiFactorAuth/requestOtpForRegisteredProperty and sat={the sat from the script in the first step} id=EMAIL (or SMS, or whatever id's where in mfapropertries)

Note, the href parameters are relative URLs (i.e. don't need the https://portal.adtpulse.com/myhome/version

to validate: POST /nga/serv/RunRRAProxy href=rest/adt/ui/client/multiFactorAuth/validateOtp id={the one time password the user received)

to save it, do: GET /nga/serv/RunRRAProxy?only=client.multiFactorAuth&exclude=&sat={sat from script}&href=rest/adt/ui/updates&sat={sat from script}&

One thing I'm not sure of is when you set the fingerprint. I'm guessing the server somehow keeps a copy of it when you log in. I haven't seen it passed around in any of the session cookies.

Anyway, hope this helps. Let me know how it goes, I'm eager to implement it in HA.

mrjackyliang commented 10 months ago

Thanks for the insights! I also want to see if the function has some sort of regex checking since during development I tried passing in some random string and it wouldn't accept it.

Probably the first thing to check in case ADT decides to change the fingerprint.

mrjackyliang commented 10 months ago

And yes, the x-dtpc header is Dynatrace tracking. Explanations are in the source files. It's not required, but I generate these tokens (random) just to weed the observability out of the way.

mrjackyliang commented 10 months ago

Also the fingerprint is set into the backend session once you hit the submit button, it just depends if you complete the 2FA process. If it does, the backend would be setting that fingerprint into the account list of approved 2FA keys.

rlippmann commented 10 months ago

I didn't see where it passed the fingerprint as a parameter. So let me know what you find.

The fingerprint looks a lot like a JWT, so they might do some validation. But if we can just make a random fingerprint, that would be great.

I can't remember exactly how, but I did generate the json. I either ran https://auth.pulse-api.io/v2/sso/US/devicefingerprint somehow, or used the javascript console in browser development tools.

I got this:

https://github.com/rlippmann/pyadtpulse/blob/master/pyadtpulse/fingerprint.json

I was going to take that and modify the ua items to reflect the actual backend OS, and change the browser name to Home Assistant or something, and do something with the uaString.

I was also thinking of changing the http user-agent header before submitting the otp to see if I could make it save it as something like "Home Assistant/Linux OS" or something to make it easier to see which fingerprint was generated.

Oh, I also found out that if you use the same fingerprint on 2 sessions (even 2 different machines) the 2nd session will log out the first. And this is regardless of whether you used different usernames or not. Just an FYI. Was driving me crazy trying to figure out why sessions were being logged out when HA was running. Turns out I was using the same browser I used to generate the fingerprint on another machine to check things.

mrjackyliang commented 10 months ago

I didn't see where it passed the fingerprint as a parameter. So let me know what you find.

The fingerprint looks a lot like a JWT, so they might do some validation. But if we can just make a random fingerprint, that would be great.

I can't remember exactly how, but I did generate the json. I either ran https://auth.pulse-api.io/v2/sso/US/devicefingerprint somehow, or used the javascript console in browser development tools.

I got this:

https://github.com/rlippmann/pyadtpulse/blob/master/pyadtpulse/fingerprint.json

I was going to take that and modify the ua items to reflect the actual backend OS, and change the browser name to Home Assistant or something, and do something with the uaString.

I was also thinking of changing the http user-agent header before submitting the otp to see if I could make it save it as something like "Home Assistant/Linux OS" or something to make it easier to see which fingerprint was generated.

Oh, I also found out that if you use the same fingerprint on 2 sessions (even 2 different machines) the 2nd session will log out the first. And this is regardless of whether you used different usernames or not. Just an FYI. Was driving me crazy trying to figure out why sessions were being logged out when HA was running. Turns out I was using the same browser I used to generate the fingerprint on another machine to check things.

@rlippmann The fingerprint is generated by the script when you click login. That's when the system possibly generates a session based of that, and if the fingerprint doesn't match your account, you get redirected to the 2FA page.

The fingerprint is essentially just base64 of the JSON object. It's like JWT, but it doesn't have the parameters surrounding it.

We definitely can make a random fingerprint, but we need to test it out because since the fingerprint is base64, it's not encrypted. The backend servers can decode it and go through necessary checks to see if the fingerprint is indeed valid + if they do any sort of profiling in the backend, then well you're sort of out of luck on that end.

However, we can still base it on a couple of samples for making the fingerprint seem valid, like in my plugin, I fake the headers to make it look like the latest version of Chrome, and if I can get a few versions of the fingerprint or a base on what the fingerprint generates from (operating systems, fonts, plugins and such), then we really don't even need the script from ADT.

I do want to note that it's probably not a good idea to have a static fingerprint since the source of this plugin is open, and well if the fingerprint matches, a wide-scale attack can happen by hackers simply guessing the username/password and using a static fingerprint part of this project as the attack vector.

And for the sessions logging out, I think it makes sense. They have to be doing some sort of decoding on the fingerprint to find out that really is the same user/browser being used. However, this does not affect the operations between let's say an iPhone logging into the same account vs a browser. Those two essentially have different fingerprints.

rlippmann commented 10 months ago

Good point about the security of the fingerprint.

I assume they just do a simple string comparison of the fingerprint, but I can see them decoding part of it as a sneaky way around the fact that you can change your user agent header.

Maybe generate a UUID and put it in some of the version fields?

Or, even simpler, just create a new field called UUID in the json.

mrjackyliang commented 10 months ago

I might need to re-write some of the default headers that I send to ADT to match the browsers. Maybe I might just allow the user to pick from a browser, and then I'll generate what I can generate from there.

rlippmann commented 10 months ago

I have a feeling it doesn't actually save the browser/os type until you submit the otp.

mrjackyliang commented 10 months ago

I have a feeling it doesn't actually save the browser/os type until you submit the otp.

Correct, but the fingerprint is passed into the session once the user clicks login. That's the only way actually, because without it, how can the user save their session vs. how can the portal know this is a saved fingerprint?

mrjackyliang commented 10 months ago

This looks interesting: https://github.com/homebridge/plugin-ui-utils

So basically, I can learn how the key is generated (or generate it using a few web browser profiles), then complete the 2FA process automatically, fill in the form, and voila!

mrjackyliang commented 4 months ago

Just updating ya'll on the progress for today

Screenshot 2024-07-23 at 11 30 35 AM
mrjackyliang commented 4 months ago

Did some testing just now. The backend actually parses your fingerprint when you save the device. If I use some random base64 string, it would have this error:

Screenshot 2024-07-25 at 8 37 08 PM
mrjackyliang commented 4 months ago

Got to generate fake fingerprints based on random data. Don't need the fingerprint script anymore unless things change in the future.

mrjackyliang commented 3 months ago

Got some progress on it, 90% complete! The old fingerprint tool will be phased out. The plugin will now help you self-generate fingerprints

mrjackyliang commented 3 months ago

The 2FA workflow is now complete and released in v3.3.5! I wanted to finish the new settings panel, but unfortunately, the form parser doesn't do array of strings (or I couldn't find a way to make it work).

So v3.4.0 would be released with a breaking change notice (again).

Anyways, this was a fun 2 week project for me. I'm going to take a break from this before continuing.

mrjackyliang commented 3 months ago

The feature request is now 100% complete. v3.4.0 now utilizes a brand new settings panel (React-based) with the option to view the older settings panel (must manually click in). This version brings:

github-actions[bot] commented 2 months ago

Due to inactivity, this issue will be locked and marked as resolved. If you have any further questions or inquiries, please feel free to open a new issue.