Compare commits

...

2 Commits

Author SHA1 Message Date
mediocregopher
1147264ff1 Comment by mediocregopher: Hi all, welcome to the welcome thread! :) The READ...
---
type: comment
comment: Hi all, welcome to the welcome thread! :) The README is a good place to start
  if you're new. Looking forward to hearing from everyone!
fingerprint: AGKKa5dQRgvJaSusKYdPT58OfebCcb8u2ab8tT7fWzU9
credentials:
- type: pgp_signature
  pub_key_id: 95C46FA6A41148AC
  body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl6rR4oACgkQlcRvpqQRSKzcaRAArMNQ3v3zooQnZn3X44G0wrDngrxpYBtVOpfQIszPtWE5F/BlxCzzGa+5GyDg6w4TuoUPcv0boa7Klu4XWMZWO43YXgal+6A9i9I8jtsIBBdatdRKd10JlwUjEyb0gmQT+4Ili1FBTN7y3KiIXLzLPALZ3NELGURp3Lo7aYsVQUxNsHXDXbawYQQRz1yCN9CRMD1UEHvpEZPt7ohUSo2sYefWLsR6WVzTx4fJq6Nc4A/kStQ7iUvq3uI7rDA/w3foo2oFIxkYta9BpEe7ly+Dwmx0CNHjPi3EW2zeei9lQxA0fihiin/0XnN5Kncu+sNgzW7TC5BJEXoVWQcgrSLX9S2KWl92iaNWAUtPDzUZWyY+d1Qq0VDdd4ZZ5Lhmbi8oNbj4JRiK9OLG2EULmVOItH3OY5Z3Pqv3MEkXTavCYsLuLaE/umBzewWMcgx+yk0T9+0frdPbZ5X5wu2S1eToyf0cThRaUB1OG2jrHbo/62dFgmHouSnaX0r2pg0MhIfGA/knk51VLT+RWfBYNwnDhigqVvM8zUAUlPBzWNtdCOLIgTxcHufzqfJeTRt0n3vl2F7FO1LAfT00bun+8hMzSb2nslskfBKdACUfU6GGlSqZMqi5gixnuV+q9POWrNHpNiT3qrjAMw9qRiln4ohTz5X/VtmGsMaLF5zOwfSJgC0=
  account: mediocregopher
2020-05-02 13:47:44 -06:00
mediocregopher
f5584f1505 Create the welcome thread, and a README for it
---
type: change
description: Create the welcome thread, and a README for it
fingerprint: ACfbSiTJmQ04DduNlyf0kNvJgqhGkJC1osSEZ9kdO6+o
credentials:
- type: pgp_signature
  pub_key_id: 95C46FA6A41148AC
  body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl6tzlUACgkQlcRvpqQRSKyTYxAAjfPI881Xu168EJmwi2by9QLcUlcYY+t5DmJaxtGB+WT7W9qcfZ3WOwzST+X4rBvoA8oTPnfI6PE2tuF9RPgRBSxn3JOALRH2VwqoY5fsuTOk5/BO1uukPZdycdDpYZRKpQZKC8kzt3KwYskRR4CoxVroqmAzxEVba4dZTAXprov724cU7QXWXjOtU2iX0JNn/S/yX3L3g1v4sOVbaaUmif4aOLntx+7E2R7v28aBg0HL2uTgSs5nsHLXXfdRcm1CFmGzX8FNAChHkpUg9OdDpd5+mqBf7ymKBWuv0z+I2qe6xTPAshcMm3EWfbUpb1+Bux7UpywwZnz97HvdopFnKaHAfbv99Sfm/OqzgMeLClWv3Iysm1k5PcXobvfs2E9MUfIjG085jTZ0cq0OPqGhODkBOVHyn4Cm71ZMELt9yAkihxKLHjkp3J0WwQv0HbEieA0fE6Czmc481oTd0kGlDWTla/LMd3/vU4Gpx89Y9+2lTV0WaXoAawjJmEXQwqSCiPHYSnfAWgjDTkEAkNMHN8HgMYsVxhtipayvYPiWJFRhL5LVKGNgwUefTfhhhvrx1FBza5sF06XB7vKbb3npvZrfm2faLi1eyFX2xIl7m7dY6C4XYr3CBgEPiBh/NiCaZiOtjxOkzrJ/bsWNGolURMhNt9NAWFms8Nz5bXnRwZQ=
  account: mediocregopher
2020-05-02 13:47:34 -06:00
29 changed files with 85 additions and 2686 deletions

View File

@ -6,9 +6,15 @@ accounts:
path: ".dehub/mediocregopher.asc"
access_controls:
- pattern: "**"
condition:
type: signature
account_ids:
- mediocregopher
count: 100%
- action: allow
filters:
- type: branch
pattern: public/welcome
- type: payload_type
payload_type: comment
- type: not
filter:
type: commit_attributes
non_fast_forward: true
- type: signature
any: true

2
.gitignore vendored
View File

@ -1 +1 @@
dehub
/dehub

72
README.md Normal file
View File

@ -0,0 +1,72 @@
# Welcome!
Hello! Welcome to the dehub project. You've found your way onto the welcome
branch. This branch is open for anyone to leave a comment commit on it, provided
they sign their commit with a PGP key.
## Viewing comments
If you've gotten this far then viewing comments is as easy as doing `git log`.
All commits will be shown from newest to oldest. You will only see the latest
snapshot of comments that you've pulled from the server. In order to update that
snapshot do:
```
git pull -f origin public/welcome
```
## Leaving a comment
The first step to leaving a comment of your own is to install dehub. Visit
`https://dehub.dev` for more on how to do that.
Once done, and assuming you have this branch checked out (how are you reading
this if you don't?), just do the following:
```
dehub commit --anon-pgp-key=KEY_NAME comment
```
(`KEY_NAME` should be replaced with any selector which will match your pgp key,
such as the key ID, the name on the key, or the email.)
Your default text editor (defined by the `EDITOR` environment variable) will pop
up and you can then write down your comment. When you save and close your editor
dehub will sign the comment with your pgp key and create a commit with it.
You can view your newly created commit by calling `git show`.
If after you've created your comment commit (but before you've pushed it) you'd
like to amend it, do:
```
dehub commit --anon-pgp-key=KEY_NAME comment --amend
```
Finally, to push your comment commit up, you can do:
```
git push origin public/welcome
```
Once pushed, everyone will be able to see your comment!
### What to say?
Here's some starting points if you're not sure what to write in your first
comment:
* Introduce yourself; say where you're from and what your interests are.
* How did you find dehub? Why is it interesting to you?
* If you're using dehub for a project, shill your project!
* If you'd like to get involved in dehub's development, let us know what your
skills are and how you can help. Remember, it takes more than expert
programmers to make a project successful.
## Rules
Please be kind to others, and keep discussion related to dehub and
dehub-adjacent topics. Politics, in general, is not going to be related to
dehub. Comments which are off-topic or otherwise abusive are subject to being
removed.

195
SPEC.md
View File

@ -1,195 +0,0 @@
# .dehub
The `.dehub` directory contains all meta information related to
decentralized repository management and access control.
## config.yml
The `.dehub/config.yml` file takes the following structure:
```yaml
# accounts defines all accounts which are known to the repo.
accounts:
# Each account is an object with an id and at least one identifier. The id
# must be unique for each account.
- id: some_user_id:
# signifiers describes different methods the account might use to
# identify itself. Generally, these will be different public keys which
# commits will be signed with. At least one is required.
signifiers:
- type: "pgp_public_key"
body: "FULL PGP PUBLIC KEY STRING"
- type: "pgp_public_key_file"
path: ".dehub/some_user_id.asc"
- type: "keybase"
user: "some_keybase_user_id"
# access_controls defines under what conditions different files in the repo may
# be modified. For each file modified in a commit, all access control patterns
# are applied sequentially until one matches, and the associated access control
# conditions are checked. A commit is only allowed if the conditions of all
# modified files are met.
access_controls:
# pattern is a glob pattern describing what files this access control
# applies to. Single star matches all characters except path separators,
# double star matches everything.
- pattern: ".dehub/**"
# signature conditions indicate that a commit must be signed by one or
# more accounts to be allowed.
condition:
type: signature
# account_ids lists all accounts whose signature will count towards
# meeting the condition
account_ids:
- some_user_id
# count describes how many signatures are required. It can be either a
# contrete integer (e.g. 2, meaning any 2 accounts listed by
# account_ids) or a percent.
count: 100%
# This catch-all pattern for the rest of the repo requires that changes to
# any files not under `.dehub/` are signed by at least one of the
# defined accounts.
- pattern: "**"
condition:
type: signature
any_account: true # indicates any account defined in accounts is valid
count: 1
```
# Master commit
All new commits being appended to the HEAD of the `master` branch are subject to
the following requirements:
* Must conform to all requirements defined by the `access_controls` section of
the `config.yml`, as found in the HEAD. If the commit is the initial commit of
the repo then it instead uses the `config.yml` found in itself.
* Must not be a merge commit (this may be amended later, but at present it
simplifies implementation).
* The commit message must conform to the format and semantics defined below.
## Master Commit Message
The commit message for a commit being appended to the HEAD of the `master`
branch must conform to the following format: a single line (the message head)
giving a short description of the change, then two newlines, then a body which
is a yaml formatted string:
```yaml
This is the message head. It will be re-iterated within the yaml body.
# Now the yaml body begins
---
message: >
This is the message head. It will be re-iterated within the yaml body.
The rest of this field is for the message body, which corresponds to the
body of a normal commit message which might give a more long-form
explanation of the commit's changes.
Since the message is used in generating the signature it's necessary for it
to be encoded here fully formed, even though the message head is then
duplicated. Otherwise the exact bytes of the message would be ambiguous.
This situation is ugly, but not unbearable.
# See the Commit Signatures section below for how this is computed. The
# change_hash is always recomputed when verifying a commit, but is reproduced in
# the commit message itself for cases of forward compatibility, e.g. if the
algorithm to compute the hash changes.
change_hash: XXX
# Credentials are the set of credentials which count towards requirements
# specified in the `access_controls` section of the `config.yml` file.
credentials:
- type: pgp_signature
account_id: some_user_id
pub_key_id: XXX
body: "base-64 signature body"
```
## Commit Signatures
When a commit is being signed by a signifier there is an expected data format
for the data to be signed. The format is a SHA-256 hash of the following pieces
of data concatenated together (the "change_hash"):
* A uvarint indicating the number of bytes in the commit message.
* The message.
* A uvarint indicating the number of files changed.
* For each file changed in the commit, ordered lexographically-ascending based
on its full relative path within the repo, the following is then written:
* A uvarint indicating the length of the full relative path of the file
within the repo.
* The full relative path of the file within the repo.
* A little-endian uint32 representing the previous file mode of the file (or 0
if the file is being inserted).
* The 20-byte SHA1 hash of the previous version of the file's contents (or 20
0 bytes if the file is being inserted).
* A little-endian uint32 representing the new file mode of the file (or 0
if the file is being deleted).
* The 20-byte SHA1 hash of the new version of the file's contents (or 20
0 bytes if the file is being deleted).
The raw output from the SHA-256 is then prepended with a `0` byte (for forward
compatibility) and signed, and the result used as the signature body.
# Merge Requests
A merge request (MR) may be pushed to the repository as a new branch at any
time. All MR branch names follow the naming convention `DHMR-short-description`.
An MR branch has the following qualities:
* Meta commits (see sub-section) will only contain a commit message head/body,
but no file changes.
* The most recent substantial commit (as opposed to meta commits) should always
contain the full commit message head and body.
## Meta Commits
Meta commits are those which add information about the changes being requested,
but do not modify the changes themselves.
### Signature Commits
Signature commits sign the changes requested in order to count towards their
access control requirements. The message head of these are arbitrary, but the
body must be formatted as such:
```yaml
# This object matches the one found in the `credentials` section of the master
# commit message.
type: pgp_signature
account_id: some_user_id ```
pub_key_id: XXX
body: "base-64 signature body" # see Commit Signatures sub-section.
```
If a signature commit is added to a MR branch, and a substantial commit is
added after it, then that signature commit will no longer be valid, as it was
only signing the the prior changeset. The signer will need to create and push a
new signature commit, if they agree with the new changes.
## Merging MRs
When an MR has accumulated enough meta commits to fulfuill access control
requirements it may be coalesced into a single commit destined for the master
branch. See the Master Commit Message sub-section for details on how commits in
the master branch must be formatted.
# TODO
* access control patterns related to who may push to MR branches, and what types
of commits they can push.

View File

@ -1,53 +0,0 @@
package accessctl
import (
"fmt"
"github.com/bmatcuk/doublestar"
)
// AccessControl represents an access control object being defined in the
// Config.
type AccessControl struct {
Pattern string `yaml:"pattern"`
Condition Condition `yaml:"condition"`
}
// ErrNoApplicableAccessControls is returned from ApplicableAccessControls when
// a changed path has no applicable AccessControls which match it.
type ErrNoApplicableAccessControls struct {
Path string
}
func (err ErrNoApplicableAccessControls) Error() string {
return fmt.Sprintf("no AccessControls which apply to changed file %q", err.Path)
}
// ApplicableAccessControls returns a subset of the given AccessControls which
// are applicable to the given file paths (ie those whose Conditions must be met
// in order for the changes to go through.
func ApplicableAccessControls(accessControls []AccessControl, filesChanged []string) ([]AccessControl, error) {
applicableSet := map[AccessControl]struct{}{}
for _, path := range filesChanged {
var any bool
for _, ac := range accessControls {
if ok, err := doublestar.PathMatch(ac.Pattern, path); err != nil {
return nil, fmt.Errorf("error matching path %q to patterrn %q: %w",
path, ac.Pattern, err)
} else if ok {
applicableSet[ac] = struct{}{}
any = true
break
}
}
if !any {
return nil, ErrNoApplicableAccessControls{Path: path}
}
}
applicable := make([]AccessControl, 0, len(applicableSet))
for ac := range applicableSet {
applicable = append(applicable, ac)
}
return applicable, nil
}

View File

@ -1,118 +0,0 @@
package accessctl
import (
"errors"
"reflect"
"sort"
"testing"
)
func TestApplicableAccessControls(t *testing.T) {
tests := []struct {
descr string
patterns, filesChanged []string
exp []string
expErrPath string
}{
{
descr: "empty input empty output",
},
{
descr: "empty patterns",
filesChanged: []string{"foo", "bar"},
expErrPath: "foo",
},
{
descr: "empty filesChanged",
patterns: []string{"patternA", "patternB"},
},
{
descr: "no applicable files",
filesChanged: []string{"foo"},
patterns: []string{"bar"},
expErrPath: "foo",
},
{
descr: "all applicable files",
filesChanged: []string{"foo", "bar"},
patterns: []string{"**"},
exp: []string{"**"},
},
{
descr: "pattern precedent",
filesChanged: []string{"foo"},
patterns: []string{"foo", "**"},
exp: []string{"foo"},
},
{
descr: "pattern precedent inv",
filesChanged: []string{"foo"},
patterns: []string{"**", "foo"},
exp: []string{"**"},
},
{
descr: "individual matches",
filesChanged: []string{"foo", "bar/baz"},
patterns: []string{"foo", "bar/baz"},
exp: []string{"foo", "bar/baz"},
},
{
descr: "star match dir",
filesChanged: []string{"foo", "bar/baz"},
patterns: []string{"foo", "bar/*"},
exp: []string{"foo", "bar/*"},
},
{
descr: "star not match dir",
filesChanged: []string{"foo", "bar/baz/biz"},
patterns: []string{"foo", "bar/*"},
expErrPath: "bar/baz/biz",
},
{
descr: "doublestar match dir",
filesChanged: []string{"foo", "bar/bar", "bar/baz/biz"},
patterns: []string{"foo", "bar/**"},
exp: []string{"foo", "bar/**"},
},
}
for _, test := range tests {
t.Run(test.descr, func(t *testing.T) {
accessControls := make([]AccessControl, len(test.patterns))
for i := range test.patterns {
accessControls[i] = AccessControl{Pattern: test.patterns[i]}
}
out, err := ApplicableAccessControls(accessControls, test.filesChanged)
if err != nil && test.expErrPath == "" {
t.Fatalf("unexpected error: %v", err)
} else if test.expErrPath != "" {
if noAppErr := (ErrNoApplicableAccessControls{}); !errors.As(err, &noAppErr) {
t.Fatalf("expected ErrNoApplicableAccessControls for path %q, but got %v", test.expErrPath, err)
} else if test.expErrPath != noAppErr.Path {
t.Fatalf("expected ErrNoApplicableAccessControls for path %q, but got one for path %q", test.expErrPath, noAppErr.Path)
}
return
}
outPatterns := make([]string, len(out))
for i := range out {
outPatterns[i] = out[i].Pattern
}
clean := func(s []string) []string {
if len(s) == 0 {
return nil
}
sort.Strings(s)
return s
}
outPatterns = clean(outPatterns)
test.exp = clean(test.exp)
if !reflect.DeepEqual(outPatterns, test.exp) {
t.Fatalf("expected: %+v\ngot: %+v", test.exp, outPatterns)
}
})
}
}

View File

@ -1,130 +0,0 @@
package accessctl
import (
"dehub/sigcred"
"dehub/typeobj"
"errors"
"fmt"
"math"
"strconv"
"strings"
)
// ConditionInterface describes the methods that all Signifiers must implement.
type ConditionInterface interface {
// Satisfied asserts that the Condition is satisfied by the given set of
// Credentials. If it is not (or something else went wrong) then an error is
// returned.
//
// NOTE that Satisfied assumes the Credential has already been Verify'd.
Satisfied([]sigcred.Credential) error
}
// Condition represents an access control condition being defined in the Config.
// Only one of its fields may be filled in at a time.
type Condition struct {
Signature *ConditionSignature `type:"signature"`
}
// MarshalYAML implements the yaml.Marshaler interface.
func (c Condition) MarshalYAML() (interface{}, error) {
return typeobj.MarshalYAML(c)
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *Condition) UnmarshalYAML(unmarshal func(interface{}) error) error {
return typeobj.UnmarshalYAML(c, unmarshal)
}
// Interface returns the ConditionInterface encapsulated by this Condition
// object.
func (c Condition) Interface() (ConditionInterface, error) {
el, _, err := typeobj.Element(c)
if err != nil {
return nil, err
}
return el.(ConditionInterface), nil
}
// ConditionSignature represents the configuration of an access control
// condition which requires one or more signatures to be present on a commit.
//
// Either AccountIDs or AccountIDsByMeta must be filled.
type ConditionSignature struct {
AccountIDs []string `yaml:"account_ids,omitempty"`
AnyAccount bool `yaml:"any_account,omitempty"`
Count string `yaml:"count"`
}
var _ ConditionInterface = ConditionSignature{}
func (condSig ConditionSignature) targetNum() (int, error) {
if !strings.HasSuffix(condSig.Count, "%") {
return strconv.Atoi(condSig.Count)
} else if condSig.AnyAccount {
return 0, errors.New("cannot use AnyAccount and a percent Count together")
}
percentStr := strings.TrimRight(condSig.Count, "%")
percent, err := strconv.ParseFloat(percentStr, 64)
if err != nil {
return 0, fmt.Errorf("could not parse Count as percent %q: %w", condSig.Count, err)
}
targetF := float64(len(condSig.AccountIDs)) * percent / 100
targetF = math.Ceil(targetF)
return int(targetF), nil
}
// ErrConditionSignatureUnsatisfied is returned from ConditionSignature's
// Satisfied method when the Condition has not been satisfied.
type ErrConditionSignatureUnsatisfied struct {
TargetNumAccounts, NumAccounts int
}
func (err ErrConditionSignatureUnsatisfied) Error() string {
return fmt.Sprintf("not enough valid signature credentials, requires %d but only had %d",
err.TargetNumAccounts, err.NumAccounts)
}
// Satisfied asserts that the given Credentials contains enough signatures to be
// satisfied.
func (condSig ConditionSignature) Satisfied(creds []sigcred.Credential) error {
targetN, err := condSig.targetNum()
if err != nil {
return fmt.Errorf("could not compute ConditionSignature target number of accounts: %w", err)
}
credAccountIDs := map[string]struct{}{}
for _, cred := range creds {
// TODO currently only signature credentials are implemented, so we can
// just assume that the given AccountID has provided a sig. In the
// future this may not be true.
credAccountIDs[cred.AccountID] = struct{}{}
}
var n int
if condSig.AnyAccount {
// TODO this doesn't actually check that the accounts are defined in the
// Config.
n = len(credAccountIDs)
} else {
targetAccountIDs := map[string]struct{}{}
for _, accountID := range condSig.AccountIDs {
targetAccountIDs[accountID] = struct{}{}
}
for accountID := range targetAccountIDs {
if _, ok := credAccountIDs[accountID]; ok {
n++
}
}
}
if n < targetN {
return ErrConditionSignatureUnsatisfied{
TargetNumAccounts: targetN,
NumAccounts: n,
}
}
return nil
}

View File

@ -1,110 +0,0 @@
package accessctl
import (
"dehub/sigcred"
"reflect"
"testing"
)
func TestConditionSignatureSatisfied(t *testing.T) {
tests := []struct {
descr string
cond ConditionSignature
credAccountIDs []string
err error
}{
{
descr: "no cred accounts",
cond: ConditionSignature{
AnyAccount: true,
Count: "1",
},
err: ErrConditionSignatureUnsatisfied{
TargetNumAccounts: 1,
NumAccounts: 0,
},
},
{
descr: "one cred account",
cond: ConditionSignature{
AnyAccount: true,
Count: "1",
},
credAccountIDs: []string{"foo"},
},
{
descr: "one matching cred account",
cond: ConditionSignature{
AccountIDs: []string{"foo", "bar"},
Count: "1",
},
credAccountIDs: []string{"foo"},
},
{
descr: "no matching cred account",
cond: ConditionSignature{
AccountIDs: []string{"foo", "bar"},
Count: "1",
},
credAccountIDs: []string{"baz"},
err: ErrConditionSignatureUnsatisfied{
TargetNumAccounts: 1,
NumAccounts: 0,
},
},
{
descr: "two matching cred accounts",
cond: ConditionSignature{
AccountIDs: []string{"foo", "bar"},
Count: "2",
},
credAccountIDs: []string{"foo", "bar"},
},
{
descr: "one matching cred account, missing one",
cond: ConditionSignature{
AccountIDs: []string{"foo", "bar"},
Count: "2",
},
credAccountIDs: []string{"foo", "baz"},
err: ErrConditionSignatureUnsatisfied{
TargetNumAccounts: 2,
NumAccounts: 1,
},
},
{
descr: "50 percent matching cred accounts",
cond: ConditionSignature{
AccountIDs: []string{"foo", "bar", "baz"},
Count: "50%",
},
credAccountIDs: []string{"foo", "bar"},
},
{
descr: "not 50 percent matching cred accounts",
cond: ConditionSignature{
AccountIDs: []string{"foo", "bar", "baz"},
Count: "50%",
},
credAccountIDs: []string{"foo"},
err: ErrConditionSignatureUnsatisfied{
TargetNumAccounts: 2,
NumAccounts: 1,
},
},
}
for _, test := range tests {
t.Run(test.descr, func(t *testing.T) {
creds := make([]sigcred.Credential, len(test.credAccountIDs))
for i := range test.credAccountIDs {
creds[i].AccountID = test.credAccountIDs[i]
}
err := test.cond.Satisfied(creds)
if !reflect.DeepEqual(err, test.err) {
t.Fatalf("Satisfied returned %#v\nexpected %#v", err, test.err)
}
})
}
}

View File

@ -1,75 +0,0 @@
package dehub
import (
"crypto/sha256"
"encoding/binary"
"fmt"
"hash"
"sort"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
var (
defaultHashHelperAlgo = sha256.New
)
type hashHelper struct {
hash.Hash
varintBuf []byte
}
// if h is nil it then defaultHashHelperAlgo will be used
func newHashHelper(h hash.Hash) *hashHelper {
if h == nil {
h = defaultHashHelperAlgo()
}
s := &hashHelper{
Hash: h,
varintBuf: make([]byte, binary.MaxVarintLen64),
}
return s
}
func (s *hashHelper) writeUint(i uint64) {
n := binary.PutUvarint(s.varintBuf, i)
if _, err := s.Write(s.varintBuf[:n]); err != nil {
panic(fmt.Sprintf("error writing %x to sha256 sum: %v", s.varintBuf[:n], err))
}
}
func (s *hashHelper) writeStr(str string) {
s.writeUint(uint64(len(str)))
s.Write([]byte(str))
}
func (s *hashHelper) writeTreeDiff(from, to *object.Tree) {
filesChanged, err := calcDiff(from, to)
if err != nil {
panic(err.Error())
}
sort.Slice(filesChanged, func(i, j int) bool {
return filesChanged[i].path < filesChanged[j].path
})
s.writeUint(uint64(len(filesChanged)))
for _, fileChanged := range filesChanged {
s.writeStr(fileChanged.path)
s.Write(fileChanged.fromMode.Bytes())
s.Write(fileChanged.fromHash[:])
s.Write(fileChanged.toMode.Bytes())
s.Write(fileChanged.toHash[:])
}
}
var changeHashVersion = []byte{0}
// if h is nil it then defaultHashHelperAlgo will be used
func genChangeHash(h hash.Hash, msg string, from, to *object.Tree) []byte {
s := newHashHelper(h)
s.writeStr(msg)
s.writeTreeDiff(from, to)
return s.Sum(changeHashVersion)
}

View File

@ -1,136 +0,0 @@
package main
import (
"dehub"
"errors"
"flag"
"fmt"
"os"
"strings"
"gopkg.in/src-d/go-git.v4/plumbing"
)
type subCmdCtx struct {
repo *dehub.Repo
args []string
}
var subCmds = []struct {
name, descr string
body func(sctx subCmdCtx) error
}{
{
name: "commit",
descr: "commits staged changes to the head of the current branch",
body: func(sctx subCmdCtx) error {
flag := flag.NewFlagSet("commit", flag.ExitOnError)
msg := flag.String("msg", "", "Commit message to use")
accountID := flag.String("account-id", "", "Account to sign commit as")
flag.Parse(sctx.args)
if *msg == "" || *accountID == "" {
return errors.New("-msg and -account-id are both required")
}
cfg, err := sctx.repo.LoadConfig()
if err != nil {
return err
}
var account dehub.Account
var ok bool
for _, account = range cfg.Accounts {
if account.ID == *accountID {
ok = true
break
}
}
if !ok {
return fmt.Errorf("account ID %q not found in config", *accountID)
} else if l := len(account.Signifiers); l == 0 || l > 1 {
return fmt.Errorf("account %q has %d signifiers, only one is supported right now", *accountID, l)
}
sig := account.Signifiers[0]
sigInt, err := sig.Interface()
if err != nil {
return fmt.Errorf("could not cast %+v to SignifierInterface: %w", sig, err)
}
_, hash, err := sctx.repo.CommitMaster(*msg, *accountID, sigInt)
if err != nil {
return err
}
fmt.Printf("changes committed to HEAD as %s\n", hash)
return nil
},
},
{
name: "verify",
descr: "verifies one or more commits as having the proper credentials",
body: func(sctx subCmdCtx) error {
flag := flag.NewFlagSet("verify", flag.ExitOnError)
rev := flag.String("rev", "HEAD", "Revision of commit to verify")
flag.Parse(sctx.args)
h, err := sctx.repo.GitRepo.ResolveRevision(plumbing.Revision(*rev))
if err != nil {
return fmt.Errorf("could not resolve revision %q: %w", *rev, err)
}
if err := sctx.repo.VerifyMasterCommit(*h); err != nil {
return fmt.Errorf("could not verify commit at %q (%s): %w", *rev, *h, err)
}
fmt.Printf("commit at %q (%s) is good to go!\n", *rev, *h)
return nil
},
},
}
func printHelp() {
fmt.Printf("USAGE: %s <command> [-h]\n\n", os.Args[0])
fmt.Println("COMMANDS")
for _, subCmd := range subCmds {
fmt.Printf("\t%s : %s\n", subCmd.name, subCmd.descr)
}
}
func exitErr(err error) {
fmt.Fprintf(os.Stderr, "exiting: %v\n", err)
os.Stderr.Sync()
os.Stdout.Sync()
os.Exit(1)
}
func main() {
if len(os.Args) < 2 {
printHelp()
return
}
subCmdName := strings.ToLower(os.Args[1])
for _, subCmd := range subCmds {
if subCmd.name != subCmdName {
continue
}
r, err := dehub.OpenRepo(".")
if err != nil {
exitErr(err)
}
err = subCmd.body(subCmdCtx{
repo: r,
args: os.Args[2:],
})
if err != nil {
exitErr(err)
}
return
}
fmt.Printf("unknown command %q\n\n", subCmdName)
printHelp()
}

251
commit.go
View File

@ -1,251 +0,0 @@
package dehub
import (
"bytes"
"dehub/accessctl"
"dehub/fs"
"dehub/sigcred"
"dehub/yamlutil"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
yaml "gopkg.in/yaml.v2"
)
// MasterCommit describes the structure of the object encoded into the git
// message of a commit in the master branch.
type MasterCommit struct {
Message string `yaml:"message"`
ChangeHash yamlutil.Blob `yaml:"change_hash"`
Credentials []sigcred.Credential `yaml:"credentials"`
}
type mcYAML struct {
Val MasterCommit `yaml:",inline"`
}
func msgHead(msg string) string {
i := strings.Index(msg, "\n")
if i > 0 {
return msg[:i]
}
return msg
}
// MarshalText implements the encoding.TextMarshaler interface by returning the
// form the MasterCommit object takes in the git commit message.
func (mc MasterCommit) MarshalText() ([]byte, error) {
masterCommitEncoded, err := yaml.Marshal(mcYAML{mc})
if err != nil {
return nil, fmt.Errorf("failed to encode MasterCommit message: %w", err)
}
fullMsg := msgHead(mc.Message) + "\n\n" + string(masterCommitEncoded)
return []byte(fullMsg), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface by decoding a
// MasterCommit object which has been encoded into a git commit message.
func (mc *MasterCommit) UnmarshalText(msg []byte) error {
i := bytes.Index(msg, []byte("\n"))
if i < 0 {
return fmt.Errorf("commit message %q is malformed", msg)
}
msgHead, msg := msg[:i], msg[i:]
var mcy mcYAML
if err := yaml.Unmarshal(msg, &mcy); err != nil {
return fmt.Errorf("could not unmarshal MasterCommit message: %w", err)
}
*mc = mcy.Val
if !strings.HasPrefix(mc.Message, string(msgHead)) {
return errors.New("encoded MasterCommit is malformed, it might not be an encoded MasterCommit")
}
return nil
}
// CommitMaster constructs a MasterCommit using the given SignifierInterface to
// create a Credential for it. It returns the commit's hash after having set it
// to HEAD.
//
// TODO this method is a prototype and does not reflect the method's final form.
func (r *Repo) CommitMaster(msg, accountID string, sig sigcred.SignifierInterface) (MasterCommit, plumbing.Hash, error) {
_, headTree, err := r.head()
if errors.Is(err, plumbing.ErrReferenceNotFound) {
headTree = &object.Tree{}
} else if err != nil {
return MasterCommit{}, plumbing.ZeroHash, err
}
_, stagedTree, err := fs.FromStagedChangesTree(r.GitRepo)
if err != nil {
return MasterCommit{}, plumbing.ZeroHash, err
}
// this is necessarily different than headTree for the case of there being
// no HEAD (ie it's the first commit). In that case we want headTree to be
// empty (because it's being used to generate the change hash), but we want
// the signifier to use the raw fs (because that's where the signifier's
// data might be).
sigFS, err := r.headOrRawFS()
if err != nil {
return MasterCommit{}, plumbing.ZeroHash, err
}
cfg, err := r.loadConfig(sigFS)
if err != nil {
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("could not load config: %w", err)
}
changeHash := genChangeHash(nil, msg, headTree, stagedTree)
cred, err := sig.Sign(sigFS, changeHash)
if err != nil {
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("failed to sign commit hash: %w", err)
}
cred.AccountID = accountID
// This isn't strictly necessary, but we want to save people the effort of
// creating an invalid commit, pushing it, having it be rejected, then
// having to reset on the commit.
err = r.assertAccessControls(
cfg.AccessControls, []sigcred.Credential{cred},
headTree, stagedTree,
)
if err != nil {
return MasterCommit{}, plumbing.ZeroHash, fmt.Errorf("commit would not satisfy access controls: %w", err)
}
masterCommit := MasterCommit{
Message: msg,
ChangeHash: changeHash,
Credentials: []sigcred.Credential{cred},
}
masterCommitB, err := masterCommit.MarshalText()
if err != nil {
return masterCommit, plumbing.ZeroHash, err
}
w, err := r.GitRepo.Worktree()
if err != nil {
return masterCommit, plumbing.ZeroHash, fmt.Errorf("could not get git worktree: %w", err)
}
hash, err := w.Commit(string(masterCommitB), &git.CommitOptions{
Author: &object.Signature{
Name: accountID,
When: time.Now(),
},
})
if err != nil {
return masterCommit, hash, fmt.Errorf("failed to commit changed: %w", err)
}
return masterCommit, hash, nil
}
func (r *Repo) assertAccessControls(
accessCtls []accessctl.AccessControl, creds []sigcred.Credential,
from, to *object.Tree,
) error {
filesChanged, err := calcDiff(from, to)
if err != nil {
return err
}
pathsChanged := make([]string, len(filesChanged))
for i := range filesChanged {
pathsChanged[i] = filesChanged[i].path
}
accessCtls, err = accessctl.ApplicableAccessControls(accessCtls, pathsChanged)
if err != nil {
return fmt.Errorf("could not determine applicable access controls: %w", err)
}
for _, accessCtl := range accessCtls {
condInt, err := accessCtl.Condition.Interface()
if err != nil {
return fmt.Errorf("could not cast Condition to interface: %w", err)
} else if err := condInt.Satisfied(creds); err != nil {
return fmt.Errorf("access control for pattern %q not satisfied: %w",
accessCtl.Pattern, err)
}
}
return nil
}
// VerifyMasterCommit verifies that the commit at the given hash, which is
// presumably on the master branch, is gucci.
func (r *Repo) VerifyMasterCommit(h plumbing.Hash) error {
commit, err := r.GitRepo.CommitObject(h)
if err != nil {
return fmt.Errorf("could not retrieve commit object: %w", err)
}
commitTree, err := r.GitRepo.TreeObject(commit.TreeHash)
if err != nil {
return fmt.Errorf("could not retrieve tree object: %w", err)
}
var masterCommit MasterCommit
if err := masterCommit.UnmarshalText([]byte(commit.Message)); err != nil {
return err
}
sigTree := commitTree // only for root commit
parentTree := &object.Tree{}
if commit.NumParents() > 0 {
parent, err := commit.Parent(0)
if err != nil {
return fmt.Errorf("could not retrieve parent of commit: %w", err)
} else if parentTree, err = r.GitRepo.TreeObject(parent.TreeHash); err != nil {
return fmt.Errorf("could not retrieve tree object of parent %q: %w", parent.Hash, err)
}
sigTree = parentTree
}
sigFS := fs.FromTree(sigTree)
cfg, err := r.loadConfig(sigFS)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
err = r.assertAccessControls(
cfg.AccessControls, masterCommit.Credentials,
parentTree, commitTree,
)
if err != nil {
return fmt.Errorf("failed to satisfy all access controls: %w", err)
}
expectedChangeHash := genChangeHash(nil, masterCommit.Message, parentTree, commitTree)
if !bytes.Equal(masterCommit.ChangeHash, expectedChangeHash) {
return fmt.Errorf("malformed change_hash in commit body, is %s but should be %s",
base64.StdEncoding.EncodeToString(expectedChangeHash),
base64.StdEncoding.EncodeToString(masterCommit.ChangeHash))
}
for _, cred := range masterCommit.Credentials {
sig, err := r.signifierForCredential(sigFS, cred)
if err != nil {
return fmt.Errorf("error finding signifier for credential %+v: %w", cred, err)
} else if err := sig.Verify(sigFS, expectedChangeHash, cred); err != nil {
return fmt.Errorf("error verifying credential %+v: %w", cred, err)
}
}
// TODO access controls
return nil
}

View File

@ -1,170 +0,0 @@
package dehub
import (
"dehub/accessctl"
"dehub/sigcred"
"errors"
"reflect"
"strings"
"testing"
"github.com/davecgh/go-spew/spew"
"gopkg.in/src-d/go-git.v4/plumbing"
yaml "gopkg.in/yaml.v2"
)
func TestMasterCommitVerify(t *testing.T) {
type step struct {
msg string
msgHead string // defaults to msg
tree map[string]string
}
testCases := []struct {
descr string
steps []step
}{
{
descr: "single commit",
steps: []step{
{
msg: "first commit",
tree: map[string]string{"a": "0", "b": "1"},
},
},
},
{
descr: "multiple commits",
steps: []step{
{
msg: "first commit",
tree: map[string]string{"a": "0", "b": "1"},
},
{
msg: "second commit, changing a",
tree: map[string]string{"a": "1"},
},
{
msg: "third commit, empty",
},
{
msg: "fourth commit, adding c, removing b",
tree: map[string]string{"b": "", "c": "2"},
},
},
},
{
descr: "big body commits",
steps: []step{
{
msg: "first commit, single line but with newline\n",
},
{
msg: "second commit, single line but with two newlines\n\n",
msgHead: "second commit, single line but with two newlines\n\n",
},
{
msg: "third commit, multi-line with one newline\nanother line!",
msgHead: "third commit, multi-line with one newline\n\n",
},
{
msg: "fourth commit, multi-line with two newlines\n\nanother line!",
msgHead: "fourth commit, multi-line with two newlines\n\n",
},
},
},
}
for _, test := range testCases {
t.Run(test.descr, func(t *testing.T) {
h := newHarness(t)
for _, step := range test.steps {
h.stage(step.tree)
account := h.cfg.Accounts[0]
masterCommit, hash, err := h.repo.CommitMaster(step.msg, account.ID, h.sig)
if err != nil {
t.Fatalf("failed to make MasterCommit: %v", err)
} else if err := h.repo.VerifyMasterCommit(hash); err != nil {
t.Fatalf("could not verify hash %v: %v", hash, err)
}
commit, err := h.repo.GitRepo.CommitObject(hash)
if err != nil {
t.Fatalf("failed to retrieve commit %v: %v", hash, err)
} else if step.msgHead == "" {
step.msgHead = strings.TrimSpace(step.msg) + "\n\n"
}
if !strings.HasPrefix(commit.Message, step.msgHead) {
t.Fatalf("commit message %q does not start with expected head %q", commit.Message, step.msgHead)
}
var actualMasterCommit MasterCommit
if err := actualMasterCommit.UnmarshalText([]byte(commit.Message)); err != nil {
t.Fatalf("error unmarshaling commit body: %v", err)
} else if !reflect.DeepEqual(actualMasterCommit, masterCommit) {
t.Fatalf("returned master commit:\n%s\ndoes not match actual one:\n%s",
spew.Sdump(masterCommit), spew.Sdump(actualMasterCommit))
}
}
})
}
}
func TestConfigChange(t *testing.T) {
h := newHarness(t)
var hashes []plumbing.Hash
// commit the initial staged changes, which merely include the config and
// public key
_, hash, err := h.repo.CommitMaster("commit configuration", h.cfg.Accounts[0].ID, h.sig)
if err != nil {
t.Fatal(err)
}
hashes = append(hashes, hash)
// create a new account and add it to the configuration. It should not be
// able to actually make that commit though.
newSig, newPubKeyBody := sigcred.SignifierPGPTmp(h.rand)
h.cfg.Accounts = append(h.cfg.Accounts, Account{
ID: "toot",
Signifiers: []sigcred.Signifier{{PGPPublicKey: &sigcred.SignifierPGP{
Body: string(newPubKeyBody),
}}},
})
h.cfg.AccessControls[0].Condition.Signature.AccountIDs = []string{"root", "toot"}
h.cfg.AccessControls[0].Condition.Signature.Count = "1"
cfgBody, err := yaml.Marshal(h.cfg)
if err != nil {
t.Fatal(err)
}
h.stage(map[string]string{ConfigPath: string(cfgBody)})
_, _, err = h.repo.CommitMaster("add toot user", h.cfg.Accounts[1].ID, newSig)
if aclErr := (accessctl.ErrConditionSignatureUnsatisfied{}); !errors.As(err, &aclErr) {
t.Fatalf("CommitMaster should have returned an ErrConditionSignatureUnsatisfied, but returned %v", err)
}
// now add with the root user, this should work.
_, hash, err = h.repo.CommitMaster("add toot user", h.cfg.Accounts[0].ID, h.sig)
if err != nil {
t.Fatalf("got an unexpected error committing with root: %v", err)
}
hashes = append(hashes, hash)
// _now_ the toot user should be able to do things.
h.stage(map[string]string{"foo/bar": "what a cool file"})
_, hash, err = h.repo.CommitMaster("add a cool file", h.cfg.Accounts[1].ID, newSig)
if err != nil {
t.Fatalf("got an unexpected error committing with toot: %v", err)
}
hashes = append(hashes, hash)
for i, hash := range hashes {
if err := h.repo.VerifyMasterCommit(hash); err != nil {
t.Fatalf("commit %d (%v) should have been verified but wasn't: %v", i, hash, err)
}
}
}

View File

@ -1,83 +0,0 @@
package dehub
import (
"dehub/accessctl"
"dehub/fs"
"dehub/sigcred"
"errors"
"fmt"
yaml "gopkg.in/yaml.v2"
)
// Account represents a single account defined in the Config.
type Account struct {
ID string `yaml:"id"`
Signifiers []sigcred.Signifier `yaml:"signifiers"`
Meta map[string]string `yaml:"meta,omitempty"`
}
// Config represents the structure of the main dehub configuration file, and is
// used to marshal/unmarshal the yaml file.
type Config struct {
Accounts []Account `yaml:"accounts"`
AccessControls []accessctl.AccessControl `yaml:"access_controls"`
}
func (r *Repo) loadConfig(fs fs.FS) (Config, error) {
rc, err := fs.Open(ConfigPath)
if err != nil {
return Config{}, fmt.Errorf("could not open config.yml: %w", err)
}
defer rc.Close()
var cfg Config
if err := yaml.NewDecoder(rc).Decode(&cfg); err != nil {
return cfg, fmt.Errorf("could not decode config.yml: %w", err)
}
// TODO validate Config
return cfg, nil
}
// LoadConfig loads the Config object from the HEAD of the repo, or directly
// from the filesystem if there is no HEAD yet.
func (r *Repo) LoadConfig() (Config, error) {
headFS, err := r.headOrRawFS()
if err != nil {
return Config{}, fmt.Errorf("error retrieving repo HEAD: %w", err)
}
return r.loadConfig(headFS)
}
func (r *Repo) signifierForCredential(fs fs.FS, cred sigcred.Credential) (sigcred.SignifierInterface, error) {
cfg, err := r.loadConfig(fs)
if err != nil {
return nil, fmt.Errorf("error loading config: %w", err)
}
var account Account
var ok bool
for _, account = range cfg.Accounts {
if account.ID == cred.AccountID {
ok = true
break
}
}
if !ok {
return nil, fmt.Errorf("no account object for account id %q present in config", cred.AccountID)
}
for i, sig := range account.Signifiers {
if sigInt, err := sig.Interface(); err != nil {
return nil, fmt.Errorf("error converting signifier index:%d to inteface: %w", i, err)
} else if ok, err := sigInt.Signed(fs, cred); err != nil {
return nil, fmt.Errorf("error checking if signfier index:%d signed credential: %w", i, err)
} else if ok {
return sigInt, nil
}
}
return nil, errors.New("no signifier found for credential")
}

41
diff.go
View File

@ -1,41 +0,0 @@
package dehub
import (
"fmt"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/filemode"
"gopkg.in/src-d/go-git.v4/plumbing/object"
)
type fileChanged struct {
path string
fromMode, toMode filemode.FileMode
fromHash, toHash plumbing.Hash
}
func calcDiff(from, to *object.Tree) ([]fileChanged, error) {
changes, err := object.DiffTree(from, to)
if err != nil {
return nil, fmt.Errorf("could not calculate tree diff: %w", err)
}
filesChanged := make([]fileChanged, len(changes))
for i, change := range changes {
if from := change.From; from.Name != "" {
filesChanged[i].path = from.Name
filesChanged[i].fromMode = from.TreeEntry.Mode
filesChanged[i].fromHash = from.TreeEntry.Hash
}
if to := change.To; to.Name != "" {
if exPath := filesChanged[i].path; exPath != "" && exPath != to.Name {
panic(fmt.Sprintf("DiffTree entry changed path from %q to %q", exPath, to.Name))
}
filesChanged[i].path = to.Name
filesChanged[i].toMode = to.TreeEntry.Mode
filesChanged[i].toHash = to.TreeEntry.Hash
}
}
return filesChanged, nil
}

View File

@ -1,117 +0,0 @@
package fs
import (
"path"
"sort"
"strings"
"gopkg.in/src-d/go-billy.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/filemode"
"gopkg.in/src-d/go-git.v4/plumbing/format/index"
"gopkg.in/src-d/go-git.v4/plumbing/object"
"gopkg.in/src-d/go-git.v4/storage"
)
// This file is largely copied from the git-go project's worktree_commit.go @ v4.13.1
// buildTreeHelper converts a given index.Index file into multiple git objects
// reading the blobs from the given filesystem and creating the trees from the
// index structure. The created objects are pushed to a given Storer.
type buildTreeHelper struct {
fs billy.Filesystem
s storage.Storer
trees map[string]*object.Tree
entries map[string]*object.TreeEntry
}