RSDK-11520: Persist app+signaling connection jwts on the robot - #6384
RSDK-11520: Persist app+signaling connection jwts on the robot#6384Evan Dorsky (EvanDorsky) wants to merge 18 commits into
Conversation
|
|
||
| authenticator, ok := conn.(rpc.ClientConnAuthenticator) | ||
| if !ok { | ||
| // we cannot auth this connection, but it might still be valid, so just return it |
There was a problem hiding this comment.
We don't expect any code paths to hit this? If so, let's log as a warning.
There was a problem hiding this comment.
we know the types of everything we're returning (even if it's a bit hidden in goutils), so we should know if something is wrong/unexpected. the comment/log should be more opinionated on that front
There was a problem hiding this comment.
Yeah, good idea. I will need to test whether we hit this path when the user has explicitly disabled authentication in their config. If we do, warning level is probably still fine since that's a rare use case.
| "error", authErr, "code", code.String()) | ||
| return conn, nil | ||
| } | ||
| // auth failed but our token was not explicitly rejected, so check if we have a cached token, then try dialing with it |
There was a problem hiding this comment.
Can you describe the scenario where this helps?
| // dial completely failed, pass the results through for the retry loop | ||
| return conn, err | ||
| } | ||
|
|
There was a problem hiding this comment.
The rest of this function omits newlines. 66 lines of concise logic, minus the 1 empty line here. This is a real missed opportunity. /s
| return newConn, nil | ||
| } | ||
| // just in case we got an empty token, we don't want to overwrite a potentially good cache | ||
| // this check is most likely unnecessary, but it's cheap and harmless |
There was a problem hiding this comment.
I disagree this is harmless. I think it's good to call out the assumption that the token is not nil. I think we should log if the string is nil.
| // if our auth request gets denied with certain failure codes, it means serverside auth is working fine | ||
| // and our credentials were just bad. in that case, we want to fail early instead of trying the cached token, | ||
| // which could mask a real problem with auth | ||
| if code := status.Code(authErr); code == codes.Unauthenticated || code == codes.PermissionDenied { |
There was a problem hiding this comment.
I think we need to be clear about what error code "KMS is down" manifests as.
There was a problem hiding this comment.
Agreed - I'll be doing testing today to figure this out.
| // authedDialDirectGRPC calls DialDirectGRPC while also explicitly authenticating the connection | ||
| // and caching the resulting token on disk. If the normal auth path fails, we attempt to auth | ||
| // with the cached token | ||
| func authedDialDirectGRPC(ctx context.Context, |
There was a problem hiding this comment.
Getting to the bottom of this function, I think the answer to most of the "what case hits this" is "we don't know".
Please let me know if that's not the case for some of them. Assuming I'm right though, I'd prefer to simplify the flow through exactly what we expect. We can certainly log more such that if this isn't working right for a customer, we can ascertain whether those other code paths would have been helpful. But I think we can save some cognitive load by reducing this down to:
- Dial to app presenting our robot secret to exchange for a JWT
- If that succeeds, write down the new JWT. Exit
- If that fails, dial with a cached JWT.
- If the cached JWT works, early exit -- there's nothing new to write to disk.
- Distinguish between "no cached jwt file" and "cannot read cached jwt file" errors for logging.
I think the code for this function should also be opinionated. Particularly about variable names. So calling out robot secret vs signed JWT. We can relax some on the name there, but I think the vague "token" is too opaque. We want to communicate that this is a signed token. Giving the user intuition why the follow-up might succeed where the original had a problem.
There was a problem hiding this comment.
Reading through the code again today, I'm agreeing with you that the variable names should be more opinionated, I think that will make the logic and intent easier to follow.
And I do have ideas for what case would hit most of the error branches in here, but I don't think the comments are doing a good enough job explaining them. Also the happy path is kind of buried by all the error handling. I think better comments + variable names + a bit of refactoring will improve this a lot, and I might be able to get rid of a few cases too. I'll be back with this soon. Also good call on "no cached jwt" vs "can't read jwt".
There was a problem hiding this comment.
Distinguish between "no cached jwt file" and "cannot read cached jwt file" errors for logging.
I think logging the error message from jwtCacheRead (new name) will differentiate between these cases, but let me know if you want stronger differentiation than that - this feels like enough to me.
|
|
||
| func tokenInfo(partID, host string) (string, string) { | ||
| cacheDir := filepath.Join(rutils.ViamDotDir, "grpc") | ||
| tokenFilename := base64.RawURLEncoding.EncodeToString([]byte(partID + "_" + host)) |
There was a problem hiding this comment.
Dumb question, what is host? I'm guessing it's app.viam.com or app.viam.dev? Is it just for guaranteed uniqueness as opposed to "statistically unique"?
There was a problem hiding this comment.
Yes, those are the possible hosts. I was a little confused here at first because I didn't understand that the signaling server and app share the same url - so I was adding host to the filename to make sure that tokens from different hosts wouldn't overwrite each other.
I still think accounting for host in the filename is still reasonable, since in testing (and maybe at some point in the future?) the app and signaling hosts will sometimes be different.
Cheuk (cheukt)
left a comment
There was a problem hiding this comment.
generally agree with dan's comments, I think the code/comments should be more precise about what can/cannot happen and what is expected/unexpected so that future readers are not taking on the cognitive burden of trying to figure out what might be happening
|
|
||
| authenticator, ok := conn.(rpc.ClientConnAuthenticator) | ||
| if !ok { | ||
| // we cannot auth this connection, but it might still be valid, so just return it |
There was a problem hiding this comment.
we know the types of everything we're returning (even if it's a bit hidden in goutils), so we should know if something is wrong/unexpected. the comment/log should be more opinionated on that front
| // if our auth request gets denied with certain failure codes, it means serverside auth is working fine | ||
| // and our credentials were just bad. in that case, we want to fail early instead of trying the cached token, | ||
| // which could mask a real problem with auth | ||
| if code := status.Code(authErr); code == codes.Unauthenticated || code == codes.PermissionDenied { |
| if err != nil { | ||
| return err | ||
| } | ||
|
|
There was a problem hiding this comment.
probably want to make these atomic - we have an atomic store in goutils
There was a problem hiding this comment.
Agree. Claude recommended against pulling in the goutils implementation - since goutils.artifact pulls in all the gcs dependencies which are pretty heavy - so I just replicated the atomic write logic here, which wasn't that bad. Hope that sounds reasonable.
|
Re-requested reviews - thanks for all the input. I made the variable names, error messages, and comments a lot more clear and specific to what this change is actually doing. Also factored the retry path into its own function so that the happy path is much more clear. |
This is part of APP-8535 for resilient signaling so that robots can make connections to smart machines even if there is degradation on the app/system.
This branch adds jwt caching to
NewAppConninviam-serverby wrapping calls torpc.DialDirectGRPCin a new method,authedDialDirectGRPC.authedDialDirectGRPChas largely the same signature and behavior asrpc.DialDirectGRPC- it dials the requested server (app or signaling) and returns anrpc.ClientConn.However,
authedDialDirectGRPCalso explicitly authenticates the connection - instead of relying on lazy auth - and then caches the resulting jwt on disk. It proceeds as usual on the happy path, but if - and only if - authentication fails,authedDialDirectGRPCwill attempt to use the corresponding cached token to authenticate its connection.Tokens are cached in
$VIAM_HOME/grpcand are named with a (filepath-safe) base64 encoding of the machine's part ID + the host that minted the token.This branch does not include any cache invalidation i.e. client-side token revocation. I think the server (app/signaling) is the right place for revocation to live, and this change to rdk should continue to work if we change our revocation policy, since we always start by requesting a new token.
While approaching resilient signaling, I went for this change first for a few reasons:
Why this doesn't degrade the security story
note: here, "rdk" refers to
viam-serverand "server" refers to app or signaling. "token" refers to the jwt issued by the server.rdk provides two pieces of information to the server it's dialing when it authenticates its connection with that server:
The server uses this information to mint a token which it sends back to rdk. rdk caches this token in process memory and uses it to authenticate its connection with the host. Today, the token is lost when
viam-serverrestarts.Caching the token on disk does not degrade the security story at all, because the robot part id and secret - all we need to get a brand new token from the server - are already stored unencrypted on disk inside the cloud config
.jsonfile that robots need to connect to app (this is why they need that file to connect to app).Also, rdk is open source, so in theory anybody could just fork rdk, build their own binary which cached the token like we do here, and point their robot to that new binary.
Basically, any threat model that involves compromising this cached token (i.e. physical/root access to the machine) also involves compromising the secrets required to request the token.
Caching tokens on disk actually pairs well with moving the robot part ID and secret from the filesystem into a user's env, which - combined with more aggressive token revocation - could open a path to marginally improving the security story.
I'm still new to the auth system so I might have gotten things wrong, will definitely appreciate the review.