Blog
Jul 16, 2026 | 8 min

Reversing 1Password's Proprietary SRP Authentication Protocol

What if the credentials you created to access a single vault had broader organizational read visibility than you’d expect from its scope?

That’s exactly what we uncovered when a simple customer question led us down a rabbit hole of undocumented APIs, reverse-engineered cryptography, and a proprietary authentication flow built for 1Password’s own clients.

Over three days, 960 failed attempts, and one very strange Beatles lyric, we tore apart 1Password’s SRP-based authentication to answer a basic question: what does the CLI actually see that the SDK masks?

The answer wasn’t just “more data.” It was everything (users, groups, permissions) accessible with a token whose vault scope leads many practitioners to expect narrower visibility than the org-wide read access it actually carries by design.

This isn’t a story about breaking encryption. The encryption is excellent. It’s about what happens when a token’s real reach is objectively broader than its vault label implies, and a defender’s mental model never catches up to the gap.

SRP (Secure Remote Password) is an RFC-standardized zero-knowledge password proof protocol (RFC 5054). Both sides compute a shared secret via modular exponentiation without the password ever crossing the wire.

One of our customers needed full identity visibility over their 1Password deployment – users, groups, vault access, and permissions. Our existing integration used the SCIM bridge, which required self-hosted infrastructure and couldn’t surface half of what they needed.

The customer asked the obvious question:

“I can get more data from the CLI than the SDK or SCIM. Why can’t your collector use the CLI’s API directly?”

We looked into it. What we found pulled us into three days of binary reversing, MITM captures, ARM64 disassembly, Frida instrumentation, and a Beatles lyric embedded in a cryptographic constant.

The Gap Between the CLI and the SDK

We audited both the CLI (op v2.32.1) and the Python SDK (v0.4.0). The difference was stark:

Capability CLI (op) SDK (Python)
List users op user list (all org users) Not available
Get user detail op user get (includes last_auth_at) Not available
List groups op group list (all groups) Not available
Group members op group user list Not available
Vault users + permissions op vault user list Not available
Vault groups op vault group list Not available
Rate limits op service-account ratelimit Not available

The same token reaches three very different surfaces, and each shows a different slice of the org:

Diagram 1
Diagram 1

The SDK exposes client.vaults and client.items. No client.users. No client.groups. For our use case (mapping identity relationships across an organization) this gap was a significant limitation.

Here’s the part worth sitting with: a service-account token scoped to one vault can read all org users and all groups through the CLI,  not just the members of that vault. Names, emails, states, last_auth_at timestamps, group memberships, vault-level ACL permissions. This is by design: 1Password supports vault-management workflows where a service account legitimately needs to add users or groups to a vault. But it is far more reach than the token’s vault label suggests.

The SDK wraps a compiled Rust FFI binary (libop_uniffi_core.dylib). We tried monkey-patching the internal invoke() method with ~35 invocation names guessed from the binary’s symbol table – UsersList, UsersGet, GroupsList, GroupsMembership. Every single one: UNSUPPORTED or FORBIDDEN.

We also tried three Python wrappers around the CLI. They worked, but shelling out to a Go binary from a Python microservice is operationally painful. We wanted to talk directly to the API.

The catch: there’s no public API for this. None. Not deprecated, not behind a flag, not “contact us for access.” The CLI talks to an internal endpoint 1Password built for its own clients, not for third parties. It speaks SRP-g-4096 + AES-256-GCM. Genuinely strong, correctly-implemented crypto. Every request body is encrypted. A proprietary handshake derived from RFC 5054 with enough deviations to break every standard SRP library we threw at it.

We decided to implement it ourselves. On March 4, 2026, we started. By March 6 at 14:29 UTC, we had HTTP 200.

“What Does the CLI Actually Do?” (March 5)

The op CLI is a Go binary (v2.32.1, ARM64). Go binaries are a reverser’s best friend.

Unlike stripped C/C++ binaries, Go embeds full symbol tables in __gopclntab sections of the Mach-O binary. Every function name, every source file path, every line number mapping – sitting there in plaintext. We wrote Go tools (dump_funcs.go, extract_funcs.go, gore_extract.go) using debug/gosym and debug/macho to walk the pclntab and extract everything.

What fell out:

  • ~80 internal API endpoints organized by source file (auth_endpoint.go, vault_endpoint.go, user_endpoint.go)
  • The full crypto function roster: computeX, HKDF, pbes2g_hs256ComputeX, makeLittleKNonStd, calculateUNonStd
  • The critical function name: NewSRPWithEmailAccountKeyComputedXSessionID

That last one told us something important. Service accounts bypass the Two-Secret Key Derivation (2SKD) entirely. The password-based path goes through HKDF and XOR operations to derive x from a password + account key. But service account tokens include srpX – the pre-computed x value – right in the base64url-encoded token payload. No password derivation needed.

We dumped raw ARM64 bytecode from four key crypto functions to .bin files for offline disassembly:

func_computeX.bin                        -- crypto.computeX
func_computeXWithPreparedPassword.bin    -- password-based x derivation
func_HKDF.bin                            -- HKDF key derivation
func_pbes2g_hs256ComputeX.bin            -- 2SKD XOR operation


The endpoint catalog was gold. We now know where the CLI talks. But not how.

“Let’s Just Watch It”

We needed to see actual bytes on the wire.

First attempt: SSL key log capture (SSLKEYLOGFILE env var). Go’s crypto/tls didn’t cooperate.

What worked: mitmproxy with CA cert injection. We set HTTPS_PROXY and SSL_CERT_FILE to mitmproxy’s CA cert, ran op whoami, and watched the traffic flow through. Eight MITM sessions total. The capture (mitm_requests.log) showed the full auth handshake:

Diagram 2
Diagram 2

Three things jumped out:

MAC headers. Starting at auth/verify, every request carried x-agilebits-mac: v1|24043|TNkUKElT-JYYV_JI. Three pipe-separated fields: version, a request ID, and 12 bytes of base64url-encoded HMAC.

Request ID sequence. The IDs went 24043 -> 24299 -> 24555. Increments of exactly 256. We decoded these:

24043 = 0x5DEB -> pidByte=0xEB(235), counter=93
24299 = 0x5EEB -> pidByte=0xEB(235), counter=94
24555 = 0x5FEB -> pidByte=0xEB(235), counter=95


Low byte is os.Getpid() & 0xFF. Upper bytes are an incrementing counter. Each request bumps the counter by 1, so the raw ID increases by 256. Deterministic.

The __t cache-buster. Every authenticated request appends ?__t={epoch_seconds}. Required – skip it and you get a 403.

We also discovered that the OP-User-Agent header is mandatory in a specific format (1|SDK|999999999|{deviceUuid}|||reqwest|0.11.24|darwin|0.0.0|arm64). Without it, you hit a WAF layer before reaching the auth handler.

Why Won’t This Work?

To their credit, 1Password open-sources their SRP library at github.com/1Password/srp, which meant we could read the real auth code instead of guessing. We cloned it. This was ground truth: the actual Go code backing the CLI’s auth.

SRP (Secure Remote Password) is an RFC-standardized zero-knowledge password proof protocol (RFC 5054). Both sides compute a shared secret via modular exponentiation without the password ever crossing the wire. 1Password uses the RFC 5054 4096-bit prime, generator g=5, and SHA-256. Textbook.

The deviations from the RFC are what killed us.

The Non-Standard Parameters

Parameter RFC 5054 (standard) 1Password (“SRP-g-4096”)
k SHA256(pad(N, 512) \|\| pad(g, 512)) Go CLI: SHA256(N.Bytes() \|\| pad(g, 512)). Rust SDK: RFC standard. Server accepts both.
u SHA256(pad(A, 512) \|\| pad(B, 512)) Same (verified via Frida)
K SHA256(S.Bytes()) SHA256(hex(S)) – hash the hex STRING
M1 identity email Custom derived value (NOT email)
M1 components Padded big-endian bytes Minimal big-endian (Go’s big.Int.Bytes())

Here’s the part that’s easy to get wrong about a story like this: 1Password didn’t hide these deviations. They documented them themselves, in plain English, in the very library we cloned. The function names (makeLittleKNonStd, calculateUNonStd) say what they are, and the source carries explicit BUG() notes:

// makeLittleK initializes multiplier based on group parameters
// k = H(N, g)
// BUG(jpg): Creation of multiplier, little k, does _not_ conform to RFC 5054 padding

// calculateU creates a hash A and B
// BUG(jpg): Calculation of u does not use RFC 5054 compatable (sic) padding/hashing
// The scheme we use (see source) is to use SHA256 of the concatenation of A and B
// each represented as a lowercase hexadecimal string.
// Additionally those hex strings have leading "0" removed even if that makes them of odd length


Their commit history shows the same care: Explicitly trims hex in calculateU, Explicit lowercasing and separate hex function, tests for 0 stripping, and Replace bytes equal by subtle ConstantTimeCompare (constant-time proof comparison, exactly what you want). The deviations are deliberate, tested, and self-described. Our obstacle wasn’t secrecy; it was that the server’s accepted formulation had to be reconstructed across two client paths, the Go CLI and the Rust SDK, that don’t pad identically. We later confirmed via Frida that the Rust SDK uses standard RFC 5054 padding for k, and the server accepts both. We’d been chasing the CLI path’s non-standard k, which turned out to be a red herring.

K was the breakthrough. Standard SRP computes the session key as K = SHA256(S_bytes). 1Password:

K = hashlib.sha256(_trimmed_hex(S).encode()).digest()

Hash the hex string of S. Not the bytes. The string. In Go: sha256.Sum256([]byte(fmt.Sprintf("%x", premasterKey))).

That .encode() is the difference between 200 and 401. One line. Three days.

The whole parameter chain is more tangled than a textbook SRP run, because two of the inputs (K and the identity I) are computed in non-obvious ways:

Diagram 3
Diagram 3

The two red boxes are where every standard library got it wrong, and where we did, too, for most of three days.

The Dead End Parade

This wasn’t a clean A-to-B. We went through nine iterations before anything worked.

Attempt 1: Wrong generator entirely. We started with g=2, the default in many SRP examples. The RFC 5054 4096-bit group uses g=5. We also used standard padding and computed K as SHA256(raw_bytes). Wrong on three axes simultaneously.

Attempt 2: Tried the srptools Python library, hoping an existing implementation would handle the parameter formatting. We tested all 8 combinations of (k padding) × (u padding) × (K derivation). All failed. The library’s internal assumptions about byte encoding are fundamentally incompatible with 1Password’s formulation.

Attempt 3: First from-scratch implementation modeled on the op-srp Go source. Auth/start and auth returned 200. Auth/verify: 401. The SRP math was right; the MAC computation was wrong.

Attempt 4: Added MAC computation using the Beatles purpose string we’d found in the binary. Tried 5 different MAC message formats. All 401.

Attempt 5: Diagnostic run: sent verify with a deliberately wrong M1, no JWE, wrong content-type. Confirmed the server rejects at the MAC layer before even checking M1. We were debugging the wrong thing.

Then we started spraying. Different MAC formats, different element orderings, with body, without body, and different request ID formulas. We were guessing.

At some point, requests that had been working started failing. Auth/start, which had returned 200 for days, was now returning errors. We spent six hours debugging – re-reading the binary, diffing our code against earlier versions, questioning our life choices, convinced we’d broken something fundamental. Turns out 1Password had IP-banned us. Six hours chasing a phantom bug before realizing the code was fine. Their abuse defenses had simply, and correctly, decided that a host firing hundreds of failed auth attempts deserved a timeout. Hard to be mad at security working exactly as it should. And the error message? Same errorCode:102 "Authentication required" as every other failure. Which, to be fair, is exactly what a zero-knowledge auth server should return. It owes an attacker nothing, and it tells one nothing. Maddening to debug against; hard to argue with as security design.

We stopped ourselves:

“dont guess. we have data to validate all.”

That one sentence changed the approach. Stop generating hypotheses from theory. We have MITM captures with known-good MAC values. We have the binary with the actual implementation. Validate each computation step against captured ground truth instead of testing combinations against the server.

But even with that discipline, we hit a wall. Nine Python files. 960 MAC format attempts against the live server. The same errorCode:102 "Authentication required" every time, by design, revealing nothing about which computation was wrong. You’re debugging a 6-variable equation with a single boolean output. Zero-knowledge proofs are beautiful in theory and absolutely maddening to debug. A well-built proof server genuinely cannot, and should not, tell you what you got wrong.

We needed a different approach entirely.

The Frida Pivot (March 6)

960 failed attempts was the threshold. We stopped hitting the server and pivoted to instrumentation.

The Rust SDK (libop_uniffi_core.dylib) successfully authenticates every time the Python SDK runs. If we could hook the crypto functions inside that binary while it performed a working auth, we’d have ground truth values for every intermediate computation: the actual k, u, S, K, M1, and MAC that the server accepts.

We used Frida. Hooked into the live SDK process and attached to the functions that mattered:

  • ring::aead::aes_gcm::seal – captured JWE plaintext before encryption
  • op_srp::B5AuthIdent::compute_identifier – the SRP identity computation
  • SHA-256 calls with input/output capture

Then we built solve_srp.py – an offline solver that took the Frida-captured values and validated each SRP parameter independently. No more hitting the server to test hypotheses. Pure math verification against known-good data.

The solver returned results that surprised us.

The Identity Mystery

The SRP proof (M1) includes a hash of the “identity” – what identifies the authenticating party. In standard SRP, this is usually the username or email. We’d been using the email.

Wrong.

Frida captured the output of op_srp::B5AuthIdent::compute_identifier in the Rust SDK. The identity is not the email. It’s a custom value derived from the account key UUID:

def _compute_identity(email: str, secret_key: str) -> str:
    ak_uuid = secret_key.split("-", 2)[1]   # e.g. "2JWFCQ" from "A3-2JWFCQ-..."
    h_ak = SHA256(ak_uuid.encode())
    h_email = SHA256(email.encode())
    identity_bytes = SHA256(h_ak + h_email)
    return base64url_encode(identity_bytes)


A double-hash construction: SHA256 of the concatenation of SHA256(account_key_uuid) and SHA256(email). Base64url-encoded. This derivation lives only in the compiled client. It isn’t spelled out in the open-source SRP library or the SDK surface, which makes sense: it’s an internal detail of 1Password’s own auth path. We found it purely through Frida instrumentation of the Rust binary.

This was why M1 kept failing. Every SRP parameter was correct – k, u, S, K – but the identity input to M1 was wrong. And since the server returns the same 401 for wrong-K and wrong-M1 – the same opaque errorCode:102 "Authentication required", exactly as a zero-knowledge server should – we couldn’t tell the difference from the outside.

What the Solver Confirmed

The Frida capture + offline solver verified the complete parameter chain:

Step Formula Verification Method
k SHA256(N_bytes \|\| pad(g, 512)) Frida padded_bytes capture
u SHA256(pad(A,512) \|\| pad(B,512)) Frida padded_bytes capture
S (B - k*g^x)^(a + u*x) mod N Standard SRP math
K SHA256(lowercase_hex(S)) Frida + offline solve
M1 SHA256(H(N)^H(g) \|\| H(I) \|\| salt \|\| A \|\| B \|\| K) Frida SHA-256 trace
MAC HMAC(HMAC(K, purpose), msg)[:12] Frida HMAC chain

One thing the solver revealed: k and u actually use standard RFC 5054 padding in the SDK path (pad g to 512 bytes for k, pad A and B to 512 bytes for u). The Go CLI’s makeLittleKNonStd uses different padding than the Rust SDK, but the server accepts both. We’d been chasing the wrong k/u formulation because we reverse-engineered the CLI path first.

The MAC, the Beatles, and Element 9

With SRP math and identity fixed, the MAC was the last gate. We already knew the key derivation:

mac_key = HMAC_SHA256(key=K, message=purpose_string)

The purpose string, found at binary address 0x100bbd0ea (56 bytes):

He never wears a Mac, in the pouring rain. Very strange.

From “Penny Lane” by the Beatles. A line about a Mac, used as the MAC key purpose string. Verified byte-for-byte from the __rodata section. Someone at AgileBits had fun with that one.

The MAC message format was the remaining unknown. We traced generateMACMessage at 0x100456ea0 through ARM64 assembly. The function calls runtime.concatstrings with 10 elements:

Index Content
0 session_id
1 \|
2 strings.ToUpper(method)
3 \|
4 strings.ToLower(host)
5 URL.Path
6 ?
7 URL.RawQuery
8 \|v1\|
9 requestID (NOT the HTTP body)

The body doesn’t exist yet when computeRequestMAC runs. The call chain proves it: request.Init generates the MAC, then later doEncryptedWithHeaders creates the encrypted body. The MAC authenticates which request you’re making; the body is protected separately by AES-256-GCM authenticated encryption. Two independent layers:

Diagram 4
Diagram 4

Final format:

{session_id}|{METHOD}|{host_lower}{path}?{rawquery}|v1|{requestID}

HTTP 200 (March 6, 14:29 UTC)

We fixed the identity derivation. We fixed the MAC message format. We ran the auth flow.

auth/start  -> 200
auth        -> 200
auth/verify -> 200


Three days. March 4 through March 6. Binary RE started March 5. 960 brute-force attempts before the Frida pivot. And then – pure Python SRP authentication with 1Password’s internal API. No SDK, no CLI, no Frida in the loop. Working end-to-end.

Within hours, we had the API surface mapped:

Endpoint Status Data
/api/v2/users 200 All users (uuid, name, email, state, type)
/api/v1/groups 200 All groups (uuid, name, permissions, state)
/api/v3/account 200 Account metadata
/api/v1/vaults 200 Vault list with access info
/api/v2/overview 200 WebSocket notifier config

Rich enough to replace SCIM entirely – plus vault access info that SCIM never had.

The Production Implementation

The production code lives at one_password_srp_auth.py. ~420 lines of Python. No CLI dependency, no Rust FFI, no SCIM bridge. The shipped flow follows the SDK’s path end to end (it splits the CLI’s old auth/verify into confirm-key + complete):

Diagram 5
Diagram 5

The core SRP computation:

N = int(SRP_PRIME_HEX, 16)  # RFC 5054 4096-bit prime
g = 5

def _int_to_bytes(n: int) -> bytes:
    """Go's big.Int.Bytes() -- minimal big-endian, no padding."""
    if n == 0:
        return b""
    return n.to_bytes((n.bit_length() + 7) // 8, "big")

def _trimmed_hex(n: int) -> str:
    """Go's fmt.Sprintf("%x", bigInt) -- lowercase, no leading zeros."""
    return format(n, "x") if n else "0"

class _SRPClient:
    def __init__(self, x: int) -> None:
        self.x = x
        self.a = int.from_bytes(secrets.token_bytes(32), "big")
        self.A = pow(g, self.a, N)

    def process_challenge(self, B_hex, salt_b64, identity):
        B = int(B_hex, 16)
        u = _sha256_int(
            _pad_to(_int_to_bytes(self.A), 512)
            + _pad_to(_int_to_bytes(B), 512)
        )
        S = pow((B - K_MULTIPLIER * pow(g, self.x, N)) % N,
                self.a + u * self.x, N)

        # THE critical line -- hash the hex string, not bytes
        K = hashlib.sha256(_trimmed_hex(S).encode()).digest()

        # M1 proof with custom identity (NOT email)
        H_N_xor_H_g = _xor_bytes(_sha256(_int_to_bytes(N)), _sha256(b"\x05"))
        M1 = _sha256(H_N_xor_H_g, _sha256(identity.encode()),
                      _b64url_decode(salt_b64),
                      _int_to_bytes(self.A), _int_to_bytes(B), K)
        return K, M1


The MAC:

MAC_PURPOSE = b"He never wears a Mac, in the pouring rain. Very strange."

def _compute_mac_header(K, session_id, method, url):
    mac_key = hmac.new(K, MAC_PURPOSE, hashlib.sha256).digest()
    req_id = str(int.from_bytes(secrets.token_bytes(4)))
    parsed = urlparse(url)
    msg = f"{session_id}|{method.upper()}|{parsed.hostname.lower()}{parsed.path}?{parsed.query}|v1|{req_id}"
    mac_bytes = hmac.new(mac_key, msg.encode(), hashlib.sha256).digest()[:12]
    return f"v1|{req_id}|{_b64url_encode(mac_bytes)}"


Usage:

session = await authenticate(service_account_token)
users = await session.api_get("/api/v2/users")
groups = await session.api_get("/api/v2/group/{group_id}")
vault_access = await session.api_get("/api/v2/vault/{vault_id}/vaultaccess")


No infrastructure to deploy. No binary to ship. No SCIM bridge to self-host. Python and HTTPS. A simpler deployment model for our use case.

In Summary

What we found:

We discovered that service account tokens have broader organizational read access than their vault scope implies: a capability that is documented in 1Password’s developer docs and that many practitioners may not have front-of-mind when they create a vault-scoped token. 1Password has confirmed this is by design, intended to support vault management workflows in which a service account may need to add users or groups to a vault. More granular access scoping for service accounts is on the product roadmap.

Why it matters:

Even though the authentication protocol is cryptographically strong, the access model grants broader organizational read visibility than many practitioners would expect from a vault-scoped token. A compromised service account token, even one created for a single vault,  provides an attacker with meaningful identity enumeration capability. This makes proper token management and rotation hygiene critical.

What it means for cybersecurity professionals:

This highlights that security risk often comes from internal APIs your tooling doesn’t expose, access scopes that are broader than their labels imply, and gaps between what an SDK surfaces and what the underlying service can actually do, not just cryptographic weaknesses. Teams should audit “unofficial” or reverse-engineered interfaces, enforce strict privilege boundaries, and monitor what tokens can actually access in practice, not just what documentation claims.

Conclusion

The cryptography held. The protocol worked. The math was sound and genuinely well-built, all the way down to constant-time proof comparison. And that was never the question.

Because the hard part of security isn’t where things are strongest; it’s where assumptions go unchallenged. Here, the surprise wasn’t in SRP. It was in how much a vault-scoped token can legitimately see once it’s authenticated, and how easily that breadth slips past a defender’s mental model.

For defenders, the lesson is clear. Don’t just trust scopes, SDKs, or documentation. Validate what tokens can actually do. Inspect the paths your SDK doesn’t surface. Assume there are capabilities your tools aren’t showing you.

Because somewhere in your stack, there’s a credential whose actual access scope is broader than its label suggests, and the only way to know is to look.


The production 1Password SRP auth implementation ships as part of Token Security’s identity collection platform, replacing the SCIM bridge dependency for organizations that need full identity enumeration without self-hosted infrastructure.

Discover other articles

Be the first to learn about Machine-First identity security