package main import ( "bufio" "fmt" "log" "os" "path/filepath" "regexp" "sync" "time" ssh "golang.org/x/crypto/ssh" ) type Login = string func ParseLogin(data string) (Login, bool) { re := regexp.MustCompile(`^~([0-9a-z_.-]+)(/([0-9a-z_.-]+)\.mch)?$`) if !re.MatchString(data) { return "", false } return string(data), true } type Auth struct { // the directory under which all `~owner/repo.git` repositories reside RepositoriesRoot string // each auth token is issued to a specific public key's fingerprint // with an expiry time authtokenExpiry map[string]time.Time authtokenFingerprint map[string]string // One public key may be *claimed* by a malicious actor who does (hopefully) // not actually own the corresponding private key. // As such, the public key alone is not enough to deduce access permissions. logins map[Login][]string // repositories (explicitly) marked as private or public repositoryPrivate, repositoryPublic map[string]bool // repositories declared as shared or semipublic with their corresponding // collaborating owner or owned machine repositoryShared, repositorySemipublic map[string][]Login mux sync.Mutex } func NewAuth(repositoriesRoot string) (auth *Auth) { auth = &Auth{ RepositoriesRoot: repositoriesRoot, authtokenExpiry: make(map[string]time.Time), authtokenFingerprint: make(map[string]string), logins: make(map[Login][]string), repositoryPrivate: make(map[string]bool), repositoryPublic: make(map[string]bool), repositoryShared: make(map[string][]Login), repositorySemipublic: make(map[string][]Login), } entries, err := os.ReadDir(auth.RepositoriesRoot) if err != nil { log.Printf("gruau: computing access: %v", err) return } for _, entry := range entries { if !entry.IsDir() { log.Printf("gruau: alien file: %q/%q", auth.RepositoriesRoot, entry.Name()) continue } smatchs := reHomeDirectory.FindStringSubmatch(entry.Name()) if len(smatchs) != 1+1 { log.Printf("gruau: misnamed home directory: %q/%q", auth.RepositoriesRoot, entry.Name()) continue } owner := smatchs[1] err := auth.readConf(owner) if err != nil { log.Printf("gruau: access config for ~%s: %v", owner, err) } } return } func (auth *Auth) readConf(confOwner string) error { auth.mux.Lock() defer auth.mux.Unlock() root := filepath.Join(GruauRepositoriesRoot, "~" + confOwner) f, err := os.Open(filepath.Join(root, GruauAccountAccessFileName)) if err != nil { return err } defer f.Close() entries, err := os.ReadDir(root) if err != nil { return err } for _, entry := range entries { ErrAlienFile := fmt.Errorf("alien file: %q/%q", root, entry.Name()) if entry.IsDir() { if !reRepo.MatchString(entry.Name()) { return ErrAlienFile } } else if entry.Type().IsRegular() { if entry.Name() != GruauAccountAccessFileName { return ErrAlienFile } } else { return ErrAlienFile } } emptyline := regexp.MustCompile(`^ *(#.*)?$`) definingline := regexp.MustCompile(`^([a-z]+) +(.*)$`) scanner := bufio.NewScanner(f) for n := 1; scanner.Scan(); n++ { if emptyline.MatchString(scanner.Text()) { continue } ewrap := func(format string, a ...interface{}) error { return fmt.Errorf("line %d of ~%s's config: %s", n, confOwner, fmt.Sprintf(format, a...)) } smatchs := definingline.FindStringSubmatch(scanner.Text()) if len(smatchs) != 1+2 { return ewrap("invalid defining line") } def, arg := smatchs[1], smatchs[2] switch def { case "key": owner, machine, fingerprint, err := ParseOpenSSHPublicKey([]byte(arg)) if err != nil { return ewrap("%v", err) } if owner != confOwner { return ewrap("owner appropriation") } var login Login = "~" + owner if machine != "" { login += "/" + machine + ".mch" } auth.logins[fingerprint] = append(auth.logins[fingerprint], login) case "public": fallthrough case "private": repo, ok := ParseGitRepositoryID(arg) if !ok { return ewrap("invalid repository id") } if repo.Owner != confOwner { return ewrap("owner appropriation") } switch def { case "public": auth.repositoryPublic[repo.ID()] = true case "private": auth.repositoryPrivate[repo.ID()] = true default: return ewrap("invalid def") } case "semipublic": fallthrough case "shared": re := regexp.MustCompile(`^(~[0-9a-z_.-]+/[0-9a-z_.-]+\.git) (~[0-9a-z_.-]+(/[0-9a-z_.-]+\.mch)?)$`) smatches := re.FindStringSubmatch(arg) if len(smatches) != 1+3 { return ewrap("invalid repo-login bind") } repo, ok := ParseGitRepositoryID(smatches[1]) if !ok { return ewrap("invalid repository id") } login, ok := ParseLogin(smatches[2]) if !ok { return ewrap("invalid login") } switch def { case "semipublic": auth.repositorySemipublic[repo.ID()] = append(auth.repositorySemipublic[repo.ID()], login) case "shared": auth.repositoryShared[repo.ID()] = append(auth.repositoryShared[repo.ID()], login) default: return ewrap("invalid def") } default: return ewrap("invalid def") } } return nil } func ParseOpenSSHPublicKey(data []byte) (owner, machine string, fingerprint string, err error) { pubkey, comment, options, rest, err := ssh.ParseAuthorizedKey(data) if err != nil { return "", "", "", err } if len(rest) > 0 { return "", "", "", ErrOpenSSHKeyFormatSuperfluousBytes } if len(options) > 0 { return "", "", "", ErrOpenSSHKeyFormatOptionsNotAllowed } re := regexp.MustCompile(`^~([0-9a-z_.-]+)(/([0-9a-z_.-]+)\.mch)?( .*)?$`) smatchs := re.FindStringSubmatch(comment) if len(smatchs) != 1+4 { return "", "", "", ErrOpenSSHKeyFormat } owner = smatchs[1] machine = smatchs[3] fingerprint = ssh.FingerprintSHA256(pubkey) return }