Skip to content

Commit

Permalink
randutil: allow pseudo rng seeding by caller
Browse files Browse the repository at this point in the history
The randutil package was promoted to the x-go library because it provides
helper functions needed by other projects (Pebble). The changes proposed
here will be applied to snapd so that both packages are kept in sync.

Feedback from canonical#17 highlighted the
need for seeding of the pseudo random dependent functions to be left to
the user of randutil (the crypto/rand functions are unaffected). The reason
is simply that the seeding requirements differ between use cases
(and projects), and it should be the responsibility of the caller to supply
the seed.

In addition to the requirement above, the original snapd randutil pseudo
random implementation has the following side effects:

(1) It is based on the Go global pseudo random provided by math/rand. This
means that re-seeding affects all packages dependent on the prng,
irrespective of the individual seeding requirements.

For example:

Calling RandomDuration() the first time would re-seed the
global prng and all subsequent calls to RandomString() and
RandomDuration() would use the new seed.

At the same time, anyone accidentally calling rand.Seed() in any other
package or imported library will reset the seed, affecting both
RandomDuration() and RandomString().

(2) Access from all users are serialised by a single global lock. This is
not an issue for snapd, but may not be performance optimal for all
applications. This proposal does not address issue directly yet.

https://qqq.ninja/blog/post/fast-threadsafe-randomness-in-go/

(3) Automatic seeding of the global prng was enabled from Go v1.20. This
does not directly affect is, but introduces changes that could effect
tests build on assumptions of code using the global prng.

This patch makes the following changes:

- RandomDuration can no longer create a panic (make this a caller decision).
  Code that wants to panic can still panic before calling RandomDuration if
  the duration is negative. Instead, always return zero (0) duration for
  inputs <= 0.

  (snapd already added caller code to explicitly avoid this in the first place)

- Improve the unit testing to cover the complete range for both pseudo
  random functions.

- Change the randutil pseudo random functions to standalone prng instances,
  each initialised with their own seed value.

- Add a local lock to make the prng instance go-routine safe.

- Supply two template seeding functions based on the previously used snapd
  code:

  SeedDatePid()
  SeedDatePidHostMac()

- Add Reseed() method for resetting the seed during testing.

Example Usage:

prng := randutil.NewPseudoRand(nil)
:
tmpfile := prng.RandomString(12)
:
wait := prng.RandomDuration(time.Hour)

Signed-off-by: Fred Lotter <fred.lotter@canonical.com>
  • Loading branch information
flotter committed May 18, 2023
1 parent 47eca1c commit 1ed038e
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 60 deletions.
127 changes: 85 additions & 42 deletions randutil/rand.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
*
*/

// Package randutil initialises properly random value generation and
// exposes a streamlined set of functions for it, including for crypto
// random tokens.
// Package randutil exposes a streamlined set of randomisation helper
// functions, including for crypto random tokens. Pseudo random based
// functions avoid using the math/rand global RNG as it is impossible
// to track seed changes globally. The global RNG is also automatically
// seeded from Go v1.20 onwards.
package randutil

import (
Expand All @@ -32,34 +34,72 @@ import (
"time"
)

func init() {
// golang does not init Seed() itself
rand.Seed(time.Now().UnixNano() + int64(os.Getpid()))
var defaultSeeder = SeedDatePid

// SeedDatePid provides a basic seed value based only on
// time and os PID value.
func SeedDatePid() int64 {
return time.Now().UnixNano() + int64(os.Getpid())
}

var moreMixedSeedOnce sync.Once

func moreMixedSeed() {
moreMixedSeedOnce.Do(func() {
h := sha256.New224()
// do this instead of asking for time and pid again
var b [8]byte
rand.Read(b[:])
h.Write(b[:])
// mix in the hostname
if hostname, err := os.Hostname(); err == nil {
h.Write([]byte(hostname))
}
// mix in net interfaces hw addresses (MACs etc)
if ifaces, err := net.Interfaces(); err == nil {
for _, iface := range ifaces {
h.Write(iface.HardwareAddr)
}
// SeedDatePidHostMac provides a seed value that in addition to
// time and os PID, also takes into account the device hostname
// and network inteface MAC addresses. This may be required when
// you are trying to randomize the time of actions within a
// fleet of similar devices, where the time and PID values may
// not provide enough variance within the device pool.
func SeedDatePidHostMac() int64 {
// Use a pseudo RNG initially for time and pid inclusion
var b [8]byte
pr := NewPseudoRand(SeedDatePid)
pr.rand.Read(b[:])

h := sha256.New224()
// Mix in the time and pid
h.Write(b[:])
// Mix in the hostname
if hostname, err := os.Hostname(); err == nil {
h.Write([]byte(hostname))
}
// Mix in net interfaces hw addresses (MACs etc)
if ifaces, err := net.Interfaces(); err == nil {
for _, iface := range ifaces {
h.Write(iface.HardwareAddr)
}
hs := h.Sum(nil)
s := binary.LittleEndian.Uint64(hs[0:])
rand.Seed(int64(s))
})
}
hs := h.Sum(nil)
s := binary.LittleEndian.Uint64(hs[0:])
return int64(s)
}

// PseudoRand provides a go-routine safe randomisation helper methods.
type PseudoRand struct {
rand *rand.Rand
lk sync.Mutex
}

// SeedFunc can compute a pseudo RNG seed value.
type SeedFunc func() int64

// NewPseudoRand returns a new pseudo RNG instance. Note that passing
// nil is shorthand using the date and pid based seeder.
func NewPseudoRand(seeder SeedFunc) *PseudoRand {
var seed int64
if seeder == nil {
seed = defaultSeeder()
} else {
seed = seeder()
}
return &PseudoRand{rand: rand.New(rand.NewSource(seed))}
}

// Reseed is exposed to allow tests to reseed the pseudo RNG to
// allow for deterministic results.
func (r *PseudoRand) Reseed(seed int64) {
r.lk.Lock()
defer r.lk.Unlock()

r.rand.Seed(seed)
}

const letters = "BCDFGHJKLMNPQRSTVWXYbcdfghjklmnpqrstvwxy0123456789"
Expand All @@ -70,26 +110,29 @@ const letters = "BCDFGHJKLMNPQRSTVWXYbcdfghjklmnpqrstvwxy0123456789"
// chance. Numbers are included.
//
// Not cryptographically secure.
func RandomString(length int) string {
func (r *PseudoRand) RandomString(length int) string {
r.lk.Lock()
defer r.lk.Unlock()

out := ""
for i := 0; i < length; i++ {
out += string(letters[rand.Intn(len(letters))])
out += string(letters[r.rand.Intn(len(letters))])
}

return out
}

// Re-exported from math/rand for streamlining.
var (
Intn = rand.Intn
Int63n = rand.Int63n
)
// RandomDuration returns a positive random duration in the half-open positive
// interval [0,d). Any zero or negative input duration results in a return
// of zero duration.
func (r *PseudoRand) RandomDuration(d time.Duration) time.Duration {
r.lk.Lock()
defer r.lk.Unlock()

// Prevent a panic on <= 0, rather return 0
if d <= 0 {
return time.Duration(0)
}

// RandomDuration returns a random duration up to the given length.
func RandomDuration(d time.Duration) time.Duration {
// try to switch to more mixed seed to avoid subsets of a
// fleet of machines with similar initial conditions to behave
// the same
moreMixedSeed()
return time.Duration(Int63n(int64(d)))
return time.Duration(r.rand.Int63n(int64(d)))
}
72 changes: 54 additions & 18 deletions randutil/rand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
package randutil_test

import (
"math/rand"
"testing"
"time"

Expand All @@ -35,28 +34,65 @@ type randutilSuite struct{}

var _ = Suite(&randutilSuite{})

func (s *randutilSuite) TestRandomString(c *C) {
// for our tests
rand.Seed(1)
func fixedSeedOne() int64 { return 1 }

func (s *randutilSuite) TestReseed(c *C) {
// predictable starting point
r := randutil.NewPseudoRand(fixedSeedOne)
s1 := r.RandomString(100)
r.Reseed(1)
s2 := r.RandomString(100)
c.Check(s1, Equals, s2)
r.Reseed(1)
d1 := r.RandomDuration(100)
r.Reseed(1)
d2 := r.RandomDuration(100)
c.Check(d1, Equals, d2)
}

s1 := randutil.RandomString(10)
c.Assert(s1, Equals, "pw7MpXh0JB")
func (s *randutilSuite) TestRandomString(c *C) {
// predictable starting point
r := randutil.NewPseudoRand(fixedSeedOne)

s2 := randutil.RandomString(5)
c.Assert(s2, Equals, "4PQyl")
for _, v := range []struct {
length int
result string
}{
{10, "pw7MpXh0JB"},
{5, "4PQyl"},
{0, ""},
{-1000, ""},
} {
c.Assert(r.RandomString(v.length), Equals, v.result)
}
}

func (s *randutilSuite) TestRandomDuration(c *C) {
// ensure moreMixedSeed is done
d := randutil.RandomDuration(time.Hour)
c.Check(d < time.Hour, Equals, true)

// for our tests
rand.Seed(1)
// predictable starting point
r := randutil.NewPseudoRand(fixedSeedOne)

d1 := randutil.RandomDuration(time.Hour)
c.Assert(d1, Equals, time.Duration(1991947779410))
for _, v := range []struct {
duration time.Duration
result time.Duration
}{
{time.Hour, 1991947779410},
{4 * time.Hour, 4423082153551},
{0, 0},
{-4 * time.Hour, 0},
} {
res := r.RandomDuration(v.duration)
// Automatic bounds verification for positive ranges
// because this is difficult to infer by simply looking
// at the nano-second totals.
if v.duration > 0 {
c.Check(res < v.duration, Equals, true)
}
c.Assert(res, Equals, v.result)
}
}

d2 := randutil.RandomDuration(4 * time.Hour)
c.Assert(d2, Equals, time.Duration(4423082153551))
func (s *randutilSuite) TestRandomDurationWithSeedDatePidHostMac(c *C) {
r := randutil.NewPseudoRand(randutil.SeedDatePidHostMac)
d := r.RandomDuration(time.Hour)
c.Check(d < time.Hour, Equals, true)
}

0 comments on commit 1ed038e

Please sign in to comment.