Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// Package secret holds the small amount of cryptography the server needs:
|
||||
// random identifiers, bearer tokens and password verifiers.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// iterations follows the OWASP recommendation for PBKDF2-HMAC-SHA256.
|
||||
const iterations = 600000
|
||||
|
||||
// ID returns a short random identifier, safe in URLs and filenames.
|
||||
func ID() string { return randHex(8) }
|
||||
|
||||
// Token returns a 256-bit bearer token.
|
||||
func Token() string { return randHex(32) }
|
||||
|
||||
// UserCode returns a short, human-transcribable code for device login.
|
||||
// The alphabet omits characters that are easy to confuse when read aloud.
|
||||
func UserCode() string {
|
||||
const alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789"
|
||||
b := randBytes(8)
|
||||
out := make([]byte, 0, 9)
|
||||
for i, v := range b {
|
||||
if i == 4 {
|
||||
out = append(out, '-')
|
||||
}
|
||||
out = append(out, alphabet[int(v)%len(alphabet)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Hash returns a verifier string for a password.
|
||||
func Hash(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", errors.New("secret: empty password")
|
||||
}
|
||||
salt := randBytes(16)
|
||||
dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, 32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", iterations,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(dk)), nil
|
||||
}
|
||||
|
||||
// Verify reports whether password matches a verifier produced by Hash.
|
||||
// An unparsable or empty verifier never matches.
|
||||
func Verify(verifier, password string) bool {
|
||||
parts := strings.Split(verifier, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iter, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iter < 1000 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// Equal compares two secrets without leaking their contents through timing.
|
||||
func Equal(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func randBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("secret: " + err.Error()) // crypto/rand never fails on supported platforms
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func randHex(n int) string { return hex.EncodeToString(randBytes(n)) }
|
||||
@@ -0,0 +1,31 @@
|
||||
package secret
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashVerifyRoundTrip(t *testing.T) {
|
||||
h, err := Hash("correct horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Verify(h, "correct horse") {
|
||||
t.Fatal("correct password rejected")
|
||||
}
|
||||
if Verify(h, "wrong horse") {
|
||||
t.Fatal("wrong password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsJunkVerifier(t *testing.T) {
|
||||
for _, v := range []string{"", "x", "pbkdf2-sha256$1$a$b", "md5$1$a$b"} {
|
||||
if Verify(v, "anything") {
|
||||
t.Fatalf("verifier %q accepted", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCodeShape(t *testing.T) {
|
||||
c := UserCode()
|
||||
if len(c) != 9 || c[4] != '-' {
|
||||
t.Fatalf("user code %q", c)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user