85 lines
2.6 KiB
Go
85 lines
2.6 KiB
Go
package deviceauth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const deviceSecretReferencePrefix = "device-secret://"
|
|
|
|
var deviceSecretNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
|
|
|
// DirectorySecretResolver resolves a per-device credential from a controlled
|
|
// secret mount. It is suitable for Docker/Kubernetes Secrets and cloud KMS CSI
|
|
// drivers: the database stores only device-secret://<name>, never secret bytes.
|
|
// Files are read for every verification so an atomic secret-mount rotation is
|
|
// observed without restarting the API process.
|
|
type DirectorySecretResolver struct {
|
|
root string
|
|
}
|
|
|
|
func NewDirectorySecretResolver(root string) (*DirectorySecretResolver, error) {
|
|
if strings.TrimSpace(root) == "" || !filepath.IsAbs(root) {
|
|
return nil, errors.New("device secret directory must be an absolute path")
|
|
}
|
|
absolute, err := filepath.Abs(filepath.Clean(root))
|
|
if err != nil {
|
|
return nil, errors.New("resolve device secret directory")
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(absolute)
|
|
if err != nil {
|
|
return nil, errors.New("resolve device secret directory")
|
|
}
|
|
info, err := os.Stat(resolved)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, errors.New("device secret directory is not readable")
|
|
}
|
|
return &DirectorySecretResolver{root: resolved}, nil
|
|
}
|
|
|
|
func (r *DirectorySecretResolver) Resolve(ctx context.Context, reference string) ([]byte, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.HasPrefix(reference, deviceSecretReferencePrefix) {
|
|
return nil, ErrCredentialInvalid
|
|
}
|
|
name := strings.TrimPrefix(reference, deviceSecretReferencePrefix)
|
|
if !deviceSecretNamePattern.MatchString(name) || name == "." || name == ".." {
|
|
return nil, ErrCredentialInvalid
|
|
}
|
|
candidate := filepath.Join(r.root, name)
|
|
resolved, err := filepath.EvalSymlinks(candidate)
|
|
if err != nil || !pathWithin(r.root, resolved) {
|
|
return nil, ErrCredentialInvalid
|
|
}
|
|
file, err := os.Open(resolved)
|
|
if err != nil {
|
|
return nil, ErrCredentialInvalid
|
|
}
|
|
defer file.Close()
|
|
secret, err := io.ReadAll(io.LimitReader(file, 4097))
|
|
if err != nil || len(secret) > 4096 {
|
|
return nil, ErrCredentialInvalid
|
|
}
|
|
secret = bytes.TrimRight(secret, "\r\n")
|
|
if len(secret) < 32 {
|
|
return nil, ErrCredentialInvalid
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return append([]byte(nil), secret...), nil
|
|
}
|
|
|
|
func pathWithin(root, candidate string) bool {
|
|
relative, err := filepath.Rel(root, candidate)
|
|
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
|
}
|