Skip to content

Commit

Permalink
Make challenges that are hash-related to the signing data
Browse files Browse the repository at this point in the history
  • Loading branch information
Daedaluz committed Dec 20, 2024
1 parent 257f0de commit a2ab82e
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 65 deletions.
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ It is a simple and secure way to authenticate users without the need for usernam
- [x] Bank-ID similar api
- [x] Consider move the front-end to its own repo (update: It will stay in-repo for simplicity)
- [x] Create a suitable Cross-Origin policy
- [x] Actually create challenges that are hash-related to the sing-data, allowing "Document signing" (only BankID flow)

## Future plans

- [ ] Actually create challenges that are hash-related to the sing-data, allowing "Document signing"
- [ ] Better error handling, logging and documentation
- [ ] Nicer Web UI
- [ ] Replace the websocket-based remote-signer with some webrtc-based solution (eliminate load-balancer issue with
Expand Down Expand Up @@ -77,6 +77,12 @@ sequenceDiagram
ClientServer ->> ClientFrontend: User Loged in
```

## Challenge hash calculation

When challenges are created with a `text` prompt, the webauthn challenge is calculated as follows:

SHA256(UserID + '\n' + ChallengeID '\n' + nonce + '\n' + Text + '\n' + Data)

## API

The API is split into four parts;
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ require (
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jmoiron/sqlx v1.4.0
github.com/lestrrat-go/jwx v1.2.30
github.com/spf13/cobra v1.8.1
Expand Down Expand Up @@ -73,3 +72,5 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace github.com/go-webauthn/webauthn => github.com/daedaluz/webauthn v0.0.0-20241220074114-7407a678362b
18 changes: 4 additions & 14 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/daedaluz/webauthn v0.0.0-20241220074114-7407a678362b h1:SFxnNLs0YgU1lYV8rzdgC1zmiiVaNm5Imdx3n0NDs8o=
github.com/daedaluz/webauthn v0.0.0-20241220074114-7407a678362b/go.mod h1:qU5XxhBojKd+qGfXMjYlxd535D6esH8QgFo6DBWG9W8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
Expand Down Expand Up @@ -42,8 +44,6 @@ github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.15 h1:eG1OhggBJTkDE8gUeOlGRbRe8E/PSVG26YG4AyFbwkU=
github.com/go-webauthn/x v0.1.15/go.mod h1:pf7VI23raFLHPO9VVIs9/u1etqwAOP0S2KoHGL6WbZ8=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
Expand All @@ -52,15 +52,11 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.2 h1:Gh8CMnMm06b09DmcsuY9fI3oF69188lGXCpiT/a05T4=
github.com/google/go-tpm v0.9.2/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
Expand Down Expand Up @@ -125,8 +121,6 @@ github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9yS
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
Expand All @@ -144,8 +138,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
Expand All @@ -162,12 +156,8 @@ golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
7 changes: 4 additions & 3 deletions internal/api/v1/client/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import (
)

type SignatureData struct {
Text string `json:"text"`
Data []byte `json:"data"`
Nonce string `json:"nonce"`
Text string `json:"text"`
Data []byte `json:"data"`
}

type CollectResponseExp struct {
Expand Down Expand Up @@ -83,7 +84,7 @@ func (c *CollectResponseExp) Response() *CollectResponse {
func collectResponseFromChallenge(challenge *challengedb.Data) *CollectResponseExp {
res := &CollectResponseExp{
ChallengeID: challenge.ID,
SignatureData: SignatureData{Text: challenge.SignatureText, Data: challenge.SignatureData},
SignatureData: SignatureData{Text: challenge.SignatureText, Data: challenge.SignatureData, Nonce: challenge.Nonce},
Status: challenge.Status,
Signed: challenge.Signed.Time,
}
Expand Down
50 changes: 45 additions & 5 deletions internal/api/v1/client/sign.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package client

import (
"bytes"
"crypto/sha256"
"net/http"
"slices"
"strconv"
Expand All @@ -10,6 +12,7 @@ import (
"uyulala/internal/api"
"uyulala/internal/api/application"
"uyulala/internal/authn"
"uyulala/internal/db"
"uyulala/internal/db/challengedb"
"uyulala/internal/db/userdb"
"uyulala/openid/discovery"
Expand Down Expand Up @@ -126,6 +129,26 @@ func createBIDChallenge(ctx *gin.Context) {
opts = append(opts, webauthn.WithAllowedCredentials(keys))
}

var nonce string
var hash [32]byte
challengeID := db.GenerateID(8)
if req.Text != "" {
nonce = db.GenerateID(8)
buff := bytes.Buffer{}
buff.Write([]byte(req.UserID))
buff.WriteByte('\n')
buff.Write([]byte(challengeID))
buff.WriteByte('\n')
buff.Write([]byte(nonce))
buff.WriteByte('\n')
buff.Write([]byte(req.Text))
buff.WriteByte('\n')
buff.Write(req.Data)
hash = sha256.Sum256(buff.Bytes())

opts = append(opts, webauthn.WithChallenge(hash[:]))
}

cfg := authn.CreateWebauthnConfig()

var login *protocol.CredentialAssertion
Expand All @@ -140,8 +163,17 @@ func createBIDChallenge(ctx *gin.Context) {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
}
challenge, secret, err := challengedb.CreateChallenge(ctx, "webauthn.get", app.ID,
time.Now().Add(time.Duration(req.Timeout).Abs()*time.Second), login, sessionData, req.Text, req.Data, req.Redirect)
challenge, secret, err := challengedb.CreateChallenge2(ctx, &challengedb.CreateChallengeData{
Type: "webauthn.get",
AppID: app.ID,
Expire: time.Now().Add(time.Duration(req.Timeout).Abs() * time.Second),
PublicData: login,
PrivateData: sessionData,
Nonce: nonce,
SignatureText: req.Text,
SignatureData: req.Data,
RedirectURL: req.Redirect,
}, challengeID)
if err != nil {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
Expand Down Expand Up @@ -268,9 +300,17 @@ func createCIBAChallenge(ctx *gin.Context) {
api.AbortError(ctx, http.StatusBadRequest, "invalid_request", "Invalid binding_message, must be utf8", nil)
return
}

challenge, secret, err := challengedb.CreateChallenge(ctx, "webauthn.get", app.ID,
time.Now().Add(time.Duration(timeout).Abs()*time.Second), login, sessionData, bindingMessage, nil, "")
challenge, secret, err := challengedb.CreateChallenge2(ctx, &challengedb.CreateChallengeData{
Type: "webauthn.get",
AppID: app.ID,
Expire: time.Now().Add(time.Duration(timeout).Abs() * time.Second),
PublicData: login,
PrivateData: sessionData,
Nonce: "",
SignatureText: bindingMessage,
SignatureData: nil,
RedirectURL: "",
}, "")
if err != nil {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
Expand Down
5 changes: 3 additions & 2 deletions internal/api/v1/public/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ func getChallengeHandlerPost(ctx *gin.Context) {

if data.SignatureText != "" {
res["signData"] = gin.H{
"text": data.SignatureText,
"data": data.SignatureData,
"nonce": data.Nonce,
"text": data.SignatureText,
"data": data.SignatureData,
}
}
ctx.JSON(200, res)
Expand Down
15 changes: 11 additions & 4 deletions internal/api/v1/public/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,17 @@ func createOAuth2ChallengeHandler(ctx *gin.Context) {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
}

challenge, secret, err := challengedb.CreateChallenge(ctx, "webauthn.get", client.ID, time.Now().Add(time.Minute*5),
login, session,
bindingMessage, signatureData, redirectURI.String())
challenge, secret, err := challengedb.CreateChallenge2(ctx, &challengedb.CreateChallengeData{
Type: "webauthn.get",
AppID: client.ID,
Expire: time.Now().Add(time.Minute * 5),
PublicData: login,
PrivateData: session,
Nonce: "",
SignatureText: bindingMessage,
SignatureData: signatureData,
RedirectURL: redirectURI.String(),
}, "")
if err != nil {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
Expand Down
12 changes: 11 additions & 1 deletion internal/api/v1/service/addUserKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,17 @@ func createKeyHandler(ctx *gin.Context) {

expires := time.Now().Add(time.Duration(req.Timeout).Abs() * time.Second)
app := application.GetCurrentApplication(ctx)
challengeID, secret, err := challengedb.CreateChallenge(ctx, "webauthn.create", app.ID, expires, credential, sessionData, "", []byte{}, req.Redirect)
challengeID, secret, err := challengedb.CreateChallenge2(ctx, &challengedb.CreateChallengeData{
Type: "webauthn.create",
AppID: app.ID,
Expire: expires,
PublicData: credential,
PrivateData: sessionData,
Nonce: "",
SignatureText: "",
SignatureData: nil,
RedirectURL: req.Redirect,
}, "")
if err != nil {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
Expand Down
15 changes: 11 additions & 4 deletions internal/api/v1/service/createUser.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,17 @@ func createUserHandler(ctx *gin.Context) {
}
expires := time.Now().Add(time.Duration(userRegistration.Timeout).Abs() * time.Second)
app := application.GetCurrentApplication(ctx)
challengeID, secret, err := challengedb.CreateChallenge(ctx, "webauthn.create",
app.ID, expires, credential, sessionData,
"", []byte{},
userRegistration.Redirect)
challengeID, secret, err := challengedb.CreateChallenge2(ctx, &challengedb.CreateChallengeData{
Type: "webauthn.create",
AppID: app.ID,
Expire: expires,
PublicData: credential,
PrivateData: sessionData,
Nonce: "",
SignatureText: "",
SignatureData: []byte{},
RedirectURL: userRegistration.Redirect,
}, "")
if err != nil {
api.AbortError(ctx, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
Expand Down
12 changes: 11 additions & 1 deletion internal/api/v1/user/addKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ func addKey(c *gin.Context) {
}

expires := time.Now().Add(time.Duration(req.Timeout).Abs() * time.Second)
challengeID, secret, err := challengedb.CreateChallenge(c, "webauthn.create", "", expires, credential, sessionData, "", []byte{}, req.Redirect)
challengeID, secret, err := challengedb.CreateChallenge2(c, &challengedb.CreateChallengeData{
Type: "webauthn.create",
AppID: "",
Expire: expires,
PublicData: credential,
PrivateData: sessionData,
Nonce: "",
SignatureText: "",
SignatureData: []byte{},
RedirectURL: req.Redirect,
}, "")
if err != nil {
api.AbortError(c, http.StatusInternalServerError, "internal_error", "Unexpected error", err)
return
Expand Down
35 changes: 26 additions & 9 deletions internal/db/challengedb/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,48 @@ const (
StatusRejected = "rejected"
)

func CreateChallenge(ctx *gin.Context, typ, appID string, expire time.Time, publicData, privateData any,
signatureText string, signatureData []byte,
redirectURL string) (challengeID, secret string, err error) {
type CreateChallengeData struct {
Type string
AppID string
Expire time.Time
PublicData any
PrivateData any
Nonce string
SignatureText string
SignatureData []byte
RedirectURL string
}

func CreateChallenge2(ctx *gin.Context, data *CreateChallengeData, id string) (challengeID, secret string, err error) {
var pubData, privData []byte
var secretToken uuid.UUID
secretToken, err = uuid.NewRandom()

if id == "" {
id = db.GenerateID(8)
}

if err != nil {
return "", "", err
}
if pubData, err = db.GobEncodeData(publicData); err != nil {
if pubData, err = db.GobEncodeData(data.PublicData); err != nil {
return "", "", err
}
if privData, err = db.GobEncodeData(privateData); err != nil {
if privData, err = db.GobEncodeData(data.PrivateData); err != nil {
return "", "", err
}

tx := gindb.GetTX(ctx)
res, err := tx.Queryx(`call create_challenge(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, db.GenerateID(8), typ, appID, expire,
res, err := tx.Queryx(`call create_challenge(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, data.Type, data.AppID, data.Expire,
pubData, privData,
signatureText, signatureData,
redirectURL, secretToken)
data.SignatureText, data.SignatureData, data.Nonce,
data.RedirectURL, secretToken)
if err != nil {
return "", "", err
}
defer res.Close()
defer func() {
_ = res.Close()
}()
if !res.Next() {
return "", "", sql.ErrNoRows
}
Expand All @@ -68,6 +84,7 @@ type Data struct {

SignatureText string `db:"signature_text"`
SignatureData []byte `db:"signature_data"`
Nonce string `db:"nonce"`

Signature []byte `db:"signature"`
Credential []byte `db:"credential"`
Expand Down
Loading

0 comments on commit a2ab82e

Please sign in to comment.