Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
469 changes: 418 additions & 51 deletions commonspace/object/acl/aclrecordproto/aclrecord.pb.go

Large diffs are not rendered by default.

1,178 changes: 1,170 additions & 8 deletions commonspace/object/acl/aclrecordproto/aclrecord_vtproto.pb.go

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions commonspace/object/acl/aclrecordproto/protos/aclrecord.proto
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,26 @@ message AclRoot {
bytes encryptedOwnerMetadata = 9;
AclOneToOneInfo oneToOneInfo = 10;
AclSpaceOptions options = 11;
// parentSpaceId declares this space a child of the parent space (nested spaces); empty for top-level spaces.
// Mirrors SpaceHeader.parentSpaceId so the ACL is self-describing.
string parentSpaceId = 12;
// legalOwner is the public key of the parent space's owner at genesis. It anchors the
// signature-induction chain advanced by AclLegalOwnerUpdate; empty for top-level spaces.
bytes legalOwner = 13;
// parentAclRootId is the parent space's acl root record id — the binding scope for
// AclLegalOwnerUpdate proofs; set together with parentSpaceId/legalOwner.
string parentAclRootId = 14;
}

// AclSpaceOptions contains space-level toggles/options
message AclSpaceOptions {
bool deleteRestricted = 1;
// editorsCanCompleteKeylessRotation lets Writers author the standalone read-key rotation
// that completes a pending keyless removal (nested spaces); off = admins/owner only
bool editorsCanCompleteKeylessRotation = 2;
// childrenCreationDisallowed forbids registering child spaces under this space (nested spaces);
// zero value = children allowed, Admin+ may register
bool childrenCreationDisallowed = 3;
}

// AclSpaceOptionsChange changes space options (owner-only)
Expand Down Expand Up @@ -58,6 +73,11 @@ enum AclInviteType {
message AclOwnershipChange {
bytes newOwnerIdentity = 1;
AclUserPermissions oldOwnerPermissions = 2;
// aclRootId binds the transfer to the acl it belongs to (the acl root record id).
// Optional for backward compatibility, but REQUIRED for a record to serve as an
// AclLegalOwnerUpdate proof — without it a genuine transfer of any other space
// signed by the same key could be replayed to take over a child's governance.
string aclRootId = 3;
}

// AclAccountRequestJoin contains the reference to the invite record and the data of the person who wants to join, confirmed by the private invite key
Expand Down Expand Up @@ -152,10 +172,40 @@ message AclAccountRemove {
AclReadKeyChange readKeyChange = 2;
}

// AclAccountRemoveNoRotate removes accounts WITHOUT a read-key rotation. Only the legalOwner of a
// child (nested) space may author it — it is the keyless-governance removal: membership drops to
// None immediately, and the space enters a pending-rotation state until a key-holding member
// authors a standard AclReadKeyChange. Never use for ordinary removals — use AclAccountRemove.
message AclAccountRemoveNoRotate {
repeated bytes identities = 1;
}

// AclAccountRequestRemove adds a request to remove an account
message AclAccountRequestRemove {
}

// AclChildRegister registers a child space under THIS (parent) space
message AclChildRegister {
string childSpaceId = 1;
// childAclRootId binds the registration to a specific child ACL root record
string childAclRootId = 2;
// orgPermission is the permission the parent grants ITSELF in the child (None = keyless governance only)
AclUserPermissions orgPermission = 3;
}

// AclChildRegisterRevoke de-lists a previously registered child space
message AclChildRegisterRevoke {
string childSpaceId = 1;
}

// AclLegalOwnerUpdate advances the stored legalOwner of THIS (child) space after parent ownership transfers.
// Each entry is a raw consensus record (consensusproto.RawRecord bytes) from the PARENT acl containing exactly
// one AclOwnershipChange. Validation is signature induction: the first record must be signed by the currently
// stored legalOwner, each next by the owner the previous one named; the author of this record must be the final owner.
message AclLegalOwnerUpdate {
repeated bytes ownershipChanges = 1;
}

// AclContentValue contains possible values for Acl
message AclContentValue {
oneof value {
Expand All @@ -176,6 +226,10 @@ message AclContentValue {
AclAccountInviteChange inviteChange = 14;
AclOwnershipChange ownershipChange = 15;
AclSpaceOptionsChange spaceOptionsChange = 16;
AclChildRegister childRegister = 17;
AclChildRegisterRevoke childRegisterRevoke = 18;
AclLegalOwnerUpdate legalOwnerUpdate = 19;
AclAccountRemoveNoRotate accountRemoveNoRotate = 20;
}
}

Expand Down
228 changes: 228 additions & 0 deletions commonspace/object/acl/list/aclkeylessremove_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package list

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/anyproto/any-sync/commonspace/object/accountdata"
"github.com/anyproto/any-sync/commonspace/object/acl/aclrecordproto"
"github.com/anyproto/any-sync/commonspace/object/acl/list/listtest"
"github.com/anyproto/any-sync/commonspace/object/acl/recordverifier"
"github.com/anyproto/any-sync/consensus/consensusproto"
"github.com/anyproto/any-sync/util/crypto"
)

// childAclFixture is a child space acl shared by its owner and one writer, with the
// parent owner (legalOwner) holding no permissions in it
type childAclFixture struct {
legalOwnerKeys *accountdata.AccountKeys
ownerKeys *accountdata.AccountKeys
writerKeys *accountdata.AccountKeys
ownerAcl AclList
writerAcl AclList
}

func addToAll(t *testing.T, rec *consensusproto.RawRecordWithId, acls ...AclList) {
for _, acl := range acls {
require.NoError(t, acl.AddRawRecord(rec))
}
}

func newChildAclFixture(t *testing.T, options *aclrecordproto.AclSpaceOptions) *childAclFixture {
legalOwnerKeys, err := accountdata.NewRandom()
require.NoError(t, err)
ownerKeys, err := accountdata.NewRandom()
require.NoError(t, err)
writerKeys, err := accountdata.NewRandom()
require.NoError(t, err)

masterKey, _, err := crypto.GenerateRandomEd25519KeyPair()
require.NoError(t, err)
root, err := newTestAclRecordBuilder(ownerKeys).BuildRoot(RootContent{
PrivKey: ownerKeys.SignKey,
MasterKey: masterKey,
Change: newTestReadKeyChangePayload(),
Metadata: []byte("m"),
Options: options,
ParentSpaceId: "parent.id",
LegalOwner: legalOwnerKeys.SignKey.GetPublic(),
ParentAclRootId: testParentAclRootId,
})
require.NoError(t, err)

storage, err := NewInMemoryStorage(root.Id, []*consensusproto.RawRecordWithId{root})
require.NoError(t, err)
ownerAcl, err := BuildAclListWithIdentity(ownerKeys, storage, recordverifier.NewValidateFull())
require.NoError(t, err)
writerAcl, err := BuildAclListWithIdentity(writerKeys, storage, recordverifier.NewValidateFull())
require.NoError(t, err)

fx := &childAclFixture{
legalOwnerKeys: legalOwnerKeys,
ownerKeys: ownerKeys,
writerKeys: writerKeys,
ownerAcl: ownerAcl,
writerAcl: writerAcl,
}

// the owner adds the writer directly (docs/15 direct-add path)
add, err := ownerAcl.RecordBuilder().BuildAccountsAdd(AccountsAddPayload{
Additions: []AccountAdd{{
Identity: writerKeys.SignKey.GetPublic(),
Permissions: AclPermissionsWriter,
Metadata: []byte("wm"),
}},
})
require.NoError(t, err)
addToAll(t, listtest.WrapAclRecord(add), ownerAcl, writerAcl)
return fx
}

func (fx *childAclFixture) removeNoRotateRecord(t *testing.T, signer *accountdata.AccountKeys, targets ...crypto.PubKey) *consensusproto.RawRecordWithId {
var identities [][]byte
for _, target := range targets {
protoIdentity, err := target.Marshall()
require.NoError(t, err)
identities = append(identities, protoIdentity)
}
content := &aclrecordproto.AclContentValue{
Value: &aclrecordproto.AclContentValue_AccountRemoveNoRotate{
AccountRemoveNoRotate: &aclrecordproto.AclAccountRemoveNoRotate{Identities: identities},
},
}
return buildAclRecordSignedBy(t, fx.ownerAcl.Head().Id, signer, content)
}

func TestKeylessRemove_LegalOwnerRemovesWriter(t *testing.T) {
fx := newChildAclFixture(t, nil)
readKeyIdBefore := fx.ownerAcl.AclState().CurrentReadKeyId()

rec := fx.removeNoRotateRecord(t, fx.legalOwnerKeys, fx.writerKeys.SignKey.GetPublic())
addToAll(t, rec, fx.ownerAcl, fx.writerAcl)

st := fx.ownerAcl.AclState()
require.True(t, st.Permissions(fx.writerKeys.SignKey.GetPublic()).NoPermissions())
require.True(t, st.HasPendingKeylessRemovals())
require.Len(t, st.PendingKeylessRemovals(), 1)
require.True(t, st.PendingKeylessRemovals()[0].Equals(fx.writerKeys.SignKey.GetPublic()))
// no rotation happened
require.Equal(t, readKeyIdBefore, st.CurrentReadKeyId())

// a key-holding admin (the owner) completes the cut-off with a standard rotation
rotation, err := fx.ownerAcl.RecordBuilder().BuildReadKeyChange(newTestReadKeyChangePayload())
require.NoError(t, err)
require.NoError(t, fx.ownerAcl.AddRawRecord(listtest.WrapAclRecord(rotation)))

st = fx.ownerAcl.AclState()
require.False(t, st.HasPendingKeylessRemovals())
require.NotEqual(t, readKeyIdBefore, st.CurrentReadKeyId())
}

func TestKeylessRemove_Rejections(t *testing.T) {
t.Run("only the legal owner may author", func(t *testing.T) {
fx := newChildAclFixture(t, nil)
stranger, err := accountdata.NewRandom()
require.NoError(t, err)
rec := fx.removeNoRotateRecord(t, stranger, fx.writerKeys.SignKey.GetPublic())
require.ErrorIs(t, fx.ownerAcl.AddRawRecord(rec), ErrInsufficientPermissions)

// even the child owner cannot use the keyless record — it must rotate via AclAccountRemove
rec = fx.removeNoRotateRecord(t, fx.ownerKeys, fx.writerKeys.SignKey.GetPublic())
require.ErrorIs(t, fx.ownerAcl.AddRawRecord(rec), ErrInsufficientPermissions)
})

t.Run("cannot remove the child owner", func(t *testing.T) {
fx := newChildAclFixture(t, nil)
rec := fx.removeNoRotateRecord(t, fx.legalOwnerKeys, fx.ownerKeys.SignKey.GetPublic())
require.ErrorIs(t, fx.ownerAcl.AddRawRecord(rec), ErrInsufficientPermissions)
})

t.Run("unknown identity", func(t *testing.T) {
fx := newChildAclFixture(t, nil)
stranger, err := accountdata.NewRandom()
require.NoError(t, err)
rec := fx.removeNoRotateRecord(t, fx.legalOwnerKeys, stranger.SignKey.GetPublic())
require.ErrorIs(t, fx.ownerAcl.AddRawRecord(rec), ErrNoSuchAccount)
})

t.Run("empty identities", func(t *testing.T) {
fx := newChildAclFixture(t, nil)
rec := fx.removeNoRotateRecord(t, fx.legalOwnerKeys)
require.ErrorIs(t, fx.ownerAcl.AddRawRecord(rec), ErrIncorrectNumberOfAccounts)
})

t.Run("not a child space", func(t *testing.T) {
a := NewAclExecutor("spaceId")
require.NoError(t, a.Execute("a.init::a"))
ownerAcl := a.ActualAccounts()["a"].Acl
legalOwnerKeys, err := accountdata.NewRandom()
require.NoError(t, err)
target, err := accountdata.NewRandom()
require.NoError(t, err)
protoIdentity, err := target.SignKey.GetPublic().Marshall()
require.NoError(t, err)
content := &aclrecordproto.AclContentValue{
Value: &aclrecordproto.AclContentValue_AccountRemoveNoRotate{
AccountRemoveNoRotate: &aclrecordproto.AclAccountRemoveNoRotate{Identities: [][]byte{protoIdentity}},
},
}
rec := buildAclRecordSignedBy(t, ownerAcl.Head().Id, legalOwnerKeys, content)
require.ErrorIs(t, ownerAcl.AddRawRecord(rec), ErrNotChildSpace)
})
}

func TestKeylessRemove_EditorRotation(t *testing.T) {
t.Run("writer cannot rotate without the opt-in", func(t *testing.T) {
fx := newChildAclFixture(t, nil)
rec := fx.removeNoRotateRecord(t, fx.legalOwnerKeys, fx.writerKeys.SignKey.GetPublic())
// remove a second member so the writer remains; here remove nobody relevant: add one more writer to remove
_ = rec
// add a reader, remove it keylessly, then the writer attempts the rotation
readerKeys, err := accountdata.NewRandom()
require.NoError(t, err)
add, err := fx.ownerAcl.RecordBuilder().BuildAccountsAdd(AccountsAddPayload{
Additions: []AccountAdd{{
Identity: readerKeys.SignKey.GetPublic(),
Permissions: AclPermissionsReader,
Metadata: []byte("rm"),
}},
})
require.NoError(t, err)
addToAll(t, listtest.WrapAclRecord(add), fx.ownerAcl, fx.writerAcl)

remove := fx.removeNoRotateRecord(t, fx.legalOwnerKeys, readerKeys.SignKey.GetPublic())
addToAll(t, remove, fx.ownerAcl, fx.writerAcl)
require.True(t, fx.writerAcl.AclState().HasPendingKeylessRemovals())

_, err = fx.writerAcl.RecordBuilder().BuildReadKeyChange(newTestReadKeyChangePayload())
require.ErrorIs(t, err, ErrInsufficientPermissions)
})

t.Run("writer rotates with the opt-in and a pending removal", func(t *testing.T) {
fx := newChildAclFixture(t, &aclrecordproto.AclSpaceOptions{EditorsCanCompleteKeylessRotation: true})
readerKeys, err := accountdata.NewRandom()
require.NoError(t, err)
add, err := fx.ownerAcl.RecordBuilder().BuildAccountsAdd(AccountsAddPayload{
Additions: []AccountAdd{{
Identity: readerKeys.SignKey.GetPublic(),
Permissions: AclPermissionsReader,
Metadata: []byte("rm"),
}},
})
require.NoError(t, err)
addToAll(t, listtest.WrapAclRecord(add), fx.ownerAcl, fx.writerAcl)

// without a pending removal the writer still cannot rotate
_, err = fx.writerAcl.RecordBuilder().BuildReadKeyChange(newTestReadKeyChangePayload())
require.ErrorIs(t, err, ErrInsufficientPermissions)

remove := fx.removeNoRotateRecord(t, fx.legalOwnerKeys, readerKeys.SignKey.GetPublic())
addToAll(t, remove, fx.ownerAcl, fx.writerAcl)

rotation, err := fx.writerAcl.RecordBuilder().BuildReadKeyChange(newTestReadKeyChangePayload())
require.NoError(t, err)
addToAll(t, listtest.WrapAclRecord(rotation), fx.ownerAcl, fx.writerAcl)
require.False(t, fx.ownerAcl.AclState().HasPendingKeylessRemovals())
})
}
Loading
Loading