#authentication #zk *2025-04-24* [@mempirate](https://x.com/mempirate) Recently, I came across [Stealthnote](https://stealthnote.xyz/) X, and it piqued my interest. Stealthnote allows you to post something on a public feed under your Google workspace organization, without revealing anything more than the message and the organization itself (so who you actually are is kept private, which can make for some interesting posts). ![[stealthnote.png]] At first I thought this probably used some form of [zkTLS](https://medium.com/zkpass/zktls-the-cornerstone-of-verifiable-internet-da8609a32754) (a.k.a. web proofs), but after reading the [explainer](https://saleel.xyz/blog/stealthnote/), turns out I was wrong! Let's take a look at how this works, and some other projects that leverage the same mechanisms. I won't talk too much about the ZK behind it, but more about the "hijacking" of OIDC that's involved. ## OAuth 2.0 Let's take a quick detour into the land of web authentication and authorization, where OAuth 2.0 is king. OAuth 2.0 was built as a way to authorize third party access to some user-owned resources on a server, like Google or Facebook. Users can granularly define scopes to endow third party applications with, and after some OAuth magic, the application ends up with an access token that they can use in API calls to access resources. If you've ever seen panels like this, you've used OAuth: ![[oauth.png]] > [!info] > Access tokens are usually not signed by the authorization server, which is the server issuing the token. Whenever a third party uses the access token to access a resource, the *resource server* needs to check in with the authorization server about the token's metadata (permissions and such). ## Hijacking OIDC Now, OAuth is primarily used for authorization as opposed to authentication, but there is a protocol called [OpenID Connect](https://openid.net/developers/how-connect-works/) (OIDC) that extends OAuth 2.0 with authentication. Often, when you're using a "Sign in with `X`" flow (where `X` = an OpenID [provider](https://en.wikipedia.org/wiki/List_of_OAuth_providers)), you're using OIDC. The tokens returned by OIDC (`id_token`) are usually [JSON Web Tokens](https://jwt.io/) (JWT), which are a form of tokens that **self-contain** all the metadata ("claims" in JWT terminology) about the authenticating user. More importantly, they are **signed** by the identity provider to ensure the integrity and authenticity of the claims in the token. JWTs consist of a header, a payload, and a signature over the header and the payload, encoded as `base64(header).base64(payload).base64(signature)`. The URI to kick off an OIDC authentication flow looks like this: ``` https://accounts.google.com/o/oauth2/v2/auth? response_type=code& client_id=424911365001.apps.googleusercontent.com& scope=openid%20email& redirect_uri=https%3A//oauth2.example.com/code& state=security_token%3D138r5719ru3e1%26url%3Dhttps%3A%2F%2Foauth2-login-demo.example.com%2FmyHome& [email protected]& nonce=0394852-3190485-2490358& hd=example.com ``` Notice the `nonce` field. This is a value that an application can set freely at the start of the authentication process, to protect against replay attacks. We'll get back to this later. Notice also the `client_id`: this is a mandatory parameter to the request made to Google, and they represent client IDs, which are like application identifiers issued by Google. Luckily, client IDs are *kind of* public, and easy to capture. Exercise to the reader as to how to do that :) After some [back-and-forth](https://developers.google.com/identity/openid-connect/openid-connect), a JWT payload from Google might look like this: ```json { "iss": "https://accounts.google.com", "azp": "1234987819200.apps.googleusercontent.com", "aud": "1234987819200.apps.googleusercontent.com", "sub": "10769150350006150715113082367", "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q", "hd": "example.com", "email": "[email protected]", "email_verified": "true", "iat": 1353601026, "exp": 1353604926, "nonce": "0394852-3190485-2490358" } ``` A lot of useful metadata here! We can see some time related stuff, like issued-at (`iat`) and expiration (`exp`) fields, and some personal information like an email. The `client_id` field shows up in the `azp` and `aud` claims. Ok, let's backtrack. We now have a JWT that is signed by Google, and that contains a bunch of metadata. Additionally, we have a `nonce` field that we can piggyback on to **include any custom data in the signed payload**. This will irrevocably link my identity to whatever is in the `nonce`, meaning we can **bind** our identity on Google to any custom data! We can prove our identity and the bind to any third party verifier by giving them the full JWT and Google's public key, which they can find here: https://www.googleapis.com/oauth2/v3/certs. Alternatively, we can selectively disclose only *parts* of the JWT, and prove that these parts are authentic. Importantly, we don't need an intermediate trusted server to link our identity to any of the metadata we want to bind to. Having the signed JWT is enough (with some ZK for privacy) to convince any verifier of the bind, meaning we can eliminate one [TTP](https://nakamotoinstitute.org/library/trusted-third-parties/) from the picture. ## Use Cases So, what can we do with this? In [zkLogin](https://docs.sui.io/concepts/cryptography/zklogin), the `nonce` field is hijacked to store, among other things, an ephemeral public key (all hashed and salted to prevent Google from seeing anything). The corresponding private key can be used to sign transactions for a brief session. A ZK proof is generated that conceals the private fields, but shows that Google's signature is still valid. Users can then submit a transaction on-chain with the ephemeral signature and the ZK proof, which will be verified by validators. This allows users to transact purely using authentication with a third party identity provider. They don't even have to remember the ephemeral key, because it's renewed on every session. You may be wondering then, if the ephemeral key is discarded after every session, how is the user's address defined? You can read more about the details [here](https://docs.sui.io/concepts/cryptography/zklogin#how-zklogin-works), but the gist of it is that the address is derived from static fields in the JWT that are consistent per `(user, provider, application)` tuple (the `sub`, `iss`, and `aud` claims in the JWT). These static fields maintain the persistent identity of the user over time, with some caveats: - Assumption that Google keeps these fields static - If the application (defined by `client_id`) pulls the plug on you, you won't be able to authenticate yourself anymore > **SIDE QUEST**: > With account abstraction, these types of custom authentication could also be possible on EVM chains. The persistent contract address can be derived from static claims using [`CREATE2`](https://docs.openzeppelin.com/cli/2.8/deploying-with-create2). Stealthnote actually works similarly. They also store a public key in the `nonce` field that can be used to sign messages. As a prover, you have to generate a ZK proof that verifies Google's signature on the JWT, and extracts the `nonce` and **domain** from the `email` claim (while hiding the username). It then checks that the provided public key was present in the `nonce`. Check out the Noir circuit [here](https://github.com/saleel/stealthnote/blob/main/circuit/src/main.nr). Verifiers can then [verify the proof](https://github.com/saleel/stealthnote/blob/82792a2613e32519d22133bd1c0ccbb67158e6b3/app/lib/providers/google-oauth.ts#L93-L98) using the domain, the ephemeral public key (and its expiry), and Google's public key. They also verify that the signature on the message matches the public key. --- This reminded me a bit of zkTLS. Because Google signs the returned payload directly, and because JWT contains relevant metadata, there's no need for another party to link your identity to some other metadata. The signature by Google is enough to prove who you are. With zkTLS, you always need a trusted third party (often called "notary" or "attestor") to attest that some data actually came from the server a client is interacting with. This is because TLS works with symmetric keys (owned by both the client and the server), which means that anything the client proves about the TLS transcript is not to be trusted, because they can rewrite the transcript themselves. With a third party attesting to the flow of information, this is mitigated. However, the TTP is purely required because HTTPS content isn't signed by the private key of the server. zkTLS is, in a sense, [a band-aid](https://x.com/_weidai/status/1858192527125319904), with the real solution being proposals like [RFC-9421](https://datatracker.ietf.org/doc/html/rfc9421). RFC-9421 in short proposes that HTTP server responses are signed by the server's keypair, which would provide all the authenticity, provenance and non-repudiation guarantees that zkTLS aims to introduce with a TTP! However, this requires explicit opt-in from the server, which [may](https://x.com/0xFanZhang/status/1858205411901350004) [be](https://x.com/Euler__Lagrange/status/1858207597079535793) [unlikely](https://x.com/yeak__/status/1849107369352983007) any time soon. **Other references**: - https://developers.google.com/identity/openid-connect/openid-connect - [OAuth 2.0 explainer](https://www.youtube.com/watch?v=ZV5yTm4pT8g) - https://oauth.net/id-tokens-vs-access-tokens/ - https://github.com/JBANKS040/OpenBands: Like Stealthnote, but instead of posting messages you post your salary. Uses zkEmail to verify the salary.