Initial commit, can create master commit and verify previous master commits

message: Initial commit, can create master commit and verify previous master commits
change_hash: ADgeVBdfi1hA0TTDrBIkYHaQQYoxZaInZz1p/BAH35Ng
credentials:
- type: pgp_signature
  pub_key_id: 95C46FA6A41148AC
  body: iQIzBAABAgAdFiEEJ6tQKp6olvZKJ0lwlcRvpqQRSKwFAl5IbRgACgkQlcRvpqQRSKzWjg/+P0a3einWQ8wFUe05qXUbmMQ4K86Oa4I85pF6kubZlFy/UbcjiPnTPRMKAhmGZi4WCz1sW1F2al4qKvtq3nvn6+hZY8dj0SjPgGG2lkMMLEVy1hjsO7d9S9ZEfUv0cHOcvkphgVQk+InkegBXvFS45mwKQLDOiW5tPcTFDHTHBmC/nlCV/sKCrZEmQGU7KaELJKOf26LSY2zXe6fbVCa8njpIycYS7Wulu2OODcI5n6Ye2U6DvxN6MvuNvziyX7VMePS1xEdJYpltsNMhSkMMGLU7dovxbrhD617uwOsm1847YX9HTJ3Ixs+M0yobHmz8ob4OBcZx8r3AoiyDo+HNMmAZ96ue8pPHmI+2O9jEmbmbH61yq4crhUVAP8PncSTdq0tiYKj/zaSTJ8CT2W0uicX/3v9EtIFn0thqe/qZzHh6upixvpXDpNjZZ5SxiVm8MITnWzInQRbo9yvFsfgd7LqMGKZeGv5q5rgNTRM4fwGrJDuslwj8V2B4uw1ofPncL+LHmXArXWiewvvJFU2uRpfvsl+u4is2dl2SGVpe7ixm+a088gllOQCMRgLbuaN8dQ/eqdkfdxUg+SYQlx6vykrdJOSQrs9zaX/JuxnaNBTi/yLY1FqFXaXBGID6qX1cnPilw+J6vEZYt1MBtzXX+UEjHyVowIhMRsnts6Wq3Z8=
  account: mediocregopher
public/welcome
mediocregopher 4 years ago
commit 7c891bd5f2
  1. 14
      .dehub/config.yml
  2. 51
      .dehub/mediocregopher.asc
  3. 1
      .gitignore
  4. 195
      SPEC.md
  5. 53
      accessctl/access_control.go
  6. 118
      accessctl/access_control_test.go
  7. 130
      accessctl/condition.go
  8. 110
      accessctl/condition_test.go
  9. 75
      change_hash.go
  10. 136
      cmd/dehub/main.go
  11. 251
      commit.go
  12. 170
      commit_test.go
  13. 83
      config.go
  14. 41
      diff.go
  15. 117
      fs/build_tree_helper.go
  16. 100
      fs/fs.go
  17. 12
      go.mod
  18. 82
      go.sum
  19. 104
      repo.go
  20. 118
      repo_test.go
  21. 25
      sigcred/credential.go
  22. 279
      sigcred/pgp.go
  23. 66
      sigcred/pgp_test.go
  24. 5
      sigcred/sigcred.go
  25. 50
      sigcred/signifier.go
  26. 158
      typeobj/typeobj.go
  27. 114
      typeobj/typeobj_test.go
  28. 32
      yamlutil/yamlutil.go
  29. 55
      yamlutil/yamlutil_test.go

@ -0,0 +1,14 @@
---
accounts:
- id: mediocregopher
signifiers:
- type: pgp_public_key_file
path: ".dehub/mediocregopher.asc"
access_controls:
- pattern: "**"
condition:
type: signature
account_ids:
- mediocregopher
count: 100%

@ -0,0 +1,51 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBFKnIg4BEAC5GIlQMaXZkx/Ppt32yPztV9xhqy+NU2g9MeXnxXo6en+K7OCu
+w0DE5CRLzaq5gLOUMOL5hz+TNwWfCaO7NixYPvsTwfvb41K8kT3UrdHtOAgmEZM
RPa9PyIEFF3WPJa5QfLoYPVSKmryQprxdTxfMO05Pa8E99pVYJXENH6p/YgGj6Td
4kK1d43EzOrvTQlPtYfZnmESjQ8qqlrPfoFBCutfhNHuJu+fAR7sJa123pYEaeiu
eJZjqxuM7yihdEp+umQGl9QsFlAvzvHPE/heO26AV11f+RRqzuYklf/L2NGGkOpi
XpX6zK3YMW/8AHiXl+ev6eD65pN48yhzlvxiVCERBFhhSeK2GIsVtkk8ZLJ5w5PL
xCm3RFR8+M/gjeAmYm0zZix3uPF5AD88KU1ffKeCZrzzzOHbePpdx3t5fapUXVcv
PTNATFbCWSVUQgMztV7nhpL5B7HV2d7V1GxtrlrvR//K388Los2/mq107+0yRhSc
OkOtclgQ0d93EqmjE5gRyg7lHts2r3KKhzuUdseJoOCgazvN5dlnuUZ4WAHDyXmY
VOOtjpAmRjU7ZfTHx+rXSh76Y7gy/5Lc6PbakNs15wszGKPv6FRWV/qHmwD8HVED
CGwaxm9Q/r0YCtyBY0HTsBiBdHDSFUtmuyfDDVrgPFIBew0l+iL8lUj6dQARAQAB
tClCcmlhbiBQaWNjaWFubyA8bWVkaW9jcmVnb3BoZXJAZ21haWwuY29tPokCOQQT
AQIAIwIbAwcLCQgHAwIBBhUIAgkKCwQWAgMBAh4BAheABQJWiK/oAAoJEJXEb6ak
EUisCD8P/1FBrmfko5b5om2xUquzPmLwAyzWz4KUxAp8PGI0hMit21v/+u5yoSLC
Pr6JBikbJ+A1VjJUBtAF6uPJ8zXLkelb8jQc/leySEZ95p71+7M4XHtgtTVq8stl
Sj+N3Ccsv4KT20+c62O3CV/hQlHJeWXN/L03WyViMUW6pjIaohT7hN31djJspfZD
ZqYn/WZNfFf/InfMXLnYsyfKnf2F+/uh5t80CvaYfD/tavJYbLVxJ0baOZ+NaUh6
P9W4mBEQofoU6JVH4jHM+dOZYL6ZNDmwJDxt/6bw4LLhxm45VeCxqXsWdWsEs4zG
uaUGmnDaO1o2OThr3G6aW61RjmfaIhFpwtbNVBnqyj4Gr26gARR2qeSeo2I24mm9
QxMW3LpQfEUS2tjgty+JYGQctYFzGPYPT6YEezkmt6q3K9czhY5NVzO7H/G0V3dT
nSA9qWGHrfTvriHiCVn29bR4GD2jAXiBkpgt8T77eRwnR6CwsDMX/oL7SdiRWQzs
oGIk5pXFjKKZgRcoP/HpnknuCp9syUhn0ZL9MRMGuEp6FMB62cmrIF5uGWQR1FTk
bpP3f/2XvC54+boSeHgZsTJfYz2fVJBnWXsP9Ysnk7TcpSXbQ2XWP/M0CF17BnVc
HkJeNf7YBHELlXyhb3d5ZDfPVwvWb3wOdIpJjh/JexTM+gF5TReVuQINBFKnIg4B
EADudzImkE0O0dOLVO+OPWIGBnE3cRYEIG1IDnpJgjpyj+hpqFgzP+MfqUzhQAvv
Q0ZbB5Sb3+/ZDPoY2vQx1SXaLT4lPlyb/dKqX20VEQb2FRY9SgtA3ZV1kMexChNZ
5jGmXKz9RbJyGUz5Ms0pnozjwe5h/5iSKhT3DMNOvdNY1BOca00ycPpNUkNfX8j8
kuNc1qRzYAaNsAmGVLGhMSIi5v7C2LK0lEE313+c4eP/eWG+6Pexqjr29rVYXZ3s
99TuMcfWhJ6fbKWKmMBS0kNMZDEemoE7RAcDyprFgvSJFj+K0HCDq4F4954hpKcy
FJEY0q5beYOQ8pd/nBNsxDpWIu+/L+JYoXvfJcjCLTDi5IjMBz6PXSHYrnneb9+3
gDXUloFcF4fjadT/zFjYQKGRmj0/wf1HACs/Zht9qv4uc9MSijy/bDfRZ8vVb/wl
0MnuaDhRFlrmV7MSD0aRW6amUAtnJVhgD92WUJfQs1yIXS7ZpFEgAopJMXlEnxlv
z7UQn4dGSEqrcbk7Ge2wX9cMcVEPzFhNfFU9MNBDaXz60mILAZ8w3ukMkqb/kqVx
T+1nkbKMhbiD7iuILjyKvN8OC+qbsXckiSgv+KcS1cb0W0An5wugQgNCUrU1thN+
LShRq0NiQqQj1wC4EHs4+lRnIkP+rIz1h300mIxestpPNQARAQABiQIfBBgBAgAJ
AhsMBQJWiLBmAAoJEJXEb6akEUisCvUQAKfK1Zfzs+GsNBLnpKNVYn8xKByDZZkH
vYf0Nq1ySPX3H9wvkCJ+dXOZChG9dzIvpqaL3Twi/fGliyh6o0jKlUOkHdlxya/w
owCMQ5RMC+Z8V+HqrJczVeF6altq4rx5TDHeV9g0tkUkyA0l3r+RUBt63sNTdZLt
fLGkWCUh5yfxxsGlpVFBE7GF0uagi3/ISejotOgVfo8ZY8RMVK0UTB5fNXwH/Clh
t31JWgNTotUoacokufWiEVJ0yzWLXJ+bDIvX2nrEPUJG8yxyeVbzCVEAX0qwxE81
XR/BTyJLbzGrE+uhG7H6noM8wkoM+N1tHiRv3B1x++YCyuMQ98sBFZUaln+OO1G9
RuW5M6+RQdDE/XlH27WTbHLWtPZkcNS25YTleY89bLvOguKc0Gw5O3k66rB8r/xO
O0lgnEI0I7EJLTODO++f4iyoUmPkDknXe5PoBbHhQqe9QWbzh2U5/B8Abn7zxSJu
Hc4CGlHq+ztlnbaWWvtBe9t29o8iH+wGiHi+qqT9RNr97NDONMt2U5xbsbmaFvxN
GhO2r3xQsROxHhPO6nHWFThBE+GjUdBQAKUV2UsK/dttap8DjIgfb1rfJxPMQShF
TQkMRx4JkFjWH5QripM+uFYPtChI3yZaf85s4r6pHkLSL/qhitWoW2hu43La5hrV
uIQiD0KB791m
=OXq+
-----END PGP PUBLIC KEY BLOCK-----

1
.gitignore vendored

@ -0,0 +1 @@
dehub

@ -0,0 +1,195 @@
# .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.

@ -0,0 +1,53 @@
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
}

@ -0,0 +1,118 @@
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)
}
})
}
}

@ -0,0 +1,130 @@
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
}

@ -0,0 +1,110 @@
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)
}
})
}
}

@ -0,0 +1,75 @@
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)
}

@ -0,0 +1,136 @@
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()
}

@ -0,0 +1,251 @@
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
}

@ -0,0 +1,170 @@
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)
}
}
}

@ -0,0 +1,83 @@
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")
}

@ -0,0 +1,41 @@
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
}
<