From 81d11c135ebd970e2bb0311b460907e0d8c52682 Mon Sep 17 00:00:00 2001 From: Will Date: Sat, 23 May 2026 16:40:20 +0100 Subject: [PATCH] create a totp style nonce generator Signed-off-by: Will --- cmd/cocoon/main.go | 6 +++ go.mod | 4 ++ oauth/dpop/manager.go | 12 +++-- oauth/dpop/nonce_totp.go | 99 +++++++++++++++++++++++++++++++++++ oauth/dpop/nonce_totp_test.go | 69 ++++++++++++++++++++++++ server/server.go | 13 +++-- 6 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 oauth/dpop/nonce_totp.go create mode 100644 oauth/dpop/nonce_totp_test.go diff --git a/cmd/cocoon/main.go b/cmd/cocoon/main.go index 435c666..8dc66bc 100644 --- a/cmd/cocoon/main.go +++ b/cmd/cocoon/main.go @@ -177,6 +177,11 @@ func main() { Name: "push-based-events", EnvVars: []string{"PUSH_BASED_EVENTS"}, }, + &cli.StringFlag{ + Name: "nonce-secret", + Usage: "To set a nonce secret", + EnvVars: []string{"COCOON_NONCE_SECRET"}, + }, }, Commands: []*cli.Command{ runServe, @@ -241,6 +246,7 @@ var runServe = &cli.Command{ Relays: cmd.StringSlice("relays"), AdminPassword: cmd.String("admin-password"), RequireInvite: cmd.Bool("require-invite"), + NonceSecret: cmd.String("nonce-secret"), SmtpUser: cmd.String("smtp-user"), SmtpPass: cmd.String("smtp-pass"), SmtpHost: cmd.String("smtp-host"), diff --git a/go.mod b/go.mod index 2075396..0b9e30f 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/multiformats/go-multihash v0.2.3 github.com/prometheus/client_golang v1.23.2 github.com/samber/slog-echo v1.16.1 + github.com/stretchr/testify v1.11.1 github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc github.com/urfave/cli/v2 v2.27.6 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e @@ -47,6 +48,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -107,6 +109,7 @@ require ( github.com/multiformats/go-varint v0.0.7 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -137,5 +140,6 @@ require ( google.golang.org/protobuf v1.36.9 // indirect gopkg.in/go-playground/assert.v1 v1.2.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.2.1 // indirect ) diff --git a/oauth/dpop/manager.go b/oauth/dpop/manager.go index 103e004..ed2f4d0 100644 --- a/oauth/dpop/manager.go +++ b/oauth/dpop/manager.go @@ -21,7 +21,7 @@ import ( ) type Manager struct { - nonce *Nonce + nonce *TotpNonce jtiCache *jtiCache logger *slog.Logger hostname string @@ -54,10 +54,12 @@ func NewManager(args ManagerArgs) *Manager { } return &Manager{ - nonce: NewNonce(NonceArgs{ - RotationInterval: args.NonceRotationInterval, - Secret: args.NonceSecret, - OnSecretCreated: args.OnNonceSecretCreated, + nonce: NewTotpNonce(TotpNonceArgs{ + // TODO: pass this in from the args + // timeRoundDuration: args.NonceRotationInterval, + timeRoundDuration: time.Minute * 2, + Secret: args.NonceSecret, + OnSecretCreated: args.OnNonceSecretCreated, }), jtiCache: newJTICache(args.JTICacheSize), logger: args.Logger, diff --git a/oauth/dpop/nonce_totp.go b/oauth/dpop/nonce_totp.go new file mode 100644 index 0000000..582e3e8 --- /dev/null +++ b/oauth/dpop/nonce_totp.go @@ -0,0 +1,99 @@ +package dpop + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "sync" + "time" + + "github.com/haileyok/cocoon/internal/helpers" + "github.com/haileyok/cocoon/oauth/constants" +) + +type TotpNonce struct { + secret []byte + + mu sync.RWMutex + + currentTimePeriodStart time.Time + timeRoundDuration time.Duration + + prev string + curr string + next string +} + +type TotpNonceArgs struct { + timeRoundDuration time.Duration + Secret []byte + OnSecretCreated func([]byte) +} + +func NewTotpNonce(args TotpNonceArgs) *TotpNonce { + if args.timeRoundDuration == 0 { + args.timeRoundDuration = time.Minute * 2 + } + + if args.timeRoundDuration > constants.DpopNonceMaxAge { + args.timeRoundDuration = constants.DpopNonceMaxAge + } + + if args.Secret == nil { + args.Secret = helpers.RandomBytes(constants.NonceSecretByteLength) + args.OnSecretCreated(args.Secret) + } + + n := &TotpNonce{ + secret: args.Secret, + mu: sync.RWMutex{}, + timeRoundDuration: time.Minute * 15, + } + + n.currentTimePeriodStart = time.Now().Truncate(n.timeRoundDuration) + n.prev = n.compute(n.currentTimePeriodStart.Add(-n.timeRoundDuration)) + n.curr = n.compute(n.currentTimePeriodStart) + n.next = n.compute(n.currentTimePeriodStart.Add(n.timeRoundDuration)) + + return n +} + +func (n *TotpNonce) currentTruncatedTime(now time.Time) time.Time { + return now.Truncate(n.timeRoundDuration) +} + +func (n *TotpNonce) compute(ti time.Time) string { + h := hmac.New(sha256.New, n.secret) + unixBytes := make([]byte, 8) + binary.BigEndian.PutUint64(unixBytes, uint64(ti.UnixNano())) + h.Write(unixBytes) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +func (n *TotpNonce) rotate(now time.Time) { + currentTruncated := n.currentTruncatedTime(now) + + if currentTruncated == n.currentTimePeriodStart { + return + } + + n.currentTimePeriodStart = currentTruncated + n.prev = n.curr + n.curr = n.next + n.next = n.compute(currentTruncated.Add(n.timeRoundDuration)) +} + +func (n *TotpNonce) NextNonce() string { + n.mu.Lock() + defer n.mu.Unlock() + n.rotate(time.Now()) + return n.next +} + +func (n *TotpNonce) Check(nonce string) bool { + n.mu.Lock() + defer n.mu.Unlock() + n.rotate(time.Now()) + return nonce == n.prev || nonce == n.curr || nonce == n.next +} diff --git a/oauth/dpop/nonce_totp_test.go b/oauth/dpop/nonce_totp_test.go new file mode 100644 index 0000000..efa81c7 --- /dev/null +++ b/oauth/dpop/nonce_totp_test.go @@ -0,0 +1,69 @@ +package dpop + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRoundtime(t *testing.T) { + tt := map[string]struct { + input time.Time + truncateTo time.Duration + expected time.Time + }{ + "between 15:00:00 and 15:15:00 - rounds to 15:00:00": { + input: time.Date(2026, time.May, 01, 15, 14, 59, 0, time.UTC), + truncateTo: time.Minute * 15, + expected: time.Date(2026, time.May, 01, 15, 00, 0, 0, time.UTC), + }, + "between 15:15:01 - rounds to 15:15:00": { + input: time.Date(2026, time.May, 01, 15, 15, 01, 0, time.UTC), + truncateTo: time.Minute * 15, + expected: time.Date(2026, time.May, 01, 15, 15, 0, 0, time.UTC), + }, + "between 15:15:00 - rounds to 15:15:00": { + input: time.Date(2026, time.May, 01, 15, 15, 0, 0, time.UTC), + truncateTo: time.Minute * 15, + expected: time.Date(2026, time.May, 01, 15, 15, 0, 0, time.UTC), + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + res := tc.input.Truncate(tc.truncateTo) + assert.Equal(t, tc.expected, res) + }) + } +} + +func TestTotpNonce(t *testing.T) { + startTime := time.Date(2026, time.May, 23, 15, 0, 0, 0, time.UTC) + args := TotpNonceArgs{ + timeRoundDuration: time.Minute * 2, + Secret: []byte("secret"), + } + nonce := NewTotpNonce(args) + nonce.currentTimePeriodStart = startTime + + assert.Equal(t, "ntNYQtG1F3h1U5OKz8Rs4yMJf08GUAtrGU9qg58Rt1o", nonce.curr) + assert.Equal(t, "aIHUJrjFeadCwoZQmd4aJ_g-Pm4ehkwckeXmqz3_42g", nonce.prev) + assert.Equal(t, "ON5oeN1v2NnseUWoUgK1CIU__qQn4mL9xbxJSu-ifUY", nonce.next) + + // try and rotate after a simulated 16 minutes to make it go into the next time period + // and so will rotate the nonces + nonce.rotate(nonce.currentTimePeriodStart.Add(time.Minute * 16)) + + assert.Equal(t, "ON5oeN1v2NnseUWoUgK1CIU__qQn4mL9xbxJSu-ifUY", nonce.curr) + assert.Equal(t, "ntNYQtG1F3h1U5OKz8Rs4yMJf08GUAtrGU9qg58Rt1o", nonce.prev) + assert.Equal(t, "HTvHm6VQP7cBbTZKWT_zhJ09bxD6B6JfhX-kArn1Hvo", nonce.next) + + // try and rotate after a simulated 5 minutes to which won't make it go into + // the next time period and so won't rotate + nonce.rotate(nonce.currentTimePeriodStart.Add(time.Minute * 5)) + + assert.Equal(t, "ON5oeN1v2NnseUWoUgK1CIU__qQn4mL9xbxJSu-ifUY", nonce.curr) + assert.Equal(t, "ntNYQtG1F3h1U5OKz8Rs4yMJf08GUAtrGU9qg58Rt1o", nonce.prev) + assert.Equal(t, "HTvHm6VQP7cBbTZKWT_zhJ09bxD6B6JfhX-kArn1Hvo", nonce.next) +} diff --git a/server/server.go b/server/server.go index 3b376e4..79cebb9 100644 --- a/server/server.go +++ b/server/server.go @@ -127,6 +127,7 @@ type Args struct { PushBasedEvents bool SubscribeReposServiceURL string + NonceSecret string } type config struct { @@ -420,11 +421,15 @@ func New(args *Args) (*Server, error) { } var nonceSecret []byte - maybeSecret, err := os.ReadFile("nonce.secret") - if err != nil && !os.IsNotExist(err) { - logger.Error("error attempting to read nonce secret", "error", err) + if args.NonceSecret != "" { + nonceSecret = []byte(args.NonceSecret) } else { - nonceSecret = maybeSecret + maybeSecret, err := os.ReadFile("nonce.secret") + if err != nil && !os.IsNotExist(err) { + logger.Error("error attempting to read nonce secret", "error", err) + } else { + nonceSecret = maybeSecret + } } evtPersister, err := NewDbPersister(gdb, 72*time.Hour) -- 2.51.2