Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions connector/connector_dimo.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,12 @@ type Web3Connector interface {
// account.
Verify(address, msg, signedMsg string) (identity Identity, err error)
}

// Web3SignerData is the ConnectorData the web3 connector attaches to an identity it
// verified through ERC-1271 with a plain 65-byte ECDSA signature: the EOA that signed the
// challenge. For a developer license, whose license account accepts a signature only when
// isSigner(recovered) is true, this is the license signer (API key) that minted the token.
// The server emits it as the signer_address claim.
type Web3SignerData struct {
SignerAddress string `json:"signer_address"`
}
35 changes: 33 additions & 2 deletions connector/web3/web3.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package web3

import (
"encoding/json"
"errors"
"fmt"
"log/slog"
Expand Down Expand Up @@ -128,16 +129,46 @@ func (c *web3Connector) VerifyERC1271Signature(contractAddress common.Address, h
return identity, fmt.Errorf("given address and address recovered from signed message do not match")
}

return connector.Identity{
identity = connector.Identity{
UserID: contractAddress.Hex(),
Username: contractAddress.Hex(),
}, nil
}
if signer, ok := recoverSigner(hash, signature); ok {
data, err := json.Marshal(connector.Web3SignerData{SignerAddress: signer.Hex()})
if err != nil {
return identity, fmt.Errorf("failed to encode signer data: %w", err)
}
identity.ConnectorData = data
}
return identity, nil
}

func signHash(data []byte) []byte {
return accounts.TextHash(data)
}

// recoverSigner returns the EOA behind a plain 65-byte ECDSA signature over hash, the same
// recovery a developer license account performs before it calls isSigner. Smart-account
// signatures (other lengths or recovery ids) report false, so those logins carry no signer.
func recoverSigner(hash []byte, signature []byte) (common.Address, bool) {
if len(signature) != 65 {
return common.Address{}, false
}
sig := make([]byte, 65)
copy(sig, signature)
if sig[64] >= 27 {
sig[64] -= 27
}
if sig[64] > 1 {
return common.Address{}, false
}
pub, err := crypto.SigToPub(hash, sig)
if err != nil {
return common.Address{}, false
}
return crypto.PubkeyToAddress(*pub), true
}

func createEthClient(rpcURL string) (bind.ContractBackend, error) {
return ethclient.Dial(rpcURL)
}
59 changes: 55 additions & 4 deletions connector/web3/web3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package web3

import (
"crypto/ecdsa"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -58,6 +59,13 @@ func signMessage(msg string, pk *ecdsa.PrivateKey) ([]byte, []byte, error) {
return signature, hash, nil
}

func signerData(t *testing.T, signer common.Address) []byte {
t.Helper()
data, err := json.Marshal(connector.Web3SignerData{SignerAddress: signer.Hex()})
assert.NoError(t, err)
return data
}

func TestEOALogin(t *testing.T) {
conn := newConnector()
// Create and deploy eth client
Expand Down Expand Up @@ -168,8 +176,9 @@ func TestEOALogin(t *testing.T) {
shouldErr: false,
err: nil,
identity: connector.Identity{
UserID: ctrAddr.Hex(),
Username: ctrAddr.Hex(),
UserID: ctrAddr.Hex(),
Username: ctrAddr.Hex(),
ConnectorData: signerData(t, crypto.PubkeyToAddress(ctrPk.PublicKey)),
},
}
},
Expand Down Expand Up @@ -250,8 +259,9 @@ func TestBlockchainBackend(t *testing.T) {
shouldErr: false,
err: nil,
identity: connector.Identity{
UserID: ctrAddr.Hex(),
Username: ctrAddr.Hex(),
UserID: ctrAddr.Hex(),
Username: ctrAddr.Hex(),
ConnectorData: signerData(t, crypto.PubkeyToAddress(pk.PublicKey)),
},
}
},
Expand Down Expand Up @@ -317,3 +327,44 @@ func (b BkTest) createMockBlockchain() (*backends.SimulatedBackend, *bind.Transa

return sim, auth, pk, nil
}

func TestRecoverSigner(t *testing.T) {
pk, addr, err := generateWallet()
assert.NoError(t, err)

sig, hash, err := signMessage("Mock Signable Message", pk)
assert.NoError(t, err)

withV := func(v byte) []byte {
s := append([]byte(nil), sig...)
s[64] = v
return s
}

tests := map[string]struct {
signature []byte
want common.Address
ok bool
}{
"recovery id 0 or 1": {signature: sig, want: *addr, ok: true},
"recovery id 27 or 28": {signature: withV(sig[64] + 27), want: *addr, ok: true},
"64-byte signature": {signature: sig[:64]},
"Kernel smart-account signature longer than 65 bytes": {
signature: append([]byte{0x01}, sig...),
},
"recovery id out of range": {signature: withV(31)},
"all-zero signature": {signature: make([]byte, 65)},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
before := append([]byte(nil), tc.signature...)

got, ok := recoverSigner(hash, tc.signature)

assert.Equal(t, tc.ok, ok)
assert.Equal(t, tc.want, got)
assert.Equal(t, before, tc.signature, "the caller's signature is not modified")
})
}
}
1 change: 1 addition & 0 deletions server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,7 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
}

func (s *Server) exchangeAuthCode(ctx context.Context, w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenResponse, error) {
ctx = withSignerAddress(ctx, authCode.ConnectorData)
accessToken, _, err := s.newAccessToken(ctx, client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID)
if err != nil {
s.logger.ErrorContext(ctx, "failed to create new access token", "err", err)
Expand Down
16 changes: 10 additions & 6 deletions server/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ type idTokenClaims struct {
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
EthereumAddress string `json:"ethereum_address,omitempty"`
// SignerAddress is the developer-license signer (API key) that signed the web3
// challenge, set on ERC-1271 developer-license logins. DIMO addition.
SignerAddress string `json:"signer_address,omitempty"`

Groups []string `json:"groups,omitempty"`

Expand Down Expand Up @@ -397,12 +400,13 @@ func (s *Server) newIDToken(ctx context.Context, clientID string, claims storage
}

tok := idTokenClaims{
Issuer: s.issuerURL.String(),
Subject: subjectString,
Nonce: nonce,
Expiry: expiry.Unix(),
IssuedAt: issuedAt.Unix(),
ProviderID: connID,
Issuer: s.issuerURL.String(),
Subject: subjectString,
Nonce: nonce,
Expiry: expiry.Unix(),
IssuedAt: issuedAt.Unix(),
ProviderID: connID,
SignerAddress: signerAddressFromContext(ctx),
}

if accessToken != "" {
Expand Down
39 changes: 39 additions & 0 deletions server/signer_claim_dimo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package server

import (
"context"
"encoding/json"

"github.com/ethereum/go-ethereum/common"

"github.com/dexidp/dex/connector"
)

// signerAddressKey carries the developer-license signer from an auth code to the tokens
// minted for it.
type signerAddressKey struct{}

// withSignerAddress returns ctx carrying, checksummed, the signer the web3 connector stored
// in the auth code's connector data (see connector.Web3SignerData). Connector data from any
// other connector, or without a well-formed address, leaves ctx unchanged.
//
// Only exchangeAuthCode calls this, so only the authorization-code flow carries the claim.
// Implicit and hybrid responses (handleApproval's responseTypeToken and responseTypeIDToken
// cases) mint tokens without it; production enables only the code flow (server.go defaults
// SupportedResponseTypes to [code]). Enabling either flow needs the same call there.
func withSignerAddress(ctx context.Context, connectorData []byte) context.Context {
if len(connectorData) == 0 {
return ctx
}
var data connector.Web3SignerData
if err := json.Unmarshal(connectorData, &data); err != nil || !addressRegex.MatchString(data.SignerAddress) {
return ctx
}
return context.WithValue(ctx, signerAddressKey{}, common.HexToAddress(data.SignerAddress).Hex())
}

// signerAddressFromContext returns the signer set by withSignerAddress, or "".
func signerAddressFromContext(ctx context.Context) string {
signer, _ := ctx.Value(signerAddressKey{}).(string)
return signer
}
Loading
Loading