From 7e45f3ec2dd48a7e1132e37b726e6301c041948e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A1s=20Ar=C3=A1nyi?= Date: Thu, 8 Aug 2024 01:46:42 +0400 Subject: [PATCH] feat: introduce act (#4692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ferenc Sárai Co-authored-by: Bálint Ujvári <58116288+bosi95@users.noreply.github.com> Co-authored-by: Ferenc Sárai --- .github/workflows/beekeeper.yml | 6 +- Makefile | 2 +- cmd/bee/cmd/start.go | 7 +- openapi/Swarm.yaml | 165 ++++ openapi/SwarmCommon.yaml | 70 ++ pkg/accesscontrol/access.go | 160 ++++ pkg/accesscontrol/access_test.go | 214 ++++++ pkg/accesscontrol/controller.go | 294 ++++++++ pkg/accesscontrol/controller_test.go | 335 +++++++++ pkg/accesscontrol/grantee.go | 177 +++++ pkg/accesscontrol/grantee_test.go | 237 ++++++ pkg/accesscontrol/history.go | 199 +++++ pkg/accesscontrol/history_test.go | 164 ++++ pkg/accesscontrol/kvs/kvs.go | 106 +++ pkg/accesscontrol/kvs/kvs_test.go | 184 +++++ pkg/accesscontrol/kvs/mock/kvs.go | 83 ++ pkg/accesscontrol/mock/controller.go | 205 +++++ pkg/accesscontrol/mock/grantee.go | 55 ++ pkg/accesscontrol/mock/session.go | 44 ++ pkg/accesscontrol/session.go | 66 ++ pkg/accesscontrol/session_test.go | 146 ++++ pkg/api/accesscontrol.go | 565 ++++++++++++++ pkg/api/accesscontrol_test.go | 1041 ++++++++++++++++++++++++++ pkg/api/api.go | 11 + pkg/api/api_test.go | 7 + pkg/api/bytes.go | 60 +- pkg/api/bzz.go | 73 +- pkg/api/chunk.go | 42 +- pkg/api/chunk_address.go | 9 +- pkg/api/dirs.go | 25 +- pkg/api/export_test.go | 2 + pkg/api/feed.go | 35 +- pkg/api/router.go | 29 +- pkg/api/soc.go | 31 +- pkg/manifest/mantaray.go | 4 + pkg/manifest/mantaray/node.go | 7 +- pkg/node/devnode.go | 23 +- pkg/node/node.go | 9 + pkg/soc/testing/soc.go | 36 + 39 files changed, 4857 insertions(+), 71 deletions(-) create mode 100644 pkg/accesscontrol/access.go create mode 100644 pkg/accesscontrol/access_test.go create mode 100644 pkg/accesscontrol/controller.go create mode 100644 pkg/accesscontrol/controller_test.go create mode 100644 pkg/accesscontrol/grantee.go create mode 100644 pkg/accesscontrol/grantee_test.go create mode 100644 pkg/accesscontrol/history.go create mode 100644 pkg/accesscontrol/history_test.go create mode 100644 pkg/accesscontrol/kvs/kvs.go create mode 100644 pkg/accesscontrol/kvs/kvs_test.go create mode 100644 pkg/accesscontrol/kvs/mock/kvs.go create mode 100644 pkg/accesscontrol/mock/controller.go create mode 100644 pkg/accesscontrol/mock/grantee.go create mode 100644 pkg/accesscontrol/mock/session.go create mode 100644 pkg/accesscontrol/session.go create mode 100644 pkg/accesscontrol/session_test.go create mode 100644 pkg/api/accesscontrol.go create mode 100644 pkg/api/accesscontrol_test.go diff --git a/.github/workflows/beekeeper.yml b/.github/workflows/beekeeper.yml index 470712c5566..944076d5676 100644 --- a/.github/workflows/beekeeper.yml +++ b/.github/workflows/beekeeper.yml @@ -166,6 +166,9 @@ jobs: - name: Test redundancy id: redundancy run: timeout ${TIMEOUT} beekeeper check --cluster-name local-dns --checks ci-redundancy + - name: Test act + id: act + run: timeout ${TIMEOUT} bash -c 'until beekeeper check --cluster-name local-dns --checks ci-act; do echo "waiting for act..."; sleep .3; done' - name: Collect debug artifacts if: failure() run: | @@ -181,6 +184,7 @@ jobs: if ${{ steps.retrieval.outcome=='failure' }}; then FAILED=retrieval; fi if ${{ steps.manifest.outcome=='failure' }}; then FAILED=manifest; fi if ${{ steps.content-availability.outcome=='failure' }}; then FAILED=content-availability; fi + if ${{ steps.act.outcome=='failure' }}; then FAILED=act; fi curl -sSf -X POST -H "Content-Type: application/json" -d "{\"text\": \"**${RUN_TYPE}** Beekeeper Error\nBranch: \`${{ github.head_ref }}\`\nUser: @${{ github.event.pull_request.user.login }}\nDebugging artifacts: [click](https://$BUCKET_NAME.$AWS_ENDPOINT/artifacts_$VERTAG.tar.gz)\nStep failed: \`${FAILED}\`\"}" https://beehive.ethswarm.org/hooks/${{ secrets.TUNSHELL_KEY }} echo "Failed test: ${FAILED}" - name: Create tunshell session for debug @@ -231,4 +235,4 @@ jobs: token: ${{ secrets.GHA_PAT_BASIC }} repository: ethersphere/bee-factory event-type: build-images - client-payload: '{"tag": "latest"}' + client-payload: '{"tag": "latest"}' \ No newline at end of file diff --git a/Makefile b/Makefile index 9aa79e4f9cd..2c654ea5d7d 100644 --- a/Makefile +++ b/Makefile @@ -160,4 +160,4 @@ clean: $(GO) clean rm -rf dist/ -FORCE: +FORCE: \ No newline at end of file diff --git a/cmd/bee/cmd/start.go b/cmd/bee/cmd/start.go index 037b0e83686..026b7fbcdf0 100644 --- a/cmd/bee/cmd/start.go +++ b/cmd/bee/cmd/start.go @@ -24,6 +24,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/rpc" "github.com/ethersphere/bee/v2" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" chaincfg "github.com/ethersphere/bee/v2/pkg/config" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/crypto/clef" @@ -287,7 +288,7 @@ func buildBeeNode(ctx context.Context, c *command, cmd *cobra.Command, logger lo neighborhoodSuggester = c.config.GetString(optionNameNeighborhoodSuggester) } - b, err := node.NewBee(ctx, c.config.GetString(optionNameP2PAddr), signerConfig.publicKey, signerConfig.signer, networkID, logger, signerConfig.libp2pPrivateKey, signerConfig.pssPrivateKey, &node.Options{ + b, err := node.NewBee(ctx, c.config.GetString(optionNameP2PAddr), signerConfig.publicKey, signerConfig.signer, networkID, logger, signerConfig.libp2pPrivateKey, signerConfig.pssPrivateKey, signerConfig.session, &node.Options{ DataDir: c.config.GetString(optionNameDataDir), CacheCapacity: c.config.GetUint64(optionNameCacheCapacity), DBOpenFilesLimit: c.config.GetUint64(optionNameDBOpenFilesLimit), @@ -365,6 +366,7 @@ type signerConfig struct { publicKey *ecdsa.PublicKey libp2pPrivateKey *ecdsa.PrivateKey pssPrivateKey *ecdsa.PrivateKey + session accesscontrol.Session } func waitForClef(logger log.Logger, maxRetries uint64, endpoint string) (externalSigner *external.ExternalSigner, err error) { @@ -395,6 +397,7 @@ func (c *command) configureSigner(cmd *cobra.Command, logger log.Logger) (config var signer crypto.Signer var password string var publicKey *ecdsa.PublicKey + var session accesscontrol.Session if p := c.config.GetString(optionNamePassword); p != "" { password = p } else if pf := c.config.GetString(optionNamePasswordFile); pf != "" { @@ -467,6 +470,7 @@ func (c *command) configureSigner(cmd *cobra.Command, logger log.Logger) (config } signer = crypto.NewDefaultSigner(swarmPrivateKey) publicKey = &swarmPrivateKey.PublicKey + session = accesscontrol.NewDefaultSession(swarmPrivateKey) } logger.Info("swarm public key", "public_key", hex.EncodeToString(crypto.EncodeSecp256k1PublicKey(publicKey))) @@ -505,6 +509,7 @@ func (c *command) configureSigner(cmd *cobra.Command, logger log.Logger) (config publicKey: publicKey, libp2pPrivateKey: libp2pPrivateKey, pssPrivateKey: pssPrivateKey, + session: session, }, nil } diff --git a/openapi/Swarm.yaml b/openapi/Swarm.yaml index 64ce1ec1f44..a73849d7a94 100644 --- a/openapi/Swarm.yaml +++ b/openapi/Swarm.yaml @@ -31,6 +31,135 @@ servers: description: Service port provided in bee node config paths: + "/grantee": + post: + summary: "Create grantee list" + tags: + - ACT + parameters: + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" + name: swarm-postage-batch-id + required: true + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmTagParameter" + name: swarm-tag + required: false + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmPinParameter" + name: swarm-pin + required: false + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmDeferredUpload" + name: swarm-deferred-upload + required: false + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" + name: swarm-act-history-address + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/ActGranteesCreateRequest" + responses: + "201": + description: Ok + content: + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/ActGranteesOperationResponse" + "400": + $ref: "SwarmCommon.yaml#/components/responses/400" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + + "/grantee/{reference}": + get: + summary: "Get grantee list" + tags: + - ACT + parameters: + - in: path + name: reference + schema: + $ref: "SwarmCommon.yaml#/components/schemas/SwarmEncryptedReference" + required: true + description: Grantee list reference + responses: + "200": + description: Ok + content: + application/json: + schema: + type: array + items: + $ref: "SwarmCommon.yaml#/components/schemas/PublicKey" + "404": + $ref: "SwarmCommon.yaml#/components/responses/404" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + patch: + summary: "Update grantee list" + description: "Add or remove grantees from an existing grantee list" + tags: + - ACT + parameters: + - in: path + name: reference + schema: + $ref: "SwarmCommon.yaml#/components/schemas/SwarmEncryptedReference" + required: true + description: Grantee list reference + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" + name: swarm-act-history-address + required: true + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" + name: swarm-postage-batch-id + required: true + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmTagParameter" + name: swarm-tag + required: false + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmPinParameter" + name: swarm-pin + required: false + - in: header + schema: + $ref: "SwarmCommon.yaml#/components/parameters/SwarmDeferredUpload" + name: swarm-deferred-upload + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/ActGranteesPatchRequest" + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: "SwarmCommon.yaml#/components/schemas/ActGranteesOperationResponse" + "400": + $ref: "SwarmCommon.yaml#/components/responses/400" + "500": + $ref: "SwarmCommon.yaml#/components/responses/500" + "/bytes": post: summary: "Upload data" @@ -109,6 +238,9 @@ paths: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyStrategyParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyFallbackModeParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmChunkRetrievalTimeoutParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": description: Retrieved content specified by reference @@ -132,6 +264,9 @@ paths: $ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress" required: true description: Swarm address of chunk + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": description: Chunk exists @@ -155,6 +290,8 @@ paths: $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" required: false - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageStamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmAct" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" requestBody: description: Chunk binary data that has to have at least 8 bytes. content: @@ -170,6 +307,8 @@ paths: description: Tag UID if it was passed to the request `swarm-tag` header. schema: $ref: "SwarmCommon.yaml#/components/schemas/Uid" + "swarm-act-history-address": + $ref: "SwarmCommon.yaml#/components/headers/SwarmActHistoryAddress" content: application/json: schema: @@ -226,6 +365,8 @@ paths: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmDeferredUpload" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyLevelParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmAct" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" requestBody: content: multipart/form-data: @@ -252,6 +393,8 @@ paths: $ref: "SwarmCommon.yaml#/components/headers/SwarmTag" "etag": $ref: "SwarmCommon.yaml#/components/headers/ETag" + "swarm-act-history-address": + $ref: "SwarmCommon.yaml#/components/headers/SwarmActHistoryAddress" content: application/json: schema: @@ -281,6 +424,9 @@ paths: - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyStrategyParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmRedundancyFallbackModeParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmChunkRetrievalTimeoutParameter" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": description: Ok @@ -310,6 +456,9 @@ paths: $ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress" required: true description: Swarm address of chunk + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": description: Chunk exists @@ -697,6 +846,8 @@ paths: $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" required: false - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageStamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmAct" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" requestBody: required: true description: The SOC binary data is composed of the span (8 bytes) and the at most 4KB payload. @@ -712,6 +863,9 @@ paths: application/json: schema: $ref: "SwarmCommon.yaml#/components/schemas/ReferenceResponse" + headers: + "swarm-act-history-address": + $ref: "SwarmCommon.yaml#/components/headers/SwarmActHistoryAddress" "400": $ref: "SwarmCommon.yaml#/components/responses/400" "401": @@ -749,6 +903,8 @@ paths: description: "Feed indexing scheme (default: sequence)" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPinParameter" - $ref: "SwarmCommon.yaml#/components/parameters/SwarmPostageBatchId" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmAct" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "201": description: Created @@ -756,6 +912,9 @@ paths: application/json: schema: $ref: "SwarmCommon.yaml#/components/schemas/ReferenceResponse" + headers: + "swarm-act-history-address": + $ref: "SwarmCommon.yaml#/components/headers/SwarmActHistoryAddress" "400": $ref: "SwarmCommon.yaml#/components/responses/400" "401": @@ -1090,6 +1249,9 @@ paths: $ref: "SwarmCommon.yaml#/components/parameters/SwarmCache" name: swarm-cache required: false + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": description: Retrieved chunk content @@ -1117,6 +1279,9 @@ paths: $ref: "SwarmCommon.yaml#/components/schemas/SwarmAddress" required: true description: Swarm address of chunk + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActTimestamp" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActPublisher" + - $ref: "SwarmCommon.yaml#/components/parameters/SwarmActHistoryAddress" responses: "200": description: Chunk exists diff --git a/openapi/SwarmCommon.yaml b/openapi/SwarmCommon.yaml index 50e56a96949..9a487ba61c1 100644 --- a/openapi/SwarmCommon.yaml +++ b/openapi/SwarmCommon.yaml @@ -87,6 +87,36 @@ components: ghostBalance: $ref: "#/components/schemas/BigInt" + ActGranteesCreateRequest: + type: object + properties: + grantees: + type: array + items: + $ref: "#/components/schemas/PublicKey" + + ActGranteesPatchRequest: + type: object + properties: + add: + type: array + items: + $ref: "#/components/schemas/PublicKey" + description: List of grantees to add + revoke: + type: array + items: + $ref: "#/components/schemas/PublicKey" + description: List of grantees to revoke future access from + + ActGranteesOperationResponse: + type: object + properties: + ref: + $ref: "#/components/schemas/SwarmEncryptedReference" + historyref: + $ref: "#/components/schemas/SwarmEncryptedReference" + Balance: type: object properties: @@ -993,6 +1023,12 @@ components: schema: $ref: "#/components/schemas/HexString" + SwarmActHistoryAddress: + description: "Swarm address reference to the new ACT history entry" + schema: + $ref: "#/components/schemas/SwarmAddress" + required: false + ETag: description: | The RFC7232 ETag header field in a response provides the current entity- @@ -1167,6 +1203,40 @@ components: required: false description: "Determines if the download data should be cached on the node. By default the download will be cached" + SwarmAct: + in: header + name: swarm-act + schema: + type: boolean + default: "false" + required: false + description: "Determines if the uploaded data should be treated as ACT content" + + SwarmActPublisher: + in: header + name: swarm-act-publisher + schema: + $ref: "#/components/schemas/PublicKey" + required: false + description: "ACT content publisher's public key" + + SwarmActHistoryAddress: + in: header + name: swarm-act-history-address + schema: + $ref: "#/components/schemas/SwarmAddress" + required: false + description: "ACT history reference address" + + SwarmActTimestamp: + in: header + name: swarm-act-timestamp + schema: + type: integer + format: int64 + required: false + description: "ACT history Unix timestamp" + responses: "200": description: OK. diff --git a/pkg/accesscontrol/access.go b/pkg/accesscontrol/access.go new file mode 100644 index 00000000000..0b7f9a094ac --- /dev/null +++ b/pkg/accesscontrol/access.go @@ -0,0 +1,160 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol + +import ( + "context" + "crypto/ecdsa" + "errors" + "fmt" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol/kvs" + "github.com/ethersphere/bee/v2/pkg/encryption" + "github.com/ethersphere/bee/v2/pkg/swarm" + "golang.org/x/crypto/sha3" +) + +//nolint:gochecknoglobals +var ( + hashFunc = sha3.NewLegacyKeccak256 + oneByteArray = []byte{1} + zeroByteArray = []byte{0} +) + +// Decryptor is a read-only interface for the ACT. +type Decryptor interface { + // DecryptRef will return a decrypted reference, for given encrypted reference and grantee. + DecryptRef(ctx context.Context, storage kvs.KeyValueStore, encryptedRef swarm.Address, publisher *ecdsa.PublicKey) (swarm.Address, error) + Session +} + +// Control interface for the ACT (does write operations). +type Control interface { + Decryptor + // AddGrantee adds a new grantee to the ACT. + AddGrantee(ctx context.Context, storage kvs.KeyValueStore, publisherPubKey, granteePubKey *ecdsa.PublicKey) error + // EncryptRef encrypts a Swarm reference for a given grantee. + EncryptRef(ctx context.Context, storage kvs.KeyValueStore, grantee *ecdsa.PublicKey, ref swarm.Address) (swarm.Address, error) +} + +// ActLogic represents the access control logic. +type ActLogic struct { + Session +} + +var _ Control = (*ActLogic)(nil) + +// EncryptRef encrypts a Swarm reference for a publisher. +func (al ActLogic) EncryptRef(ctx context.Context, storage kvs.KeyValueStore, publisherPubKey *ecdsa.PublicKey, ref swarm.Address) (swarm.Address, error) { + accessKey, err := al.getAccessKey(ctx, storage, publisherPubKey) + if err != nil { + return swarm.ZeroAddress, err + } + refCipher := encryption.New(accessKey, 0, 0, hashFunc) + encryptedRef, err := refCipher.Encrypt(ref.Bytes()) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("failed to encrypt reference: %w", err) + } + + return swarm.NewAddress(encryptedRef), nil +} + +// AddGrantee adds a new grantee to the ACT. +func (al ActLogic) AddGrantee(ctx context.Context, storage kvs.KeyValueStore, publisherPubKey, granteePubKey *ecdsa.PublicKey) error { + var ( + accessKey encryption.Key + err error + ) + + // Create new access key because grantee is the publisher. + if publisherPubKey.Equal(granteePubKey) { + accessKey = encryption.GenerateRandomKey(encryption.KeyLength) + } else { + // Get previously generated access key. + accessKey, err = al.getAccessKey(ctx, storage, publisherPubKey) + if err != nil { + return err + } + } + + lookupKey, accessKeyDecryptionKey, err := al.getKeys(granteePubKey) + if err != nil { + return err + } + + // Encrypt the access key for the new Grantee. + cipher := encryption.New(encryption.Key(accessKeyDecryptionKey), 0, 0, hashFunc) + granteeEncryptedAccessKey, err := cipher.Encrypt(accessKey) + if err != nil { + return fmt.Errorf("failed to encrypt access key: %w", err) + } + + // Add the new encrypted access key to the Act. + err = storage.Put(ctx, lookupKey, granteeEncryptedAccessKey) + if err != nil { + return fmt.Errorf("failed to put value to KVS: %w", err) + } + + return nil +} + +// Will return the access key for a publisher (public key). +func (al *ActLogic) getAccessKey(ctx context.Context, storage kvs.KeyValueStore, publisherPubKey *ecdsa.PublicKey) ([]byte, error) { + publisherLookupKey, publisherAKDecryptionKey, err := al.getKeys(publisherPubKey) + if err != nil { + return nil, err + } + // no need for constructor call if value not found in act. + accessKeyDecryptionCipher := encryption.New(encryption.Key(publisherAKDecryptionKey), 0, 0, hashFunc) + encryptedAK, err := storage.Get(ctx, publisherLookupKey) + if err != nil { + switch { + case errors.Is(err, kvs.ErrNotFound): + return nil, ErrNotFound + default: + return nil, fmt.Errorf("failed go get value from KVS: %w", err) + } + } + + accessKey, err := accessKeyDecryptionCipher.Decrypt(encryptedAK) + if err != nil { + return nil, fmt.Errorf("failed to decrypt access key: %w", err) + } + + return accessKey, nil +} + +// Generate lookup key and access key decryption key for a given public key. +func (al *ActLogic) getKeys(publicKey *ecdsa.PublicKey) ([]byte, []byte, error) { + nonces := [][]byte{zeroByteArray, oneByteArray} + keys, err := al.Session.Key(publicKey, nonces) + if len(keys) != len(nonces) { + return nil, nil, err + } + return keys[0], keys[1], err +} + +// DecryptRef will return a decrypted reference, for given encrypted reference and publisher. +func (al ActLogic) DecryptRef(ctx context.Context, storage kvs.KeyValueStore, encryptedRef swarm.Address, publisher *ecdsa.PublicKey) (swarm.Address, error) { + accessKey, err := al.getAccessKey(ctx, storage, publisher) + if err != nil { + return swarm.ZeroAddress, err + } + + refCipher := encryption.New(accessKey, 0, 0, hashFunc) + ref, err := refCipher.Decrypt(encryptedRef.Bytes()) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("failed to decrypt reference: %w", err) + } + + return swarm.NewAddress(ref), nil +} + +// NewLogic creates a new ACT Logic from a session. +func NewLogic(s Session) ActLogic { + return ActLogic{ + Session: s, + } +} diff --git a/pkg/accesscontrol/access_test.go b/pkg/accesscontrol/access_test.go new file mode 100644 index 00000000000..0b2eeaa1e56 --- /dev/null +++ b/pkg/accesscontrol/access_test.go @@ -0,0 +1,214 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/hex" + "fmt" + "testing" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + kvsmock "github.com/ethersphere/bee/v2/pkg/accesscontrol/kvs/mock" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/stretchr/testify/assert" +) + +func assertNoError(t *testing.T, msg string, err error) { + t.Helper() + if err != nil { + assert.FailNowf(t, err.Error(), msg) + } +} + +func assertError(t *testing.T, msg string, err error) { + t.Helper() + if err == nil { + assert.FailNowf(t, fmt.Sprintf("Expected %s error, got nil", msg), "") + } +} + +// Generates a new test environment with a fix private key. +func setupAccessLogic() accesscontrol.ActLogic { + privateKey := getPrivKey(1) + diffieHellman := accesscontrol.NewDefaultSession(privateKey) + al := accesscontrol.NewLogic(diffieHellman) + + return al +} + +func getPrivKey(keyNumber int) *ecdsa.PrivateKey { + var keyHex string + + switch keyNumber { + case 0: + keyHex = "a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa" + case 1: + keyHex = "b786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfb" + case 2: + keyHex = "c786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfc" + default: + panic("Invalid key number") + } + + data, err := hex.DecodeString(keyHex) + if err != nil { + panic(err) + } + + privKey, err := crypto.DecodeSecp256k1PrivateKey(data) + if err != nil { + panic(err) + } + + return privKey +} + +func TestDecryptRef_Publisher(t *testing.T) { + t.Parallel() + ctx := context.Background() + id1 := getPrivKey(1) + s := kvsmock.New() + al := setupAccessLogic() + err := al.AddGrantee(ctx, s, &id1.PublicKey, &id1.PublicKey) + assertNoError(t, "AddGrantee", err) + + byteRef, err := hex.DecodeString("39a5ea87b141fe44aa609c3327ecd896c0e2122897f5f4bbacf74db1033c5559") + assertNoError(t, "DecodeString", err) + expectedRef := swarm.NewAddress(byteRef) + encryptedRef, err := al.EncryptRef(ctx, s, &id1.PublicKey, expectedRef) + assertNoError(t, "al encryptref", err) + + t.Run("decrypt success", func(t *testing.T) { + actualRef, err := al.DecryptRef(ctx, s, encryptedRef, &id1.PublicKey) + assertNoError(t, "decrypt ref", err) + + if !expectedRef.Equal(actualRef) { + assert.FailNowf(t, fmt.Sprintf("DecryptRef gave back wrong Swarm reference! Expedted: %v, actual: %v", expectedRef, actualRef), "") + } + }) + t.Run("decrypt with nil publisher", func(t *testing.T) { + _, err = al.DecryptRef(ctx, s, encryptedRef, nil) + assertError(t, "al decryptref", err) + }) +} + +func TestDecryptRefWithGrantee_Success(t *testing.T) { + t.Parallel() + ctx := context.Background() + id0, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + assertNoError(t, "GenerateKey", err) + diffieHellman := accesscontrol.NewDefaultSession(id0) + al := accesscontrol.NewLogic(diffieHellman) + + s := kvsmock.New() + err = al.AddGrantee(ctx, s, &id0.PublicKey, &id0.PublicKey) + assertNoError(t, "AddGrantee publisher", err) + + id1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + assertNoError(t, "GenerateKey", err) + err = al.AddGrantee(ctx, s, &id0.PublicKey, &id1.PublicKey) + assertNoError(t, "AddGrantee id1", err) + + byteRef, err := hex.DecodeString("39a5ea87b141fe44aa609c3327ecd896c0e2122897f5f4bbacf74db1033c5559") + assertNoError(t, "DecodeString", err) + + expectedRef := swarm.NewAddress(byteRef) + + encryptedRef, err := al.EncryptRef(ctx, s, &id0.PublicKey, expectedRef) + assertNoError(t, "al encryptref", err) + + diffieHellman2 := accesscontrol.NewDefaultSession(id1) + granteeAccessLogic := accesscontrol.NewLogic(diffieHellman2) + actualRef, err := granteeAccessLogic.DecryptRef(ctx, s, encryptedRef, &id0.PublicKey) + assertNoError(t, "grantee al decryptref", err) + + if !expectedRef.Equal(actualRef) { + assert.FailNowf(t, fmt.Sprintf("DecryptRef gave back wrong Swarm reference! Expedted: %v, actual: %v", expectedRef, actualRef), "") + } +} + +func TestAddPublisher(t *testing.T) { + t.Parallel() + id0 := getPrivKey(0) + savedLookupKey := "b6ee086390c280eeb9824c331a4427596f0c8510d5564bc1b6168d0059a46e2b" + s := kvsmock.New() + ctx := context.Background() + + al := setupAccessLogic() + err := al.AddGrantee(ctx, s, &id0.PublicKey, &id0.PublicKey) + assertNoError(t, "AddGrantee", err) + + decodedSavedLookupKey, err := hex.DecodeString(savedLookupKey) + assertNoError(t, "decode LookupKey", err) + + encryptedAccessKey, err := s.Get(ctx, decodedSavedLookupKey) + assertNoError(t, "kvs Get accesskey", err) + + decodedEncryptedAccessKey := hex.EncodeToString(encryptedAccessKey) + + // A random value is returned, so it is only possible to check the length of the returned value + // We know the lookup key because the generated private key is fixed + if len(decodedEncryptedAccessKey) != 64 { + assert.FailNowf(t, fmt.Sprintf("AddGrantee: expected encrypted access key length 64, got %d", len(decodedEncryptedAccessKey)), "") + } +} + +func TestAddNewGranteeToContent(t *testing.T) { + t.Parallel() + id0 := getPrivKey(0) + id1 := getPrivKey(1) + id2 := getPrivKey(2) + ctx := context.Background() + + publisherLookupKey := "b6ee086390c280eeb9824c331a4427596f0c8510d5564bc1b6168d0059a46e2b" + firstAddedGranteeLookupKey := "a13678e81f9d939b9401a3ad7e548d2ceb81c50f8c76424296e83a1ad79c0df0" + secondAddedGranteeLookupKey := "d5e9a6499ca74f5b8b958a4b89b7338045b2baa9420e115443a8050e26986564" + + s := kvsmock.New() + al := setupAccessLogic() + err := al.AddGrantee(ctx, s, &id0.PublicKey, &id0.PublicKey) + assertNoError(t, "AddGrantee id0", err) + + err = al.AddGrantee(ctx, s, &id0.PublicKey, &id1.PublicKey) + assertNoError(t, "AddGrantee id1", err) + + err = al.AddGrantee(ctx, s, &id0.PublicKey, &id2.PublicKey) + assertNoError(t, "AddGrantee id2", err) + + lookupKeyAsByte, err := hex.DecodeString(publisherLookupKey) + assertNoError(t, "publisher lookupkey DecodeString", err) + + result, err := s.Get(ctx, lookupKeyAsByte) + assertNoError(t, "1st kvs get", err) + hexEncodedEncryptedAK := hex.EncodeToString(result) + if len(hexEncodedEncryptedAK) != 64 { + assert.FailNowf(t, fmt.Sprintf("AddNewGrantee: expected encrypted access key length 64, got %d", len(hexEncodedEncryptedAK)), "") + } + + lookupKeyAsByte, err = hex.DecodeString(firstAddedGranteeLookupKey) + assertNoError(t, "1st lookupkey DecodeString", err) + + result, err = s.Get(ctx, lookupKeyAsByte) + assertNoError(t, "2nd kvs get", err) + hexEncodedEncryptedAK = hex.EncodeToString(result) + if len(hexEncodedEncryptedAK) != 64 { + assert.FailNowf(t, fmt.Sprintf("AddNewGrantee: expected encrypted access key length 64, got %d", len(hexEncodedEncryptedAK)), "") + } + + lookupKeyAsByte, err = hex.DecodeString(secondAddedGranteeLookupKey) + assertNoError(t, "2nd lookupkey DecodeString", err) + + result, err = s.Get(ctx, lookupKeyAsByte) + assertNoError(t, "3rd kvs get", err) + hexEncodedEncryptedAK = hex.EncodeToString(result) + if len(hexEncodedEncryptedAK) != 64 { + assert.FailNowf(t, fmt.Sprintf("AddNewGrantee: expected encrypted access key length 64, got %d", len(hexEncodedEncryptedAK)), "") + } +} diff --git a/pkg/accesscontrol/controller.go b/pkg/accesscontrol/controller.go new file mode 100644 index 00000000000..b0a33855e55 --- /dev/null +++ b/pkg/accesscontrol/controller.go @@ -0,0 +1,294 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package accesscontrol provides functionalities needed +// for managing access control on Swarm +package accesscontrol + +import ( + "context" + "crypto/ecdsa" + "fmt" + "io" + "time" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol/kvs" + "github.com/ethersphere/bee/v2/pkg/encryption" + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +// Grantees represents an interface for managing and retrieving grantees for a publisher. +type Grantees interface { + // UpdateHandler manages the grantees for the given publisher, updating the list based on provided public keys to add or remove. + // Only the publisher can make changes to the grantee list. + UpdateHandler(ctx context.Context, ls file.LoadSaver, gls file.LoadSaver, granteeRef swarm.Address, historyRef swarm.Address, publisher *ecdsa.PublicKey, addList, removeList []*ecdsa.PublicKey) (swarm.Address, swarm.Address, swarm.Address, swarm.Address, error) + // Get returns the list of grantees for the given publisher. + // The list is accessible only by the publisher. + Get(ctx context.Context, ls file.LoadSaver, publisher *ecdsa.PublicKey, encryptedglRef swarm.Address) ([]*ecdsa.PublicKey, error) +} + +// Controller represents an interface for managing access control on Swarm. +// It provides methods for handling downloads, uploads and updates for grantee lists and references. +type Controller interface { + Grantees + // DownloadHandler decrypts the encryptedRef using the lookupkey based on the history and timestamp. + DownloadHandler(ctx context.Context, ls file.LoadSaver, encryptedRef swarm.Address, publisher *ecdsa.PublicKey, historyRef swarm.Address, timestamp int64) (swarm.Address, error) + // UploadHandler encrypts the reference and stores it in the history as the latest update. + UploadHandler(ctx context.Context, ls file.LoadSaver, reference swarm.Address, publisher *ecdsa.PublicKey, historyRef swarm.Address) (swarm.Address, swarm.Address, swarm.Address, error) + io.Closer +} + +// ControllerStruct represents a controller for access control logic. +type ControllerStruct struct { + access ActLogic +} + +var _ Controller = (*ControllerStruct)(nil) + +// NewController creates a new access controller with the given access logic. +func NewController(access ActLogic) *ControllerStruct { + return &ControllerStruct{ + access: access, + } +} + +// DownloadHandler decrypts the encryptedRef using the lookupkey based on the history and timestamp. +func (c *ControllerStruct) DownloadHandler( + ctx context.Context, + ls file.LoadSaver, + encryptedRef swarm.Address, + publisher *ecdsa.PublicKey, + historyRef swarm.Address, + timestamp int64, +) (swarm.Address, error) { + _, act, err := c.getHistoryAndAct(ctx, ls, historyRef, publisher, timestamp) + if err != nil { + return swarm.ZeroAddress, err + } + + return c.access.DecryptRef(ctx, act, encryptedRef, publisher) +} + +// UploadHandler encrypts the reference and stores it in the history as the latest update. +func (c *ControllerStruct) UploadHandler( + ctx context.Context, + ls file.LoadSaver, + reference swarm.Address, + publisher *ecdsa.PublicKey, + historyRef swarm.Address, +) (swarm.Address, swarm.Address, swarm.Address, error) { + history, act, err := c.getHistoryAndAct(ctx, ls, historyRef, publisher, time.Now().Unix()) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + actRef := swarm.ZeroAddress + newHistoryRef := historyRef + if historyRef.IsZero() { + newHistoryRef, actRef, err = c.saveHistoryAndAct(ctx, history, nil, act) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + } + + encryptedRef, err := c.access.EncryptRef(ctx, act, publisher, reference) + return actRef, newHistoryRef, encryptedRef, err +} + +// UpdateHandler manages the grantees for the given publisher, updating the list based on provided public keys to add or remove. +// Only the publisher can make changes to the grantee list. +// Limitation: If an upadate is called again within a second from the latest upload/update then mantaray save fails with ErrInvalidInput, +// because the key (timestamp) is already present, hence a new fork is not created. +func (c *ControllerStruct) UpdateHandler( + ctx context.Context, + ls file.LoadSaver, + gls file.LoadSaver, + encryptedglRef swarm.Address, + historyRef swarm.Address, + publisher *ecdsa.PublicKey, + addList []*ecdsa.PublicKey, + removeList []*ecdsa.PublicKey, +) (swarm.Address, swarm.Address, swarm.Address, swarm.Address, error) { + history, act, err := c.getHistoryAndAct(ctx, ls, historyRef, publisher, time.Now().Unix()) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + + gl, err := c.getGranteeList(ctx, gls, encryptedglRef, publisher) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + if len(addList) != 0 { + err = gl.Add(addList) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + } + granteesToAdd := addList + if len(removeList) != 0 { + err = gl.Remove(removeList) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + // generate new access key and new act, only if history was not newly created + if !historyRef.IsZero() { + act, err = c.newActWithPublisher(ctx, ls, publisher) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + } + granteesToAdd = gl.Get() + } + + for _, grantee := range granteesToAdd { + err := c.access.AddGrantee(ctx, act, publisher, grantee) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + } + + granteeRef, err := gl.Save(ctx) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + + egranteeRef, err := c.encryptRefForPublisher(publisher, granteeRef) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + // need to re-initialize history, because Lookup loads the forks causing the manifest save to skip the root node + if !historyRef.IsZero() { + history, err = NewHistoryReference(ls, historyRef) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + } + + mtdt := map[string]string{"encryptedglref": egranteeRef.String()} + hRef, actRef, err := c.saveHistoryAndAct(ctx, history, &mtdt, act) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + + return granteeRef, egranteeRef, hRef, actRef, nil +} + +// Get returns the list of grantees for the given publisher. +// The list is accessible only by the publisher. +func (c *ControllerStruct) Get(ctx context.Context, ls file.LoadSaver, publisher *ecdsa.PublicKey, encryptedglRef swarm.Address) ([]*ecdsa.PublicKey, error) { + gl, err := c.getGranteeList(ctx, ls, encryptedglRef, publisher) + if err != nil { + return nil, err + } + return gl.Get(), nil +} + +func (c *ControllerStruct) newActWithPublisher(ctx context.Context, ls file.LoadSaver, publisher *ecdsa.PublicKey) (kvs.KeyValueStore, error) { + act, err := kvs.New(ls) + if err != nil { + return nil, err + } + err = c.access.AddGrantee(ctx, act, publisher, publisher) + if err != nil { + return nil, err + } + + return act, nil +} + +func (c *ControllerStruct) getHistoryAndAct(ctx context.Context, ls file.LoadSaver, historyRef swarm.Address, publisher *ecdsa.PublicKey, timestamp int64) (history History, act kvs.KeyValueStore, err error) { + if historyRef.IsZero() { + history, err = NewHistory(ls) + if err != nil { + return nil, nil, err + } + act, err = c.newActWithPublisher(ctx, ls, publisher) + if err != nil { + return nil, nil, err + } + } else { + history, err = NewHistoryReference(ls, historyRef) + if err != nil { + return nil, nil, err + } + entry, err := history.Lookup(ctx, timestamp) + if err != nil { + return nil, nil, err + } + act, err = kvs.NewReference(ls, entry.Reference()) + if err != nil { + return nil, nil, err + } + } + + return history, act, nil +} + +func (c *ControllerStruct) saveHistoryAndAct(ctx context.Context, history History, mtdt *map[string]string, act kvs.KeyValueStore) (swarm.Address, swarm.Address, error) { + actRef, err := act.Save(ctx) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, err + } + err = history.Add(ctx, actRef, nil, mtdt) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, err + } + historyRef, err := history.Store(ctx) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, err + } + + return historyRef, actRef, nil +} + +func (c *ControllerStruct) getGranteeList(ctx context.Context, ls file.LoadSaver, encryptedglRef swarm.Address, publisher *ecdsa.PublicKey) (gl GranteeList, err error) { + if encryptedglRef.IsZero() { + gl = NewGranteeList(ls) + } else { + granteeref, err := c.decryptRefForPublisher(publisher, encryptedglRef) + if err != nil { + return nil, err + } + + gl, err = NewGranteeListReference(ctx, ls, granteeref) + if err != nil { + return nil, err + } + } + + return gl, nil +} + +func (c *ControllerStruct) encryptRefForPublisher(publisherPubKey *ecdsa.PublicKey, ref swarm.Address) (swarm.Address, error) { + keys, err := c.access.Session.Key(publisherPubKey, [][]byte{oneByteArray}) + if err != nil { + return swarm.ZeroAddress, err + } + refCipher := encryption.New(keys[0], 0, 0, hashFunc) + encryptedRef, err := refCipher.Encrypt(ref.Bytes()) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("failed to encrypt reference: %w", err) + } + + return swarm.NewAddress(encryptedRef), nil +} + +func (c *ControllerStruct) decryptRefForPublisher(publisherPubKey *ecdsa.PublicKey, encryptedRef swarm.Address) (swarm.Address, error) { + keys, err := c.access.Session.Key(publisherPubKey, [][]byte{oneByteArray}) + if err != nil { + return swarm.ZeroAddress, err + } + refCipher := encryption.New(keys[0], 0, 0, hashFunc) + ref, err := refCipher.Decrypt(encryptedRef.Bytes()) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("failed to decrypt reference: %w", err) + } + + return swarm.NewAddress(ref), nil +} + +// Close simply returns nil +func (c *ControllerStruct) Close() error { + return nil +} diff --git a/pkg/accesscontrol/controller_test.go b/pkg/accesscontrol/controller_test.go new file mode 100644 index 00000000000..cf1eba59a56 --- /dev/null +++ b/pkg/accesscontrol/controller_test.go @@ -0,0 +1,335 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol_test + +import ( + "context" + "crypto/ecdsa" + "reflect" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + "github.com/ethersphere/bee/v2/pkg/accesscontrol/kvs" + encryption "github.com/ethersphere/bee/v2/pkg/encryption" + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" +) + +//nolint:errcheck,gosec,wrapcheck +func getHistoryFixture(t *testing.T, ctx context.Context, ls file.LoadSaver, al accesscontrol.ActLogic, publisher *ecdsa.PublicKey) (swarm.Address, error) { + t.Helper() + h, err := accesscontrol.NewHistory(ls) + if err != nil { + return swarm.ZeroAddress, err + } + pk1 := getPrivKey(1) + pk2 := getPrivKey(2) + + kvs0, err := kvs.New(ls) + assertNoError(t, "kvs0 create", err) + al.AddGrantee(ctx, kvs0, publisher, publisher) + kvs0Ref, err := kvs0.Save(ctx) + assertNoError(t, "kvs0 save", err) + kvs1, err := kvs.New(ls) + assertNoError(t, "kvs1 create", err) + al.AddGrantee(ctx, kvs1, publisher, publisher) + al.AddGrantee(ctx, kvs1, publisher, &pk1.PublicKey) + kvs1Ref, err := kvs1.Save(ctx) + assertNoError(t, "kvs1 save", err) + kvs2, err := kvs.New(ls) + assertNoError(t, "kvs2 create", err) + al.AddGrantee(ctx, kvs2, publisher, publisher) + al.AddGrantee(ctx, kvs2, publisher, &pk2.PublicKey) + kvs2Ref, err := kvs2.Save(ctx) + assertNoError(t, "kvs2 save", err) + firstTime := time.Date(1994, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + secondTime := time.Date(2000, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + thirdTime := time.Date(2015, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + + h.Add(ctx, kvs0Ref, &thirdTime, nil) + h.Add(ctx, kvs1Ref, &firstTime, nil) + h.Add(ctx, kvs2Ref, &secondTime, nil) + return h.Store(ctx) +} + +func TestController_UploadHandler(t *testing.T) { + t.Parallel() + ctx := context.Background() + publisher := getPrivKey(0) + diffieHellman := accesscontrol.NewDefaultSession(publisher) + al := accesscontrol.NewLogic(diffieHellman) + c := accesscontrol.NewController(al) + ls := createLs() + + t.Run("New upload", func(t *testing.T) { + ref := swarm.RandAddress(t) + _, hRef, encRef, err := c.UploadHandler(ctx, ls, ref, &publisher.PublicKey, swarm.ZeroAddress) + assertNoError(t, "UploadHandler", err) + + h, err := accesscontrol.NewHistoryReference(ls, hRef) + assertNoError(t, "create history ref", err) + entry, err := h.Lookup(ctx, time.Now().Unix()) + assertNoError(t, "history lookup", err) + actRef := entry.Reference() + act, err := kvs.NewReference(ls, actRef) + assertNoError(t, "kvs create ref", err) + expRef, err := al.EncryptRef(ctx, act, &publisher.PublicKey, ref) + + assertNoError(t, "encrypt ref", err) + assert.Equal(t, encRef, expRef) + assert.NotEqual(t, hRef, swarm.ZeroAddress) + }) + + t.Run("Upload to same history", func(t *testing.T) { + ref := swarm.RandAddress(t) + _, hRef1, _, err := c.UploadHandler(ctx, ls, ref, &publisher.PublicKey, swarm.ZeroAddress) + assertNoError(t, "1st upload", err) + _, hRef2, encRef, err := c.UploadHandler(ctx, ls, ref, &publisher.PublicKey, hRef1) + assertNoError(t, "2nd upload", err) + h, err := accesscontrol.NewHistoryReference(ls, hRef2) + assertNoError(t, "create history ref", err) + hRef2, err = h.Store(ctx) + assertNoError(t, "store history", err) + assert.True(t, hRef1.Equal(hRef2)) + + h, err = accesscontrol.NewHistoryReference(ls, hRef2) + assertNoError(t, "create history ref", err) + entry, err := h.Lookup(ctx, time.Now().Unix()) + assertNoError(t, "history lookup", err) + actRef := entry.Reference() + act, err := kvs.NewReference(ls, actRef) + assertNoError(t, "kvs create ref", err) + expRef, err := al.EncryptRef(ctx, act, &publisher.PublicKey, ref) + + assertNoError(t, "encrypt ref", err) + assert.Equal(t, expRef, encRef) + assert.NotEqual(t, hRef2, swarm.ZeroAddress) + }) +} + +func TestController_PublisherDownload(t *testing.T) { + t.Parallel() + ctx := context.Background() + publisher := getPrivKey(0) + diffieHellman := accesscontrol.NewDefaultSession(publisher) + al := accesscontrol.NewLogic(diffieHellman) + c := accesscontrol.NewController(al) + ls := createLs() + ref := swarm.RandAddress(t) + href, err := getHistoryFixture(t, ctx, ls, al, &publisher.PublicKey) + assertNoError(t, "history fixture create", err) + h, err := accesscontrol.NewHistoryReference(ls, href) + assertNoError(t, "create history ref", err) + entry, err := h.Lookup(ctx, time.Now().Unix()) + assertNoError(t, "history lookup", err) + actRef := entry.Reference() + act, err := kvs.NewReference(ls, actRef) + assertNoError(t, "kvs create ref", err) + encRef, err := al.EncryptRef(ctx, act, &publisher.PublicKey, ref) + assertNoError(t, "encrypt ref", err) + + dref, err := c.DownloadHandler(ctx, ls, encRef, &publisher.PublicKey, href, time.Now().Unix()) + assertNoError(t, "download by publisher", err) + assert.Equal(t, ref, dref) +} + +func TestController_GranteeDownload(t *testing.T) { + t.Parallel() + ctx := context.Background() + publisher := getPrivKey(0) + grantee := getPrivKey(2) + publisherDH := accesscontrol.NewDefaultSession(publisher) + publisherAL := accesscontrol.NewLogic(publisherDH) + + diffieHellman := accesscontrol.NewDefaultSession(grantee) + al := accesscontrol.NewLogic(diffieHellman) + ls := createLs() + c := accesscontrol.NewController(al) + ref := swarm.RandAddress(t) + href, err := getHistoryFixture(t, ctx, ls, publisherAL, &publisher.PublicKey) + assertNoError(t, "history fixture create", err) + h, err := accesscontrol.NewHistoryReference(ls, href) + assertNoError(t, "history fixture create", err) + ts := time.Date(2001, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + entry, err := h.Lookup(ctx, ts) + assertNoError(t, "history lookup", err) + actRef := entry.Reference() + act, err := kvs.NewReference(ls, actRef) + assertNoError(t, "kvs create ref", err) + encRef, err := publisherAL.EncryptRef(ctx, act, &publisher.PublicKey, ref) + + assertNoError(t, "encrypt ref", err) + dref, err := c.DownloadHandler(ctx, ls, encRef, &publisher.PublicKey, href, ts) + assertNoError(t, "download by grantee", err) + assert.Equal(t, ref, dref) +} + +func TestController_UpdateHandler(t *testing.T) { + t.Parallel() + ctx := context.Background() + publisher := getPrivKey(1) + diffieHellman := accesscontrol.NewDefaultSession(publisher) + al := accesscontrol.NewLogic(diffieHellman) + keys, err := al.Session.Key(&publisher.PublicKey, [][]byte{{1}}) + assertNoError(t, "Session key", err) + refCipher := encryption.New(keys[0], 0, 0, sha3.NewLegacyKeccak256) + ls := createLs() + gls := loadsave.New(mockStorer.ChunkStore(), mockStorer.Cache(), requestPipelineFactory(context.Background(), mockStorer.Cache(), true, redundancy.NONE)) + c := accesscontrol.NewController(al) + href, err := getHistoryFixture(t, ctx, ls, al, &publisher.PublicKey) + assertNoError(t, "history fixture create", err) + + grantee1 := getPrivKey(0) + grantee := getPrivKey(2) + + t.Run("add to new list", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey} + granteeRef, _, _, _, err := c.UpdateHandler(ctx, ls, ls, swarm.ZeroAddress, swarm.ZeroAddress, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandlererror", err) + + gl, err := accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + + assertNoError(t, "create granteelist ref", err) + assert.Len(t, gl.Get(), 1) + }) + t.Run("add to existing list", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey} + granteeRef, eglref, _, _, err := c.UpdateHandler(ctx, ls, gls, swarm.ZeroAddress, href, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandlererror", err) + + gl, err := accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + + assertNoError(t, "create granteelist ref", err) + assert.Len(t, gl.Get(), 1) + + addList = []*ecdsa.PublicKey{&getPrivKey(0).PublicKey} + granteeRef, _, _, _, err = c.UpdateHandler(ctx, ls, ls, eglref, href, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandler", err) + gl, err = accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + assertNoError(t, "create granteelist ref", err) + assert.Len(t, gl.Get(), 2) + }) + t.Run("add and revoke", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey} + revokeList := []*ecdsa.PublicKey{&grantee1.PublicKey} + gl := accesscontrol.NewGranteeList(ls) + err = gl.Add([]*ecdsa.PublicKey{&publisher.PublicKey, &grantee1.PublicKey}) + granteeRef, err := gl.Save(ctx) + assertNoError(t, "granteelist save", err) + eglref, err := refCipher.Encrypt(granteeRef.Bytes()) + assertNoError(t, "encrypt granteeref", err) + + granteeRef, _, _, _, err = c.UpdateHandler(ctx, ls, gls, swarm.NewAddress(eglref), href, &publisher.PublicKey, addList, revokeList) + assertNoError(t, "UpdateHandler", err) + gl, err = accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + + assertNoError(t, "create granteelist ref", err) + assert.Len(t, gl.Get(), 2) + }) + t.Run("add and revoke then get from history", func(t *testing.T) { + addRevokeList := []*ecdsa.PublicKey{&grantee.PublicKey} + ref := swarm.RandAddress(t) + _, hRef, encRef, err := c.UploadHandler(ctx, ls, ref, &publisher.PublicKey, swarm.ZeroAddress) + require.NoError(t, err) + + // Need to wait a second before each update call so that a new history mantaray fork is created for the new key(timestamp) entry + time.Sleep(1 * time.Second) + beforeRevokeTS := time.Now().Unix() + _, egranteeRef, hrefUpdate1, _, err := c.UpdateHandler(ctx, ls, gls, swarm.ZeroAddress, hRef, &publisher.PublicKey, addRevokeList, nil) + require.NoError(t, err) + + time.Sleep(1 * time.Second) + granteeRef, _, hrefUpdate2, _, err := c.UpdateHandler(ctx, ls, gls, egranteeRef, hrefUpdate1, &publisher.PublicKey, nil, addRevokeList) + require.NoError(t, err) + + gl, err := accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + require.NoError(t, err) + assert.Empty(t, gl.Get()) + // expect history reference to be different after grantee list update + assert.NotEqual(t, hrefUpdate1, hrefUpdate2) + + granteeDH := accesscontrol.NewDefaultSession(grantee) + granteeAl := accesscontrol.NewLogic(granteeDH) + granteeCtrl := accesscontrol.NewController(granteeAl) + // download with grantee shall still work with the timestamp before the revoke + decRef, err := granteeCtrl.DownloadHandler(ctx, ls, encRef, &publisher.PublicKey, hrefUpdate2, beforeRevokeTS) + require.NoError(t, err) + assert.Equal(t, ref, decRef) + + // download with grantee shall NOT work with the latest timestamp + decRef, err = granteeCtrl.DownloadHandler(ctx, ls, encRef, &publisher.PublicKey, hrefUpdate2, time.Now().Unix()) + require.Error(t, err) + assert.Equal(t, swarm.ZeroAddress, decRef) + + // publisher shall still be able to download with the timestamp before the revoke + decRef, err = c.DownloadHandler(ctx, ls, encRef, &publisher.PublicKey, hrefUpdate2, beforeRevokeTS) + require.NoError(t, err) + assert.Equal(t, ref, decRef) + }) + t.Run("add twice", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey, &grantee.PublicKey} + //nolint:ineffassign,staticcheck,wastedassign + granteeRef, eglref, _, _, err := c.UpdateHandler(ctx, ls, gls, swarm.ZeroAddress, href, &publisher.PublicKey, addList, nil) + granteeRef, _, _, _, err = c.UpdateHandler(ctx, ls, ls, eglref, href, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandler", err) + gl, err := accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + + assertNoError(t, "create granteelist ref", err) + assert.Len(t, gl.Get(), 1) + }) + t.Run("revoke non-existing", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey} + granteeRef, _, _, _, err := c.UpdateHandler(ctx, ls, ls, swarm.ZeroAddress, href, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandler", err) + gl, err := accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + + assertNoError(t, "create granteelist ref", err) + assert.Len(t, gl.Get(), 1) + }) +} + +func TestController_Get(t *testing.T) { + t.Parallel() + ctx := context.Background() + publisher := getPrivKey(1) + caller := getPrivKey(0) + grantee := getPrivKey(2) + diffieHellman1 := accesscontrol.NewDefaultSession(publisher) + diffieHellman2 := accesscontrol.NewDefaultSession(caller) + al1 := accesscontrol.NewLogic(diffieHellman1) + al2 := accesscontrol.NewLogic(diffieHellman2) + ls := createLs() + gls := loadsave.New(mockStorer.ChunkStore(), mockStorer.Cache(), requestPipelineFactory(context.Background(), mockStorer.Cache(), true, redundancy.NONE)) + c1 := accesscontrol.NewController(al1) + c2 := accesscontrol.NewController(al2) + + t.Run("get by publisher", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey} + granteeRef, eglRef, _, _, err := c1.UpdateHandler(ctx, ls, gls, swarm.ZeroAddress, swarm.ZeroAddress, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandler", err) + + grantees, err := c1.Get(ctx, ls, &publisher.PublicKey, eglRef) + assertNoError(t, "get by publisher", err) + assert.True(t, reflect.DeepEqual(grantees, addList)) + + gl, err := accesscontrol.NewGranteeListReference(ctx, ls, granteeRef) + assertNoError(t, "create granteelist ref", err) + assert.True(t, reflect.DeepEqual(gl.Get(), addList)) + }) + t.Run("get by non-publisher", func(t *testing.T) { + addList := []*ecdsa.PublicKey{&grantee.PublicKey} + _, eglRef, _, _, err := c1.UpdateHandler(ctx, ls, gls, swarm.ZeroAddress, swarm.ZeroAddress, &publisher.PublicKey, addList, nil) + assertNoError(t, "UpdateHandler", err) + grantees, err := c2.Get(ctx, ls, &publisher.PublicKey, eglRef) + assertError(t, "controller get by non-publisher", err) + assert.Nil(t, grantees) + }) +} diff --git a/pkg/accesscontrol/grantee.go b/pkg/accesscontrol/grantee.go new file mode 100644 index 00000000000..902aebbf433 --- /dev/null +++ b/pkg/accesscontrol/grantee.go @@ -0,0 +1,177 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +const ( + publicKeyLen = 65 +) + +var ( + // ErrNothingToRemove indicates that the remove list is empty. + ErrNothingToRemove = errors.New("nothing to remove") + // ErrNoGranteeFound indicates that the grantee list is empty. + ErrNoGranteeFound = errors.New("no grantee found") + // ErrNothingToAdd indicates that the add list is empty. + ErrNothingToAdd = errors.New("nothing to add") +) + +// GranteeList manages a list of public keys. +type GranteeList interface { + // Add adds a list of public keys to the grantee list. It filters out duplicates. + Add(addList []*ecdsa.PublicKey) error + // Remove removes a list of public keys from the grantee list, if there is any. + Remove(removeList []*ecdsa.PublicKey) error + // Get simply returns the list of public keys. + Get() []*ecdsa.PublicKey + // Save saves the grantee list to the underlying storage and returns the reference. + Save(ctx context.Context) (swarm.Address, error) +} + +// GranteeListStruct represents a list of grantee public keys. +type GranteeListStruct struct { + grantees []*ecdsa.PublicKey + loadSave file.LoadSaver +} + +var _ GranteeList = (*GranteeListStruct)(nil) + +// Get simply returns the list of public keys. +func (g *GranteeListStruct) Get() []*ecdsa.PublicKey { + return g.grantees +} + +// Add adds a list of public keys to the grantee list. It filters out duplicates. +func (g *GranteeListStruct) Add(addList []*ecdsa.PublicKey) error { + if len(addList) == 0 { + return ErrNothingToAdd + } + filteredList := make([]*ecdsa.PublicKey, 0, len(addList)) + for _, addkey := range addList { + add := true + for _, granteekey := range g.grantees { + if granteekey.Equal(addkey) { + add = false + break + } + } + for _, filteredkey := range filteredList { + if filteredkey.Equal(addkey) { + add = false + break + } + } + if add { + filteredList = append(filteredList, addkey) + } + } + g.grantees = append(g.grantees, filteredList...) + + return nil +} + +// Save saves the grantee list to the underlying storage and returns the reference. +func (g *GranteeListStruct) Save(ctx context.Context) (swarm.Address, error) { + data := serialize(g.grantees) + refBytes, err := g.loadSave.Save(ctx, data) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("grantee save error: %w", err) + } + + return swarm.NewAddress(refBytes), nil +} + +// Remove removes a list of public keys from the grantee list, if there is any. +func (g *GranteeListStruct) Remove(keysToRemove []*ecdsa.PublicKey) error { + if len(keysToRemove) == 0 { + return ErrNothingToRemove + } + + if len(g.grantees) == 0 { + return ErrNoGranteeFound + } + grantees := g.grantees + + for _, remove := range keysToRemove { + for i := 0; i < len(grantees); i++ { + if grantees[i].Equal(remove) { + grantees[i] = grantees[len(grantees)-1] + grantees = grantees[:len(grantees)-1] + } + } + } + g.grantees = grantees + + return nil +} + +// NewGranteeList creates a new (and empty) grantee list. +func NewGranteeList(ls file.LoadSaver) *GranteeListStruct { + return &GranteeListStruct{ + grantees: []*ecdsa.PublicKey{}, + loadSave: ls, + } +} + +// NewGranteeListReference loads an existing grantee list. +func NewGranteeListReference(ctx context.Context, ls file.LoadSaver, reference swarm.Address) (*GranteeListStruct, error) { + data, err := ls.Load(ctx, reference.Bytes()) + if err != nil { + return nil, fmt.Errorf("failed to load grantee list reference, %w", err) + } + grantees := deserialize(data) + + return &GranteeListStruct{ + grantees: grantees, + loadSave: ls, + }, nil +} + +func serialize(publicKeys []*ecdsa.PublicKey) []byte { + b := make([]byte, 0, len(publicKeys)*publicKeyLen) + for _, key := range publicKeys { + b = append(b, serializePublicKey(key)...) + } + return b +} + +func serializePublicKey(pub *ecdsa.PublicKey) []byte { + return elliptic.Marshal(pub.Curve, pub.X, pub.Y) +} + +func deserialize(data []byte) []*ecdsa.PublicKey { + if len(data) == 0 { + return []*ecdsa.PublicKey{} + } + + p := make([]*ecdsa.PublicKey, 0, len(data)/publicKeyLen) + for i := 0; i < len(data); i += publicKeyLen { + pubKey := deserializeBytes(data[i : i+publicKeyLen]) + if pubKey == nil { + return []*ecdsa.PublicKey{} + } + p = append(p, pubKey) + } + return p +} + +func deserializeBytes(data []byte) *ecdsa.PublicKey { + key, err := btcec.ParsePubKey(data) + if err != nil { + return nil + } + return key.ToECDSA() +} diff --git a/pkg/accesscontrol/grantee_test.go b/pkg/accesscontrol/grantee_test.go new file mode 100644 index 00000000000..f3c54dc907d --- /dev/null +++ b/pkg/accesscontrol/grantee_test.go @@ -0,0 +1,237 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol_test + +import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/pipeline" + "github.com/ethersphere/bee/v2/pkg/file/pipeline/builder" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/storage" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/stretchr/testify/assert" +) + +var mockStorer = mockstorer.New() + +func requestPipelineFactory(ctx context.Context, s storage.Putter, encrypt bool, rLevel redundancy.Level) func() pipeline.Interface { + return func() pipeline.Interface { + return builder.NewPipelineBuilder(ctx, s, encrypt, rLevel) + } +} + +func createLs() file.LoadSaver { + return loadsave.New(mockStorer.ChunkStore(), mockStorer.Cache(), requestPipelineFactory(context.Background(), mockStorer.Cache(), false, redundancy.NONE)) +} + +func generateKeyListFixture() ([]*ecdsa.PublicKey, error) { + key1, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) + if err != nil { + return nil, err + } + key2, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) + if err != nil { + return nil, err + } + key3, err := ecdsa.GenerateKey(btcec.S256(), rand.Reader) + if err != nil { + return nil, err + } + return []*ecdsa.PublicKey{&key1.PublicKey, &key2.PublicKey, &key3.PublicKey}, nil +} + +func TestGranteeAddGet(t *testing.T) { + t.Parallel() + gl := accesscontrol.NewGranteeList(createLs()) + keys, err := generateKeyListFixture() + assertNoError(t, "key generation", err) + + t.Run("Get empty grantee list should return", func(t *testing.T) { + val := gl.Get() + assert.Empty(t, val) + }) + + t.Run("Get should return value equal to put value", func(t *testing.T) { + var ( + keys2, err = generateKeyListFixture() + addList1 = []*ecdsa.PublicKey{keys[0]} + addList2 = []*ecdsa.PublicKey{keys[1], keys[2]} + addList3 = keys2 + ) + assertNoError(t, "key generation", err) + testCases := []struct { + name string + list []*ecdsa.PublicKey + }{ + { + name: "Test list = 1", + list: addList1, + }, + { + name: "Test list = duplicate1", + list: addList1, + }, + { + name: "Test list = 2", + list: addList2, + }, + { + name: "Test list = 3", + list: addList3, + }, + { + name: "Test empty add list", + list: nil, + }, + } + + expList := []*ecdsa.PublicKey{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := gl.Add(tc.list) + if tc.list == nil { + assertError(t, "granteelist add", err) + } else { + assertNoError(t, "granteelist add", err) + if tc.name != "Test list = duplicate1" { + expList = append(expList, tc.list...) + } + retVal := gl.Get() + assert.Equal(t, expList, retVal) + } + }) + } + }) +} + +func TestGranteeRemove(t *testing.T) { + t.Parallel() + gl := accesscontrol.NewGranteeList(createLs()) + keys, err := generateKeyListFixture() + assertNoError(t, "key generation", err) + + t.Run("Add should NOT return", func(t *testing.T) { + err := gl.Add(keys) + assertNoError(t, "granteelist add", err) + retVal := gl.Get() + assert.Equal(t, keys, retVal) + }) + removeList1 := []*ecdsa.PublicKey{keys[0]} + removeList2 := []*ecdsa.PublicKey{keys[2], keys[1]} + t.Run("Remove the first item should return NO", func(t *testing.T) { + err := gl.Remove(removeList1) + assertNoError(t, "granteelist remove", err) + retVal := gl.Get() + assert.Equal(t, removeList2, retVal) + }) + t.Run("Remove non-existent item should return NO", func(t *testing.T) { + err := gl.Remove(removeList1) + assertNoError(t, "granteelist remove", err) + retVal := gl.Get() + assert.Equal(t, removeList2, retVal) + }) + t.Run("Remove second and third item should return NO", func(t *testing.T) { + err := gl.Remove(removeList2) + assertNoError(t, "granteelist remove", err) + retVal := gl.Get() + assert.Empty(t, retVal) + }) + t.Run("Remove from empty grantee list should return", func(t *testing.T) { + err := gl.Remove(removeList1) + assertError(t, "remove from empty grantee list", err) + retVal := gl.Get() + assert.Empty(t, retVal) + }) + t.Run("Remove empty remove list should return", func(t *testing.T) { + err := gl.Remove(nil) + assertError(t, "remove empty list", err) + retVal := gl.Get() + assert.Empty(t, retVal) + }) +} + +func TestGranteeSave(t *testing.T) { + t.Parallel() + ctx := context.Background() + keys, err := generateKeyListFixture() + assertNoError(t, "key generation", err) + + t.Run("Create grantee list with invalid reference, expect", func(t *testing.T) { + gl, err := accesscontrol.NewGranteeListReference(ctx, createLs(), swarm.RandAddress(t)) + assertError(t, "create grantee list ref", err) + assert.Nil(t, gl) + }) + t.Run("Save empty grantee list return NO", func(t *testing.T) { + gl := accesscontrol.NewGranteeList(createLs()) + _, err := gl.Save(ctx) + assertNoError(t, "granteelist save", err) + }) + t.Run("Save not empty grantee list return valid swarm address", func(t *testing.T) { + gl := accesscontrol.NewGranteeList(createLs()) + err = gl.Add(keys) + assertNoError(t, "granteelist add", err) + ref, err := gl.Save(ctx) + assertNoError(t, "granteelist save", err) + assert.True(t, ref.IsValidNonEmpty()) + }) + t.Run("Save grantee list with one item, no error, pre-save value exist", func(t *testing.T) { + ls := createLs() + gl1 := accesscontrol.NewGranteeList(ls) + + err := gl1.Add(keys) + assertNoError(t, "granteelist add", err) + + ref, err := gl1.Save(ctx) + assertNoError(t, "1st granteelist save", err) + + gl2, err := accesscontrol.NewGranteeListReference(ctx, ls, ref) + assertNoError(t, "create grantee list ref", err) + val := gl2.Get() + assertNoError(t, "2nd granteelist save", err) + assert.Equal(t, keys, val) + }) + t.Run("Save grantee list and add one item, no error, after-save value exist", func(t *testing.T) { + ls := createLs() + keys2, err := generateKeyListFixture() + assertNoError(t, "key generation", err) + + gl1 := accesscontrol.NewGranteeList(ls) + + err = gl1.Add(keys) + assertNoError(t, "granteelist1 add", err) + + ref, err := gl1.Save(ctx) + assertNoError(t, "granteelist1 save", err) + + gl2, err := accesscontrol.NewGranteeListReference(ctx, ls, ref) + assertNoError(t, "create grantee list ref", err) + err = gl2.Add(keys2) + assertNoError(t, "create grantee list ref", err) + + val := gl2.Get() + assert.Equal(t, append(keys, keys2...), val) + }) +} + +func TestGranteeRemoveTwo(t *testing.T) { + gl := accesscontrol.NewGranteeList(createLs()) + keys, err := generateKeyListFixture() + assertNoError(t, "key generation", err) + err = gl.Add([]*ecdsa.PublicKey{keys[0]}) + assertNoError(t, "1st granteelist add", err) + err = gl.Add([]*ecdsa.PublicKey{keys[0]}) + assertNoError(t, "2nd granteelist add", err) + err = gl.Remove([]*ecdsa.PublicKey{keys[0]}) + assertNoError(t, "granteelist remove", err) +} diff --git a/pkg/accesscontrol/history.go b/pkg/accesscontrol/history.go new file mode 100644 index 00000000000..b8aba6e5cc9 --- /dev/null +++ b/pkg/accesscontrol/history.go @@ -0,0 +1,199 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "time" + + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/manifest" + "github.com/ethersphere/bee/v2/pkg/manifest/mantaray" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +var ( + // ErrEndIteration indicates that the iteration terminated. + ErrEndIteration = errors.New("end iteration") + // ErrUnexpectedType indicates that an error occurred during the mantary-manifest creation. + ErrUnexpectedType = errors.New("unexpected type") + // ErrInvalidTimestamp indicates that the timestamp given to Lookup is invalid. + ErrInvalidTimestamp = errors.New("invalid timestamp") + // ErrNotFound is returned when an Entry is not found in the history. + ErrNotFound = errors.New("access control: not found") +) + +// History represents the interface for managing access control history. +type History interface { + // Add adds a new entry to the access control history with the given timestamp and metadata. + Add(ctx context.Context, ref swarm.Address, timestamp *int64, metadata *map[string]string) error + // Lookup retrieves the entry from the history based on the given timestamp or returns error if not found. + Lookup(ctx context.Context, timestamp int64) (manifest.Entry, error) + // Store stores the history to the underlying storage and returns the reference. + Store(ctx context.Context) (swarm.Address, error) +} + +var _ History = (*HistoryStruct)(nil) + +// manifestInterface extends the `manifest.Interface` interface and adds a `Root` method. +type manifestInterface interface { + manifest.Interface + Root() *mantaray.Node +} + +// HistoryStruct represents an access control histroy with a mantaray-based manifest. +type HistoryStruct struct { + manifest manifestInterface + ls file.LoadSaver +} + +// NewHistory creates a new history with a mantaray-based manifest. +func NewHistory(ls file.LoadSaver) (*HistoryStruct, error) { + m, err := manifest.NewMantarayManifest(ls, false) + if err != nil { + return nil, fmt.Errorf("failed to create mantaray manifest: %w", err) + } + + mm, ok := m.(manifestInterface) + if !ok { + return nil, fmt.Errorf("%w: expected MantarayManifest, got %T", ErrUnexpectedType, m) + } + + return &HistoryStruct{manifest: mm, ls: ls}, nil +} + +// NewHistoryReference loads a history with a mantaray-based manifest. +func NewHistoryReference(ls file.LoadSaver, ref swarm.Address) (*HistoryStruct, error) { + m, err := manifest.NewMantarayManifestReference(ref, ls) + if err != nil { + return nil, fmt.Errorf("failed to create mantaray manifest reference: %w", err) + } + + mm, ok := m.(manifestInterface) + if !ok { + return nil, fmt.Errorf("%w: expected MantarayManifest, got %T", ErrUnexpectedType, m) + } + + return &HistoryStruct{manifest: mm, ls: ls}, nil +} + +// Add adds a new entry to the access control history with the given timestamp and metadata. +func (h *HistoryStruct) Add(ctx context.Context, ref swarm.Address, timestamp *int64, metadata *map[string]string) error { + mtdt := map[string]string{} + if metadata != nil { + mtdt = *metadata + } + // add timestamps transformed so that the latests timestamp becomes the smallest key. + unixTime := time.Now().Unix() + if timestamp != nil { + unixTime = *timestamp + } + + key := strconv.FormatInt(math.MaxInt64-unixTime, 10) + err := h.manifest.Add(ctx, key, manifest.NewEntry(ref, mtdt)) + if err != nil { + return fmt.Errorf("failed to add to manifest: %w", err) + } + + return nil +} + +// Lookup retrieves the entry from the history based on the given timestamp or returns error if not found. +func (h *HistoryStruct) Lookup(ctx context.Context, timestamp int64) (manifest.Entry, error) { + if timestamp <= 0 { + return manifest.NewEntry(swarm.ZeroAddress, map[string]string{}), ErrInvalidTimestamp + } + + reversedTimestamp := math.MaxInt64 - timestamp + node, err := h.lookupNode(ctx, reversedTimestamp) + if err != nil { + switch { + case errors.Is(err, manifest.ErrNotFound): + return manifest.NewEntry(swarm.ZeroAddress, map[string]string{}), ErrNotFound + default: + return manifest.NewEntry(swarm.ZeroAddress, map[string]string{}), err + } + } + + if node != nil { + return manifest.NewEntry(swarm.NewAddress(node.Entry()), node.Metadata()), nil + } + + return manifest.NewEntry(swarm.ZeroAddress, map[string]string{}), ErrNotFound +} + +func (h *HistoryStruct) lookupNode(ctx context.Context, searchedTimestamp int64) (*mantaray.Node, error) { + // before node's timestamp is the closest one that is less than or equal to the searched timestamp + // for instance: 2030, 2020, 1994 -> search for 2021 -> before is 2020 + var beforeNode *mantaray.Node + // after node's timestamp is after the latest + // for instance: 2030, 2020, 1994 -> search for 1980 -> after is 1994 + var afterNode *mantaray.Node + + walker := func(pathTimestamp []byte, currNode *mantaray.Node, err error) error { + if err != nil { + return err + } + + if currNode.IsValueType() && len(currNode.Entry()) > 0 { + afterNode = currNode + + match, err := isBeforeMatch(pathTimestamp, searchedTimestamp) + if match { + beforeNode = currNode + // return error to stop the walk, this is how WalkNode works... + return ErrEndIteration + } + + return err + } + + return nil + } + + rootNode := h.manifest.Root() + err := rootNode.WalkNode(ctx, []byte{}, h.ls, walker) + + if err != nil && !errors.Is(err, ErrEndIteration) { + return nil, fmt.Errorf("history lookup node error: %w", err) + } + + if beforeNode != nil { + return beforeNode, nil + } + if afterNode != nil { + return afterNode, nil + } + return nil, nil +} + +// Store stores the history to the underlying storage and returns the reference. +func (h *HistoryStruct) Store(ctx context.Context) (swarm.Address, error) { + return h.manifest.Store(ctx) +} + +func bytesToInt64(b []byte) (int64, error) { + num, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return -1, err + } + + return num, nil +} + +func isBeforeMatch(pathTimestamp []byte, searchedTimestamp int64) (bool, error) { + targetTimestamp, err := bytesToInt64(pathTimestamp) + if err != nil { + return false, err + } + if targetTimestamp == 0 { + return false, nil + } + return searchedTimestamp <= targetTimestamp, nil +} diff --git a/pkg/accesscontrol/history_test.go b/pkg/accesscontrol/history_test.go new file mode 100644 index 00000000000..7c4d0ff0168 --- /dev/null +++ b/pkg/accesscontrol/history_test.go @@ -0,0 +1,164 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol_test + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/pipeline" + "github.com/ethersphere/bee/v2/pkg/file/pipeline/builder" + "github.com/ethersphere/bee/v2/pkg/storage" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/stretchr/testify/assert" +) + +func TestHistoryAdd(t *testing.T) { + t.Parallel() + h, err := accesscontrol.NewHistory(nil) + assertNoError(t, "create history", err) + + addr := swarm.NewAddress([]byte("addr")) + + ctx := context.Background() + + err = h.Add(ctx, addr, nil, nil) + assertNoError(t, "history add", err) +} + +func TestSingleNodeHistoryLookup(t *testing.T) { + t.Parallel() + storer := mockstorer.New() + ctx := context.Background() + ls := loadsave.New(storer.ChunkStore(), storer.Cache(), pipelineFactory(storer.Cache(), false)) + + h, err := accesscontrol.NewHistory(ls) + assertNoError(t, "create history", err) + + testActRef := swarm.RandAddress(t) + err = h.Add(ctx, testActRef, nil, nil) + assertNoError(t, "history add", err) + + _, err = h.Store(ctx) + assertNoError(t, "store history", err) + + searchedTime := time.Now().Unix() + entry, err := h.Lookup(ctx, searchedTime) + assertNoError(t, "history lookup", err) + actRef := entry.Reference() + assert.True(t, actRef.Equal(testActRef)) + assert.Nil(t, entry.Metadata()) +} + +func TestMultiNodeHistoryLookup(t *testing.T) { + t.Parallel() + storer := mockstorer.New() + ctx := context.Background() + ls := loadsave.New(storer.ChunkStore(), storer.Cache(), pipelineFactory(storer.Cache(), false)) + + h, err := accesscontrol.NewHistory(ls) + assertNoError(t, "create history", err) + + testActRef1 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd891")) + firstTime := time.Date(1994, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + mtdt1 := map[string]string{"firstTime": "1994-04-01"} + err = h.Add(ctx, testActRef1, &firstTime, &mtdt1) + assertNoError(t, "1st history add", err) + + testActRef2 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd892")) + secondTime := time.Date(2000, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + mtdt2 := map[string]string{"secondTime": "2000-04-01"} + err = h.Add(ctx, testActRef2, &secondTime, &mtdt2) + assertNoError(t, "2nd history add", err) + + testActRef3 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd893")) + thirdTime := time.Date(2015, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + mtdt3 := map[string]string{"thirdTime": "2015-04-01"} + err = h.Add(ctx, testActRef3, &thirdTime, &mtdt3) + assertNoError(t, "3rd history add", err) + + testActRef4 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd894")) + fourthTime := time.Date(2020, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + mtdt4 := map[string]string{"fourthTime": "2020-04-01"} + err = h.Add(ctx, testActRef4, &fourthTime, &mtdt4) + assertNoError(t, "4th history add", err) + + testActRef5 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd895")) + fifthTime := time.Date(2030, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + mtdt5 := map[string]string{"fifthTime": "2030-04-01"} + err = h.Add(ctx, testActRef5, &fifthTime, &mtdt5) + assertNoError(t, "5th history add", err) + + // latest + searchedTime := time.Date(1980, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + entry, err := h.Lookup(ctx, searchedTime) + assertNoError(t, "1st history lookup", err) + actRef := entry.Reference() + assert.True(t, actRef.Equal(testActRef1)) + assert.True(t, reflect.DeepEqual(mtdt1, entry.Metadata())) + + // before first time + searchedTime = time.Date(2021, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + entry, err = h.Lookup(ctx, searchedTime) + assertNoError(t, "2nd history lookup", err) + actRef = entry.Reference() + assert.True(t, actRef.Equal(testActRef4)) + assert.True(t, reflect.DeepEqual(mtdt4, entry.Metadata())) + + // same time + searchedTime = time.Date(2000, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + entry, err = h.Lookup(ctx, searchedTime) + assertNoError(t, "3rd history lookup", err) + actRef = entry.Reference() + assert.True(t, actRef.Equal(testActRef2)) + assert.True(t, reflect.DeepEqual(mtdt2, entry.Metadata())) + + // after time + searchedTime = time.Date(2045, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + entry, err = h.Lookup(ctx, searchedTime) + assertNoError(t, "4th history lookup", err) + actRef = entry.Reference() + assert.True(t, actRef.Equal(testActRef5)) + assert.True(t, reflect.DeepEqual(mtdt5, entry.Metadata())) +} + +func TestHistoryStore(t *testing.T) { + t.Parallel() + storer := mockstorer.New() + ctx := context.Background() + ls := loadsave.New(storer.ChunkStore(), storer.Cache(), pipelineFactory(storer.Cache(), false)) + + h1, err := accesscontrol.NewHistory(ls) + assertNoError(t, "create history", err) + + testActRef1 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd891")) + firstTime := time.Date(1994, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + mtdt1 := map[string]string{"firstTime": "1994-04-01"} + err = h1.Add(ctx, testActRef1, &firstTime, &mtdt1) + assertNoError(t, "history add", err) + + href1, err := h1.Store(ctx) + assertNoError(t, "store history", err) + + h2, err := accesscontrol.NewHistoryReference(ls, href1) + assertNoError(t, "create history ref", err) + + entry1, err := h2.Lookup(ctx, firstTime) + assertNoError(t, "history lookup", err) + actRef1 := entry1.Reference() + assert.True(t, actRef1.Equal(testActRef1)) + assert.True(t, reflect.DeepEqual(mtdt1, entry1.Metadata())) +} + +func pipelineFactory(s storage.Putter, encrypt bool) func() pipeline.Interface { + return func() pipeline.Interface { + return builder.NewPipelineBuilder(context.Background(), s, encrypt, 0) + } +} diff --git a/pkg/accesscontrol/kvs/kvs.go b/pkg/accesscontrol/kvs/kvs.go new file mode 100644 index 00000000000..a5f644e60fe --- /dev/null +++ b/pkg/accesscontrol/kvs/kvs.go @@ -0,0 +1,106 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package kvs provides functionalities needed +// for storing key-value pairs on Swarm. +// +//nolint:ireturn +package kvs + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/manifest" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +var ( + // ErrNothingToSave indicates that no new key-value pair was added to the store. + ErrNothingToSave = errors.New("nothing to save") + // ErrNotFound is returned when an Entry is not found in the storage. + ErrNotFound = errors.New("kvs entry not found") +) + +// KeyValueStore represents a key-value store. +type KeyValueStore interface { + // Get retrieves the value associated with the given key. + Get(ctx context.Context, key []byte) ([]byte, error) + // Put stores the given key-value pair in the store. + Put(ctx context.Context, key, value []byte) error + // Save saves key-value pair to the underlying storage and returns the reference. + Save(ctx context.Context) (swarm.Address, error) +} + +type keyValueStore struct { + manifest manifest.Interface + putCnt int +} + +var _ KeyValueStore = (*keyValueStore)(nil) + +// Get retrieves the value associated with the given key. +func (s *keyValueStore) Get(ctx context.Context, key []byte) ([]byte, error) { + entry, err := s.manifest.Lookup(ctx, hex.EncodeToString(key)) + if err != nil { + switch { + case errors.Is(err, manifest.ErrNotFound): + return nil, ErrNotFound + default: + return nil, fmt.Errorf("failed to get value from manifest %w", err) + } + } + ref := entry.Reference() + return ref.Bytes(), nil +} + +// Put stores the given key-value pair in the store. +func (s *keyValueStore) Put(ctx context.Context, key []byte, value []byte) error { + err := s.manifest.Add(ctx, hex.EncodeToString(key), manifest.NewEntry(swarm.NewAddress(value), map[string]string{})) + if err != nil { + return fmt.Errorf("failed to put value to manifest %w", err) + } + s.putCnt++ + return nil +} + +// Save saves key-value pair to the underlying storage and returns the reference. +func (s *keyValueStore) Save(ctx context.Context) (swarm.Address, error) { + if s.putCnt == 0 { + return swarm.ZeroAddress, ErrNothingToSave + } + ref, err := s.manifest.Store(ctx) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("failed to store manifest %w", err) + } + s.putCnt = 0 + return ref, nil +} + +// New creates a new key-value store with a simple manifest. +func New(ls file.LoadSaver) (KeyValueStore, error) { + m, err := manifest.NewSimpleManifest(ls) + if err != nil { + return nil, fmt.Errorf("failed to create simple manifest: %w", err) + } + + return &keyValueStore{ + manifest: m, + }, nil +} + +// NewReference loads a key-value store with a simple manifest. +func NewReference(ls file.LoadSaver, ref swarm.Address) (KeyValueStore, error) { + m, err := manifest.NewSimpleManifestReference(ref, ls) + if err != nil { + return nil, fmt.Errorf("failed to create simple manifest reference: %w", err) + } + + return &keyValueStore{ + manifest: m, + }, nil +} diff --git a/pkg/accesscontrol/kvs/kvs_test.go b/pkg/accesscontrol/kvs/kvs_test.go new file mode 100644 index 00000000000..ca2227e8f11 --- /dev/null +++ b/pkg/accesscontrol/kvs/kvs_test.go @@ -0,0 +1,184 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//nolint:ireturn +package kvs_test + +import ( + "context" + "testing" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol/kvs" + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/pipeline" + "github.com/ethersphere/bee/v2/pkg/file/pipeline/builder" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/storage" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/stretchr/testify/assert" +) + +//nolint:gochecknoglobals +var mockStorer = mockstorer.New() + +func requestPipelineFactory(ctx context.Context, s storage.Putter, encrypt bool, rLevel redundancy.Level) func() pipeline.Interface { + return func() pipeline.Interface { + return builder.NewPipelineBuilder(ctx, s, encrypt, rLevel) + } +} + +func createLs() file.LoadSaver { + return loadsave.New(mockStorer.ChunkStore(), mockStorer.Cache(), requestPipelineFactory(context.Background(), mockStorer.Cache(), false, redundancy.NONE)) +} + +func keyValuePair(t *testing.T) ([]byte, []byte) { + t.Helper() + return swarm.RandAddress(t).Bytes(), swarm.RandAddress(t).Bytes() +} + +func TestKvs(t *testing.T) { + t.Parallel() + s, err := kvs.New(createLs()) + assert.NoError(t, err) + + key, val := keyValuePair(t) + ctx := context.Background() + + t.Run("Get non-existent key should return error", func(t *testing.T) { + _, err := s.Get(ctx, []byte{1}) + assert.Error(t, err) + }) + + t.Run("Multiple Get with same key, no error", func(t *testing.T) { + err := s.Put(ctx, key, val) + assert.NoError(t, err) + + // get #1 + v, err := s.Get(ctx, key) + assert.NoError(t, err) + assert.Equal(t, val, v) + // get #2 + v, err = s.Get(ctx, key) + assert.NoError(t, err) + assert.Equal(t, val, v) + }) + + t.Run("Get should return value equal to put value", func(t *testing.T) { + var ( + key1 = []byte{1} + key2 = []byte{2} + key3 = []byte{3} + ) + testCases := []struct { + name string + key []byte + val []byte + }{ + { + name: "Test key = 1", + key: key1, + val: []byte{11}, + }, + { + name: "Test key = 2", + key: key2, + val: []byte{22}, + }, + { + name: "Test overwrite key = 1", + key: key1, + val: []byte{111}, + }, + { + name: "Test key = 3", + key: key3, + val: []byte{33}, + }, + { + name: "Test key = 3 with same value", + key: key3, + val: []byte{33}, + }, + { + name: "Test key = 3 with value for key1", + key: key3, + val: []byte{11}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := s.Put(ctx, tc.key, tc.val) + assert.NoError(t, err) + retVal, err := s.Get(ctx, tc.key) + assert.NoError(t, err) + assert.Equal(t, tc.val, retVal) + }) + } + }) +} + +func TestKvs_Save(t *testing.T) { + t.Parallel() + ctx := context.Background() + + key1, val1 := keyValuePair(t) + key2, val2 := keyValuePair(t) + t.Run("Save empty KVS return error", func(t *testing.T) { + s, err := kvs.New(createLs()) + assert.NoError(t, err) + _, err = s.Save(ctx) + assert.ErrorIs(t, err, kvs.ErrNothingToSave) + }) + t.Run("Save not empty KVS return valid swarm address", func(t *testing.T) { + s, err := kvs.New(createLs()) + assert.NoError(t, err) + err = s.Put(ctx, key1, val1) + assert.NoError(t, err) + ref, err := s.Save(ctx) + assert.NoError(t, err) + assert.True(t, ref.IsValidNonEmpty()) + }) + t.Run("Save KVS with one item, no error, pre-save value exist", func(t *testing.T) { + ls := createLs() + s1, err := kvs.New(ls) + assert.NoError(t, err) + + err = s1.Put(ctx, key1, val1) + assert.NoError(t, err) + + ref, err := s1.Save(ctx) + assert.NoError(t, err) + + s2, err := kvs.NewReference(ls, ref) + assert.NoError(t, err) + + val, err := s2.Get(ctx, key1) + assert.NoError(t, err) + assert.Equal(t, val1, val) + }) + t.Run("Save KVS and add one item, no error, after-save value exist", func(t *testing.T) { + ls := createLs() + + kvs1, err := kvs.New(ls) + assert.NoError(t, err) + + err = kvs1.Put(ctx, key1, val1) + assert.NoError(t, err) + ref, err := kvs1.Save(ctx) + assert.NoError(t, err) + + // New KVS + kvs2, err := kvs.NewReference(ls, ref) + assert.NoError(t, err) + err = kvs2.Put(ctx, key2, val2) + assert.NoError(t, err) + + val, err := kvs2.Get(ctx, key2) + assert.NoError(t, err) + assert.Equal(t, val2, val) + }) +} diff --git a/pkg/accesscontrol/kvs/mock/kvs.go b/pkg/accesscontrol/kvs/mock/kvs.go new file mode 100644 index 00000000000..a3d2ec3f56c --- /dev/null +++ b/pkg/accesscontrol/kvs/mock/kvs.go @@ -0,0 +1,83 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package mock provides an in-memory key-value store implementation. +package mock + +import ( + "context" + "encoding/hex" + "sync" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol/kvs" + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +var lock = &sync.Mutex{} +var lockGetPut = &sync.Mutex{} + +type single struct { + memoryMock map[string]map[string][]byte +} + +var singleInMemorySwarm *single + +func getInMemorySwarm() *single { + if singleInMemorySwarm == nil { + lock.Lock() + defer lock.Unlock() + if singleInMemorySwarm == nil { + singleInMemorySwarm = &single{ + memoryMock: make(map[string]map[string][]byte), + } + } + } + return singleInMemorySwarm +} + +func getMemory() map[string]map[string][]byte { + ch := make(chan *single) + go func() { + ch <- getInMemorySwarm() + }() + mem := <-ch + return mem.memoryMock +} + +type mockKeyValueStore struct { + address swarm.Address +} + +var _ kvs.KeyValueStore = (*mockKeyValueStore)(nil) + +func (m *mockKeyValueStore) Get(_ context.Context, key []byte) ([]byte, error) { + lockGetPut.Lock() + defer lockGetPut.Unlock() + mem := getMemory() + val := mem[m.address.String()][hex.EncodeToString(key)] + return val, nil +} + +func (m *mockKeyValueStore) Put(_ context.Context, key []byte, value []byte) error { + lockGetPut.Lock() + defer lockGetPut.Unlock() + mem := getMemory() + if _, ok := mem[m.address.String()]; !ok { + mem[m.address.String()] = make(map[string][]byte) + } + mem[m.address.String()][hex.EncodeToString(key)] = value + return nil +} + +func (m *mockKeyValueStore) Save(ctx context.Context) (swarm.Address, error) { + return m.address, nil +} + +func New() kvs.KeyValueStore { + return &mockKeyValueStore{address: swarm.EmptyAddress} +} + +func NewReference(address swarm.Address) kvs.KeyValueStore { + return &mockKeyValueStore{address: address} +} diff --git a/pkg/accesscontrol/mock/controller.go b/pkg/accesscontrol/mock/controller.go new file mode 100644 index 00000000000..efbe53c6013 --- /dev/null +++ b/pkg/accesscontrol/mock/controller.go @@ -0,0 +1,205 @@ +// Copyright 2020 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package mock provides a mock implementation for the +// access control functionalities. +// +//nolint:ireturn +package mock + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "fmt" + "time" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/encryption" + "github.com/ethersphere/bee/v2/pkg/file" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/pipeline" + "github.com/ethersphere/bee/v2/pkg/file/pipeline/builder" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/storage" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" + "golang.org/x/crypto/sha3" +) + +type mockController struct { + historyMap map[string]accesscontrol.History + refMap map[string]swarm.Address + acceptAll bool + publisher string + encrypter encryption.Interface + ls file.LoadSaver +} + +type optionFunc func(*mockController) + +// Option is an option passed to a mock accesscontrol Controller. +type Option interface { + apply(*mockController) +} + +func (f optionFunc) apply(r *mockController) { f(r) } + +// New creates a new mock accesscontrol Controller. +func New(o ...Option) accesscontrol.Controller { + storer := mockstorer.New() + m := &mockController{ + historyMap: make(map[string]accesscontrol.History), + refMap: make(map[string]swarm.Address), + publisher: "", + encrypter: encryption.New(encryption.Key("b6ee086390c280eeb9824c331a4427596f0c8510d5564bc1b6168d0059a46e2b"), 0, 0, sha3.NewLegacyKeccak256), + ls: loadsave.New(storer.ChunkStore(), storer.Cache(), requestPipelineFactory(context.Background(), storer.Cache(), false, redundancy.NONE)), + } + for _, v := range o { + v.apply(m) + } + + return m +} + +// WithAcceptAll sets the mock to return fixed references on every call to DownloadHandler. +func WithAcceptAll() Option { + return optionFunc(func(m *mockController) { m.acceptAll = true }) +} + +// WithHistory sets the mock to use the given history reference. +func WithHistory(h accesscontrol.History, ref string) Option { + return optionFunc(func(m *mockController) { + m.historyMap = map[string]accesscontrol.History{ref: h} + }) +} + +// WithPublisher sets the mock to use the given reference as the publisher address. +func WithPublisher(ref string) Option { + return optionFunc(func(m *mockController) { + m.publisher = ref + m.encrypter = encryption.New(encryption.Key(ref), 0, 0, sha3.NewLegacyKeccak256) + }) +} + +func (m *mockController) DownloadHandler(ctx context.Context, ls file.LoadSaver, encryptedRef swarm.Address, publisher *ecdsa.PublicKey, historyRootHash swarm.Address, timestamp int64) (swarm.Address, error) { + if m.acceptAll { + return swarm.ParseHexAddress("36e6c1bbdfee6ac21485d5f970479fd1df458d36df9ef4e8179708ed46da557f") + } + + publicKeyBytes := crypto.EncodeSecp256k1PublicKey(publisher) + p := hex.EncodeToString(publicKeyBytes) + if m.publisher != "" && m.publisher != p { + return swarm.ZeroAddress, accesscontrol.ErrInvalidPublicKey + } + + h, exists := m.historyMap[historyRootHash.String()] + if !exists { + return swarm.ZeroAddress, accesscontrol.ErrNotFound + } + entry, err := h.Lookup(ctx, timestamp) + if err != nil { + return swarm.ZeroAddress, err + } + kvsRef := entry.Reference() + if kvsRef.IsZero() { + return swarm.ZeroAddress, err + } + return m.refMap[encryptedRef.String()], nil +} + +func (m *mockController) UploadHandler(ctx context.Context, ls file.LoadSaver, reference swarm.Address, publisher *ecdsa.PublicKey, historyRootHash swarm.Address) (swarm.Address, swarm.Address, swarm.Address, error) { + historyRef, _ := swarm.ParseHexAddress("67bdf80a9bbea8eca9c8480e43fdceb485d2d74d5708e45144b8c4adacd13d9c") + kvsRef, _ := swarm.ParseHexAddress("3339613565613837623134316665343461613630396333333237656364383934") + if m.acceptAll { + encryptedRef, _ := swarm.ParseHexAddress("fc4e9fe978991257b897d987bc4ff13058b66ef45a53189a0b4fe84bb3346396") + return kvsRef, historyRef, encryptedRef, nil + } + var ( + h accesscontrol.History + exists bool + ) + + publicKeyBytes := crypto.EncodeSecp256k1PublicKey(publisher) + p := hex.EncodeToString(publicKeyBytes) + if m.publisher != "" && m.publisher != p { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, accesscontrol.ErrInvalidPublicKey + } + + now := time.Now().Unix() + if !historyRootHash.IsZero() { + historyRef = historyRootHash + h, exists = m.historyMap[historyRef.String()] + if !exists { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, accesscontrol.ErrNotFound + } + entry, err := h.Lookup(ctx, now) + if err != nil { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, err + } + kvsRef := entry.Reference() + if kvsRef.IsZero() { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, fmt.Errorf("kvs not found") + } + } else { + h, _ = accesscontrol.NewHistory(m.ls) + _ = h.Add(ctx, kvsRef, &now, nil) + historyRef, _ = h.Store(ctx) + m.historyMap[historyRef.String()] = h + } + + encryptedRef, _ := m.encrypter.Encrypt(reference.Bytes()) + m.refMap[(hex.EncodeToString(encryptedRef))] = reference + return kvsRef, historyRef, swarm.NewAddress(encryptedRef), nil +} + +func (m *mockController) Close() error { + return nil +} + +func (m *mockController) UpdateHandler(_ context.Context, ls file.LoadSaver, gls file.LoadSaver, encryptedglref swarm.Address, historyref swarm.Address, publisher *ecdsa.PublicKey, addList []*ecdsa.PublicKey, removeList []*ecdsa.PublicKey) (swarm.Address, swarm.Address, swarm.Address, swarm.Address, error) { + if historyref.Equal(swarm.EmptyAddress) || encryptedglref.Equal(swarm.EmptyAddress) { + return swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, swarm.ZeroAddress, accesscontrol.ErrNotFound + } + historyRef, _ := swarm.ParseHexAddress("67bdf80a9bbea8eca9c8480e43fdceb485d2d74d5708e45144b8c4adacd13d9c") + glRef, _ := swarm.ParseHexAddress("3339613565613837623134316665343461613630396333333237656364383934") + eglRef, _ := swarm.ParseHexAddress("fc4e9fe978991257b897d987bc4ff13058b66ef45a53189a0b4fe84bb3346396") + actref, _ := swarm.ParseHexAddress("39a5ea87b141fe44aa609c3327ecd896c0e2122897f5f4bbacf74db1033c5559") + return glRef, eglRef, historyRef, actref, nil +} + +func (m *mockController) Get(ctx context.Context, ls file.LoadSaver, publisher *ecdsa.PublicKey, encryptedglref swarm.Address) ([]*ecdsa.PublicKey, error) { + if m.publisher == "" { + return nil, fmt.Errorf("granteelist not found") + } + keys := []string{ + "a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa", + "b786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfb", + "c786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfc", + } + pubkeys := make([]*ecdsa.PublicKey, 0, len(keys)) + for i := range keys { + data, err := hex.DecodeString(keys[i]) + if err != nil { + panic(err) + } + + privKey, err := crypto.DecodeSecp256k1PrivateKey(data) + pubKey := privKey.PublicKey + if err != nil { + panic(err) + } + pubkeys = append(pubkeys, &pubKey) + } + return pubkeys, nil +} + +func requestPipelineFactory(ctx context.Context, s storage.Putter, encrypt bool, rLevel redundancy.Level) func() pipeline.Interface { + return func() pipeline.Interface { + return builder.NewPipelineBuilder(ctx, s, encrypt, rLevel) + } +} + +var _ accesscontrol.Controller = (*mockController)(nil) diff --git a/pkg/accesscontrol/mock/grantee.go b/pkg/accesscontrol/mock/grantee.go new file mode 100644 index 00000000000..6a8f01c87cb --- /dev/null +++ b/pkg/accesscontrol/mock/grantee.go @@ -0,0 +1,55 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package mock + +import ( + "crypto/ecdsa" + + "github.com/ethersphere/bee/v2/pkg/swarm" +) + +type GranteeListMock interface { + Add(publicKeys []*ecdsa.PublicKey) error + Remove(removeList []*ecdsa.PublicKey) error + Get() []*ecdsa.PublicKey + Save() (swarm.Address, error) +} + +type GranteeListStructMock struct { + grantees []*ecdsa.PublicKey +} + +func (g *GranteeListStructMock) Get() []*ecdsa.PublicKey { + grantees := g.grantees + keys := make([]*ecdsa.PublicKey, len(grantees)) + copy(keys, grantees) + return keys +} + +func (g *GranteeListStructMock) Add(addList []*ecdsa.PublicKey) error { + g.grantees = append(g.grantees, addList...) + return nil +} + +func (g *GranteeListStructMock) Remove(removeList []*ecdsa.PublicKey) error { + for _, remove := range removeList { + for i, grantee := range g.grantees { + if *grantee == *remove { + g.grantees[i] = g.grantees[len(g.grantees)-1] + g.grantees = g.grantees[:len(g.grantees)-1] + } + } + } + + return nil +} + +func (g *GranteeListStructMock) Save() (swarm.Address, error) { + return swarm.EmptyAddress, nil +} + +func NewGranteeList() *GranteeListStructMock { + return &GranteeListStructMock{grantees: []*ecdsa.PublicKey{}} +} diff --git a/pkg/accesscontrol/mock/session.go b/pkg/accesscontrol/mock/session.go new file mode 100644 index 00000000000..6e5d43a9576 --- /dev/null +++ b/pkg/accesscontrol/mock/session.go @@ -0,0 +1,44 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package mock + +import ( + "crypto/ecdsa" + + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/keystore" +) + +type SessionMock struct { + KeyFunc func(publicKey *ecdsa.PublicKey, nonces [][]byte) ([][]byte, error) + key *ecdsa.PrivateKey +} + +func (s *SessionMock) Key(publicKey *ecdsa.PublicKey, nonces [][]byte) ([][]byte, error) { + if s.KeyFunc == nil { + return nil, nil + } + return s.KeyFunc(publicKey, nonces) +} + +func NewSessionMock(key *ecdsa.PrivateKey) *SessionMock { + return &SessionMock{key: key} +} + +func NewFromKeystore( + ks keystore.Service, + tag, + password string, + keyFunc func(publicKey *ecdsa.PublicKey, nonces [][]byte) ([][]byte, error), +) *SessionMock { + key, created, err := ks.Key(tag, password, crypto.EDGSecp256_K1) + if !created || err != nil { + return nil + } + return &SessionMock{ + key: key, + KeyFunc: keyFunc, + } +} diff --git a/pkg/accesscontrol/session.go b/pkg/accesscontrol/session.go new file mode 100644 index 00000000000..a571337c17d --- /dev/null +++ b/pkg/accesscontrol/session.go @@ -0,0 +1,66 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol + +import ( + "crypto/ecdsa" + "errors" + "fmt" + + "github.com/ethersphere/bee/v2/pkg/crypto" +) + +var ( + // ErrInvalidPublicKey is an error that is returned when a public key is nil. + ErrInvalidPublicKey = errors.New("invalid public key") + // ErrSecretKeyInfinity is an error that is returned when the shared secret is a point at infinity. + ErrSecretKeyInfinity = errors.New("shared secret is point at infinity") +) + +// Session represents an interface for a Diffie-Hellmann key derivation +type Session interface { + // Key returns a derived key for each nonce. + Key(publicKey *ecdsa.PublicKey, nonces [][]byte) ([][]byte, error) +} + +var _ Session = (*SessionStruct)(nil) + +// SessionStruct represents a session with an access control key. +type SessionStruct struct { + key *ecdsa.PrivateKey +} + +// Key returns a derived key for each nonce. +func (s *SessionStruct) Key(publicKey *ecdsa.PublicKey, nonces [][]byte) ([][]byte, error) { + if publicKey == nil { + return nil, ErrInvalidPublicKey + } + x, y := publicKey.Curve.ScalarMult(publicKey.X, publicKey.Y, s.key.D.Bytes()) + if x == nil || y == nil { + return nil, ErrSecretKeyInfinity + } + + if len(nonces) == 0 { + return [][]byte{(*x).Bytes()}, nil + } + + keys := make([][]byte, 0, len(nonces)) + for _, nonce := range nonces { + key, err := crypto.LegacyKeccak256(append(x.Bytes(), nonce...)) + if err != nil { + return nil, fmt.Errorf("failed to get Keccak256 hash: %w", err) + } + keys = append(keys, key) + } + + return keys, nil +} + +// NewDefaultSession creates a new session from a private key. +func NewDefaultSession(key *ecdsa.PrivateKey) *SessionStruct { + return &SessionStruct{ + key: key, + } +} diff --git a/pkg/accesscontrol/session_test.go b/pkg/accesscontrol/session_test.go new file mode 100644 index 00000000000..a8dc4628f83 --- /dev/null +++ b/pkg/accesscontrol/session_test.go @@ -0,0 +1,146 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package accesscontrol_test + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "testing" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + "github.com/ethersphere/bee/v2/pkg/accesscontrol/mock" + "github.com/ethersphere/bee/v2/pkg/crypto" + memkeystore "github.com/ethersphere/bee/v2/pkg/keystore/mem" + "github.com/stretchr/testify/assert" +) + +func mockKeyFunc(*ecdsa.PublicKey, [][]byte) ([][]byte, error) { + return [][]byte{{1}}, nil +} + +func TestSessionNewDefaultSession(t *testing.T) { + pk, err := crypto.GenerateSecp256k1Key() + assertNoError(t, "generating private key", err) + si := accesscontrol.NewDefaultSession(pk) + if si == nil { + assert.FailNow(t, "Session instance is nil") + } +} + +func TestSessionNewFromKeystore(t *testing.T) { + ks := memkeystore.New() + si := mock.NewFromKeystore(ks, "tag", "password", mockKeyFunc) + if si == nil { + assert.FailNow(t, "Session instance is nil") + } +} + +func TestSessionKey(t *testing.T) { + t.Parallel() + + key1, err := crypto.GenerateSecp256k1Key() + assertNoError(t, "key1 GenerateSecp256k1Key", err) + si1 := accesscontrol.NewDefaultSession(key1) + + key2, err := crypto.GenerateSecp256k1Key() + assertNoError(t, "key2 GenerateSecp256k1Key", err) + si2 := accesscontrol.NewDefaultSession(key2) + + nonces := make([][]byte, 2) + for i := range nonces { + if _, err := io.ReadFull(rand.Reader, nonces[i]); err != nil { + assert.FailNow(t, err.Error()) + } + } + + keys1, err := si1.Key(&key2.PublicKey, nonces) + assertNoError(t, "", err) + keys2, err := si2.Key(&key1.PublicKey, nonces) + assertNoError(t, "", err) + + if !bytes.Equal(keys1[0], keys2[0]) { + assert.FailNowf(t, fmt.Sprintf("shared secrets do not match %s, %s", hex.EncodeToString(keys1[0]), hex.EncodeToString(keys2[0])), "") + } + if !bytes.Equal(keys1[1], keys2[1]) { + assert.FailNowf(t, fmt.Sprintf("shared secrets do not match %s, %s", hex.EncodeToString(keys1[1]), hex.EncodeToString(keys2[1])), "") + } +} + +func TestSessionKeyWithoutNonces(t *testing.T) { + t.Parallel() + + key1, err := crypto.GenerateSecp256k1Key() + assertNoError(t, "key1 GenerateSecp256k1Key", err) + si1 := accesscontrol.NewDefaultSession(key1) + + key2, err := crypto.GenerateSecp256k1Key() + assertNoError(t, "key2 GenerateSecp256k1Key", err) + si2 := accesscontrol.NewDefaultSession(key2) + + keys1, err := si1.Key(&key2.PublicKey, nil) + assertNoError(t, "session1 key", err) + keys2, err := si2.Key(&key1.PublicKey, nil) + assertNoError(t, "session2 key", err) + + if !bytes.Equal(keys1[0], keys2[0]) { + assert.FailNowf(t, fmt.Sprintf("shared secrets do not match %s, %s", hex.EncodeToString(keys1[0]), hex.EncodeToString(keys2[0])), "") + } +} + +func TestSessionKeyFromKeystore(t *testing.T) { + t.Parallel() + + ks := memkeystore.New() + tag1 := "tag1" + tag2 := "tag2" + password1 := "password1" + password2 := "password2" + + si1 := mock.NewFromKeystore(ks, tag1, password1, mockKeyFunc) + exists, err := ks.Exists(tag1) + assertNoError(t, "", err) + if !exists { + assert.FailNow(t, "Key1 should exist") + + } + key1, created, err := ks.Key(tag1, password1, crypto.EDGSecp256_K1) + assertNoError(t, "", err) + if created { + assert.FailNow(t, "Key1 should not be created") + + } + + si2 := mock.NewFromKeystore(ks, tag2, password2, mockKeyFunc) + exists, err = ks.Exists(tag2) + assertNoError(t, "", err) + if !exists { + assert.FailNow(t, "Key2 should exist") + } + key2, created, err := ks.Key(tag2, password2, crypto.EDGSecp256_K1) + assertNoError(t, "", err) + if created { + assert.FailNow(t, "Key2 should not be created") + } + + nonces := make([][]byte, 1) + for i := range nonces { + if _, err := io.ReadFull(rand.Reader, nonces[i]); err != nil { + assert.FailNow(t, err.Error()) + } + } + + keys1, err := si1.Key(&key2.PublicKey, nonces) + assertNoError(t, "", err) + keys2, err := si2.Key(&key1.PublicKey, nonces) + assertNoError(t, "", err) + + if !bytes.Equal(keys1[0], keys2[0]) { + assert.FailNowf(t, fmt.Sprintf("shared secrets do not match %s, %s", hex.EncodeToString(keys1[0]), hex.EncodeToString(keys2[0])), "") + } +} diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go new file mode 100644 index 00000000000..8767eb5a823 --- /dev/null +++ b/pkg/api/accesscontrol.go @@ -0,0 +1,565 @@ +// Copyright 2024 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/jsonhttp" + "github.com/ethersphere/bee/v2/pkg/postage" + "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/storer" + "github.com/ethersphere/bee/v2/pkg/swarm" + "github.com/gorilla/mux" +) + +type addressKey struct{} + +const granteeListEncrypt = true + +// getAddressFromContext is a helper function to extract the address from the context. +func getAddressFromContext(ctx context.Context) swarm.Address { + v, ok := ctx.Value(addressKey{}).(swarm.Address) + if ok { + return v + } + return swarm.ZeroAddress +} + +// setAddressInContext sets the swarm address in the context. +func setAddressInContext(ctx context.Context, address swarm.Address) context.Context { + return context.WithValue(ctx, addressKey{}, address) +} + +// GranteesPatchRequest represents a request to patch the list of grantees. +type GranteesPatchRequest struct { + // Addlist contains the list of grantees to add. + Addlist []string `json:"add"` + + // Revokelist contains the list of grantees to revoke. + Revokelist []string `json:"revoke"` +} + +// GranteesPatchResponse represents the response structure for patching grantees. +type GranteesPatchResponse struct { + // Reference represents the swarm address. + Reference swarm.Address `json:"ref"` + // HistoryReference represents the reference to the history of an access control entry. + HistoryReference swarm.Address `json:"historyref"` +} + +// GranteesPostRequest represents the request structure for adding grantees. +type GranteesPostRequest struct { + // GranteeList represents the list of grantees to be saves on Swarm. + GranteeList []string `json:"grantees"` +} + +// GranteesPostResponse represents the response structure for adding grantees. +type GranteesPostResponse struct { + // Reference represents the saved grantee list Swarm address. + Reference swarm.Address `json:"ref"` + // HistoryReference represents the reference to the history of an access control entry. + HistoryReference swarm.Address `json:"historyref"` +} + +// GranteesPatch represents a structure for modifying the list of grantees. +type GranteesPatch struct { + // Addlist is a list of ecdsa.PublicKeys to be added to a grantee list. + Addlist []*ecdsa.PublicKey + // Revokelist is a list of ecdsa.PublicKeys to be removed from a grantee list + Revokelist []*ecdsa.PublicKey +} + +// actDecryptionHandler is a middleware that looks up and decrypts the given address, +// if the act headers are present. +func (s *Service) actDecryptionHandler() func(h http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("act_decryption_handler").Build() + paths := struct { + Address swarm.Address `map:"address,resolve" validate:"required"` + }{} + if response := s.mapStructure(mux.Vars(r), &paths); response != nil { + response("invalid path params", logger, w) + return + } + + headers := struct { + Timestamp *int64 `map:"Swarm-Act-Timestamp"` + Publisher *ecdsa.PublicKey `map:"Swarm-Act-Publisher"` + HistoryAddress *swarm.Address `map:"Swarm-Act-History-Address"` + Cache *bool `map:"Swarm-Cache"` + }{} + if response := s.mapStructure(r.Header, &headers); response != nil { + response("invalid header params", logger, w) + return + } + + // Try to download the file wihtout decryption, if the act headers are not present + if headers.Publisher == nil || headers.HistoryAddress == nil { + h.ServeHTTP(w, r) + return + } + + timestamp := time.Now().Unix() + if headers.Timestamp != nil { + timestamp = *headers.Timestamp + } + + cache := true + if headers.Cache != nil { + cache = *headers.Cache + } + ctx := r.Context() + ls := loadsave.NewReadonly(s.storer.Download(cache)) + reference, err := s.accesscontrol.DownloadHandler(ctx, ls, paths.Address, headers.Publisher, *headers.HistoryAddress, timestamp) + if err != nil { + logger.Debug("access control download failed", "error", err) + logger.Error(nil, "access control download failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidTimestamp): + jsonhttp.BadRequest(w, "invalid timestamp") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActDownload) + } + return + } + h.ServeHTTP(w, r.WithContext(setAddressInContext(ctx, reference))) + }) + } +} + +// actEncryptionHandler is a middleware that encrypts the given address using the publisher's public key, +// uploads the encrypted reference, history and kvs to the store. +func (s *Service) actEncryptionHandler( + ctx context.Context, + w http.ResponseWriter, + putter storer.PutterSession, + reference swarm.Address, + historyRootHash swarm.Address, +) (swarm.Address, error) { + publisherPublicKey := &s.publicKey + ls := loadsave.New(s.storer.Download(true), s.storer.Cache(), requestPipelineFactory(ctx, putter, false, redundancy.NONE)) + storageReference, historyReference, encryptedReference, err := s.accesscontrol.UploadHandler(ctx, ls, reference, publisherPublicKey, historyRootHash) + if err != nil { + return swarm.ZeroAddress, err + } + // only need to upload history and kvs if a new history is created, + // meaning that the publisher uploaded to the history for the first time + if !historyReference.Equal(historyRootHash) { + err = putter.Done(storageReference) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("done split key-value store failed: %w", err) + } + err = putter.Done(historyReference) + if err != nil { + return swarm.ZeroAddress, fmt.Errorf("done split history failed: %w", err) + } + } + + w.Header().Set(SwarmActHistoryAddressHeader, historyReference.String()) + return encryptedReference, nil +} + +// actListGranteesHandler is a middleware that decrypts the given address and returns the list of grantees, +// only the publisher is authorized to access the list. +func (s *Service) actListGranteesHandler(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("act_list_grantees_handler").Build() + paths := struct { + GranteesAddress swarm.Address `map:"address,resolve" validate:"required"` + }{} + if response := s.mapStructure(mux.Vars(r), &paths); response != nil { + response("invalid path params", logger, w) + return + } + + headers := struct { + Cache *bool `map:"Swarm-Cache"` + }{} + if response := s.mapStructure(r.Header, &headers); response != nil { + response("invalid header params", logger, w) + return + } + cache := true + if headers.Cache != nil { + cache = *headers.Cache + } + publisher := &s.publicKey + ls := loadsave.NewReadonly(s.storer.Download(cache)) + grantees, err := s.accesscontrol.Get(r.Context(), ls, publisher, paths.GranteesAddress) + if err != nil { + logger.Debug("could not get grantees", "error", err) + logger.Error(nil, "could not get grantees") + jsonhttp.NotFound(w, "granteelist not found") + return + } + granteeSlice := make([]string, len(grantees)) + for i, grantee := range grantees { + granteeSlice[i] = hex.EncodeToString(crypto.EncodeSecp256k1PublicKey(grantee)) + } + jsonhttp.OK(w, granteeSlice) +} + +// actGrantRevokeHandler is a middleware that makes updates to the list of grantees, +// only the publisher is authorized to perform this action. +func (s *Service) actGrantRevokeHandler(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("act_grant_revoke_handler").Build() + + if r.Body == http.NoBody { + logger.Error(nil, "request has no body") + jsonhttp.BadRequest(w, errInvalidRequest) + return + } + + paths := struct { + GranteesAddress swarm.Address `map:"address,resolve" validate:"required"` + }{} + if response := s.mapStructure(mux.Vars(r), &paths); response != nil { + response("invalid path params", logger, w) + return + } + + headers := struct { + BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` + SwarmTag uint64 `map:"Swarm-Tag"` + Pin bool `map:"Swarm-Pin"` + Deferred *bool `map:"Swarm-Deferred-Upload"` + HistoryAddress *swarm.Address `map:"Swarm-Act-History-Address" validate:"required"` + }{} + if response := s.mapStructure(r.Header, &headers); response != nil { + response("invalid header params", logger, w) + return + } + + historyAddress := swarm.ZeroAddress + if headers.HistoryAddress != nil { + historyAddress = *headers.HistoryAddress + } + + var ( + tag uint64 + err error + deferred = defaultUploadMethod(headers.Deferred) + ) + + if deferred || headers.Pin { + tag, err = s.getOrCreateSessionID(headers.SwarmTag) + if err != nil { + logger.Debug("get or create tag failed", "error", err) + logger.Error(nil, "get or create tag failed") + switch { + case errors.Is(err, storage.ErrNotFound): + jsonhttp.NotFound(w, "tag not found") + default: + jsonhttp.InternalServerError(w, "cannot get or create tag") + } + return + } + } + + body, err := io.ReadAll(r.Body) + if err != nil { + if jsonhttp.HandleBodyReadError(err, w) { + return + } + logger.Debug("read request body failed", "error", err) + logger.Error(nil, "read request body failed") + jsonhttp.InternalServerError(w, "cannot read request") + return + } + + gpr := GranteesPatchRequest{} + if len(body) > 0 { + err = json.Unmarshal(body, &gpr) + if err != nil { + logger.Debug("unmarshal body failed", "error", err) + logger.Error(nil, "unmarshal body failed") + jsonhttp.InternalServerError(w, "error unmarshaling request body") + return + } + } + + grantees := GranteesPatch{} + parsedAddlist, err := parseKeys(gpr.Addlist) + if err != nil { + logger.Debug("add list key parse failed", "error", err) + logger.Error(nil, "add list key parse failed") + jsonhttp.BadRequest(w, "invalid add list") + return + } + grantees.Addlist = append(grantees.Addlist, parsedAddlist...) + + parsedRevokelist, err := parseKeys(gpr.Revokelist) + if err != nil { + logger.Debug("revoke list key parse failed", "error", err) + logger.Error(nil, "revoke list key parse failed") + jsonhttp.BadRequest(w, "invalid revoke list") + return + } + grantees.Revokelist = append(grantees.Revokelist, parsedRevokelist...) + + ctx := r.Context() + putter, err := s.newStamperPutter(ctx, putterOptions{ + BatchID: headers.BatchID, + TagID: tag, + Pin: headers.Pin, + Deferred: deferred, + }) + if err != nil { + logger.Debug("putter failed", "error", err) + logger.Error(nil, "putter failed") + switch { + case errors.Is(err, errBatchUnusable) || errors.Is(err, postage.ErrNotUsable): + jsonhttp.UnprocessableEntity(w, "batch not usable yet or does not exist") + case errors.Is(err, postage.ErrNotFound): + jsonhttp.NotFound(w, "batch with id not found") + case errors.Is(err, errInvalidPostageBatch): + jsonhttp.BadRequest(w, "invalid batch id") + case errors.Is(err, errUnsupportedDevNodeOperation): + jsonhttp.BadRequest(w, errUnsupportedDevNodeOperation) + default: + jsonhttp.BadRequest(w, nil) + } + return + } + + granteeref := paths.GranteesAddress + publisher := &s.publicKey + ls := loadsave.New(s.storer.Download(true), s.storer.Cache(), requestPipelineFactory(ctx, putter, false, redundancy.NONE)) + gls := loadsave.New(s.storer.Download(true), s.storer.Cache(), requestPipelineFactory(ctx, putter, granteeListEncrypt, redundancy.NONE)) + granteeref, encryptedglref, historyref, actref, err := s.accesscontrol.UpdateHandler(ctx, ls, gls, granteeref, historyAddress, publisher, grantees.Addlist, grantees.Revokelist) + if err != nil { + logger.Debug("failed to update grantee list", "error", err) + logger.Error(nil, "failed to update grantee list") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrNoGranteeFound): + jsonhttp.BadRequest(w, "remove from empty grantee list") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActGranteeList) + } + return + } + + err = putter.Done(actref) + if err != nil { + logger.Debug("done split act failed", "error", err) + logger.Error(nil, "done split act failed") + jsonhttp.InternalServerError(w, "done split act failed") + return + } + + err = putter.Done(historyref) + if err != nil { + logger.Debug("done split history failed", "error", err) + logger.Error(nil, "done split history failed") + jsonhttp.InternalServerError(w, "done split history failed") + return + } + + err = putter.Done(granteeref) + if err != nil { + logger.Debug("done split grantees failed", "error", err) + logger.Error(nil, "done split grantees failed") + jsonhttp.InternalServerError(w, "done split grantees failed") + return + } + + jsonhttp.OK(w, GranteesPatchResponse{ + Reference: encryptedglref, + HistoryReference: historyref, + }) +} + +// actCreateGranteesHandler is a middleware that creates a new list of grantees, +// only the publisher is authorized to perform this action. +func (s *Service) actCreateGranteesHandler(w http.ResponseWriter, r *http.Request) { + logger := s.logger.WithName("acthandler").Build() + + if r.Body == http.NoBody { + logger.Error(nil, "request has no body") + jsonhttp.BadRequest(w, errInvalidRequest) + return + } + + headers := struct { + BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` + SwarmTag uint64 `map:"Swarm-Tag"` + Pin bool `map:"Swarm-Pin"` + Deferred *bool `map:"Swarm-Deferred-Upload"` + HistoryAddress *swarm.Address `map:"Swarm-Act-History-Address"` + }{} + if response := s.mapStructure(r.Header, &headers); response != nil { + response("invalid header params", logger, w) + return + } + + historyAddress := swarm.ZeroAddress + if headers.HistoryAddress != nil { + historyAddress = *headers.HistoryAddress + } + + var ( + tag uint64 + err error + deferred = defaultUploadMethod(headers.Deferred) + ) + + if deferred || headers.Pin { + tag, err = s.getOrCreateSessionID(headers.SwarmTag) + if err != nil { + logger.Debug("get or create tag failed", "error", err) + logger.Error(nil, "get or create tag failed") + switch { + case errors.Is(err, storage.ErrNotFound): + jsonhttp.NotFound(w, "tag not found") + default: + jsonhttp.InternalServerError(w, "cannot get or create tag") + } + return + } + } + + body, err := io.ReadAll(r.Body) + if err != nil { + if jsonhttp.HandleBodyReadError(err, w) { + return + } + logger.Debug("read request body failed", "error", err) + logger.Error(nil, "read request body failed") + jsonhttp.InternalServerError(w, "cannot read request") + return + } + + gpr := GranteesPostRequest{} + if len(body) > 0 { + err = json.Unmarshal(body, &gpr) + if err != nil { + logger.Debug("unmarshal body failed", "error", err) + logger.Error(nil, "unmarshal body failed") + jsonhttp.InternalServerError(w, "error unmarshaling request body") + return + } + } + + list, err := parseKeys(gpr.GranteeList) + if err != nil { + logger.Debug("create list key parse failed", "error", err) + logger.Error(nil, "create list key parse failed") + jsonhttp.BadRequest(w, "invalid grantee list") + return + } + + ctx := r.Context() + putter, err := s.newStamperPutter(ctx, putterOptions{ + BatchID: headers.BatchID, + TagID: tag, + Pin: headers.Pin, + Deferred: deferred, + }) + if err != nil { + logger.Debug("putter failed", "error", err) + logger.Error(nil, "putter failed") + switch { + case errors.Is(err, errBatchUnusable) || errors.Is(err, postage.ErrNotUsable): + jsonhttp.UnprocessableEntity(w, "batch not usable yet or does not exist") + case errors.Is(err, postage.ErrNotFound): + jsonhttp.NotFound(w, "batch with id not found") + case errors.Is(err, errInvalidPostageBatch): + jsonhttp.BadRequest(w, "invalid batch id") + case errors.Is(err, errUnsupportedDevNodeOperation): + jsonhttp.BadRequest(w, errUnsupportedDevNodeOperation) + default: + jsonhttp.BadRequest(w, nil) + } + return + } + + publisher := &s.publicKey + ls := loadsave.New(s.storer.Download(true), s.storer.Cache(), requestPipelineFactory(ctx, putter, false, redundancy.NONE)) + gls := loadsave.New(s.storer.Download(true), s.storer.Cache(), requestPipelineFactory(ctx, putter, granteeListEncrypt, redundancy.NONE)) + granteeref, encryptedglref, historyref, actref, err := s.accesscontrol.UpdateHandler(ctx, ls, gls, swarm.ZeroAddress, historyAddress, publisher, list, nil) + if err != nil { + logger.Debug("failed to create grantee list", "error", err) + logger.Error(nil, "failed to create grantee list") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActGranteeList) + } + return + } + + err = putter.Done(actref) + if err != nil { + logger.Debug("done split act failed", "error", err) + logger.Error(nil, "done split act failed") + jsonhttp.InternalServerError(w, "done split act failed") + return + } + + err = putter.Done(historyref) + if err != nil { + logger.Debug("done split history failed", "error", err) + logger.Error(nil, "done split history failed") + jsonhttp.InternalServerError(w, "done split history failed") + return + } + + err = putter.Done(granteeref) + if err != nil { + logger.Debug("done split grantees failed", "error", err) + logger.Error(nil, "done split grantees failed") + jsonhttp.InternalServerError(w, "done split grantees failed") + return + } + + jsonhttp.Created(w, GranteesPostResponse{ + Reference: encryptedglref, + HistoryReference: historyref, + }) +} + +func parseKeys(list []string) ([]*ecdsa.PublicKey, error) { + parsedList := make([]*ecdsa.PublicKey, 0, len(list)) + for _, g := range list { + h, err := hex.DecodeString(g) + if err != nil { + return []*ecdsa.PublicKey{}, fmt.Errorf("failed to decode grantee: %w", err) + } + k, err := btcec.ParsePubKey(h) + if err != nil { + return []*ecdsa.PublicKey{}, fmt.Errorf("failed to parse grantee public key: %w", err) + } + parsedList = append(parsedList, k.ToECDSA()) + } + + return parsedList, nil +} diff --git a/pkg/api/accesscontrol_test.go b/pkg/api/accesscontrol_test.go new file mode 100644 index 00000000000..e8662a48118 --- /dev/null +++ b/pkg/api/accesscontrol_test.go @@ -0,0 +1,1041 @@ +// Copyright 2020 The Swarm Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package api_test + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + mockac "github.com/ethersphere/bee/v2/pkg/accesscontrol/mock" + "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/crypto" + "github.com/ethersphere/bee/v2/pkg/file/loadsave" + "github.com/ethersphere/bee/v2/pkg/file/redundancy" + "github.com/ethersphere/bee/v2/pkg/jsonhttp" + "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" + "github.com/ethersphere/bee/v2/pkg/log" + mockpost "github.com/ethersphere/bee/v2/pkg/postage/mock" + testingsoc "github.com/ethersphere/bee/v2/pkg/soc/testing" + mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" + "github.com/ethersphere/bee/v2/pkg/swarm" + "gitlab.com/nolash/go-mockbytes" +) + +//nolint:ireturn +func prepareHistoryFixture(storer api.Storer) (accesscontrol.History, swarm.Address) { + ctx := context.Background() + ls := loadsave.New(storer.ChunkStore(), storer.Cache(), pipelineFactory(storer.Cache(), false, redundancy.NONE)) + + h, _ := accesscontrol.NewHistory(ls) + + testActRef1 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd891")) + firstTime := time.Date(1994, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + _ = h.Add(ctx, testActRef1, &firstTime, nil) + + testActRef2 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd892")) + secondTime := time.Date(2000, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + _ = h.Add(ctx, testActRef2, &secondTime, nil) + + testActRef3 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd893")) + thirdTime := time.Date(2015, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + _ = h.Add(ctx, testActRef3, &thirdTime, nil) + + testActRef4 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd894")) + fourthTime := time.Date(2020, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + _ = h.Add(ctx, testActRef4, &fourthTime, nil) + + testActRef5 := swarm.NewAddress([]byte("39a5ea87b141fe44aa609c3327ecd895")) + fifthTime := time.Date(2030, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + _ = h.Add(ctx, testActRef5, &fifthTime, nil) + + ref, _ := h.Store(ctx) + return h, ref +} + +// nolint:paralleltest,tparallel +// TestAccessLogicEachEndpointWithAct [positive tests]: +// On each endpoint: upload w/ "Swarm-Act" header then download and check the decrypted data +func TestAccessLogicEachEndpointWithAct(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + testfile = "testfile1" + storerMock = mockstorer.New() + logger = log.Noop + now = time.Now().Unix() + chunk = swarm.NewChunk( + swarm.MustParseHexAddress("0025737be11979e91654dffd2be817ac1e52a2dadb08c97a7cef12f937e707bc"), + []byte{72, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0, 149, 179, 31, 244, 146, 247, 129, 123, 132, 248, 215, 77, 44, 47, 91, 248, 229, 215, 89, 156, 210, 243, 3, 110, 204, 74, 101, 119, 53, 53, 145, 188, 193, 153, 130, 197, 83, 152, 36, 140, 150, 209, 191, 214, 193, 4, 144, 121, 32, 45, 205, 220, 59, 227, 28, 43, 161, 51, 108, 14, 106, 180, 135, 2}, + ) + g = mockbytes.New(0, mockbytes.MockTypeStandard).WithModulus(255) + bytedata, _ = g.SequentialBytes(swarm.ChunkSize * 2) + tag, _ = storerMock.NewSession() + sch = testingsoc.GenerateMockSOCWithKey(t, []byte("foo"), pk) + dirdata = []byte("Lorem ipsum dolor sit amet") + socResource = func(owner, id, sig string) string { return fmt.Sprintf("/soc/%s/%s?sig=%s", owner, id, sig) } + ) + + tc := []struct { + name string + downurl string + upurl string + exphash string + data io.Reader + expdata []byte + contenttype string + resp struct { + Reference swarm.Address `json:"reference"` + } + }{ + { + name: "bzz", + upurl: "/bzz?name=sample.html", + downurl: "/bzz", + exphash: "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade", + resp: api.BzzUploadResponse{Reference: swarm.MustParseHexAddress("a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade")}, + data: strings.NewReader(testfile), + expdata: []byte(testfile), + contenttype: "text/html; charset=utf-8", + }, + { + name: "bzz-dir", + upurl: "/bzz?name=ipsum/lorem.txt", + downurl: "/bzz", + exphash: "6561b2a744d2a8f276270585da22e092c07c56624af83ac9969d52b54e87cee6/ipsum/lorem.txt", + resp: api.BzzUploadResponse{Reference: swarm.MustParseHexAddress("6561b2a744d2a8f276270585da22e092c07c56624af83ac9969d52b54e87cee6")}, + data: tarFiles(t, []f{ + { + data: dirdata, + name: "lorem.txt", + dir: "ipsum", + header: http.Header{ + api.ContentTypeHeader: {"text/plain; charset=utf-8"}, + }, + }, + }), + expdata: dirdata, + contenttype: api.ContentTypeTar, + }, + { + name: "bytes", + upurl: "/bytes", + downurl: "/bytes", + exphash: "e30da540bb9e1901169977fcf617f28b7f8df4537de978784f6d47491619a630", + resp: api.BytesPostResponse{Reference: swarm.MustParseHexAddress("e30da540bb9e1901169977fcf617f28b7f8df4537de978784f6d47491619a630")}, + data: bytes.NewReader(bytedata), + expdata: bytedata, + contenttype: "application/octet-stream", + }, + { + name: "chunks", + upurl: "/chunks", + downurl: "/chunks", + exphash: "ca8d2d29466e017cba46d383e7e0794d99a141185ec525086037f25fc2093155", + resp: api.ChunkAddressResponse{Reference: swarm.MustParseHexAddress("ca8d2d29466e017cba46d383e7e0794d99a141185ec525086037f25fc2093155")}, + data: bytes.NewReader(chunk.Data()), + expdata: chunk.Data(), + contenttype: "binary/octet-stream", + }, + { + name: "soc", + upurl: socResource(hex.EncodeToString(sch.Owner), hex.EncodeToString(sch.ID), hex.EncodeToString(sch.Signature)), + downurl: "/chunks", + exphash: "b100d7ce487426b17b98ff779fad4f2dd471d04ab1c8949dd2a1a78fe4a1524e", + resp: api.ChunkAddressResponse{Reference: swarm.MustParseHexAddress("b100d7ce487426b17b98ff779fad4f2dd471d04ab1c8949dd2a1a78fe4a1524e")}, + data: bytes.NewReader(sch.WrappedChunk.Data()), + expdata: sch.Chunk().Data(), + contenttype: "binary/octet-stream", + }, + } + + for _, v := range tc { + upTestOpts := []jsonhttptest.Option{ + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmPinHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmTagHeader, fmt.Sprintf("%d", tag.TagID)), + jsonhttptest.WithRequestBody(v.data), + jsonhttptest.WithExpectedJSONResponse(v.resp), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, v.contenttype), + } + if v.name == "soc" { + upTestOpts = append(upTestOpts, jsonhttptest.WithRequestHeader(api.SwarmPinHeader, "true")) + } else { + upTestOpts = append(upTestOpts, jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader)) + } + expcontenttype := v.contenttype + if v.name == "bzz-dir" { + expcontenttype = "text/plain; charset=utf-8" + upTestOpts = append(upTestOpts, jsonhttptest.WithRequestHeader(api.SwarmCollectionHeader, "True")) + } + t.Run(v.name, func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(), + }) + header := jsonhttptest.Request(t, client, http.MethodPost, v.upurl, http.StatusCreated, + upTestOpts..., + ) + + historyRef := header.Get(api.SwarmActHistoryAddressHeader) + jsonhttptest.Request(t, client, http.MethodGet, v.downurl+"/"+v.exphash, http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, historyRef), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponse(v.expdata), + jsonhttptest.WithExpectedContentLength(len(v.expdata)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, expcontenttype), + ) + + if v.name != "bzz-dir" && v.name != "soc" && v.name != "chunks" { + t.Run("head", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodHead, v.downurl+"/"+v.exphash, http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, historyRef), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithRequestBody(v.data), + jsonhttptest.WithExpectedContentLength(len(v.expdata)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, expcontenttype), + ) + }) + } + }) + } +} + +// TestAccessLogicWithoutActHeader [negative tests]: +// 1. upload w/ "Swarm-Act" header then try to dowload w/o the header. +// 2. upload w/o "Swarm-Act" header then try to dowload w/ the header. +// +//nolint:paralleltest,tparallel +func TestAccessLogicWithoutAct(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + fileUploadResource = "/bzz" + fileDownloadResource = func(addr string) string { return "/bzz/" + addr } + storerMock = mockstorer.New() + h, fixtureHref = prepareHistoryFixture(storerMock) + logger = log.Noop + fileName = "sample.html" + now = time.Now().Unix() + ) + + t.Run("upload-w/-act-then-download-w/o-act", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + var ( + testfile = "testfile1" + encryptedRef = "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + ) + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusNotFound, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "address not found or incorrect", + Code: http.StatusNotFound, + }), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) + + t.Run("upload-w/o-act-then-download-w/-act", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(), + }) + var ( + rootHash = "0cb947ccbc410c43139ba4409d83bf89114cb0d79556a651c06c888cf73f4d7e" + sampleHtml = ` + + + +

My First Heading

+ +

My first paragraph.

+ + + ` + ) + + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmDeferredUploadHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader(sampleHtml)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(rootHash), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", rootHash)), + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(rootHash), http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "act or history entry not found", + Code: http.StatusNotFound, + }), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) +} + +// TestAccessLogicInvalidPath [negative test]: Expect Bad request when the path address is invalid. +// +//nolint:paralleltest,tparallel +func TestAccessLogicInvalidPath(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + fileDownloadResource = func(addr string) string { return "/bzz/" + addr } + storerMock = mockstorer.New() + _, fixtureHref = prepareHistoryFixture(storerMock) + logger = log.Noop + now = time.Now().Unix() + ) + + t.Run("invalid-path-params", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(), + }) + encryptedRef := "asd" + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid path params", + Reasons: []jsonhttp.Reason{ + { + Field: "address", + Error: api.HexInvalidByteError('s').Error(), + }, + }, + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + ) + }) +} + +// nolint:paralleltest,tparallel +// TestAccessLogicHistory tests: +// [positive tests] 1., 2.: uploading a file w/ and w/o history address then downloading it and checking the data. +// [negative test] 3. uploading a file then downloading it with a wrong history address. +// [negative test] 4. uploading a file to a wrong history address. +// [negative test] 5. downloading a file to w/o history address. +func TestAccessLogicHistory(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + fileUploadResource = "/bzz" + fileDownloadResource = func(addr string) string { return "/bzz/" + addr } + storerMock = mockstorer.New() + h, fixtureHref = prepareHistoryFixture(storerMock) + logger = log.Noop + fileName = "sample.html" + now = time.Now().Unix() + ) + + t.Run("empty-history-upload-then-download-and-check-data", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(), + }) + var ( + testfile = "testfile1" + encryptedRef = "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + ) + header := jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + historyRef := header.Get(api.SwarmActHistoryAddressHeader) + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, historyRef), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponse([]byte(testfile)), + jsonhttptest.WithExpectedContentLength(len(testfile)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithExpectedResponseHeader(api.ContentDispositionHeader, fmt.Sprintf(`inline; filename="%s"`, fileName)), + ) + }) + + t.Run("with-history-upload-then-download-and-check-data", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + var ( + encryptedRef = "c611199e1b3674d6bf89a83e518bd16896bf5315109b4a23dcb4682a02d17b97" + testfile = ` + + + +

My First Heading

+ +

My first paragraph.

+ + + ` + ) + + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponse([]byte(testfile)), + jsonhttptest.WithExpectedContentLength(len(testfile)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithExpectedResponseHeader(api.ContentDispositionHeader, fmt.Sprintf(`inline; filename="%s"`, fileName)), + ) + }) + + t.Run("upload-then-download-wrong-history", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + var ( + testfile = "testfile1" + encryptedRef = "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + ) + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, "fc4e9fe978991257b897d987bc4ff13058b66ef45a53189a0b4fe84bb3346396"), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "act or history entry not found", + Code: http.StatusNotFound, + }), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) + + t.Run("upload-wrong-history", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(), + }) + testfile := "testfile1" + + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "act or history entry not found", + Code: http.StatusNotFound, + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + ) + }) + + t.Run("download-w/o-history", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + encryptedRef := "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) +} + +// nolint:paralleltest,tparallel +// TestAccessLogicTimestamp +// [positive test] 1.: uploading a file w/ ACT then download it w/ timestamp and check the data. +// [negative test] 2.: try to download a file w/o timestamp. +func TestAccessLogicTimestamp(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + fileUploadResource = "/bzz" + fileDownloadResource = func(addr string) string { return "/bzz/" + addr } + storerMock = mockstorer.New() + h, fixtureHref = prepareHistoryFixture(storerMock) + logger = log.Noop + fileName = "sample.html" + ) + t.Run("upload-then-download-with-timestamp-and-check-data", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + var ( + thirdTime = time.Date(2015, time.April, 1, 0, 0, 0, 0, time.UTC).Unix() + encryptedRef = "c611199e1b3674d6bf89a83e518bd16896bf5315109b4a23dcb4682a02d17b97" + testfile = ` + + + +

My First Heading

+ +

My first paragraph.

+ + + ` + ) + + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(thirdTime, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponse([]byte(testfile)), + jsonhttptest.WithExpectedContentLength(len(testfile)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithExpectedResponseHeader(api.ContentDispositionHeader, fmt.Sprintf(`inline; filename="%s"`, fileName)), + ) + }) + + t.Run("download-w/o-timestamp", func(t *testing.T) { + encryptedRef := "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) + t.Run("download-w/-invalid-timestamp", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + var ( + invalidTime = int64(-1) + encryptedRef = "c611199e1b3674d6bf89a83e518bd16896bf5315109b4a23dcb4682a02d17b97" + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(invalidTime, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: accesscontrol.ErrInvalidTimestamp.Error(), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + ) + }) +} + +// nolint:paralleltest,tparallel +// TestAccessLogicPublisher +// [positive test] 1.: uploading a file w/ ACT then download it w/ the publisher address and check the data. +// [negative test] 2.: expect Bad request when the public key is invalid. +// [negative test] 3.: try to download a file w/ an incorrect publisher address. +// [negative test] 3.: try to download a file w/o a publisher address. +func TestAccessLogicPublisher(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + fileUploadResource = "/bzz" + fileDownloadResource = func(addr string) string { return "/bzz/" + addr } + storerMock = mockstorer.New() + h, fixtureHref = prepareHistoryFixture(storerMock) + logger = log.Noop + fileName = "sample.html" + now = time.Now().Unix() + ) + + t.Run("upload-then-download-w/-publisher-and-check-data", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String()), mockac.WithPublisher(publisher)), + }) + var ( + encryptedRef = "a5a26b4915d7ce1622f9ca52252092cf2445f98d359dabaf52588c05911aaf4f" + testfile = ` + + + +

My First Heading

+ +

My first paragraph.

+ + + ` + ) + + jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponse([]byte(testfile)), + jsonhttptest.WithExpectedContentLength(len(testfile)), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithExpectedResponseHeader(api.ContentDispositionHeader, fmt.Sprintf(`inline; filename="%s"`, fileName)), + ) + }) + + t.Run("upload-then-download-invalid-publickey", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithPublisher(publisher)), + }) + var ( + publickey = "b786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfb" + encryptedRef = "a5a26b4915d7ce1622f9ca52252092cf2445f98d359dabaf52588c05911aaf4f" + testfile = ` + + + +

My First Heading

+ +

My first paragraph.

+ + + ` + ) + + header := jsonhttptest.Request(t, client, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(api.BzzUploadResponse{ + Reference: swarm.MustParseHexAddress(encryptedRef), + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + jsonhttptest.WithNonEmptyResponseHeader(api.SwarmTagHeader), + jsonhttptest.WithExpectedResponseHeader(api.ETagHeader, fmt.Sprintf("%q", encryptedRef)), + ) + + historyRef := header.Get(api.SwarmActHistoryAddressHeader) + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, historyRef), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publickey), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid header params", + Reasons: []jsonhttp.Reason{ + { + Field: "Swarm-Act-Publisher", + Error: "malformed public key: invalid length: 32", + }, + }, + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + ) + }) + + t.Run("download-w/-wrong-publisher", func(t *testing.T) { + var ( + downloader = "03c712a7e29bc792ac8d8ae49793d28d5bda27ed70f0d90697b2fb456c0a168bd2" + encryptedRef = "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + ) + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String()), mockac.WithPublisher(publisher)), + }) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, downloader), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: accesscontrol.ErrInvalidPublicKey.Error(), + Code: http.StatusBadRequest, + }), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) + + t.Run("re-upload-with-invalid-publickey", func(t *testing.T) { + var ( + downloader = "03c712a7e29bc792ac8d8ae49793d28d5bda27ed70f0d90697b2fb456c0a168bd2" + testfile = "testfile1" + ) + downloaderClient, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithPublisher(downloader)), + }) + + jsonhttptest.Request(t, downloaderClient, http.MethodPost, fileUploadResource+"?name="+fileName, http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmActHeader, "true"), + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(strings.NewReader(testfile)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid public key", + }), + jsonhttptest.WithRequestHeader(api.ContentTypeHeader, "text/html; charset=utf-8"), + ) + + }) + + t.Run("download-w/o-publisher", func(t *testing.T) { + encryptedRef := "a5df670544eaea29e61b19d8739faa4573b19e4426e58a173e51ed0b5e7e2ade" + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String()), mockac.WithPublisher(publisher)), + }) + + jsonhttptest.Request(t, client, http.MethodGet, fileDownloadResource(encryptedRef), http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmActTimestampHeader, strconv.FormatInt(now, 10)), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, fixtureHref.String()), + jsonhttptest.WithRequestHeader(api.SwarmActPublisherHeader, publisher), + jsonhttptest.WithExpectedResponseHeader(api.ContentTypeHeader, "application/json; charset=utf-8"), + ) + }) +} + +func TestAccessLogicGrantees(t *testing.T) { + t.Parallel() + var ( + spk, _ = hex.DecodeString("a786dd84b61485de12146fd9c4c02d87e8fd95f0542765cb7fc3d2e428c0bcfa") + pk, _ = crypto.DecodeSecp256k1PrivateKey(spk) + storerMock = mockstorer.New() + h, fixtureHref = prepareHistoryFixture(storerMock) + logger = log.Noop + addr = swarm.RandAddress(t) + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String())), + }) + ) + t.Run("get-grantees", func(t *testing.T) { + var ( + publicKeyBytes = crypto.EncodeSecp256k1PublicKey(&pk.PublicKey) + publisher = hex.EncodeToString(publicKeyBytes) + ) + clientwihtpublisher, _, _, _ := newTestServer(t, testServerOptions{ + Storer: storerMock, + Logger: logger, + Post: mockpost.New(mockpost.WithAcceptAll()), + PublicKey: pk.PublicKey, + AccessControl: mockac.New(mockac.WithHistory(h, fixtureHref.String()), mockac.WithPublisher(publisher)), + }) + expected := []string{ + "03d7660772cc3142f8a7a2dfac46ce34d12eac1718720cef0e3d94347902aa96a2", + "03c712a7e29bc792ac8d8ae49793d28d5bda27ed70f0d90697b2fb456c0a168bd2", + "032541acf966823bae26c2c16a7102e728ade3e2e29c11a8a17b29d8eb2bd19302", + } + jsonhttptest.Request(t, clientwihtpublisher, http.MethodGet, "/grantee/"+addr.String(), http.StatusOK, + jsonhttptest.WithExpectedJSONResponse(expected), + ) + }) + + t.Run("get-grantees-unauthorized", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodGet, "/grantee/fc4e9fe978991257b897d987bc4ff13058b66ef45a53189a0b4fe84bb3346396", http.StatusNotFound, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "granteelist not found", + Code: http.StatusNotFound, + }), + ) + }) + t.Run("get-grantees-invalid-address", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodGet, "/grantee/asd", http.StatusBadRequest, + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Code: http.StatusBadRequest, + Message: "invalid path params", + Reasons: []jsonhttp.Reason{ + { + Field: "address", + Error: api.HexInvalidByteError('s').Error(), + }, + }, + }), + ) + }) + t.Run("add-revoke-grantees", func(t *testing.T) { + body := api.GranteesPatchRequest{ + Addlist: []string{"02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4"}, + Revokelist: []string{"02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4"}, + } + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, addr.String()), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("add-revoke-grantees-wrong-history", func(t *testing.T) { + body := api.GranteesPatchRequest{ + Addlist: []string{"02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4"}, + Revokelist: []string{"02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4"}, + } + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusNotFound, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, swarm.EmptyAddress.String()), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "act or history entry not found", + Code: http.StatusNotFound, + }), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("invlaid-add-grantees", func(t *testing.T) { + body := api.GranteesPatchRequest{ + Addlist: []string{"random-string"}, + } + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, addr.String()), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "invalid add list", + Code: http.StatusBadRequest, + }), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("invlaid-revoke-grantees", func(t *testing.T) { + body := api.GranteesPatchRequest{ + Revokelist: []string{"random-string"}, + } + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, addr.String()), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "invalid revoke list", + Code: http.StatusBadRequest, + }), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("add-revoke-grantees-empty-body", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(nil)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "could not validate request", + Code: http.StatusBadRequest, + }), + ) + }) + t.Run("add-grantee-with-history", func(t *testing.T) { + body := api.GranteesPatchRequest{ + Addlist: []string{"02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4"}, + } + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusOK, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmActHistoryAddressHeader, addr.String()), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("add-grantee-without-history", func(t *testing.T) { + body := api.GranteesPatchRequest{ + Addlist: []string{"02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4"}, + } + jsonhttptest.Request(t, client, http.MethodPatch, "/grantee/"+addr.String(), http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + + t.Run("create-granteelist", func(t *testing.T) { + body := api.GranteesPostRequest{ + GranteeList: []string{ + "02ab7473879005929d10ce7d4f626412dad9fe56b0a6622038931d26bd79abf0a4", + "03d7660772cc3142f8a7a2dfac46ce34d12eac1718720cef0e3d94347902aa96a2", + }, + } + jsonhttptest.Request(t, client, http.MethodPost, "/grantee", http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("create-granteelist-without-stamp", func(t *testing.T) { + body := api.GranteesPostRequest{ + GranteeList: []string{ + "03d7660772cc3142f8a7a2dfac46ce34d12eac1718720cef0e3d94347902aa96a2", + }, + } + jsonhttptest.Request(t, client, http.MethodPost, "/grantee", http.StatusBadRequest, + jsonhttptest.WithJSONRequestBody(body), + ) + }) + t.Run("create-granteelist-empty-body", func(t *testing.T) { + jsonhttptest.Request(t, client, http.MethodPost, "/grantee", http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(nil)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "could not validate request", + Code: http.StatusBadRequest, + }), + ) + }) + t.Run("create-granteelist-invalid-body", func(t *testing.T) { + body := api.GranteesPostRequest{ + GranteeList: []string{"random-string"}, + } + jsonhttptest.Request(t, client, http.MethodPost, "/grantee", http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(nil)), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "invalid grantee list", + Code: http.StatusBadRequest, + }), + jsonhttptest.WithJSONRequestBody(body), + ) + }) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index f5db1459623..4df3d78534f 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -26,6 +26,7 @@ import ( "unicode/utf8" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/accounting" "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/feeds" @@ -84,6 +85,10 @@ const ( SwarmRedundancyFallbackModeHeader = "Swarm-Redundancy-Fallback-Mode" SwarmChunkRetrievalTimeoutHeader = "Swarm-Chunk-Retrieval-Timeout" SwarmLookAheadBufferSizeHeader = "Swarm-Lookahead-Buffer-Size" + SwarmActHeader = "Swarm-Act" + SwarmActTimestampHeader = "Swarm-Act-Timestamp" + SwarmActPublisherHeader = "Swarm-Act-Publisher" + SwarmActHistoryAddressHeader = "Swarm-Act-History-Address" ImmutableHeader = "Immutable" GasPriceHeader = "Gas-Price" @@ -116,6 +121,9 @@ var ( errBatchUnusable = errors.New("batch not usable") errUnsupportedDevNodeOperation = errors.New("operation not supported in dev mode") errOperationSupportedOnlyInFullMode = errors.New("operation is supported only in full mode") + errActDownload = errors.New("act download failed") + errActUpload = errors.New("act upload failed") + errActGranteeList = errors.New("failed to create or update grantee list") batchIdOrStampSig = fmt.Sprintf("Either '%s' or '%s' header must be set in the request", SwarmPostageStampHeader, SwarmPostageBatchIdHeader) ) @@ -147,6 +155,7 @@ type Service struct { feedFactory feeds.Factory signer crypto.Signer post postage.Service + accesscontrol accesscontrol.Controller postageContract postagecontract.Interface probe *Probe metricsRegistry *prometheus.Registry @@ -243,6 +252,7 @@ type ExtraOptions struct { Pss pss.Interface FeedFactory feeds.Factory Post postage.Service + AccessControl accesscontrol.Controller PostageContract postagecontract.Interface Staking staking.Contract Steward steward.Interface @@ -325,6 +335,7 @@ func (s *Service) Configure(signer crypto.Signer, tracer *tracing.Tracer, o Opti s.pss = e.Pss s.feedFactory = e.FeedFactory s.post = e.Post + s.accesscontrol = e.AccessControl s.postageContract = e.PostageContract s.steward = e.Steward s.stakingContract = e.Staking diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index b8c8044998a..84634930599 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -23,6 +23,8 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" + mockac "github.com/ethersphere/bee/v2/pkg/accesscontrol/mock" accountingmock "github.com/ethersphere/bee/v2/pkg/accounting/mock" "github.com/ethersphere/bee/v2/pkg/api" "github.com/ethersphere/bee/v2/pkg/crypto" @@ -100,6 +102,7 @@ type testServerOptions struct { PostageContract postagecontract.Interface StakingContract staking.Contract Post postage.Service + AccessControl accesscontrol.Controller Steward steward.Interface WsHeaders http.Header DirectUpload bool @@ -147,6 +150,9 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket. if o.Post == nil { o.Post = mockpost.New() } + if o.AccessControl == nil { + o.AccessControl = mockac.New() + } if o.BatchStore == nil { o.BatchStore = mockbatchstore.New(mockbatchstore.WithAcceptAllExistsFunc()) // default is with accept-all Exists() func } @@ -187,6 +193,7 @@ func newTestServer(t *testing.T, o testServerOptions) (*http.Client, *websocket. Pss: o.Pss, FeedFactory: o.Feeds, Post: o.Post, + AccessControl: o.AccessControl, PostageContract: o.PostageContract, Steward: o.Steward, SyncStatus: o.SyncStatus, diff --git a/pkg/api/bytes.go b/pkg/api/bytes.go index 447a8faf3ff..11a600f854b 100644 --- a/pkg/api/bytes.go +++ b/pkg/api/bytes.go @@ -11,11 +11,12 @@ import ( "net/http" "strconv" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/cac" "github.com/ethersphere/bee/v2/pkg/file/redundancy" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/postage" - storage "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/storage" "github.com/ethersphere/bee/v2/pkg/swarm" "github.com/ethersphere/bee/v2/pkg/tracing" "github.com/gorilla/mux" @@ -33,12 +34,14 @@ func (s *Service) bytesUploadHandler(w http.ResponseWriter, r *http.Request) { defer span.Finish() headers := struct { - BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` - SwarmTag uint64 `map:"Swarm-Tag"` - Pin bool `map:"Swarm-Pin"` - Deferred *bool `map:"Swarm-Deferred-Upload"` - Encrypt bool `map:"Swarm-Encrypt"` - RLevel redundancy.Level `map:"Swarm-Redundancy-Level"` + BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` + SwarmTag uint64 `map:"Swarm-Tag"` + Pin bool `map:"Swarm-Pin"` + Deferred *bool `map:"Swarm-Deferred-Upload"` + Encrypt bool `map:"Swarm-Encrypt"` + RLevel redundancy.Level `map:"Swarm-Redundancy-Level"` + Act bool `map:"Swarm-Act"` + HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -102,7 +105,7 @@ func (s *Service) bytesUploadHandler(w http.ResponseWriter, r *http.Request) { } p := requestPipelineFn(putter, headers.Encrypt, headers.RLevel) - address, err := p(ctx, r.Body) + reference, err := p(ctx, r.Body) if err != nil { logger.Debug("split write all failed", "error", err) logger.Error(nil, "split write all failed") @@ -116,9 +119,28 @@ func (s *Service) bytesUploadHandler(w http.ResponseWriter, r *http.Request) { return } - span.SetTag("root_address", address) + encryptedReference := reference + if headers.Act { + encryptedReference, err = s.actEncryptionHandler(r.Context(), w, putter, reference, headers.HistoryAddress) + if err != nil { + logger.Debug("access control upload failed", "error", err) + logger.Error(nil, "access control upload failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActUpload) + } + return + } + } + span.SetTag("root_address", encryptedReference) - err = putter.Done(address) + err = putter.Done(reference) if err != nil { logger.Debug("done split failed", "error", err) logger.Error(nil, "done split failed") @@ -135,7 +157,7 @@ func (s *Service) bytesUploadHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Expose-Headers", SwarmTagHeader) jsonhttp.Created(w, bytesPostResponse{ - Reference: address, + Reference: encryptedReference, }) } @@ -151,11 +173,16 @@ func (s *Service) bytesGetHandler(w http.ResponseWriter, r *http.Request) { return } + address := paths.Address + if v := getAddressFromContext(r.Context()); !v.IsZero() { + address = v + } + additionalHeaders := http.Header{ ContentTypeHeader: {"application/octet-stream"}, } - s.downloadHandler(logger, w, r, paths.Address, additionalHeaders, true, false) + s.downloadHandler(logger, w, r, address, additionalHeaders, true, false) } func (s *Service) bytesHeadHandler(w http.ResponseWriter, r *http.Request) { @@ -169,11 +196,16 @@ func (s *Service) bytesHeadHandler(w http.ResponseWriter, r *http.Request) { return } + address := paths.Address + if v := getAddressFromContext(r.Context()); !v.IsZero() { + address = v + } + getter := s.storer.Download(true) - ch, err := getter.Get(r.Context(), paths.Address) + ch, err := getter.Get(r.Context(), address) if err != nil { - logger.Debug("get root chunk failed", "chunk_address", paths.Address, "error", err) + logger.Debug("get root chunk failed", "chunk_address", address, "error", err) logger.Error(nil, "get rook chunk failed") w.WriteHeader(http.StatusNotFound) return diff --git a/pkg/api/bzz.go b/pkg/api/bzz.go index 26238e05fdd..c24b3eb0eef 100644 --- a/pkg/api/bzz.go +++ b/pkg/api/bzz.go @@ -21,6 +21,7 @@ import ( olog "github.com/opentracing/opentracing-go/log" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/feeds" "github.com/ethersphere/bee/v2/pkg/file/joiner" "github.com/ethersphere/bee/v2/pkg/file/loadsave" @@ -30,8 +31,8 @@ import ( "github.com/ethersphere/bee/v2/pkg/log" "github.com/ethersphere/bee/v2/pkg/manifest" "github.com/ethersphere/bee/v2/pkg/postage" - storage "github.com/ethersphere/bee/v2/pkg/storage" - storer "github.com/ethersphere/bee/v2/pkg/storer" + "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/storer" "github.com/ethersphere/bee/v2/pkg/swarm" "github.com/ethersphere/bee/v2/pkg/topology" "github.com/ethersphere/bee/v2/pkg/tracing" @@ -63,14 +64,16 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { defer span.Finish() headers := struct { - ContentType string `map:"Content-Type,mimeMediaType" validate:"required"` - BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` - SwarmTag uint64 `map:"Swarm-Tag"` - Pin bool `map:"Swarm-Pin"` - Deferred *bool `map:"Swarm-Deferred-Upload"` - Encrypt bool `map:"Swarm-Encrypt"` - IsDir bool `map:"Swarm-Collection"` - RLevel redundancy.Level `map:"Swarm-Redundancy-Level"` + ContentType string `map:"Content-Type,mimeMediaType" validate:"required"` + BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` + SwarmTag uint64 `map:"Swarm-Tag"` + Pin bool `map:"Swarm-Pin"` + Deferred *bool `map:"Swarm-Deferred-Upload"` + Encrypt bool `map:"Swarm-Encrypt"` + IsDir bool `map:"Swarm-Collection"` + RLevel redundancy.Level `map:"Swarm-Redundancy-Level"` + Act bool `map:"Swarm-Act"` + HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -134,10 +137,10 @@ func (s *Service) bzzUploadHandler(w http.ResponseWriter, r *http.Request) { } if headers.IsDir || headers.ContentType == multiPartFormData { - s.dirUploadHandler(ctx, logger, span, ow, r, putter, r.Header.Get(ContentTypeHeader), headers.Encrypt, tag, headers.RLevel) + s.dirUploadHandler(ctx, logger, span, ow, r, putter, r.Header.Get(ContentTypeHeader), headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) return } - s.fileUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel) + s.fileUploadHandler(ctx, logger, span, ow, r, putter, headers.Encrypt, tag, headers.RLevel, headers.Act, headers.HistoryAddress) } // fileUploadResponse is returned when an HTTP request to upload a file is successful @@ -157,6 +160,8 @@ func (s *Service) fileUploadHandler( encrypt bool, tagID uint64, rLevel redundancy.Level, + act bool, + historyAddress swarm.Address, ) { queries := struct { FileName string `map:"name" validate:"startsnotwith=/"` @@ -262,26 +267,46 @@ func (s *Service) fileUploadHandler( } logger.Debug("store", "manifest_reference", manifestReference) + reference := manifestReference + if act { + reference, err = s.actEncryptionHandler(r.Context(), w, putter, reference, historyAddress) + if err != nil { + logger.Debug("access control upload failed", "error", err) + logger.Error(nil, "access control upload failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActUpload) + } + return + } + } + err = putter.Done(manifestReference) if err != nil { - logger.Debug("done split failed", "error", err) + logger.Debug("done split failed", "reference", manifestReference, "error", err) logger.Error(nil, "done split failed") jsonhttp.InternalServerError(w, "done split failed") ext.LogError(span, err, olog.String("action", "putter.Done")) return } - span.LogFields(olog.Bool("success", true)) - span.SetTag("root_address", manifestReference) + span.SetTag("root_address", reference) if tagID != 0 { w.Header().Set(SwarmTagHeader, fmt.Sprint(tagID)) span.SetTag("tagID", tagID) } - w.Header().Set(ETagHeader, fmt.Sprintf("%q", manifestReference.String())) + w.Header().Set(ETagHeader, fmt.Sprintf("%q", reference.String())) w.Header().Set("Access-Control-Expose-Headers", SwarmTagHeader) + jsonhttp.Created(w, bzzUploadResponse{ - Reference: manifestReference, + Reference: reference, }) } @@ -297,11 +322,16 @@ func (s *Service) bzzDownloadHandler(w http.ResponseWriter, r *http.Request) { return } + address := paths.Address + if v := getAddressFromContext(r.Context()); !v.IsZero() { + address = v + } + if strings.HasSuffix(paths.Path, "/") { paths.Path = strings.TrimRight(paths.Path, "/") + "/" // NOTE: leave one slash if there was some. } - s.serveReference(logger, paths.Address, paths.Path, w, r, false) + s.serveReference(logger, address, paths.Path, w, r, false) } func (s *Service) bzzHeadHandler(w http.ResponseWriter, r *http.Request) { @@ -316,11 +346,16 @@ func (s *Service) bzzHeadHandler(w http.ResponseWriter, r *http.Request) { return } + address := paths.Address + if v := getAddressFromContext(r.Context()); !v.IsZero() { + address = v + } + if strings.HasSuffix(paths.Path, "/") { paths.Path = strings.TrimRight(paths.Path, "/") + "/" // NOTE: leave one slash if there was some. } - s.serveReference(logger, paths.Address, paths.Path, w, r, true) + s.serveReference(logger, address, paths.Path, w, r, true) } func (s *Service) serveReference(logger log.Logger, address swarm.Address, pathVar string, w http.ResponseWriter, r *http.Request, headerOnly bool) { diff --git a/pkg/api/chunk.go b/pkg/api/chunk.go index 1b361c39411..efce3349501 100644 --- a/pkg/api/chunk.go +++ b/pkg/api/chunk.go @@ -12,6 +12,7 @@ import ( "net/http" "strconv" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/cac" "github.com/ethersphere/bee/v2/pkg/soc" "github.com/ethersphere/bee/v2/pkg/storer" @@ -31,9 +32,11 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { logger := s.logger.WithName("post_chunk").Build() headers := struct { - BatchID []byte `map:"Swarm-Postage-Batch-Id"` - StampSig []byte `map:"Swarm-Postage-Stamp"` - SwarmTag uint64 `map:"Swarm-Tag"` + BatchID []byte `map:"Swarm-Postage-Batch-Id"` + StampSig []byte `map:"Swarm-Postage-Stamp"` + SwarmTag uint64 `map:"Swarm-Tag"` + Act bool `map:"Swarm-Act"` + HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -166,6 +169,26 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { } } + reference := chunk.Address() + if headers.Act { + reference, err = s.actEncryptionHandler(r.Context(), w, putter, reference, headers.HistoryAddress) + if err != nil { + logger.Debug("access control upload failed", "error", err) + logger.Error(nil, "access control upload failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActUpload) + } + return + } + } + err = putter.Put(r.Context(), chunk) if err != nil { logger.Debug("chunk upload: write chunk failed", "chunk_address", chunk.Address(), "error", err) @@ -194,7 +217,7 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Access-Control-Expose-Headers", SwarmTagHeader) - jsonhttp.Created(w, chunkAddressResponse{Reference: chunk.Address()}) + jsonhttp.Created(w, chunkAddressResponse{Reference: reference}) } func (s *Service) chunkGetHandler(w http.ResponseWriter, r *http.Request) { @@ -221,15 +244,20 @@ func (s *Service) chunkGetHandler(w http.ResponseWriter, r *http.Request) { return } - chunk, err := s.storer.Download(cache).Get(r.Context(), paths.Address) + address := paths.Address + if v := getAddressFromContext(r.Context()); !v.IsZero() { + address = v + } + + chunk, err := s.storer.Download(cache).Get(r.Context(), address) if err != nil { if errors.Is(err, storage.ErrNotFound) { - loggerV1.Debug("chunk not found", "address", paths.Address) + loggerV1.Debug("chunk not found", "address", address) jsonhttp.NotFound(w, "chunk not found") return } - logger.Debug("read chunk failed", "chunk_address", paths.Address, "error", err) + logger.Debug("read chunk failed", "chunk_address", address, "error", err) logger.Error(nil, "read chunk failed") jsonhttp.InternalServerError(w, "read chunk failed") return diff --git a/pkg/api/chunk_address.go b/pkg/api/chunk_address.go index 6f214a0ea03..838c9ce6e8e 100644 --- a/pkg/api/chunk_address.go +++ b/pkg/api/chunk_address.go @@ -23,9 +23,14 @@ func (s *Service) hasChunkHandler(w http.ResponseWriter, r *http.Request) { return } - has, err := s.storer.ChunkStore().Has(r.Context(), paths.Address) + address := paths.Address + if v := getAddressFromContext(r.Context()); !v.IsZero() { + address = v + } + + has, err := s.storer.ChunkStore().Has(r.Context(), address) if err != nil { - logger.Debug("has chunk failed", "chunk_address", paths.Address, "error", err) + logger.Debug("has chunk failed", "chunk_address", address, "error", err) jsonhttp.BadRequest(w, err) return } diff --git a/pkg/api/dirs.go b/pkg/api/dirs.go index f54a02807c9..23be6929acc 100644 --- a/pkg/api/dirs.go +++ b/pkg/api/dirs.go @@ -18,6 +18,7 @@ import ( "strconv" "strings" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/file/loadsave" "github.com/ethersphere/bee/v2/pkg/file/redundancy" "github.com/ethersphere/bee/v2/pkg/jsonhttp" @@ -47,6 +48,8 @@ func (s *Service) dirUploadHandler( encrypt bool, tag uint64, rLevel redundancy.Level, + act bool, + historyAddress swarm.Address, ) { if r.Body == http.NoBody { logger.Error(nil, "request has no body") @@ -98,6 +101,26 @@ func (s *Service) dirUploadHandler( return } + encryptedReference := reference + if act { + encryptedReference, err = s.actEncryptionHandler(r.Context(), w, putter, reference, historyAddress) + if err != nil { + logger.Debug("access control upload failed", "error", err) + logger.Error(nil, "access control upload failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActUpload) + } + return + } + } + err = putter.Done(reference) if err != nil { logger.Debug("store dir failed", "error", err) @@ -113,7 +136,7 @@ func (s *Service) dirUploadHandler( } w.Header().Set("Access-Control-Expose-Headers", SwarmTagHeader) jsonhttp.Created(w, bzzUploadResponse{ - Reference: reference, + Reference: encryptedReference, }) } diff --git a/pkg/api/export_test.go b/pkg/api/export_test.go index c0f2d2f29fe..d2ee5dd6e2d 100644 --- a/pkg/api/export_test.go +++ b/pkg/api/export_test.go @@ -36,6 +36,8 @@ var ( ErrInvalidNameOrAddress = errInvalidNameOrAddress ErrUnsupportedDevNodeOperation = errUnsupportedDevNodeOperation ErrOperationSupportedOnlyInFullMode = errOperationSupportedOnlyInFullMode + ErrActDownload = errActDownload + ErrActUpload = errActUpload ) var ( diff --git a/pkg/api/feed.go b/pkg/api/feed.go index 09fdf6515ec..7750fd7605c 100644 --- a/pkg/api/feed.go +++ b/pkg/api/feed.go @@ -13,6 +13,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/feeds" "github.com/ethersphere/bee/v2/pkg/file/loadsave" "github.com/ethersphere/bee/v2/pkg/jsonhttp" @@ -21,8 +22,8 @@ import ( "github.com/ethersphere/bee/v2/pkg/manifest/simple" "github.com/ethersphere/bee/v2/pkg/postage" "github.com/ethersphere/bee/v2/pkg/soc" - storage "github.com/ethersphere/bee/v2/pkg/storage" - storer "github.com/ethersphere/bee/v2/pkg/storer" + "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/storer" "github.com/ethersphere/bee/v2/pkg/swarm" "github.com/gorilla/mux" ) @@ -137,9 +138,11 @@ func (s *Service) feedPostHandler(w http.ResponseWriter, r *http.Request) { } headers := struct { - BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` - Pin bool `map:"Swarm-Pin"` - Deferred *bool `map:"Swarm-Deferred-Upload"` + BatchID []byte `map:"Swarm-Postage-Batch-Id" validate:"required"` + Pin bool `map:"Swarm-Pin"` + Deferred *bool `map:"Swarm-Deferred-Upload"` + Act bool `map:"Swarm-Act"` + HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -245,6 +248,26 @@ func (s *Service) feedPostHandler(w http.ResponseWriter, r *http.Request) { return } + encryptedReference := ref + if headers.Act { + encryptedReference, err = s.actEncryptionHandler(r.Context(), w, putter, ref, headers.HistoryAddress) + if err != nil { + logger.Debug("access control upload failed", "error", err) + logger.Error(nil, "access control upload failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActUpload) + } + return + } + } + err = putter.Done(ref) if err != nil { logger.Debug("done split failed", "error", err) @@ -253,7 +276,7 @@ func (s *Service) feedPostHandler(w http.ResponseWriter, r *http.Request) { return } - jsonhttp.Created(w, feedReferenceResponse{Reference: ref}) + jsonhttp.Created(w, feedReferenceResponse{Reference: encryptedReference}) } func parseFeedUpdate(ch swarm.Chunk) (swarm.Address, int64, error) { diff --git a/pkg/api/router.go b/pkg/api/router.go index 2fc2b9afcd1..000521aab67 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -209,10 +209,12 @@ func (s *Service) mountAPI() { "GET": web.ChainHandlers( s.contentLengthMetricMiddleware(), s.newTracingHandler("bytes-download"), + s.actDecryptionHandler(), web.FinalHandlerFunc(s.bytesGetHandler), ), "HEAD": web.ChainHandlers( s.newTracingHandler("bytes-head"), + s.actDecryptionHandler(), web.FinalHandlerFunc(s.bytesHeadHandler), ), }) @@ -230,8 +232,14 @@ func (s *Service) mountAPI() { )) handle("/chunks/{address}", jsonhttp.MethodHandler{ - "GET": http.HandlerFunc(s.chunkGetHandler), - "HEAD": http.HandlerFunc(s.hasChunkHandler), + "GET": web.ChainHandlers( + s.actDecryptionHandler(), + web.FinalHandlerFunc(s.chunkGetHandler), + ), + "HEAD": web.ChainHandlers( + s.actDecryptionHandler(), + web.FinalHandlerFunc(s.hasChunkHandler), + ), }) handle("/envelope/{address}", jsonhttp.MethodHandler{ @@ -261,6 +269,21 @@ func (s *Service) mountAPI() { ), }) + handle("/grantee", jsonhttp.MethodHandler{ + "POST": web.ChainHandlers( + web.FinalHandlerFunc(s.actCreateGranteesHandler), + ), + }) + + handle("/grantee/{address}", jsonhttp.MethodHandler{ + "GET": web.ChainHandlers( + web.FinalHandlerFunc(s.actListGranteesHandler), + ), + "PATCH": web.ChainHandlers( + web.FinalHandlerFunc(s.actGrantRevokeHandler), + ), + }) + handle("/bzz/{address}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := r.URL u.Path += "/" @@ -271,9 +294,11 @@ func (s *Service) mountAPI() { "GET": web.ChainHandlers( s.contentLengthMetricMiddleware(), s.newTracingHandler("bzz-download"), + s.actDecryptionHandler(), web.FinalHandlerFunc(s.bzzDownloadHandler), ), "HEAD": web.ChainHandlers( + s.actDecryptionHandler(), web.FinalHandlerFunc(s.bzzHeadHandler), ), }) diff --git a/pkg/api/soc.go b/pkg/api/soc.go index 3db58356313..9eff4bd3c99 100644 --- a/pkg/api/soc.go +++ b/pkg/api/soc.go @@ -9,6 +9,7 @@ import ( "io" "net/http" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/cac" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/postage" @@ -44,9 +45,11 @@ func (s *Service) socUploadHandler(w http.ResponseWriter, r *http.Request) { } headers := struct { - BatchID []byte `map:"Swarm-Postage-Batch-Id"` - StampSig []byte `map:"Swarm-Postage-Stamp"` - Pin bool `map:"Swarm-Pin"` + BatchID []byte `map:"Swarm-Postage-Batch-Id"` + StampSig []byte `map:"Swarm-Postage-Stamp"` + Pin bool `map:"Swarm-Pin"` + Act bool `map:"Swarm-Act"` + HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -185,6 +188,26 @@ func (s *Service) socUploadHandler(w http.ResponseWriter, r *http.Request) { return } + reference := sch.Address() + if headers.Act { + reference, err = s.actEncryptionHandler(r.Context(), w, putter, reference, headers.HistoryAddress) + if err != nil { + logger.Debug("access control upload failed", "error", err) + logger.Error(nil, "access control upload failed") + switch { + case errors.Is(err, accesscontrol.ErrNotFound): + jsonhttp.NotFound(w, "act or history entry not found") + case errors.Is(err, accesscontrol.ErrInvalidPublicKey) || errors.Is(err, accesscontrol.ErrSecretKeyInfinity): + jsonhttp.BadRequest(w, "invalid public key") + case errors.Is(err, accesscontrol.ErrUnexpectedType): + jsonhttp.BadRequest(w, "failed to create history") + default: + jsonhttp.InternalServerError(w, errActUpload) + } + return + } + } + err = putter.Put(r.Context(), sch) if err != nil { logger.Debug("write chunk failed", "chunk_address", sch.Address(), "error", err) @@ -201,5 +224,5 @@ func (s *Service) socUploadHandler(w http.ResponseWriter, r *http.Request) { return } - jsonhttp.Created(w, chunkAddressResponse{Reference: sch.Address()}) + jsonhttp.Created(w, socPostResponse{Reference: reference}) } diff --git a/pkg/manifest/mantaray.go b/pkg/manifest/mantaray.go index f3b86e06b66..4666a80c910 100644 --- a/pkg/manifest/mantaray.go +++ b/pkg/manifest/mantaray.go @@ -54,6 +54,10 @@ func NewMantarayManifestReference( }, nil } +func (m *mantarayManifest) Root() *mantaray.Node { + return m.trie +} + func (m *mantarayManifest) Type() string { return ManifestMantarayContentType } diff --git a/pkg/manifest/mantaray/node.go b/pkg/manifest/mantaray/node.go index 5a59c010d52..9561c299db5 100644 --- a/pkg/manifest/mantaray/node.go +++ b/pkg/manifest/mantaray/node.go @@ -16,13 +16,8 @@ const ( ) var ( - ZeroObfuscationKey []byte -) - -// nolint:gochecknoinits -func init() { ZeroObfuscationKey = make([]byte, 32) -} +) // Error used when lookup path does not match var ( diff --git a/pkg/node/devnode.go b/pkg/node/devnode.go index 1c8347bd194..5439fe2d2a7 100644 --- a/pkg/node/devnode.go +++ b/pkg/node/devnode.go @@ -17,6 +17,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" mockAccounting "github.com/ethersphere/bee/v2/pkg/accounting/mock" "github.com/ethersphere/bee/v2/pkg/api" "github.com/ethersphere/bee/v2/pkg/bzz" @@ -60,13 +61,14 @@ import ( ) type DevBee struct { - tracerCloser io.Closer - stateStoreCloser io.Closer - localstoreCloser io.Closer - apiCloser io.Closer - pssCloser io.Closer - errorLogWriter io.Writer - apiServer *http.Server + tracerCloser io.Closer + stateStoreCloser io.Closer + localstoreCloser io.Closer + apiCloser io.Closer + pssCloser io.Closer + accesscontrolCloser io.Closer + errorLogWriter io.Writer + apiServer *http.Server } type DevOptions struct { @@ -188,6 +190,11 @@ func NewDevBee(logger log.Logger, o *DevOptions) (b *DevBee, err error) { } b.localstoreCloser = localStore + session := accesscontrol.NewDefaultSession(mockKey) + actLogic := accesscontrol.NewLogic(session) + accesscontrol := accesscontrol.NewController(actLogic) + b.accesscontrolCloser = accesscontrol + pssService := pss.New(mockKey, logger) b.pssCloser = pssService @@ -337,6 +344,7 @@ func NewDevBee(logger log.Logger, o *DevOptions) (b *DevBee, err error) { Pss: pssService, FeedFactory: mockFeeds, Post: post, + AccessControl: accesscontrol, PostageContract: postageContract, Staking: mockStaking, Steward: mockSteward, @@ -423,6 +431,7 @@ func (b *DevBee) Shutdown() error { } tryClose(b.pssCloser, "pss") + tryClose(b.accesscontrolCloser, "accesscontrol") tryClose(b.tracerCloser, "tracer") tryClose(b.stateStoreCloser, "statestore") tryClose(b.localstoreCloser, ioutil.DataPathLocalstore) diff --git a/pkg/node/node.go b/pkg/node/node.go index 3d8205db72b..ed0d6b4f438 100644 --- a/pkg/node/node.go +++ b/pkg/node/node.go @@ -24,6 +24,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/accounting" "github.com/ethersphere/bee/v2/pkg/addressbook" "github.com/ethersphere/bee/v2/pkg/api" @@ -115,6 +116,7 @@ type Bee struct { shutdownInProgress bool shutdownMutex sync.Mutex syncingStopped *syncutil.Signaler + accesscontrolCloser io.Closer } type Options struct { @@ -197,6 +199,7 @@ func NewBee( logger log.Logger, libp2pPrivateKey, pssPrivateKey *ecdsa.PrivateKey, + session accesscontrol.Session, o *Options, ) (b *Bee, err error) { tracer, tracerCloser, err := tracing.NewTracer(&tracing.Options{ @@ -732,6 +735,10 @@ func NewBee( b.localstoreCloser = localStore evictFn = func(id []byte) error { return localStore.EvictBatch(context.Background(), id) } + actLogic := accesscontrol.NewLogic(session) + accesscontrol := accesscontrol.NewController(actLogic) + b.accesscontrolCloser = accesscontrol + var ( syncErr atomic.Value syncStatus atomic.Value @@ -1072,6 +1079,7 @@ func NewBee( Pss: pssService, FeedFactory: feedFactory, Post: post, + AccessControl: accesscontrol, PostageContract: postageStampContractService, Staking: stakingContract, Steward: steward, @@ -1264,6 +1272,7 @@ func (b *Bee) Shutdown() error { c() } + tryClose(b.accesscontrolCloser, "accesscontrol") tryClose(b.tracerCloser, "tracer") tryClose(b.topologyCloser, "topology driver") tryClose(b.storageIncetivesCloser, "storage incentives agent") diff --git a/pkg/soc/testing/soc.go b/pkg/soc/testing/soc.go index e5325f464b1..20bfe74c9cd 100644 --- a/pkg/soc/testing/soc.go +++ b/pkg/soc/testing/soc.go @@ -5,6 +5,7 @@ package testing import ( + "crypto/ecdsa" "testing" "github.com/ethersphere/bee/v2/pkg/cac" @@ -70,3 +71,38 @@ func GenerateMockSOC(t *testing.T, data []byte) *MockSOC { WrappedChunk: ch, } } + +// GenerateMockSOCWithKey generates a valid mocked SOC from given data and key. +func GenerateMockSOCWithKey(t *testing.T, data []byte, privKey *ecdsa.PrivateKey) *MockSOC { + t.Helper() + + signer := crypto.NewDefaultSigner(privKey) + owner, err := signer.EthereumAddress() + if err != nil { + t.Fatal(err) + } + + ch, err := cac.New(data) + if err != nil { + t.Fatal(err) + } + + id := make([]byte, swarm.HashSize) + hasher := swarm.NewHasher() + _, err = hasher.Write(append(id, ch.Address().Bytes()...)) + if err != nil { + t.Fatal(err) + } + + signature, err := signer.Sign(hasher.Sum(nil)) + if err != nil { + t.Fatal(err) + } + + return &MockSOC{ + ID: id, + Owner: owner.Bytes(), + Signature: signature, + WrappedChunk: ch, + } +}