-
Notifications
You must be signed in to change notification settings - Fork 78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Throw exceptions when event ID doesn't match configuration #1802
Throw exceptions when event ID doesn't match configuration #1802
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is good, but thinking through the security model, I don't think it's complete yet. Basically every field in pcd.claims should be validated against the expected configuration (unless it's purely an output field like nullifierHash). pcd.verify() proves that the claims are proven, but doesn't rule out someone sending you a PCD proving the wrong claims.
I've made some comments on individual lines where I think there's more to check.
packages/lib/zuauth/src/server.ts
Outdated
@@ -31,14 +33,15 @@ export async function authenticate( | |||
const pcd = await ZKEdDSAEventTicketPCDPackage.deserialize(serializedPCD.pcd); | |||
|
|||
if (!(await ZKEdDSAEventTicketPCDPackage.verify(pcd))) { | |||
throw new Error("ZK ticket PCD is not valid"); | |||
throw new ZuAuthAuthenticationError("ZK ticket PCD is not valid"); | |||
} | |||
|
|||
if (pcd.claim.watermark.toString() !== watermark) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the externalNullifier should be passed in by the caller to be verified here too (if it's set at all).
In general, it should be verified by anyone who makes use of the nullifierHash.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The external nullifier seems redundant if the watermark is used. What extra security or utility is it providing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They're for different purposes. Watermark avoids replay attacks of the exact same proof by tying it to a specific transaction. Nullifier is tied to the user's identity, to ensure that if the same user generates a new proof they generate the same nullifier, to detect things like double-voting. In Zupoll (as I understand it) the watermark is tied to the contents of this ballot to ensure you vote on what you intended, while the external nullifier is the same for all votes on a particular poll, so that if you come back to vote a different ballot again later the nullifier detects it.
The question is whether the user of this library is going to make use of the nullifier. Since this layer of code doesn't know for sure, I think it should check everything. Likely if the user didn't want to use a nullifier they wouldn't set the external nullifier anyway, and the check will be trivial.
This like several of my other suggestions are based on the principal that the prover should not modify any of the arguments to the proof before generating (other than the args they're supposed to fill in, like the ticket). If they do so, they're cheating, and should be caught by these checks.
} | ||
expect(thrown).to.be.true; | ||
}); | ||
|
||
it("should not authenticate PCDs with the wrong event ID", async function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this test case not new or modified? It seems like this test case is supposed to be testing the eventID check, but was already passing when that check was missing. That suggests to me that there might be something wrong with the test, which was causing it to throw for some other reason. Is that why you added the new ZuAuthAuthenticationError type? I think it may be worth doing a check on the error message to make sure you're getting an exception specifically from the case you expect, vs. accidentally triggering a public key check instead, or something similar.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The original test was broken and this one isn't. The only real change is the mechanism by which we test for exceptions, which is one that works rather than one that doesn't.
packages/lib/zuauth/src/server.ts
Outdated
if ( | ||
eventIds.size > 0 && | ||
pcd.claim.partialTicket.eventId && | ||
!eventIds.has(pcd.claim.partialTicket.eventId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If eventId is not revealed, you need to check whether validEventIds
matches the configured set of eventIds
. Looking at the code elsewhere, it seems like this only happens if the number of eventIds
is <= 20, which is the limit of the circuit. That does seem to mean that if you have >20 and eventID isn't revealed, there's no secure guarantee.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If eventId is not revealed, you need to check whether validEventIds matches the configured set of eventIds.
Why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general, all the claims should be checked against expectations, so that a prover can't fool you by sending a proof of something other than the conditions you asked for. That's because there's no cryptographic guarantee the prover can't change the args before generating the proof. validEventIds is simply a part of that principal.
The "If eventId is not revealed" part of my statement is simply acknowledging that checking a single revealed event ID is stricter than validEventIds, so if eventId is revealed and checked here it's okay to skip checking validEventIds. I'd probably go ahead and check everything, though, just to be safe and clear.
See my longer comment below for more thoughts on the interactions between the different ways of checking event ID.
packages/lib/zuauth/src/server.ts
Outdated
"Signing key does not match any of the configured public keys" | ||
); | ||
} | ||
|
||
if ( | ||
eventIds.size > 0 && | ||
pcd.claim.partialTicket.eventId && |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you should also check that either eventID is revealed, or validEventIDs is used and checked (see below), and throw if neither is true. Unless you think there's a case where the user intentionally doesn't want the eventID checked (in which case why would it be in the config?).
My thought process which leads to this:
One could imagine in general checking that all the fields in partialTicket
which should be revealed are actually revealed. You want to avoid cases where the remote site decided to give you a proof which doesn't reveal enough data.
Probably in the general ZuAuth case that's up to the 3rd-party code using the library, which will presumably notice if any field they depend on is missing. However for fields checked by ZuAuth (eventID, productID) the app code might reasonably assume that it doesn't need to bother looking at those fields, since ZuAuth already checked them.
A case I'm a bit worried about is this scenario:
- Proof config has >20 event IDs so no use of validEventIds
- eventID is configured to be revealed
- Malicious remote app generates a proof with eventID not revealed
- This code doesn't check anything
- App code doesn't make use of event ID (assuming that ZuAuth has verified it) and never notices.
packages/lib/zuauth/src/server.ts
Outdated
"Event ID does not match any of the configured event IDs" | ||
); | ||
} | ||
|
||
if ( | ||
productIds.size > 0 && | ||
pcd.claim.partialTicket.productId && |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As with eventID above, I think it might be necessary to throw here if the productID isn't revealed when it should be.
4578d48
to
95c00dc
Compare
I've made several changes here. The server-side code now takes a full set of arguments, identical to those provided to the client (and so including In addition, if event or product IDs are provided to authenticate against, then the relevant fields must be revealed. I have removed the use of the The tests have been updated to check specific error messages, and to test the cases where there is a mismatch in the revealed fields used when making the proof and those provided to the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I like the thoroughness of the checks vs. config, particular if it's truly always the same config used for the proof request (I didn't trace all the codepaths to confirm that).
I made a few tactical suggestions in comments. My main remaining discussion point is about what the impact of these changes might be on the assumptions of existing users/apps. Specifically:
-
Is there any case left where it's allowable to not reveal eventID and productID? I saw when they're now enforced, but I also saw them being set to true in some new places. I'm not familiar enough with the full scope of the code to know what unchanged cases might remain where they're not set to true. It seems like if there are existing apps which were choosing not to reveal those fields, it's a bit surprising if our library suddenly starts revealing them.
-
The removal of the validEventIDs feature is a bit worrying in the same way. If there are apps which were depending on it, their privacy model is suddenly changing. Also that was specifically a feature we talked about a lot at Devconnect, and used in ZKTelegram for login and ZuRat, so removing it silently seems poor from a library compatibility standpoint. Maybe it should be left in the same state as before, where it functions only within specific size limits? I don't to overstress it given we're going to have a much more safe and flexible alternative with PODTickets and GPCs, but I also don't like the idea of changing the assumption of existing working apps, particularly on a security feature.
Both points are things I'm wiling to defer to you on after discussion, but thought were worth raising explicitly.
keyof EdDSATicketFieldsToReveal, | ||
keyof ITicketData | ||
> = { | ||
revealAttendeeEmail: "attendeeEmail", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this can be done programmatically rather than with a lookup table.
output = input.slice(6, 7).toLowerCase() + input.slice(7);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes logical sense, but TypeScript really wants keys into objects to have the correct types. The key type for ITicketData
is keyof ITicketData
and not string
, so we can't construct a valid type by doing string manipulation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could do it with an as
somewhere, I think, but fair enough to stick with type safety.
|
||
// For each of the fields configured to be revealed, check that the claim | ||
// contains values. | ||
for (const [revealedField, fieldName] of Object.entries(revealedFields)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion without the lookup table:
for (const [revealedFieldName, shouldReveal] of Object.entries(fieldsToReveal)) {
checkIsDefined(
pcd.claim.partialticket[revealedFieldName.slice(6, 7).toLowerCase() + revealedFieldName.slice(7)],
revealedFieldName
);
}
packages/lib/zuauth/src/server.ts
Outdated
for (const [revealedField, fieldName] of Object.entries(revealedFields)) { | ||
if (fieldsToReveal[revealedField as keyof EdDSATicketFieldsToReveal]) { | ||
checkIsDefined(pcd.claim.partialTicket[fieldName], fieldName); | ||
} | ||
} | ||
|
||
const publicKeys = config.map((em) => em.publicKey); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a bit nervous that each of these 3 fields are being checked individually, rather than as a triple of (key, eventID, productID) which would confirm that they aren't mixed together in the wrong way.
If all issuers are well-behaved about using unique IDs, that should be a non-issue, though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand the problem here. Can you give me an example?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My config says that valid tickets to look like: (pubkeyA, eventA, productA1), (pubkeyA, eventA, productA2), (pubkeyB, eventB, productB1).
I'm presented with a ticket containing (pubkeyB, eventA, productA1). This is not actually a valid combination because the signer for B shouldn't be signing tickets from A. However the code here accepts it.
Alternately since there's usually only one signer, I could see a ticket (pubkey, eventB, productA1). This is invalid because it's mixing products from A with event B. The code here accepts it anyway.
It's an unlikely issue given there's currently only one issuer (Zupass/PODBox) and it uses unique IDs. But in theory if the signers are independent we shouldn't trust them not to step on each other. The code here could avoid the risk by forming a set of triples of (pubkey, eventID, productID) and checking that the presented IDs match exactly one triple. It's a bit more complicated because of the fact that some of the fields might not be revealed, so it's not a simple set membership check.
With GPC proofs, it'll be possible to prove that the triple is in a list in a ZK way directly.
@@ -135,8 +147,7 @@ export function constructZkTicketProofUrl(zuAuthArgs: ZuAuthArgs): string { | |||
}, | |||
validEventIds: { | |||
argumentType: ArgumentTypeName.StringArray, | |||
value: | |||
eventIds.length !== 0 && eventIds.length <= 20 ? eventIds : undefined, | |||
value: undefined, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is losing a feature which we advertized at Devconnect. How sure are you that nobody is depending on it?
Does ZKTelegram use ZuAuth? We definitely went our of our way to make the point that logging in to DMS or posting via ZuRat didn't reveal which Devconnect subevent you attended. It would be odd to silently undo that.
It would be possible to keep using this field when you know it's sufficient (number of eventIDs is <=20, and no productIDs are used). That being said, I don't like that it was an implicit feature before. It would be better if it were an explicit configuration choice by the developer to use this feature, with the library throwing an exception if the dev tries to use this feature with an overlarge list.
Note that when we get to PODTickets we can have longer lists, and also triples of (key, eventID, productID), so maybe looking ahead to that is the right way to not worry too much about getting this perfect.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ZuAuth was not used at Devconnect, because it didn't exist then. The underlying code in ZkEdDSAEventTicketPCD
and passport-interface
was used, and still permits the use of validEventIds
.
My thinking was that validEventIds
is not a great feature to expose - it's confusing because it only works for event IDs and not product IDs. The more features we expose, the more we need to test and to document, and the purpose of ZuAuth is specifically to reduce the API surface area when compared to using the underlying libraries directly.
That said, I can make it an optionally-configurable feature that is off by default, which seems like the best of both worlds.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. After implementing this, I remember why I didn't want to do it: the validEventIds
model is just fundamentally not helpful for authentication in a world with multiple signers. The signer is always revealed, but unless you can determine the connection between the signer and the event ID, you can't rule out the possibility of some event ID having been used by the "wrong" signer. Passing a validEventIds
check is just never good enough for authentication purposes, which is why we can't rely on it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood. The limitation of that feature to validEventIds does assume that the signing key and product ID are either always a single value (e.g. signing key for last year's events), or don't matter for authentication (e.g. the product IDs for devconnect). It would be possible to check for those scenarios, or leave them up to the user to configure properly. However given comments elsewhere I'm okay with removing this so long as it's not affecting the use cases which were built to depend on it (like ZuRat for Devconnect). With PODTickets we can bring back a much more flexible version of this.
8215588
to
bb47cbf
Compare
bb47cbf
to
fe70abe
Compare
I've made further modifications: From a ZuAuth perspective, it doesn't make sense to include I've tightened up all of the checks that ensure that the claim matches the configuration. This is important because we want to ensure that the same configuration is used to derive the prove screen URL on the client side, and for authentication on the server-side. Any mismatch may suggest a configuration error, or use of a credential prepared with a different configuration (though the
I have also added some tests, and cleaned up the implementation of existing tests. This includes checking the precise exceptions thrown for each failure case. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good. A few questions remaining.
I didn't review the utests in detail.
packages/lib/zuauth/src/server.ts
Outdated
return ( | ||
isEqualEdDSAPublicKey(claim.signer, config.publicKey) && | ||
claim.partialTicket.eventId === config.eventId && | ||
!!config.productId && |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This means a config with no productID never matches? Seems like you might want an OR here for the case where productID is undefined?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ping on this comment? Am I misreading this?
): Promise<ZKEdDSAEventTicketPCD> { | ||
/** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: I think the double *
in /**
here marks this as being for typedoc documentation, which doesn't seem relevant inside the body of the function. Could be just /*
or else //
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I don't use the double-asterisk, then VSCode does not insert a new asterisk at the beginning of the subsequent lines. From that I inferred a distinction between using /* ... */
as a means of "commenting out", and /** ... */
as a means of writing multi-line comments, but thiat is just my interpretation of VSCode's logic.
checkIsUndefined(pcd.claim.partialTicket[fieldName], fieldName); | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you're now desupporting validEventIds, should you check here that it's required to be undefined? I don't think it changes the security (given you're checking that all the necessary fields are being revealed), but it would catch a configuration error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added a check for this too.
@@ -47,8 +47,8 @@ function claimMatchesConfiguration( | |||
return ( | |||
isEqualEdDSAPublicKey(claim.signer, config.publicKey) && | |||
claim.partialTicket.eventId === config.eventId && | |||
!!config.productId && | |||
claim.partialTicket.productId === config.productId | |||
(config.productId === undefined || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the semantics in PODBox if productID isn't defined? The underlying PCD always has a productID, so is it meant to be ignored? Is it set to be equal to the eventID?
This code implements the former (productID will be ignored) which seems fine. We could be extra-strict and require that the productID not be revealed when the config doesn't specify it, but that seems optional
Closes https://linear.app/0xparc-pcd/issue/0XP-974/zuauth-doesnt-check-event-id-when-revealed
Fixes
authenticate
so that, if the event ID is revealed and event IDs are configured as filters, the revealed event ID must match one of the filtered values.Also updates the tests to be more careful in identifying the kind of exception thrown.