feat: share.keyRules, how long and varied a share key must be
Sharing refuses a key shorter than minLength characters (default 32, never under 16) or of fewer than minClasses kinds of lowercase, uppercase, digits and symbols (default 2, so random hex passes), saying so without showing it. Opening doesn't check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
13 files changed
+156
-21
No files matched your search
@@ -0,0 +1,59 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Rules are what a key's value must be to share with it: random enough
|
||||
// that guessing it is out of reach, since anyone with a link can try.
|
||||
type Rules struct {
|
||||
MinLength int // characters
|
||||
MinClasses int // kinds of character: lowercase, uppercase, digits, symbols
|
||||
}
|
||||
|
||||
// DefaultRules take a random base64 or hex value (openssl rand -base64
|
||||
// 32, or -hex 32) and refuse passwords made up to be remembered.
|
||||
var DefaultRules = Rules{MinLength: 32, MinClasses: 2}
|
||||
|
||||
// Check reports what key lacks, without showing it.
|
||||
func (r Rules) Check(key []byte) error {
|
||||
r = r.withDefaults()
|
||||
s := string(key)
|
||||
var lower, upper, digit, symbol bool
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case unicode.IsLower(c):
|
||||
lower = true
|
||||
case unicode.IsUpper(c):
|
||||
upper = true
|
||||
case unicode.IsDigit(c):
|
||||
digit = true
|
||||
default:
|
||||
symbol = true
|
||||
}
|
||||
}
|
||||
kinds := 0
|
||||
for _, has := range []bool{lower, upper, digit, symbol} {
|
||||
if has {
|
||||
kinds++
|
||||
}
|
||||
}
|
||||
n := len([]rune(s))
|
||||
if n >= r.MinLength && kinds >= r.MinClasses {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("the key's value is %d characters of %d kinds, and share.keyRules wants at least %d characters of %d kinds (lowercase, uppercase, digits, symbols): use a random one, like openssl rand -base64 32 makes",
|
||||
n, kinds, r.MinLength, r.MinClasses)
|
||||
}
|
||||
|
||||
func (r Rules) withDefaults() Rules {
|
||||
if r.MinLength == 0 {
|
||||
r.MinLength = DefaultRules.MinLength
|
||||
}
|
||||
if r.MinClasses == 0 {
|
||||
r.MinClasses = DefaultRules.MinClasses
|
||||
}
|
||||
r.MinLength = max(r.MinLength, MinKey)
|
||||
return r
|
||||
}
|
||||
@@ -40,7 +40,7 @@ const (
|
||||
// MaxLink keeps a link short enough for chat and tickets (Teams takes
|
||||
// about 28 KB) and for passing it to a terminal window.
|
||||
MaxLink = 16 << 10
|
||||
// MinKey is the shortest secret value accepted as a key.
|
||||
// MinKey is the shortest key the rules can ask for.
|
||||
MinKey = 16
|
||||
// maxFiles caps what opening a link unpacks.
|
||||
maxFiles = 1 << 20
|
||||
@@ -85,13 +85,14 @@ type file struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Seal encrypts files with key, the value of h.Key, into a share link.
|
||||
func Seal(h Header, key []byte, files []File) (ref.Ref, error) {
|
||||
// Seal encrypts files with key, the value of h.Key, into a share link,
|
||||
// if key keeps to rules.
|
||||
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
|
||||
if _, err := ParseKey(h.Key.String()); err != nil {
|
||||
return ref.Ref{}, fmt.Errorf("the key: %w", err)
|
||||
}
|
||||
if len(key) < MinKey {
|
||||
return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey)
|
||||
if err := rules.Check(key); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return ref.Ref{}, errors.New("no files to share")
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestSealOpen(t *testing.T) {
|
||||
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
|
||||
}
|
||||
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files)
|
||||
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func TestSealRefuses(t *testing.T) {
|
||||
files []File
|
||||
want string
|
||||
}{
|
||||
"short key": {Header{Key: key}, []byte("short"), ok, "at least 16"},
|
||||
"short key": {Header{Key: key}, []byte("short"), ok, "keyRules wants"},
|
||||
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
|
||||
"no files": {Header{Key: key}, pass, nil, "no files"},
|
||||
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
|
||||
@@ -70,7 +70,7 @@ func TestSealRefuses(t *testing.T) {
|
||||
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
|
||||
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
|
||||
} {
|
||||
if _, err := Seal(c.h, c.pass, c.files); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,31 @@ func random(n int) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
r Rules
|
||||
key string
|
||||
pass bool
|
||||
}{
|
||||
{Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32
|
||||
{Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex
|
||||
{Rules{}, "correct-horse-battery-staple", false}, // 28: short
|
||||
{Rules{}, strings.Repeat("a", 40), false}, // one kind
|
||||
{Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true},
|
||||
{Rules{MinLength: 4}, "abc123", false}, // never under MinKey
|
||||
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false},
|
||||
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true},
|
||||
} {
|
||||
if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass {
|
||||
t.Errorf("%+v %q: %v", c.r, c.key, err)
|
||||
} else if err != nil && strings.Contains(err.Error(), c.key) {
|
||||
t.Errorf("the error shows the key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadGarbled(t *testing.T) {
|
||||
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}})
|
||||
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user