diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index af4879580..08b7f4ca0 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -86,6 +86,11 @@ jobs: rust: name: Rust Dependencies runs-on: ubuntu-latest + # audit-check reports its result as a check run, which the workflow-wide + # read-only token cannot create. + permissions: + contents: read + checks: write steps: - uses: actions/checkout@v4 - uses: rustsec/audit-check@v2 diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 70eef1f34..39b2dadac 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -19,7 +19,6 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" awsconf "github.com/aws/aws-sdk-go-v2/config" "github.com/google/uuid" - "github.com/meszmate/apple-go" "github.com/redis/go-redis/v9" "github.com/warmbly/warmbly/internal/api" "github.com/warmbly/warmbly/internal/api/handler" @@ -142,6 +141,7 @@ import ( "github.com/warmbly/warmbly/internal/notify" "github.com/warmbly/warmbly/internal/observability" productanalytics "github.com/warmbly/warmbly/internal/observability/analytics" + "github.com/warmbly/warmbly/internal/pkg/appleauth" "github.com/warmbly/warmbly/internal/pkg/captcha" "github.com/warmbly/warmbly/internal/pkg/domainproof" "github.com/warmbly/warmbly/internal/pkg/emailverify" @@ -572,9 +572,9 @@ func main() { // Apple Sign in is optional. Skip it entirely when unconfigured (a // self-host without Apple creds); only warn — never fatal — when creds // are present but init fails, so Apple simply stays unavailable. - var appleAuthClient apple.AppleAuth + var appleAuthClient socialauth.AppleCodeExchanger if authCfg.AppleAppID != "" || authCfg.AppleKeySecret != "" { - appleAuthInstance, appleErr := apple.NewB64( + appleAuthInstance, appleErr := appleauth.NewFromBase64( authCfg.AppleAppID, authCfg.AppleTeamID, authCfg.AppleKeyID, diff --git a/go.mod b/go.mod index 50386db42..18479b9ce 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,6 @@ require ( github.com/hamba/avro/v2 v2.31.0 github.com/invopop/jsonschema v0.13.0 github.com/jackc/pgx/v5 v5.11.0 - github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/nats-io/jwt/v2 v2.8.1 @@ -136,7 +135,6 @@ require ( github.com/daixiang0/gci v0.13.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect - github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect @@ -287,8 +285,6 @@ require ( github.com/subosito/gotenv v1.4.1 // indirect github.com/tdakkota/asciicheck v0.4.1 // indirect github.com/tetafro/godot v1.5.0 // indirect - github.com/tideland/golib v4.24.2+incompatible // indirect - github.com/tideland/gorest v2.15.5+incompatible // indirect github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index 61d5019dd..3fb247e1b 100644 --- a/go.sum +++ b/go.sum @@ -237,8 +237,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8= github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= -github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= -github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= @@ -620,8 +618,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= -github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32 h1:t39Q4yEIyvvCiUquqFvKwmwJUT7lOYIJhLfrBRalQSM= -github.com/meszmate/apple-go v0.0.0-20250828163208-7fea48c91b32/go.mod h1:CbFm63AASrsMV+GeO2/UhxSJJ0oUy52ZGpzynk1OSUI= github.com/mgechev/revive v1.7.0 h1:JyeQ4yO5K8aZhIKf5rec56u0376h8AlKNQEmjfkjKlY= github.com/mgechev/revive v1.7.0/go.mod h1:qZnwcNhoguE58dfi96IJeSTPeZQejNeoMQLUZGi4SW4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -879,10 +875,6 @@ github.com/tetafro/godot v1.5.0 h1:aNwfVI4I3+gdxjMgYPus9eHmoBeJIbnajOyqZYStzuw= github.com/tetafro/godot v1.5.0/go.mod h1:2oVxTBSftRTh4+MVfUaUXR6bn2GDXCaMcOG4Dk3rfio= github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c= github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw= -github.com/tideland/golib v4.24.2+incompatible h1:QYMkA3Sr1G8UWJTDsptrLOUwB6N89ETlVBnK5lZXKAw= -github.com/tideland/golib v4.24.2+incompatible/go.mod h1:HPHOmtCdCHUQiGAVZnlOH5eNTAEmM7R9oCFXdgvkB+Y= -github.com/tideland/gorest v2.15.5+incompatible h1:R19qOZQaCzT0x7ZExRd3avyG39jNLFeq2/HYetctYYo= -github.com/tideland/gorest v2.15.5+incompatible/go.mod h1:iCPpLOEr3tuQa96whkwiNTyYK4u6PTpWRxf5wGAvYLQ= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/internal/app/socialauth/socialauth.go b/internal/app/socialauth/socialauth.go index a045dce4d..2dd0aded4 100644 --- a/internal/app/socialauth/socialauth.go +++ b/internal/app/socialauth/socialauth.go @@ -13,7 +13,7 @@ import ( "fmt" "strings" - apple "github.com/meszmate/apple-go" + "github.com/warmbly/warmbly/internal/pkg/appleauth" "github.com/warmbly/warmbly/internal/pkg/idtoken" "golang.org/x/oauth2" googleendpoint "golang.org/x/oauth2/google" @@ -100,15 +100,21 @@ func (g *Google) Exchange(ctx context.Context, code, verifier, expectedNonce str // only sends the email claim when the email scope is requested, and any scope // forces response_mode=form_post, so its callback arrives as a cross-site POST. type Apple struct { - client apple.AppleAuth + client AppleCodeExchanger servicesID string redirectURL string verifier *idtoken.Verifier } +// AppleCodeExchanger trades an authorization code at Apple's token endpoint; +// *appleauth.Client is the production implementation. +type AppleCodeExchanger interface { + ExchangeCode(ctx context.Context, code, redirectURI string) (*appleauth.TokenResponse, error) +} + // NewApple builds the flow from the same credentials the native path uses. The // client id is the Services ID (the web identifier), not the app's bundle ID. -func NewApple(client apple.AppleAuth, servicesID, redirectURL string) (*Apple, error) { +func NewApple(client AppleCodeExchanger, servicesID, redirectURL string) (*Apple, error) { if client == nil { return nil, errors.New("socialauth: apple client is not configured") } @@ -139,14 +145,14 @@ func (a *Apple) RedirectURL() string { return a.redirectURL } // the web flow, so the verifier is ignored; one-time state and the nonce inside // the ID token are what bind the response to this attempt. func (a *Apple) AuthCodeURL(state, nonce, _ string) string { - return apple.AuthorizeURL(apple.AuthorizeURLConfig{ + return appleauth.AuthorizeURL(appleauth.AuthorizeURLConfig{ ClientID: a.servicesID, RedirectURI: a.redirectURL, State: state, Nonce: nonce, Scope: []string{"name", "email"}, - ResponseType: apple.ResponseTypeCode, - ResponseMode: apple.ResponseModeFormPost, + ResponseType: "code", + ResponseMode: "form_post", }) } @@ -155,9 +161,9 @@ func (a *Apple) AuthCodeURL(state, nonce, _ string) string { // Apple's keys, because the audience check is what rejects one issued for a // different client. func (a *Apple) Exchange(ctx context.Context, code, _, expectedNonce string) (*idtoken.Claims, error) { - resp, err := a.client.ValidateCodeWithRedirectURI(code, a.redirectURL) + resp, err := a.client.ExchangeCode(ctx, code, a.redirectURL) if err != nil { - if errors.Is(err, apple.ErrorResponseInvalidGrant) { + if errors.Is(err, appleauth.ErrInvalidGrant) { return nil, fmt.Errorf("socialauth: apple rejected the authorization code: %w", err) } return nil, fmt.Errorf("socialauth: apple code exchange: %w", err) diff --git a/internal/app/socialauth/stub_test.go b/internal/app/socialauth/stub_test.go index faa6b42ce..d4319b69f 100644 --- a/internal/app/socialauth/stub_test.go +++ b/internal/app/socialauth/stub_test.go @@ -1,13 +1,15 @@ package socialauth -import apple "github.com/meszmate/apple-go" +import ( + "context" + + "github.com/warmbly/warmbly/internal/pkg/appleauth" +) // stubAppleClient stands in for the Apple token endpoint. Only construction and // URL building are exercised here; the exchange needs Apple's live keys. type stubAppleClient struct{} -func (stubAppleClient) ValidateCode(string) (*apple.TokenResponse, error) { return nil, nil } -func (stubAppleClient) ValidateCodeWithRedirectURI(string, string) (*apple.TokenResponse, error) { +func (stubAppleClient) ExchangeCode(context.Context, string, string) (*appleauth.TokenResponse, error) { return nil, nil } -func (stubAppleClient) ValidateRefreshToken(string) (*apple.TokenResponse, error) { return nil, nil } diff --git a/internal/pkg/appleauth/appleauth.go b/internal/pkg/appleauth/appleauth.go new file mode 100644 index 000000000..66044e8c8 --- /dev/null +++ b/internal/pkg/appleauth/appleauth.go @@ -0,0 +1,199 @@ +// Package appleauth is the server side of Sign in with Apple's web flow: it +// builds the authorization URL and trades the returned code at Apple's token +// endpoint, authenticating with a client secret signed by the team's .p8 key. +// The ID token that comes back is verified separately (internal/pkg/idtoken). +package appleauth + +import ( + "context" + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + authorizeEndpoint = "https://appleid.apple.com/auth/authorize" + tokenEndpoint = "https://appleid.apple.com/auth/token" + appleAudience = "https://appleid.apple.com" + + // A fresh secret is signed per exchange, so it only has to outlive the + // request; Apple's ceiling is six months. + clientSecretTTL = 5 * time.Minute +) + +// ErrInvalidGrant is Apple's invalid_grant: the code was expired, already used, +// or issued for a different client or redirect URI. +var ErrInvalidGrant = errors.New("appleauth: invalid_grant") + +// TokenResponse is Apple's token endpoint response. +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` +} + +// Client exchanges authorization codes for one Services ID. +type Client struct { + clientID string + teamID string + keyID string + key *ecdsa.PrivateKey + http *http.Client + tokenURL string +} + +// NewFromBase64 builds a client from the base64-encoded contents of the +// AuthKey_.p8 file, the form it takes in an env var. +func NewFromBase64(clientID, teamID, keyID, keyB64 string) (*Client, error) { + if clientID == "" || teamID == "" || keyID == "" { + return nil, errors.New("appleauth: client id, team id and key id are required") + } + pemBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(keyB64)) + if err != nil { + return nil, fmt.Errorf("appleauth: decoding key: %w", err) + } + key, err := parsePrivateKey(pemBytes) + if err != nil { + return nil, err + } + return &Client{ + clientID: clientID, + teamID: teamID, + keyID: keyID, + key: key, + http: &http.Client{Timeout: 10 * time.Second}, + tokenURL: tokenEndpoint, + }, nil +} + +func parsePrivateKey(pemBytes []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("appleauth: key is not PEM encoded") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("appleauth: parsing key: %w", err) + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, errors.New("appleauth: key is not an ECDSA private key") + } + return key, nil +} + +func (c *Client) clientSecret(now time.Time) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": c.teamID, + "sub": c.clientID, + "aud": appleAudience, + "iat": now.Unix(), + "exp": now.Add(clientSecretTTL).Unix(), + }) + token.Header["kid"] = c.keyID + return token.SignedString(c.key) +} + +// ExchangeCode trades an authorization code at Apple's token endpoint. The +// redirect URI must be the one the authorization request was sent with. +func (c *Client) ExchangeCode(ctx context.Context, code, redirectURI string) (*TokenResponse, error) { + secret, err := c.clientSecret(time.Now()) + if err != nil { + return nil, fmt.Errorf("appleauth: signing client secret: %w", err) + } + form := url.Values{ + "client_id": {c.clientID}, + "client_secret": {secret}, + "code": {code}, + "grant_type": {"authorization_code"}, + "redirect_uri": {redirectURI}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + res, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("appleauth: token request: %w", err) + } + defer func() { _ = res.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("appleauth: reading token response: %w", err) + } + + if res.StatusCode != http.StatusOK { + var apiErr struct { + Error string `json:"error"` + } + _ = json.Unmarshal(body, &apiErr) + if apiErr.Error == "invalid_grant" { + return nil, ErrInvalidGrant + } + if apiErr.Error != "" { + return nil, fmt.Errorf("appleauth: token endpoint returned %s (HTTP %d)", apiErr.Error, res.StatusCode) + } + return nil, fmt.Errorf("appleauth: token endpoint returned HTTP %d", res.StatusCode) + } + + var tr TokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("appleauth: decoding token response: %w", err) + } + return &tr, nil +} + +// AuthorizeURLConfig is the authorization request. Any scope makes Apple +// require response_mode=form_post. +type AuthorizeURLConfig struct { + ClientID string + RedirectURI string + State string + Nonce string + Scope []string + ResponseType string + ResponseMode string +} + +// AuthorizeURL builds the URL the browser is sent to. +func AuthorizeURL(cfg AuthorizeURLConfig) string { + responseType := cfg.ResponseType + if responseType == "" { + responseType = "code" + } + responseMode := cfg.ResponseMode + if responseMode == "" { + responseMode = "form_post" + } + q := url.Values{} + q.Set("response_type", responseType) + q.Set("response_mode", responseMode) + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", cfg.RedirectURI) + if cfg.State != "" { + q.Set("state", cfg.State) + } + if cfg.Nonce != "" { + q.Set("nonce", cfg.Nonce) + } + if len(cfg.Scope) > 0 { + q.Set("scope", strings.Join(cfg.Scope, " ")) + } + return authorizeEndpoint + "?" + q.Encode() +} diff --git a/internal/pkg/appleauth/appleauth_test.go b/internal/pkg/appleauth/appleauth_test.go new file mode 100644 index 000000000..f4ec2ef9c --- /dev/null +++ b/internal/pkg/appleauth/appleauth_test.go @@ -0,0 +1,125 @@ +package appleauth + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +func testClient(t *testing.T, tokenURL string) (*Client, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + p8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + c, err := NewFromBase64("com.example.service", "TEAM123", "KEY123", base64.StdEncoding.EncodeToString(p8)) + if err != nil { + t.Fatal(err) + } + c.tokenURL = tokenURL + return c, key +} + +func TestNewFromBase64RejectsBadKeys(t *testing.T) { + if _, err := NewFromBase64("id", "team", "key", "not base64!"); err == nil { + t.Error("expected an error for invalid base64") + } + if _, err := NewFromBase64("id", "team", "key", base64.StdEncoding.EncodeToString([]byte("no pem here"))); err == nil { + t.Error("expected an error for a non-PEM key") + } + if _, err := NewFromBase64("", "team", "key", ""); err == nil { + t.Error("expected an error for a missing client id") + } +} + +// The client secret is what Apple authenticates the exchange with: an ES256 +// JWT from the team, about the Services ID, for Apple, naming the key. +func TestExchangeCodeSendsSignedClientSecret(t *testing.T) { + var form url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Error(err) + } + form = r.PostForm + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at","expires_in":3600,"id_token":"idt","refresh_token":"rt","token_type":"Bearer"}`)) + })) + defer srv.Close() + + c, key := testClient(t, srv.URL) + resp, err := c.ExchangeCode(context.Background(), "the-code", "https://api.example.com/cb") + if err != nil { + t.Fatal(err) + } + if resp.IDToken != "idt" || resp.RefreshToken != "rt" { + t.Errorf("unexpected response %+v", resp) + } + + for k, want := range map[string]string{ + "client_id": "com.example.service", + "code": "the-code", + "grant_type": "authorization_code", + "redirect_uri": "https://api.example.com/cb", + } { + if got := form.Get(k); got != want { + t.Errorf("%s = %q, want %q", k, got, want) + } + } + + claims := jwt.MapClaims{} + tok, err := jwt.ParseWithClaims(form.Get("client_secret"), claims, func(*jwt.Token) (any, error) { + return &key.PublicKey, nil + }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer("TEAM123"), jwt.WithSubject("com.example.service"), jwt.WithAudience(appleAudience)) + if err != nil { + t.Fatalf("client secret does not verify: %v", err) + } + if kid := tok.Header["kid"]; kid != "KEY123" { + t.Errorf("kid = %v", kid) + } + if aud, ok := claims["aud"].(string); !ok || aud != appleAudience { + t.Errorf("aud = %#v, want the single string %q", claims["aud"], appleAudience) + } +} + +func TestExchangeCodeMapsInvalidGrant(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) + })) + defer srv.Close() + + c, _ := testClient(t, srv.URL) + if _, err := c.ExchangeCode(context.Background(), "used", "https://api.example.com/cb"); !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("err = %v, want ErrInvalidGrant", err) + } +} + +func TestExchangeCodeReportsOtherErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_client"}`)) + })) + defer srv.Close() + + c, _ := testClient(t, srv.URL) + _, err := c.ExchangeCode(context.Background(), "code", "https://api.example.com/cb") + if err == nil || errors.Is(err, ErrInvalidGrant) { + t.Fatalf("err = %v, want a non-invalid_grant error", err) + } +} diff --git a/tracking/Cargo.lock b/tracking/Cargo.lock index b10b0534c..436e84027 100644 --- a/tracking/Cargo.lock +++ b/tracking/Cargo.lock @@ -4371,9 +4371,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote",