diff options
author | Michael Muré <batolettre@gmail.com> | 2022-08-18 23:34:05 +0200 |
---|---|---|
committer | Michael Muré <batolettre@gmail.com> | 2022-08-18 23:44:06 +0200 |
commit | 5511c230b678a181cc596238bf6669428d1b1902 (patch) | |
tree | 8701efc87732439f993eb4f1d00585fc419b87ab /entities/identity | |
parent | 5ca686b59751e3c87740b84108c54fc675a074cf (diff) | |
download | git-bug-5511c230b678a181cc596238bf6669428d1b1902.tar.gz |
move {bug,identity} to /entities, move input to /commands
Diffstat (limited to 'entities/identity')
-rw-r--r-- | entities/identity/common.go | 37 | ||||
-rw-r--r-- | entities/identity/identity.go | 620 | ||||
-rw-r--r-- | entities/identity/identity_actions.go | 125 | ||||
-rw-r--r-- | entities/identity/identity_actions_test.go | 157 | ||||
-rw-r--r-- | entities/identity/identity_stub.go | 101 | ||||
-rw-r--r-- | entities/identity/identity_stub_test.go | 26 | ||||
-rw-r--r-- | entities/identity/identity_test.go | 292 | ||||
-rw-r--r-- | entities/identity/identity_user.go | 68 | ||||
-rw-r--r-- | entities/identity/interface.go | 62 | ||||
-rw-r--r-- | entities/identity/key.go | 234 | ||||
-rw-r--r-- | entities/identity/key_test.go | 60 | ||||
-rw-r--r-- | entities/identity/resolver.go | 34 | ||||
-rw-r--r-- | entities/identity/version.go | 273 | ||||
-rw-r--r-- | entities/identity/version_test.go | 78 |
14 files changed, 2167 insertions, 0 deletions
diff --git a/entities/identity/common.go b/entities/identity/common.go new file mode 100644 index 00000000..5c6445e9 --- /dev/null +++ b/entities/identity/common.go @@ -0,0 +1,37 @@ +package identity + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/MichaelMure/git-bug/entity" +) + +var ErrIdentityNotExist = errors.New("identity doesn't exist") + +func NewErrMultipleMatch(matching []entity.Id) *entity.ErrMultipleMatch { + return entity.NewErrMultipleMatch("identity", matching) +} + +// Custom unmarshaling function to allow package user to delegate +// the decoding of an Identity and distinguish between an Identity +// and a Bare. +// +// If the given message has a "id" field, it's considered being a proper Identity. +func UnmarshalJSON(raw json.RawMessage) (Interface, error) { + aux := &IdentityStub{} + + // First try to decode and load as a normal Identity + err := json.Unmarshal(raw, &aux) + if err == nil && aux.Id() != "" { + return aux, nil + } + + // abort if we have an error other than the wrong type + if _, ok := err.(*json.UnmarshalTypeError); err != nil && !ok { + return nil, err + } + + return nil, fmt.Errorf("unknown identity type") +} diff --git a/entities/identity/identity.go b/entities/identity/identity.go new file mode 100644 index 00000000..0a7642af --- /dev/null +++ b/entities/identity/identity.go @@ -0,0 +1,620 @@ +// Package identity contains the identity data model and low-level related functions +package identity + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +const identityRefPattern = "refs/identities/" +const identityRemoteRefPattern = "refs/remotes/%s/identities/" +const versionEntryName = "version" +const identityConfigKey = "git-bug.identity" + +var ErrNonFastForwardMerge = errors.New("non fast-forward identity merge") +var ErrNoIdentitySet = errors.New("No identity is set.\n" + + "To interact with bugs, an identity first needs to be created using " + + "\"git bug user create\"") +var ErrMultipleIdentitiesSet = errors.New("multiple user identities set") + +func NewErrMultipleMatchIdentity(matching []entity.Id) *entity.ErrMultipleMatch { + return entity.NewErrMultipleMatch("identity", matching) +} + +var _ Interface = &Identity{} +var _ entity.Interface = &Identity{} + +type Identity struct { + // all the successive version of the identity + versions []*version +} + +func NewIdentity(repo repository.RepoClock, name string, email string) (*Identity, error) { + return NewIdentityFull(repo, name, email, "", "", nil) +} + +func NewIdentityFull(repo repository.RepoClock, name string, email string, login string, avatarUrl string, keys []*Key) (*Identity, error) { + v, err := newVersion(repo, name, email, login, avatarUrl, keys) + if err != nil { + return nil, err + } + return &Identity{ + versions: []*version{v}, + }, nil +} + +// NewFromGitUser will query the repository for user detail and +// build the corresponding Identity +func NewFromGitUser(repo repository.ClockedRepo) (*Identity, error) { + name, err := repo.GetUserName() + if err != nil { + return nil, err + } + if name == "" { + return nil, errors.New("user name is not configured in git yet. Please use `git config --global user.name \"John Doe\"`") + } + + email, err := repo.GetUserEmail() + if err != nil { + return nil, err + } + if email == "" { + return nil, errors.New("user name is not configured in git yet. Please use `git config --global user.email johndoe@example.com`") + } + + return NewIdentity(repo, name, email) +} + +// MarshalJSON will only serialize the id +func (i *Identity) MarshalJSON() ([]byte, error) { + return json.Marshal(&IdentityStub{ + id: i.Id(), + }) +} + +// UnmarshalJSON will only read the id +// Users of this package are expected to run Load() to load +// the remaining data from the identities data in git. +func (i *Identity) UnmarshalJSON(data []byte) error { + panic("identity should be loaded with identity.UnmarshalJSON") +} + +// ReadLocal load a local Identity from the identities data available in git +func ReadLocal(repo repository.Repo, id entity.Id) (*Identity, error) { + ref := fmt.Sprintf("%s%s", identityRefPattern, id) + return read(repo, ref) +} + +// ReadRemote load a remote Identity from the identities data available in git +func ReadRemote(repo repository.Repo, remote string, id string) (*Identity, error) { + ref := fmt.Sprintf(identityRemoteRefPattern, remote) + id + return read(repo, ref) +} + +// read will load and parse an identity from git +func read(repo repository.Repo, ref string) (*Identity, error) { + id := entity.RefToId(ref) + + if err := id.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid ref") + } + + hashes, err := repo.ListCommits(ref) + if err != nil { + return nil, ErrIdentityNotExist + } + if len(hashes) == 0 { + return nil, fmt.Errorf("empty identity") + } + + i := &Identity{} + + for _, hash := range hashes { + entries, err := repo.ReadTree(hash) + if err != nil { + return nil, errors.Wrap(err, "can't list git tree entries") + } + if len(entries) != 1 { + return nil, fmt.Errorf("invalid identity data at hash %s", hash) + } + + entry := entries[0] + if entry.Name != versionEntryName { + return nil, fmt.Errorf("invalid identity data at hash %s", hash) + } + + data, err := repo.ReadData(entry.Hash) + if err != nil { + return nil, errors.Wrap(err, "failed to read git blob data") + } + + var version version + err = json.Unmarshal(data, &version) + if err != nil { + return nil, errors.Wrapf(err, "failed to decode Identity version json %s", hash) + } + + // tag the version with the commit hash + version.commitHash = hash + + i.versions = append(i.versions, &version) + } + + if id != i.versions[0].Id() { + return nil, fmt.Errorf("identity ID doesn't math the first version ID") + } + + return i, nil +} + +// ListLocalIds list all the available local identity ids +func ListLocalIds(repo repository.Repo) ([]entity.Id, error) { + refs, err := repo.ListRefs(identityRefPattern) + if err != nil { + return nil, err + } + + return entity.RefsToIds(refs), nil +} + +// RemoveIdentity will remove a local identity from its entity.Id +func RemoveIdentity(repo repository.ClockedRepo, id entity.Id) error { + var fullMatches []string + + refs, err := repo.ListRefs(identityRefPattern + id.String()) + if err != nil { + return err + } + if len(refs) > 1 { + return NewErrMultipleMatchIdentity(entity.RefsToIds(refs)) + } + if len(refs) == 1 { + // we have the identity locally + fullMatches = append(fullMatches, refs[0]) + } + + remotes, err := repo.GetRemotes() + if err != nil { + return err + } + + for remote := range remotes { + remotePrefix := fmt.Sprintf(identityRemoteRefPattern+id.String(), remote) + remoteRefs, err := repo.ListRefs(remotePrefix) + if err != nil { + return err + } + if len(remoteRefs) > 1 { + return NewErrMultipleMatchIdentity(entity.RefsToIds(refs)) + } + if len(remoteRefs) == 1 { + // found the identity in a remote + fullMatches = append(fullMatches, remoteRefs[0]) + } + } + + if len(fullMatches) == 0 { + return ErrIdentityNotExist + } + + for _, ref := range fullMatches { + err = repo.RemoveRef(ref) + if err != nil { + return err + } + } + + return nil +} + +type StreamedIdentity struct { + Identity *Identity + Err error +} + +// ReadAllLocal read and parse all local Identity +func ReadAllLocal(repo repository.ClockedRepo) <-chan StreamedIdentity { + return readAll(repo, identityRefPattern) +} + +// ReadAllRemote read and parse all remote Identity for a given remote +func ReadAllRemote(repo repository.ClockedRepo, remote string) <-chan StreamedIdentity { + refPrefix := fmt.Sprintf(identityRemoteRefPattern, remote) + return readAll(repo, refPrefix) +} + +// readAll read and parse all available bug with a given ref prefix +func readAll(repo repository.ClockedRepo, refPrefix string) <-chan StreamedIdentity { + out := make(chan StreamedIdentity) + + go func() { + defer close(out) + + refs, err := repo.ListRefs(refPrefix) + if err != nil { + out <- StreamedIdentity{Err: err} + return + } + + for _, ref := range refs { + b, err := read(repo, ref) + + if err != nil { + out <- StreamedIdentity{Err: err} + return + } + + out <- StreamedIdentity{Identity: b} + } + }() + + return out +} + +type Mutator struct { + Name string + Login string + Email string + AvatarUrl string + Keys []*Key +} + +// Mutate allow to create a new version of the Identity in one go +func (i *Identity) Mutate(repo repository.RepoClock, f func(orig *Mutator)) error { + copyKeys := func(keys []*Key) []*Key { + result := make([]*Key, len(keys)) + for i, key := range keys { + result[i] = key.Clone() + } + return result + } + + orig := Mutator{ + Name: i.Name(), + Email: i.Email(), + Login: i.Login(), + AvatarUrl: i.AvatarUrl(), + Keys: copyKeys(i.Keys()), + } + mutated := orig + mutated.Keys = copyKeys(orig.Keys) + + f(&mutated) + + if reflect.DeepEqual(orig, mutated) { + return nil + } + + v, err := newVersion(repo, + mutated.Name, + mutated.Email, + mutated.Login, + mutated.AvatarUrl, + mutated.Keys, + ) + if err != nil { + return err + } + + i.versions = append(i.versions, v) + return nil +} + +// Write the identity into the Repository. In particular, this ensure that +// the Id is properly set. +func (i *Identity) Commit(repo repository.ClockedRepo) error { + if !i.NeedCommit() { + return fmt.Errorf("can't commit an identity with no pending version") + } + + if err := i.Validate(); err != nil { + return errors.Wrap(err, "can't commit an identity with invalid data") + } + + var lastCommit repository.Hash + for _, v := range i.versions { + if v.commitHash != "" { + lastCommit = v.commitHash + // ignore already commit versions + continue + } + + blobHash, err := v.Write(repo) + if err != nil { + return err + } + + // Make a git tree referencing the blob + tree := []repository.TreeEntry{ + {ObjectType: repository.Blob, Hash: blobHash, Name: versionEntryName}, + } + + treeHash, err := repo.StoreTree(tree) + if err != nil { + return err + } + + var commitHash repository.Hash + if lastCommit != "" { + commitHash, err = repo.StoreCommit(treeHash, lastCommit) + } else { + commitHash, err = repo.StoreCommit(treeHash) + } + if err != nil { + return err + } + + lastCommit = commitHash + v.commitHash = commitHash + } + + ref := fmt.Sprintf("%s%s", identityRefPattern, i.Id().String()) + return repo.UpdateRef(ref, lastCommit) +} + +func (i *Identity) CommitAsNeeded(repo repository.ClockedRepo) error { + if !i.NeedCommit() { + return nil + } + return i.Commit(repo) +} + +func (i *Identity) NeedCommit() bool { + for _, v := range i.versions { + if v.commitHash == "" { + return true + } + } + + return false +} + +// Merge will merge a different version of the same Identity +// +// To make sure that an Identity history can't be altered, a strict fast-forward +// only policy is applied here. As an Identity should be tied to a single user, this +// should work in practice, but it does leave a possibility that a user would edit his +// Identity from two different repo concurrently and push the changes in a non-centralized +// network of repositories. In this case, it would result in some repo accepting one +// version and some other accepting another, preventing the network in general to converge +// to the same result. This would create a sort of partition of the network, and manual +// cleaning would be required. +// +// An alternative approach would be to have a determinist rebase: +// - any commits present in both local and remote version would be kept, never changed. +// - newer commits would be merged in a linear chain of commits, ordered based on the +// Lamport time +// +// However, this approach leave the possibility, in the case of a compromised crypto keys, +// of forging a new version with a bogus Lamport time to be inserted before a legit version, +// invalidating the correct version and hijacking the Identity. There would only be a short +// period of time when this would be possible (before the network converge) but I'm not +// confident enough to implement that. I choose the strict fast-forward only approach, +// despite its potential problem with two different version as mentioned above. +func (i *Identity) Merge(repo repository.Repo, other *Identity) (bool, error) { + if i.Id() != other.Id() { + return false, errors.New("merging unrelated identities is not supported") + } + + modified := false + var lastCommit repository.Hash + for j, otherVersion := range other.versions { + // if there is more version in other, take them + if len(i.versions) == j { + i.versions = append(i.versions, otherVersion) + lastCommit = otherVersion.commitHash + modified = true + } + + // we have a non fast-forward merge. + // as explained in the doc above, refusing to merge + if i.versions[j].commitHash != otherVersion.commitHash { + return false, ErrNonFastForwardMerge + } + } + + if modified { + err := repo.UpdateRef(identityRefPattern+i.Id().String(), lastCommit) + if err != nil { + return false, err + } + } + + return false, nil +} + +// Validate check if the Identity data is valid +func (i *Identity) Validate() error { + lastTimes := make(map[string]lamport.Time) + + if len(i.versions) == 0 { + return fmt.Errorf("no version") + } + + for _, v := range i.versions { + if err := v.Validate(); err != nil { + return err + } + + // check for always increasing lamport time + // check that a new version didn't drop a clock + for name, previous := range lastTimes { + if now, ok := v.times[name]; ok { + if now < previous { + return fmt.Errorf("non-chronological lamport clock %s (%d --> %d)", name, previous, now) + } + } else { + return fmt.Errorf("version has less lamport clocks than before (missing %s)", name) + } + } + + for name, now := range v.times { + lastTimes[name] = now + } + } + + return nil +} + +func (i *Identity) lastVersion() *version { + if len(i.versions) <= 0 { + panic("no version at all") + } + + return i.versions[len(i.versions)-1] +} + +// Id return the Identity identifier +func (i *Identity) Id() entity.Id { + // id is the id of the first version + return i.versions[0].Id() +} + +// Name return the last version of the name +func (i *Identity) Name() string { + return i.lastVersion().name +} + +// DisplayName return a non-empty string to display, representing the +// identity, based on the non-empty values. +func (i *Identity) DisplayName() string { + switch { + case i.Name() == "" && i.Login() != "": + return i.Login() + case i.Name() != "" && i.Login() == "": + return i.Name() + case i.Name() != "" && i.Login() != "": + return fmt.Sprintf("%s (%s)", i.Name(), i.Login()) + } + + panic("invalid person data") +} + +// Email return the last version of the email +func (i *Identity) Email() string { + return i.lastVersion().email +} + +// Login return the last version of the login +func (i *Identity) Login() string { + return i.lastVersion().login +} + +// AvatarUrl return the last version of the Avatar URL +func (i *Identity) AvatarUrl() string { + return i.lastVersion().avatarURL +} + +// Keys return the last version of the valid keys +func (i *Identity) Keys() []*Key { + return i.lastVersion().keys +} + +// SigningKey return the key that should be used to sign new messages. If no key is available, return nil. +func (i *Identity) SigningKey(repo repository.RepoKeyring) (*Key, error) { + keys := i.Keys() + for _, key := range keys { + err := key.ensurePrivateKey(repo) + if err == errNoPrivateKey { + continue + } + if err != nil { + return nil, err + } + return key, nil + } + return nil, nil +} + +// ValidKeysAtTime return the set of keys valid at a given lamport time +func (i *Identity) ValidKeysAtTime(clockName string, time lamport.Time) []*Key { + var result []*Key + + var lastTime lamport.Time + for _, v := range i.versions { + refTime, ok := v.times[clockName] + if !ok { + refTime = lastTime + } + lastTime = refTime + + if refTime > time { + return result + } + + result = v.keys + } + + return result +} + +// LastModification return the timestamp at which the last version of the identity became valid. +func (i *Identity) LastModification() timestamp.Timestamp { + return timestamp.Timestamp(i.lastVersion().unixTime) +} + +// LastModificationLamports return the lamport times at which the last version of the identity became valid. +func (i *Identity) LastModificationLamports() map[string]lamport.Time { + return i.lastVersion().times +} + +// IsProtected return true if the chain of git commits started to be signed. +// If that's the case, only signed commit with a valid key for this identity can be added. +func (i *Identity) IsProtected() bool { + // Todo + return false +} + +// SetMetadata store arbitrary metadata along the last not-commit version. +// If the version has been commit to git already, a new identical version is added and will need to be +// commit. +func (i *Identity) SetMetadata(key string, value string) { + // once commit, data is immutable so we create a new version + if i.lastVersion().commitHash != "" { + i.versions = append(i.versions, i.lastVersion().Clone()) + } + // if Id() has been called, we can't change the first version anymore, so we create a new version + if len(i.versions) == 1 && i.versions[0].id != entity.UnsetId && i.versions[0].id != "" { + i.versions = append(i.versions, i.lastVersion().Clone()) + } + + i.lastVersion().SetMetadata(key, value) +} + +// ImmutableMetadata return all metadata for this Identity, accumulated from each version. +// If multiple value are found, the first defined takes precedence. +func (i *Identity) ImmutableMetadata() map[string]string { + metadata := make(map[string]string) + + for _, version := range i.versions { + for key, value := range version.metadata { + if _, has := metadata[key]; !has { + metadata[key] = value + } + } + } + + return metadata +} + +// MutableMetadata return all metadata for this Identity, accumulated from each version. +// If multiple value are found, the last defined takes precedence. +func (i *Identity) MutableMetadata() map[string]string { + metadata := make(map[string]string) + + for _, version := range i.versions { + for key, value := range version.metadata { + metadata[key] = value + } + } + + return metadata +} diff --git a/entities/identity/identity_actions.go b/entities/identity/identity_actions.go new file mode 100644 index 00000000..b58bb2d9 --- /dev/null +++ b/entities/identity/identity_actions.go @@ -0,0 +1,125 @@ +package identity + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +// Fetch retrieve updates from a remote +// This does not change the local identities state +func Fetch(repo repository.Repo, remote string) (string, error) { + return repo.FetchRefs(remote, "identities") +} + +// Push update a remote with the local changes +func Push(repo repository.Repo, remote string) (string, error) { + return repo.PushRefs(remote, "identities") +} + +// Pull will do a Fetch + MergeAll +// This function will return an error if a merge fail +func Pull(repo repository.ClockedRepo, remote string) error { + _, err := Fetch(repo, remote) + if err != nil { + return err + } + + for merge := range MergeAll(repo, remote) { + if merge.Err != nil { + return merge.Err + } + if merge.Status == entity.MergeStatusInvalid { + return errors.Errorf("merge failure: %s", merge.Reason) + } + } + + return nil +} + +// MergeAll will merge all the available remote identity +func MergeAll(repo repository.ClockedRepo, remote string) <-chan entity.MergeResult { + out := make(chan entity.MergeResult) + + go func() { + defer close(out) + + remoteRefSpec := fmt.Sprintf(identityRemoteRefPattern, remote) + remoteRefs, err := repo.ListRefs(remoteRefSpec) + + if err != nil { + out <- entity.MergeResult{Err: err} + return + } + + for _, remoteRef := range remoteRefs { + refSplit := strings.Split(remoteRef, "/") + id := entity.Id(refSplit[len(refSplit)-1]) + + if err := id.Validate(); err != nil { + out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "invalid ref").Error()) + continue + } + + remoteIdentity, err := read(repo, remoteRef) + + if err != nil { + out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is not readable").Error()) + continue + } + + // Check for error in remote data + if err := remoteIdentity.Validate(); err != nil { + out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "remote identity is invalid").Error()) + continue + } + + localRef := identityRefPattern + remoteIdentity.Id().String() + localExist, err := repo.RefExist(localRef) + + if err != nil { + out <- entity.NewMergeError(err, id) + continue + } + + // the identity is not local yet, simply create the reference + if !localExist { + err := repo.CopyRef(remoteRef, localRef) + + if err != nil { + out <- entity.NewMergeError(err, id) + return + } + + out <- entity.NewMergeNewStatus(id, remoteIdentity) + continue + } + + localIdentity, err := read(repo, localRef) + + if err != nil { + out <- entity.NewMergeError(errors.Wrap(err, "local identity is not readable"), id) + return + } + + updated, err := localIdentity.Merge(repo, remoteIdentity) + + if err != nil { + out <- entity.NewMergeInvalidStatus(id, errors.Wrap(err, "merge failed").Error()) + return + } + + if updated { + out <- entity.NewMergeUpdatedStatus(id, localIdentity) + } else { + out <- entity.NewMergeNothingStatus(id) + } + } + }() + + return out +} diff --git a/entities/identity/identity_actions_test.go b/entities/identity/identity_actions_test.go new file mode 100644 index 00000000..351fb7a4 --- /dev/null +++ b/entities/identity/identity_actions_test.go @@ -0,0 +1,157 @@ +package identity + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/repository" +) + +func TestIdentityPushPull(t *testing.T) { + repoA, repoB, _ := repository.SetupGoGitReposAndRemote(t) + + identity1, err := NewIdentity(repoA, "name1", "email1") + require.NoError(t, err) + err = identity1.Commit(repoA) + require.NoError(t, err) + + // A --> remote --> B + _, err = Push(repoA, "origin") + require.NoError(t, err) + + err = Pull(repoB, "origin") + require.NoError(t, err) + + identities := allIdentities(t, ReadAllLocal(repoB)) + + if len(identities) != 1 { + t.Fatal("Unexpected number of bugs") + } + + // B --> remote --> A + identity2, err := NewIdentity(repoB, "name2", "email2") + require.NoError(t, err) + err = identity2.Commit(repoB) + require.NoError(t, err) + + _, err = Push(repoB, "origin") + require.NoError(t, err) + + err = Pull(repoA, "origin") + require.NoError(t, err) + + identities = allIdentities(t, ReadAllLocal(repoA)) + + if len(identities) != 2 { + t.Fatal("Unexpected number of bugs") + } + + // Update both + + err = identity1.Mutate(repoA, func(orig *Mutator) { + orig.Name = "name1b" + orig.Email = "email1b" + }) + require.NoError(t, err) + err = identity1.Commit(repoA) + require.NoError(t, err) + + err = identity2.Mutate(repoB, func(orig *Mutator) { + orig.Name = "name2b" + orig.Email = "email2b" + }) + require.NoError(t, err) + err = identity2.Commit(repoB) + require.NoError(t, err) + + // A --> remote --> B + + _, err = Push(repoA, "origin") + require.NoError(t, err) + + err = Pull(repoB, "origin") + require.NoError(t, err) + + identities = allIdentities(t, ReadAllLocal(repoB)) + + if len(identities) != 2 { + t.Fatal("Unexpected number of bugs") + } + + // B --> remote --> A + + _, err = Push(repoB, "origin") + require.NoError(t, err) + + err = Pull(repoA, "origin") + require.NoError(t, err) + + identities = allIdentities(t, ReadAllLocal(repoA)) + + if len(identities) != 2 { + t.Fatal("Unexpected number of bugs") + } + + // Concurrent update + + err = identity1.Mutate(repoA, func(orig *Mutator) { + orig.Name = "name1c" + orig.Email = "email1c" + }) + require.NoError(t, err) + err = identity1.Commit(repoA) + require.NoError(t, err) + + identity1B, err := ReadLocal(repoB, identity1.Id()) + require.NoError(t, err) + + err = identity1B.Mutate(repoB, func(orig *Mutator) { + orig.Name = "name1concurrent" + orig.Email = "name1concurrent" + }) + require.NoError(t, err) + err = identity1B.Commit(repoB) + require.NoError(t, err) + + // A --> remote --> B + + _, err = Push(repoA, "origin") + require.NoError(t, err) + + // Pulling a non-fast-forward update should fail + err = Pull(repoB, "origin") + require.Error(t, err) + + identities = allIdentities(t, ReadAllLocal(repoB)) + + if len(identities) != 2 { + t.Fatal("Unexpected number of bugs") + } + + // B --> remote --> A + + // Pushing a non-fast-forward update should fail + _, err = Push(repoB, "origin") + require.Error(t, err) + + err = Pull(repoA, "origin") + require.NoError(t, err) + + identities = allIdentities(t, ReadAllLocal(repoA)) + + if len(identities) != 2 { + t.Fatal("Unexpected number of bugs") + } +} + +func allIdentities(t testing.TB, identities <-chan StreamedIdentity) []*Identity { + var result []*Identity + for streamed := range identities { + if streamed.Err != nil { + t.Fatal(streamed.Err) + } + result = append(result, streamed.Identity) + } + return result +} diff --git a/entities/identity/identity_stub.go b/entities/identity/identity_stub.go new file mode 100644 index 00000000..fb5c90a5 --- /dev/null +++ b/entities/identity/identity_stub.go @@ -0,0 +1,101 @@ +package identity + +import ( + "encoding/json" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +var _ Interface = &IdentityStub{} + +// IdentityStub is an almost empty Identity, holding only the id. +// When a normal Identity is serialized into JSON, only the id is serialized. +// All the other data are stored in git in a chain of commit + a ref. +// When this JSON is deserialized, an IdentityStub is returned instead, to be replaced +// later by the proper Identity, loaded from the Repo. +type IdentityStub struct { + id entity.Id +} + +func (i *IdentityStub) MarshalJSON() ([]byte, error) { + // TODO: add a type marker + return json.Marshal(struct { + Id entity.Id `json:"id"` + }{ + Id: i.id, + }) +} + +func (i *IdentityStub) UnmarshalJSON(data []byte) error { + aux := struct { + Id entity.Id `json:"id"` + }{} + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + i.id = aux.Id + + return nil +} + +// Id return the Identity identifier +func (i *IdentityStub) Id() entity.Id { + return i.id +} + +func (IdentityStub) Name() string { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) DisplayName() string { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) Email() string { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) Login() string { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) AvatarUrl() string { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) Keys() []*Key { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (i *IdentityStub) SigningKey(repo repository.RepoKeyring) (*Key, error) { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) ValidKeysAtTime(_ string, _ lamport.Time) []*Key { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (i *IdentityStub) LastModification() timestamp.Timestamp { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (i *IdentityStub) LastModificationLamports() map[string]lamport.Time { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) IsProtected() bool { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (IdentityStub) Validate() error { + panic("identities needs to be properly loaded with identity.ReadLocal()") +} + +func (i *IdentityStub) NeedCommit() bool { + return false +} diff --git a/entities/identity/identity_stub_test.go b/entities/identity/identity_stub_test.go new file mode 100644 index 00000000..b01a718c --- /dev/null +++ b/entities/identity/identity_stub_test.go @@ -0,0 +1,26 @@ +package identity + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIdentityStubSerialize(t *testing.T) { + before := &IdentityStub{ + id: "id1234", + } + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after IdentityStub + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + // enforce creating the Id + before.Id() + + assert.Equal(t, before, &after) +} diff --git a/entities/identity/identity_test.go b/entities/identity/identity_test.go new file mode 100644 index 00000000..f0c3bbe9 --- /dev/null +++ b/entities/identity/identity_test.go @@ -0,0 +1,292 @@ +package identity + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" +) + +// Test the commit and load of an Identity with multiple versions +func TestIdentityCommitLoad(t *testing.T) { + repo := makeIdentityTestRepo(t) + + // single version + + identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com") + require.NoError(t, err) + + idBeforeCommit := identity.Id() + + err = identity.Commit(repo) + require.NoError(t, err) + + commitsAreSet(t, identity) + require.NotEmpty(t, identity.Id()) + require.Equal(t, idBeforeCommit, identity.Id()) + require.Equal(t, idBeforeCommit, identity.versions[0].Id()) + + loaded, err := ReadLocal(repo, identity.Id()) + require.NoError(t, err) + commitsAreSet(t, loaded) + require.Equal(t, identity, loaded) + + // multiple versions + + identity, err = NewIdentityFull(repo, "René Descartes", "rene.descartes@example.com", "", "", []*Key{generatePublicKey()}) + require.NoError(t, err) + + idBeforeCommit = identity.Id() + + err = identity.Mutate(repo, func(orig *Mutator) { + orig.Keys = []*Key{generatePublicKey()} + }) + require.NoError(t, err) + + err = identity.Mutate(repo, func(orig *Mutator) { + orig.Keys = []*Key{generatePublicKey()} + }) + require.NoError(t, err) + + require.Equal(t, idBeforeCommit, identity.Id()) + + err = identity.Commit(repo) + require.NoError(t, err) + + commitsAreSet(t, identity) + require.NotEmpty(t, identity.Id()) + require.Equal(t, idBeforeCommit, identity.Id()) + require.Equal(t, idBeforeCommit, identity.versions[0].Id()) + + loaded, err = ReadLocal(repo, identity.Id()) + require.NoError(t, err) + commitsAreSet(t, loaded) + require.Equal(t, identity, loaded) + + // add more version + + err = identity.Mutate(repo, func(orig *Mutator) { + orig.Email = "rene@descartes.com" + orig.Keys = []*Key{generatePublicKey()} + }) + require.NoError(t, err) + + err = identity.Mutate(repo, func(orig *Mutator) { + orig.Email = "rene@descartes.com" + orig.Keys = []*Key{generatePublicKey(), generatePublicKey()} + }) + require.NoError(t, err) + + err = identity.Commit(repo) + require.NoError(t, err) + + commitsAreSet(t, identity) + require.NotEmpty(t, identity.Id()) + require.Equal(t, idBeforeCommit, identity.Id()) + require.Equal(t, idBeforeCommit, identity.versions[0].Id()) + + loaded, err = ReadLocal(repo, identity.Id()) + require.NoError(t, err) + commitsAreSet(t, loaded) + require.Equal(t, identity, loaded) +} + +func TestIdentityMutate(t *testing.T) { + repo := makeIdentityTestRepo(t) + + identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com") + require.NoError(t, err) + + require.Len(t, identity.versions, 1) + + err = identity.Mutate(repo, func(orig *Mutator) { + orig.Email = "rene@descartes.fr" + orig.Name = "René" + orig.Login = "rene" + }) + require.NoError(t, err) + + require.Len(t, identity.versions, 2) + require.Equal(t, identity.Email(), "rene@descartes.fr") + require.Equal(t, identity.Name(), "René") + require.Equal(t, identity.Login(), "rene") +} + +func commitsAreSet(t *testing.T, identity *Identity) { + for _, version := range identity.versions { + require.NotEmpty(t, version.commitHash) + } +} + +// Test that the correct crypto keys are returned for a given lamport time +func TestIdentity_ValidKeysAtTime(t *testing.T) { + pubKeyA := generatePublicKey() + pubKeyB := generatePublicKey() + pubKeyC := generatePublicKey() + pubKeyD := generatePublicKey() + pubKeyE := generatePublicKey() + + identity := Identity{ + versions: []*version{ + { + times: map[string]lamport.Time{"foo": 100}, + keys: []*Key{pubKeyA}, + }, + { + times: map[string]lamport.Time{"foo": 200}, + keys: []*Key{pubKeyB}, + }, + { + times: map[string]lamport.Time{"foo": 201}, + keys: []*Key{pubKeyC}, + }, + { + times: map[string]lamport.Time{"foo": 201}, + keys: []*Key{pubKeyD}, + }, + { + times: map[string]lamport.Time{"foo": 300}, + keys: []*Key{pubKeyE}, + }, + }, + } + + require.Nil(t, identity.ValidKeysAtTime("foo", 10)) + require.Equal(t, identity.ValidKeysAtTime("foo", 100), []*Key{pubKeyA}) + require.Equal(t, identity.ValidKeysAtTime("foo", 140), []*Key{pubKeyA}) + require.Equal(t, identity.ValidKeysAtTime("foo", 200), []*Key{pubKeyB}) + require.Equal(t, identity.ValidKeysAtTime("foo", 201), []*Key{pubKeyD}) + require.Equal(t, identity.ValidKeysAtTime("foo", 202), []*Key{pubKeyD}) + require.Equal(t, identity.ValidKeysAtTime("foo", 300), []*Key{pubKeyE}) + require.Equal(t, identity.ValidKeysAtTime("foo", 3000), []*Key{pubKeyE}) +} + +// Test the immutable or mutable metadata search +func TestMetadata(t *testing.T) { + repo := makeIdentityTestRepo(t) + + identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com") + require.NoError(t, err) + + identity.SetMetadata("key1", "value1") + assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1") + assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1") + + err = identity.Commit(repo) + require.NoError(t, err) + + assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1") + assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1") + + // try override + err = identity.Mutate(repo, func(orig *Mutator) { + orig.Email = "rene@descartes.fr" + }) + require.NoError(t, err) + + identity.SetMetadata("key1", "value2") + assertHasKeyValue(t, identity.ImmutableMetadata(), "key1", "value1") + assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value2") + + err = identity.Commit(repo) + require.NoError(t, err) + + // reload + loaded, err := ReadLocal(repo, identity.Id()) + require.NoError(t, err) + + assertHasKeyValue(t, loaded.ImmutableMetadata(), "key1", "value1") + assertHasKeyValue(t, loaded.MutableMetadata(), "key1", "value2") + + // set metadata after commit + versionCount := len(identity.versions) + identity.SetMetadata("foo", "bar") + require.True(t, identity.NeedCommit()) + require.Len(t, identity.versions, versionCount+1) + + err = identity.Commit(repo) + require.NoError(t, err) + require.Len(t, identity.versions, versionCount+1) +} + +func assertHasKeyValue(t *testing.T, metadata map[string]string, key, value string) { + val, ok := metadata[key] + require.True(t, ok) + require.Equal(t, val, value) +} + +func TestJSON(t *testing.T) { + repo := makeIdentityTestRepo(t) + + identity, err := NewIdentity(repo, "René Descartes", "rene.descartes@example.com") + require.NoError(t, err) + + // commit to make sure we have an Id + err = identity.Commit(repo) + require.NoError(t, err) + require.NotEmpty(t, identity.Id()) + + // serialize + data, err := json.Marshal(identity) + require.NoError(t, err) + + // deserialize, got a IdentityStub with the same id + var i Interface + i, err = UnmarshalJSON(data) + require.NoError(t, err) + require.Equal(t, identity.Id(), i.Id()) + + // make sure we can load the identity properly + i, err = ReadLocal(repo, i.Id()) + require.NoError(t, err) +} + +func TestIdentityRemove(t *testing.T) { + repo := repository.CreateGoGitTestRepo(t, false) + remoteA := repository.CreateGoGitTestRepo(t, true) + remoteB := repository.CreateGoGitTestRepo(t, true) + + err := repo.AddRemote("remoteA", remoteA.GetLocalRemote()) + require.NoError(t, err) + + err = repo.AddRemote("remoteB", remoteB.GetLocalRemote()) + require.NoError(t, err) + + // generate an identity for testing + rene, err := NewIdentity(repo, "René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + err = rene.Commit(repo) + require.NoError(t, err) + + _, err = Push(repo, "remoteA") + require.NoError(t, err) + + _, err = Push(repo, "remoteB") + require.NoError(t, err) + + _, err = Fetch(repo, "remoteA") + require.NoError(t, err) + + _, err = Fetch(repo, "remoteB") + require.NoError(t, err) + + err = RemoveIdentity(repo, rene.Id()) + require.NoError(t, err) + + _, err = ReadLocal(repo, rene.Id()) + require.Error(t, ErrIdentityNotExist, err) + + _, err = ReadRemote(repo, "remoteA", string(rene.Id())) + require.Error(t, ErrIdentityNotExist, err) + + _, err = ReadRemote(repo, "remoteB", string(rene.Id())) + require.Error(t, ErrIdentityNotExist, err) + + ids, err := ListLocalIds(repo) + require.NoError(t, err) + require.Len(t, ids, 0) +} diff --git a/entities/identity/identity_user.go b/entities/identity/identity_user.go new file mode 100644 index 00000000..cd67459e --- /dev/null +++ b/entities/identity/identity_user.go @@ -0,0 +1,68 @@ +package identity + +import ( + "fmt" + "os" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +// SetUserIdentity store the user identity's id in the git config +func SetUserIdentity(repo repository.RepoConfig, identity *Identity) error { + return repo.LocalConfig().StoreString(identityConfigKey, identity.Id().String()) +} + +// GetUserIdentity read the current user identity, set with a git config entry +func GetUserIdentity(repo repository.Repo) (*Identity, error) { + id, err := GetUserIdentityId(repo) + if err != nil { + return nil, err + } + + i, err := ReadLocal(repo, id) + if err == ErrIdentityNotExist { + innerErr := repo.LocalConfig().RemoveAll(identityConfigKey) + if innerErr != nil { + _, _ = fmt.Fprintln(os.Stderr, errors.Wrap(innerErr, "can't clear user identity").Error()) + } + return nil, err + } + + return i, nil +} + +func GetUserIdentityId(repo repository.Repo) (entity.Id, error) { + val, err := repo.LocalConfig().ReadString(identityConfigKey) + if err == repository.ErrNoConfigEntry { + return entity.UnsetId, ErrNoIdentitySet + } + if err == repository.ErrMultipleConfigEntry { + return entity.UnsetId, ErrMultipleIdentitiesSet + } + if err != nil { + return entity.UnsetId, err + } + + var id = entity.Id(val) + + if err := id.Validate(); err != nil { + return entity.UnsetId, err + } + + return id, nil +} + +// IsUserIdentitySet say if the user has set his identity +func IsUserIdentitySet(repo repository.Repo) (bool, error) { + _, err := repo.LocalConfig().ReadString(identityConfigKey) + if err == repository.ErrNoConfigEntry { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/entities/identity/interface.go b/entities/identity/interface.go new file mode 100644 index 00000000..c6e22e00 --- /dev/null +++ b/entities/identity/interface.go @@ -0,0 +1,62 @@ +package identity + +import ( + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" + "github.com/MichaelMure/git-bug/util/timestamp" +) + +type Interface interface { + entity.Interface + + // Name return the last version of the name + // Can be empty. + Name() string + + // DisplayName return a non-empty string to display, representing the + // identity, based on the non-empty values. + DisplayName() string + + // Email return the last version of the email + // Can be empty. + Email() string + + // Login return the last version of the login + // Can be empty. + // Warning: this login can be defined when importing from a bridge but should *not* be + // used to identify an identity as multiple bridge with different login can map to the same + // identity. Use the metadata system for that usage instead. + Login() string + + // AvatarUrl return the last version of the Avatar URL + // Can be empty. + AvatarUrl() string + + // Keys return the last version of the valid keys + // Can be empty. + Keys() []*Key + + // SigningKey return the key that should be used to sign new messages. If no key is available, return nil. + SigningKey(repo repository.RepoKeyring) (*Key, error) + + // ValidKeysAtTime return the set of keys valid at a given lamport time for a given clock of another entity + // Can be empty. + ValidKeysAtTime(clockName string, time lamport.Time) []*Key + + // LastModification return the timestamp at which the last version of the identity became valid. + LastModification() timestamp.Timestamp + + // LastModificationLamports return the lamport times at which the last version of the identity became valid. + LastModificationLamports() map[string]lamport.Time + + // IsProtected return true if the chain of git commits started to be signed. + // If that's the case, only signed commit with a valid key for this identity can be added. + IsProtected() bool + + // Validate check if the Identity data is valid + Validate() error + + // NeedCommit indicate that the in-memory state changed and need to be committed in the repository + NeedCommit() bool +} diff --git a/entities/identity/key.go b/entities/identity/key.go new file mode 100644 index 00000000..82b9b95c --- /dev/null +++ b/entities/identity/key.go @@ -0,0 +1,234 @@ +package identity + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/repository" +) + +var errNoPrivateKey = fmt.Errorf("no private key") + +type Key struct { + public *packet.PublicKey + private *packet.PrivateKey +} + +// GenerateKey generate a keypair (public+private) +// The type and configuration of the key is determined by the default value in go's OpenPGP. +func GenerateKey() *Key { + entity, err := openpgp.NewEntity("", "", "", &packet.Config{ + // The armored format doesn't include the creation time, which makes the round-trip data not being fully equal. + // We don't care about the creation time so we can set it to the zero value. + Time: func() time.Time { + return time.Time{} + }, + }) + if err != nil { + panic(err) + } + return &Key{ + public: entity.PrimaryKey, + private: entity.PrivateKey, + } +} + +// generatePublicKey generate only a public key (only useful for testing) +// See GenerateKey for the details. +func generatePublicKey() *Key { + k := GenerateKey() + k.private = nil + return k +} + +func (k *Key) Public() *packet.PublicKey { + return k.public +} + +func (k *Key) Private() *packet.PrivateKey { + return k.private +} + +func (k *Key) Validate() error { + if k.public == nil { + return fmt.Errorf("nil public key") + } + if !k.public.CanSign() { + return fmt.Errorf("public key can't sign") + } + + if k.private != nil { + if !k.private.CanSign() { + return fmt.Errorf("private key can't sign") + } + } + + return nil +} + +func (k *Key) Clone() *Key { + clone := &Key{} + + pub := *k.public + clone.public = &pub + + if k.private != nil { + priv := *k.private + clone.private = &priv + } + + return clone +} + +func (k *Key) MarshalJSON() ([]byte, error) { + // Serialize only the public key, in the armored format. + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) + if err != nil { + return nil, err + } + + err = k.public.Serialize(w) + if err != nil { + return nil, err + } + err = w.Close() + if err != nil { + return nil, err + } + return json.Marshal(buf.String()) +} + +func (k *Key) UnmarshalJSON(data []byte) error { + // De-serialize only the public key, in the armored format. + var armored string + err := json.Unmarshal(data, &armored) + if err != nil { + return err + } + + block, err := armor.Decode(strings.NewReader(armored)) + if err == io.EOF { + return fmt.Errorf("no armored data found") + } + if err != nil { + return err + } + + if block.Type != openpgp.PublicKeyType { + return fmt.Errorf("invalid key type") + } + + p, err := packet.Read(block.Body) + if err != nil { + return errors.Wrap(err, "failed to read public key packet") + } + + public, ok := p.(*packet.PublicKey) + if !ok { + return errors.New("got no packet.publicKey") + } + + // The armored format doesn't include the creation time, which makes the round-trip data not being fully equal. + // We don't care about the creation time so we can set it to the zero value. + public.CreationTime = time.Time{} + + k.public = public + return nil +} + +func (k *Key) loadPrivate(repo repository.RepoKeyring) error { + item, err := repo.Keyring().Get(k.public.KeyIdString()) + if err == repository.ErrKeyringKeyNotFound { + return errNoPrivateKey + } + if err != nil { + return err + } + + block, err := armor.Decode(bytes.NewReader(item.Data)) + if err == io.EOF { + return fmt.Errorf("no armored data found") + } + if err != nil { + return err + } + + if block.Type != openpgp.PrivateKeyType { + return fmt.Errorf("invalid key type") + } + + p, err := packet.Read(block.Body) + if err != nil { + return errors.Wrap(err, "failed to read private key packet") + } + + private, ok := p.(*packet.PrivateKey) + if !ok { + return errors.New("got no packet.privateKey") + } + + // The armored format doesn't include the creation time, which makes the round-trip data not being fully equal. + // We don't care about the creation time so we can set it to the zero value. + private.CreationTime = time.Time{} + + k.private = private + return nil +} + +// ensurePrivateKey attempt to load the corresponding private key if it is not loaded already. +// If no private key is found, returns errNoPrivateKey +func (k *Key) ensurePrivateKey(repo repository.RepoKeyring) error { + if k.private != nil { + return nil + } + + return k.loadPrivate(repo) +} + +func (k *Key) storePrivate(repo repository.RepoKeyring) error { + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + if err != nil { + return err + } + err = k.private.Serialize(w) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + + return repo.Keyring().Set(repository.Item{ + Key: k.public.KeyIdString(), + Data: buf.Bytes(), + }) +} + +func (k *Key) PGPEntity() *openpgp.Entity { + uid := packet.NewUserId("", "", "") + return &openpgp.Entity{ + PrimaryKey: k.public, + PrivateKey: k.private, + Identities: map[string]*openpgp.Identity{ + uid.Id: { + Name: uid.Id, + UserId: uid, + SelfSignature: &packet.Signature{ + IsPrimaryId: func() *bool { b := true; return &b }(), + }, + }, + }, + } +} diff --git a/entities/identity/key_test.go b/entities/identity/key_test.go new file mode 100644 index 00000000..6e320dc2 --- /dev/null +++ b/entities/identity/key_test.go @@ -0,0 +1,60 @@ +package identity + +import ( + "crypto/rsa" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/repository" +) + +func TestPublicKeyJSON(t *testing.T) { + k := generatePublicKey() + + dataJSON, err := json.Marshal(k) + require.NoError(t, err) + + var read Key + err = json.Unmarshal(dataJSON, &read) + require.NoError(t, err) + + require.Equal(t, k, &read) +} + +func TestStoreLoad(t *testing.T) { + repo := repository.NewMockRepoKeyring() + + // public + private + k := GenerateKey() + + // Store + + dataJSON, err := json.Marshal(k) + require.NoError(t, err) + + err = k.storePrivate(repo) + require.NoError(t, err) + + // Load + + var read Key + err = json.Unmarshal(dataJSON, &read) + require.NoError(t, err) + + err = read.ensurePrivateKey(repo) + require.NoError(t, err) + + require.Equal(t, k.public, read.public) + + require.IsType(t, (*rsa.PrivateKey)(nil), k.private.PrivateKey) + + // See https://github.com/golang/crypto/pull/175 + rsaPriv := read.private.PrivateKey.(*rsa.PrivateKey) + back := rsaPriv.Primes[0] + rsaPriv.Primes[0] = rsaPriv.Primes[1] + rsaPriv.Primes[1] = back + + require.True(t, k.private.PrivateKey.(*rsa.PrivateKey).Equal(read.private.PrivateKey)) +} diff --git a/entities/identity/resolver.go b/entities/identity/resolver.go new file mode 100644 index 00000000..5468a8f8 --- /dev/null +++ b/entities/identity/resolver.go @@ -0,0 +1,34 @@ +package identity + +import ( + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" +) + +var _ entity.Resolver = &SimpleResolver{} + +// SimpleResolver is a Resolver loading Identities directly from a Repo +type SimpleResolver struct { + repo repository.Repo +} + +func NewSimpleResolver(repo repository.Repo) *SimpleResolver { + return &SimpleResolver{repo: repo} +} + +func (r *SimpleResolver) Resolve(id entity.Id) (entity.Interface, error) { + return ReadLocal(r.repo, id) +} + +var _ entity.Resolver = &StubResolver{} + +// StubResolver is a Resolver that doesn't load anything, only returning IdentityStub instances +type StubResolver struct{} + +func NewStubResolver() *StubResolver { + return &StubResolver{} +} + +func (s *StubResolver) Resolve(id entity.Id) (entity.Interface, error) { + return &IdentityStub{id: id}, nil +} diff --git a/entities/identity/version.go b/entities/identity/version.go new file mode 100644 index 00000000..9a52d089 --- /dev/null +++ b/entities/identity/version.go @@ -0,0 +1,273 @@ +package identity + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "time" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" + "github.com/MichaelMure/git-bug/util/text" +) + +// 1: original format +// 2: Identity Ids are generated from the first version serialized data instead of from the first git commit +// + Identity hold multiple lamport clocks from other entities, instead of just bug edit +const formatVersion = 2 + +// version is a complete set of information about an Identity at a point in time. +type version struct { + name string + email string // as defined in git or from a bridge when importing the identity + login string // from a bridge when importing the identity + avatarURL string + + // The lamport times of the other entities at which this version become effective + times map[string]lamport.Time + unixTime int64 + + // The set of keys valid at that time, from this version onward, until they get removed + // in a new version. This allow to have multiple key for the same identity (e.g. one per + // device) as well as revoke key. + keys []*Key + + // mandatory random bytes to ensure a better randomness of the data of the first + // version of an identity, used to later generate the ID + // len(Nonce) should be > 20 and < 64 bytes + // It has no functional purpose and should be ignored. + // TODO: optional after first version? + nonce []byte + + // A set of arbitrary key/value to store metadata about a version or about an Identity in general. + metadata map[string]string + + // Not serialized. Store the version's id in memory. + id entity.Id + // Not serialized + commitHash repository.Hash +} + +func newVersion(repo repository.RepoClock, name string, email string, login string, avatarURL string, keys []*Key) (*version, error) { + clocks, err := repo.AllClocks() + if err != nil { + return nil, err + } + + times := make(map[string]lamport.Time) + for name, clock := range clocks { + times[name] = clock.Time() + } + + return &version{ + id: entity.UnsetId, + name: name, + email: email, + login: login, + avatarURL: avatarURL, + times: times, + unixTime: time.Now().Unix(), + keys: keys, + nonce: makeNonce(20), + }, nil +} + +type versionJSON struct { + // Additional field to version the data + FormatVersion uint `json:"version"` + + Times map[string]lamport.Time `json:"times"` + UnixTime int64 `json:"unix_time"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Login string `json:"login,omitempty"` + AvatarUrl string `json:"avatar_url,omitempty"` + Keys []*Key `json:"pub_keys,omitempty"` + Nonce []byte `json:"nonce"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Id return the identifier of the version +func (v *version) Id() entity.Id { + if v.id == "" { + // something went really wrong + panic("version's id not set") + } + if v.id == entity.UnsetId { + // This means we are trying to get the version's Id *before* it has been stored. + // As the Id is computed based on the actual bytes written on the disk, we are going to predict + // those and then get the Id. This is safe as it will be the exact same code writing on disk later. + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + v.id = entity.DeriveId(data) + } + return v.id +} + +// Make a deep copy +func (v *version) Clone() *version { + // copy direct fields + clone := *v + + // reset some fields + clone.commitHash = "" + clone.id = entity.UnsetId + + clone.times = make(map[string]lamport.Time) + for name, t := range v.times { + clone.times[name] = t + } + + clone.keys = make([]*Key, len(v.keys)) + for i, key := range v.keys { + clone.keys[i] = key.Clone() + } + + clone.nonce = make([]byte, len(v.nonce)) + copy(clone.nonce, v.nonce) + + // not copying metadata + + return &clone +} + +func (v *version) MarshalJSON() ([]byte, error) { + return json.Marshal(versionJSON{ + FormatVersion: formatVersion, + Times: v.times, + UnixTime: v.unixTime, + Name: v.name, + Email: v.email, + Login: v.login, + AvatarUrl: v.avatarURL, + Keys: v.keys, + Nonce: v.nonce, + Metadata: v.metadata, + }) +} + +func (v *version) UnmarshalJSON(data []byte) error { + var aux versionJSON + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + if aux.FormatVersion != formatVersion { + return entity.NewErrInvalidFormat(aux.FormatVersion, formatVersion) + } + + v.id = entity.DeriveId(data) + v.times = aux.Times + v.unixTime = aux.UnixTime + v.name = aux.Name + v.email = aux.Email + v.login = aux.Login + v.avatarURL = aux.AvatarUrl + v.keys = aux.Keys + v.nonce = aux.Nonce + v.metadata = aux.Metadata + + return nil +} + +func (v *version) Validate() error { + // time must be set after a commit + if v.commitHash != "" && v.unixTime == 0 { + return fmt.Errorf("unix time not set") + } + + if text.Empty(v.name) && text.Empty(v.login) { + return fmt.Errorf("either name or login should be set") + } + if !text.SafeOneLine(v.name) { + return fmt.Errorf("name has unsafe characters") + } + + if !text.SafeOneLine(v.login) { + return fmt.Errorf("login has unsafe characters") + } + + if !text.SafeOneLine(v.email) { + return fmt.Errorf("email has unsafe characters") + } + + if v.avatarURL != "" && !text.ValidUrl(v.avatarURL) { + return fmt.Errorf("avatarUrl is not a valid URL") + } + + if len(v.nonce) > 64 { + return fmt.Errorf("nonce is too big") + } + if len(v.nonce) < 20 { + return fmt.Errorf("nonce is too small") + } + + for _, k := range v.keys { + if err := k.Validate(); err != nil { + return errors.Wrap(err, "invalid key") + } + } + + return nil +} + +// Write will serialize and store the version as a git blob and return +// its hash +func (v *version) Write(repo repository.Repo) (repository.Hash, error) { + // make sure we don't write invalid data + err := v.Validate() + if err != nil { + return "", errors.Wrap(err, "validation error") + } + + data, err := json.Marshal(v) + if err != nil { + return "", err + } + + hash, err := repo.StoreData(data) + if err != nil { + return "", err + } + + // make sure we set the Id when writing in the repo + v.id = entity.DeriveId(data) + + return hash, nil +} + +func makeNonce(len int) []byte { + result := make([]byte, len) + _, err := rand.Read(result) + if err != nil { + panic(err) + } + return result +} + +// SetMetadata store arbitrary metadata about a version or an Identity in general +// If the version has been commit to git already, it won't be overwritten. +// Beware: changing the metadata on a version will change it's ID +func (v *version) SetMetadata(key string, value string) { + if v.metadata == nil { + v.metadata = make(map[string]string) + } + v.metadata[key] = value +} + +// GetMetadata retrieve arbitrary metadata about the version +func (v *version) GetMetadata(key string) (string, bool) { + val, ok := v.metadata[key] + return val, ok +} + +// AllMetadata return all metadata for this version +func (v *version) AllMetadata() map[string]string { + return v.metadata +} diff --git a/entities/identity/version_test.go b/entities/identity/version_test.go new file mode 100644 index 00000000..385ad4d7 --- /dev/null +++ b/entities/identity/version_test.go @@ -0,0 +1,78 @@ +package identity + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/lamport" +) + +func makeIdentityTestRepo(t *testing.T) repository.ClockedRepo { + repo := repository.NewMockRepo() + + clock1, err := repo.GetOrCreateClock("foo") + require.NoError(t, err) + err = clock1.Witness(42) + require.NoError(t, err) + + clock2, err := repo.GetOrCreateClock("bar") + require.NoError(t, err) + err = clock2.Witness(34) + require.NoError(t, err) + + return repo +} + +func TestVersionJSON(t *testing.T) { + repo := makeIdentityTestRepo(t) + + keys := []*Key{ + generatePublicKey(), + generatePublicKey(), + } + + before, err := newVersion(repo, "name", "email", "login", "avatarUrl", keys) + require.NoError(t, err) + + before.SetMetadata("key1", "value1") + before.SetMetadata("key2", "value2") + + expected := &version{ + id: entity.UnsetId, + name: "name", + email: "email", + login: "login", + avatarURL: "avatarUrl", + unixTime: time.Now().Unix(), + times: map[string]lamport.Time{ + "foo": 42, + "bar": 34, + }, + keys: keys, + nonce: before.nonce, + metadata: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + } + + require.Equal(t, expected, before) + + data, err := json.Marshal(before) + assert.NoError(t, err) + + var after version + err = json.Unmarshal(data, &after) + assert.NoError(t, err) + + // make sure we now have an Id + expected.Id() + + assert.Equal(t, expected, &after) +} |