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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion pkg/mediorum/server/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ type QmAudioAnalysis struct {
type Upload struct {
ID string `json:"id"` // base32 file hash

UserWallet sql.NullString `json:"user_wallet"`
// UserWallet is populated only by the legacy POST /uploads and gRPC paths,
// from an unverified X-User-Wallet-Addr header. It plays no part in
// attestation; the tus path leaves it null.
UserWallet sql.NullString `json:"user_wallet"`
// UserID is the user this upload was made for, asserted in tus metadata.
// An assertion, not proof — see upload_auth.go for why that is safe and
// for the constraint it puts on how claims may be interpreted.
UserID sql.NullInt64 `json:"user_id"`
Template JobTemplate `json:"template"`
OrigFileName string `json:"orig_filename"`
OrigFileCID string `json:"orig_file_cid" gorm:"column:orig_file_cid;index:idx_uploads_orig_file_cid"` //
Expand Down
85 changes: 85 additions & 0 deletions pkg/mediorum/server/preview_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package server

import (
"context"
"errors"
"fmt"

"go.uber.org/zap"
)

// Authorization for previews generated from a bare cid.
//
// POST /generate_preview takes a cid and an offset and produces new bytes with
// nothing to credit them to. It never consults an upload record, and often
// there is none to consult — the source may be a legacy Qm cid, which is why
// repair scrolls qm_cids separately from uploads.
//
// The result still needs a claim: a preview cid that anyone could name on
// their own track would let an attacker tile a gated track at 30-second
// offsets and reassemble it. The claim goes to the asserted user, and only if
// that user already claims the source — crediting a user who does not hold
// the source is the whole attack; crediting one who does grants nothing they
// did not already hold.
//
// The requester is deliberately not authenticated. Asserting the owner's id
// gets a preview attested to the owner, which the requester cannot use: the
// preview cid streams only through signed, track-scoped URLs (see serveBlob —
// audio is never served by bare cid), a streaming signature is only issued
// for cids resolved from a track record, and naming the cid on a track
// requires a signed entity-manager write backed by the claim. That serving
// invariant is load-bearing for gated content generally, not just previews:
// if audio ever becomes fetchable by bare cid, gated tracks leak directly,
// previews or not.

// errPreviewUnverifiable means the node could not reach the state it needs to
// authorize, as opposed to the caller getting it wrong. The handler turns this
// into a 503 rather than a 401 so clients retry instead of re-asserting.
var errPreviewUnverifiable = errors.New("cannot authorize a preview while core is unavailable")

// previewClaimant confirms the asserted user may claim the source cid,
// returning the user id to attest the preview to.
//
// Returns 0 with no error when content auth is off, meaning generate a preview
// as before and attest nothing.
func (ss *MediorumServer) previewClaimant(ctx context.Context, sourceCID string, userID int64) (int64, error) {
if !ss.contentAuthEnabled() {
return 0, nil
}

// Checked before core availability: a missing user id is the caller's
// error whatever state this node is in, and saying so is both more useful
// and cheaper than reporting an outage.
if userID == 0 {
return 0, errors.New("preview requests must carry the requesting user's id")
}

if ss.core == nil {
return 0, errPreviewUnverifiable
}

// The source claim is what makes this safe. Without it the endpoint would
// mint a claim over a 30-second window of anyone's audio.
claimed, err := ss.core.IsCidClaimedByUser(ctx, sourceCID, userID)
if err != nil {
return 0, fmt.Errorf("%w: %w", errPreviewUnverifiable, err)
}
if !claimed {
ss.logger.Info("refusing preview of unclaimed source",
zap.String("sourceCID", sourceCID), zap.Int64("userID", userID))
return 0, fmt.Errorf("user %d may not claim %s", userID, sourceCID)
}

return userID, nil
}

// attestPreviewCid records the preview as belonging to the user that claims
// the source. Blocks until the transaction commits: the caller writes this cid
// straight onto a track, and consensus rejects a track naming a cid it has no
// claim for.
func (ss *MediorumServer) attestPreviewCid(ctx context.Context, userID int64, previewCID string) error {
if userID == 0 || previewCID == "" || ss.core == nil {
return nil
}
return ss.sendContentAttestation(ctx, contentAttestation(userID, previewCID, ss.Config.Self.Wallet))
}
97 changes: 97 additions & 0 deletions pkg/mediorum/server/preview_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package server

import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"

"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

// With content auth off the endpoint behaves as it always has: no user id
// required, and nothing attested — even when one is asserted.
func TestPreviewClaimantSkipsWhenContentAuthDisabled(t *testing.T) {
ss := &MediorumServer{logger: zap.NewNop()}
ss.Config.ContentAuthEnabled = false

for _, userID := range []int64{0, 7} {
got, err := ss.previewClaimant(context.Background(), "sourcecid", userID)
if err != nil {
t.Fatalf("expected the request to be allowed: %v", err)
}
if got != 0 {
t.Fatalf("expected no claimant, got %d", got)
}
}
}

// A request with no asserted user is refused before any transcode work: the
// preview it would produce could never be attested, and the endpoint is
// otherwise an unauthenticated ffmpeg trigger over arbitrary cids.
func TestPreviewClaimantRequiresAUserID(t *testing.T) {
ss := &MediorumServer{logger: zap.NewNop()}
ss.Config.ContentAuthEnabled = true

_, err := ss.previewClaimant(context.Background(), "sourcecid", 0)
if err == nil {
t.Fatal("expected the request to be refused")
}
if !strings.Contains(err.Error(), "user") {
t.Fatalf("expected a user id error, got %v", err)
}
}

// An asserted user still cannot proceed when the claim is uncheckable —
// crediting them unchecked is the attack. It reports as a node problem, not a
// caller one, so the client retries rather than reasserting.
func TestPreviewClaimantRefusesWhenClaimIsUnverifiable(t *testing.T) {
ss := &MediorumServer{logger: zap.NewNop()}
ss.Config.ContentAuthEnabled = true

_, err := ss.previewClaimant(context.Background(), "sourcecid", 7)
if !errors.Is(err, errPreviewUnverifiable) {
t.Fatalf("expected an unverifiable-claim error, got %v", err)
}
}

// Absent means 0 so previewClaimant can apply the content-auth rules;
// malformed is an error regardless, so a bad assertion cannot masquerade as no
// assertion.
func TestPreviewRequestUserIDParsing(t *testing.T) {
newCtx := func(query string) echo.Context {
req := httptest.NewRequest("POST", "/generate_preview/cid/30"+query, nil)
return echo.New().NewContext(req, httptest.NewRecorder())
}

if got, err := newCtx("").QueryParam("userId"), ""; got != err {
t.Fatalf("test setup: expected empty query param, got %q", got)
}

userID, err := previewRequestUserID(newCtx(""))
if err != nil || userID != 0 {
t.Fatalf("expected an absent user id to read as 0, got %d, %v", userID, err)
}

userID, err = previewRequestUserID(newCtx("?userId=42"))
if err != nil || userID != 42 {
t.Fatalf("expected user id 42, got %d, %v", userID, err)
}

for _, raw := range []string{"abc", "0", "-3", "1.5"} {
if _, err := previewRequestUserID(newCtx("?userId=" + raw)); err == nil {
t.Fatalf("expected user id %q to be rejected", raw)
}
}
}

// A preview attestation credits the user for exactly the one cid this node
// produced.
func TestContentAttestationForPreview(t *testing.T) {
ca := contentAttestation(7, "previewcid", "0xValidator")
if ca.UserId != 7 || len(ca.Cids) != 1 || ca.Cids[0] != "previewcid" {
t.Fatalf("unexpected attestation: %+v", ca)
}
}
45 changes: 44 additions & 1 deletion pkg/mediorum/server/serve_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"database/sql"
"errors"
"fmt"
"io"
"mime/multipart"
Expand Down Expand Up @@ -80,14 +81,50 @@ func (ss *MediorumServer) generatePreview(c echo.Context) error {
fileHash := c.Param("cid")
previewStartSeconds := c.Param("previewStartSeconds")

// Authorize before transcoding, not after: this endpoint pulls a blob from
// the network and runs ffmpeg, so an unauthorized caller should not get that
// work done for them.
userID, err := previewRequestUserID(c)
if err != nil {
return c.String(http.StatusBadRequest, err.Error())
}
claimant, err := ss.previewClaimant(ctx, fileHash, userID)
if err != nil {
if errors.Is(err, errPreviewUnverifiable) {
return c.String(http.StatusServiceUnavailable, err.Error())
}
return c.String(http.StatusUnauthorized, err.Error())
}

audioPreview, err := ss.generateAudioPreview(ctx, fileHash, previewStartSeconds)
if err != nil {
return err
}

// Before responding: the caller writes this cid onto a track as soon as it
// has it, and consensus rejects a track naming an unclaimed cid.
if err := ss.attestPreviewCid(ctx, claimant, audioPreview.CID); err != nil {
return err
}

return c.JSON(200, audioPreview)
}

// previewRequestUserID reads the asserted user from the query string. Absent
// is 0, letting previewClaimant apply the content-auth rules; malformed is an
// error regardless, so a bad assertion cannot masquerade as no assertion.
func previewRequestUserID(c echo.Context) (int64, error) {
raw := c.QueryParam("userId")
if raw == "" {
return 0, nil
}
userID, err := strconv.ParseInt(raw, 10, 64)
if err != nil || userID <= 0 {
return 0, fmt.Errorf("preview request carries an unusable user id %q", raw)
}
return userID, nil
}

// this endpoint should be replaced by generate_preview
// when client is fully using generate_preview
// this can be removed.
Expand Down Expand Up @@ -136,10 +173,16 @@ func (ss *MediorumServer) updateUpload(c echo.Context) error {
// Do not support deleting previews
if selectedPreview.Valid && selectedPreview != upload.SelectedPreview {
upload.SelectedPreview = selectedPreview
err := ss.generateAudioPreviewForUpload(c.Request().Context(), upload)
previewCID, err := ss.generateAudioPreviewForUpload(c.Request().Context(), upload)
if err != nil {
return err
}
// A changed preview start yields a cid the original attestation never
// covered. Attest before responding, so the caller does not receive a
// preview cid it cannot yet name on its track.
if err := ss.attestUploadCids(c.Request().Context(), upload, previewCID); err != nil {
return err
}
}

return c.JSON(200, upload)
Expand Down
9 changes: 9 additions & 0 deletions pkg/mediorum/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ type MediorumConfig struct {
ProgrammableDistributionEnabled bool
BlobStorageStreaming bool

// ContentAuthEnabled turns on upload-signature verification and on-chain
// cid attestation. Deliberately separate from
// ProgrammableDistributionEnabled: that flag governs the DDEX subsystem,
// and content authorization protects the ordinary track-upload path, so
// tying them together would make closing the cid-claim bypass conditional
// on enabling an unrelated feature.
ContentAuthEnabled bool

// should have a basedir type of thing
// by default will put db + blobs there

Expand Down Expand Up @@ -201,6 +209,7 @@ func New(lc *lifecycle.Lifecycle, logger *zap.Logger, config MediorumConfig, pos
config.Env = v
}
config.ProgrammableDistributionEnabled = common.IsProgrammableDistributionEnabled(config.Env)
config.ContentAuthEnabled = common.IsContentAuthEnabled(config.Env)
if config.StoreRecentTTL <= 0 {
config.StoreRecentTTL = DefaultStoreRecentTTL
}
Expand Down
16 changes: 14 additions & 2 deletions pkg/mediorum/server/transcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,8 +366,8 @@ func (ss *MediorumServer) transcodeFullAudio(ctx context.Context, upload *Upload

// if a start time is set, also transcode an audio preview from the full 320kbps downsample
if upload.SelectedPreview.Valid {
err := ss.generateAudioPreviewForUpload(ctx, upload)
if err != nil {
// Attested below with the rest of the upload's cids.
if _, err := ss.generateAudioPreviewForUpload(ctx, upload); err != nil {
return onError(err, upload.Status, "generateAudioPreview")
}
}
Expand Down Expand Up @@ -483,6 +483,18 @@ func (ss *MediorumServer) transcode(ctx context.Context, upload *Upload) error {
if err := ss.crud.DB.Where("id = ?", upload.ID).First(&dbUpload).Error; err != nil {
return fmt.Errorf("failed to get upload from DB: %w", err)
}
// Attest before publishing done, not after. A client creates its track as
// soon as the upload reads done, and consensus rejects a track naming cids
// it holds no claim for, so flipping the status first would race the
// attestation.
cids := []string{dbUpload.OrigFileCID, upload.TranscodeResults["320"]}
if upload.SelectedPreview.Valid {
cids = append(cids, upload.TranscodeResults[upload.SelectedPreview.String])
}
if err := ss.attestUploadCids(ctx, &dbUpload, cids...); err != nil {
return onError(err, upload.Status, "attesting cids")
}

dbUpload.TranscodeProgress = 1
dbUpload.TranscodedAt = time.Now().UTC()
dbUpload.Status = JobStatusDone
Expand Down
15 changes: 11 additions & 4 deletions pkg/mediorum/server/transcode_preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,28 @@ import (
// which stored preview CID on the upload record itself.
// This is still expected by client when creating + editing a preview.
// When client is fully using generate_preview endpoint, this can probably go away.
func (ss *MediorumServer) generateAudioPreviewForUpload(ctx context.Context, upload *Upload) error {
// Returns the cid it generated, empty if the upload has no preview selected.
// Callers are responsible for attesting it: the cid is attested with the rest
// of the upload during transcoding, but an edit that changes the preview start
// runs this again and produces a cid that needs an attestation of its own.
func (ss *MediorumServer) generateAudioPreviewForUpload(ctx context.Context, upload *Upload) (string, error) {
// if a start time is set, also transcode an audio preview from the full 320kbps downsample
if upload.SelectedPreview.Valid {
splitPreview := strings.Split(upload.SelectedPreview.String, "|")
previewStart := splitPreview[1]

audioPreview, err := ss.generateAudioPreview(ctx, upload.TranscodeResults["320"], previewStart)
if err != nil {
return err
return "", err
}

upload.TranscodeResults[upload.SelectedPreview.String] = audioPreview.CID
return ss.crud.Update(upload)
if err := ss.crud.Update(upload); err != nil {
return "", err
}
return audioPreview.CID, nil
}
return nil
return "", nil
}

// generateAudioPreview is the new preview impl which requires only a CID + previewStartSeconds, so that it works with Qm CIDs too.
Expand Down
Loading
Loading