Skip to content
Merged
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
18 changes: 10 additions & 8 deletions CLAUDE.md

Large diffs are not rendered by default.

48 changes: 26 additions & 22 deletions blob/encrypted.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@ import (
)

// End-to-end encrypted blobs are the one kind of blob the server cannot read.
// A client encrypts an image for a DM (see messagingpb.EncryptedContent, which
// fixes the format) and uploads the ciphertext as an opaque octet stream, so
// the server derives nothing from the bytes: no metadata, no renditions, no
// moderation, no privacy-metadata check. What it does instead is pin the blob
// to the DM at reservation — the caller must be a member, and the chat must be
// a DM — check nothing but the size at finalization, grant the DM read access
// in the same step that makes the blob READY, and refuse the blob on every
// surface other than encrypted content in that chat (see validateAttachable).
// Everything specific to that kind is gathered here; the pipeline arms live
// beside their plaintext counterparts.
// A client encrypts an image for a chat (see messagingpb.EncryptedContent,
// which fixes the format) and uploads the ciphertext as an opaque octet
// stream, so the server derives nothing from the bytes: no metadata, no
// renditions, no moderation, no privacy-metadata check. What it does instead
// is pin the blob to the chat at reservation — the chat must take encrypted
// content and the caller must be able to send it there (see
// EncryptedUploadGate) — check nothing but the size at finalization, grant
// the chat read access in the same step that makes the blob READY, and refuse
// the blob on every surface other than encrypted content in that chat (see
// validateAttachable). Everything specific to that kind is gathered here; the
// pipeline arms live beside their plaintext counterparts.
const (
// MaxEncryptedBlobSizeBytes bounds the declared size of an end-to-end
// encrypted upload. It is the only constraint the server enforces on such a
Expand All @@ -43,16 +44,19 @@ const (
maxEncryptedImagePixels = maxEncryptedImageDimension * maxEncryptedImageDimension
)

// DMMembership is the slice of the chat domain the upload path needs to admit
// an end-to-end encrypted upload: whether a user is currently a member of a
// chat that is a DM. It is declared here (consumer side) so this package need
// not import chat — which imports this package to attach pictures and match
// its errors — and the chat package supplies an adapter over its store that
// also owns the DM-versus-group rule, so the chat ID discriminator is never
// duplicated here.
type DMMembership interface {
// IsDMMember reports whether chatID names a DM and userID is a member of
// it. A group chat ID, an unknown chat, or a non-member is false with no
// error.
IsDMMember(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
// EncryptedUploadGate is the slice of the chat domain the upload path needs
// to admit an end-to-end encrypted upload: whether a chat takes encrypted
// content, and whether the caller may send it there right now. Which chats
// do — a DM, and a private group once it has its chat key — and what admits
// a sender are the chat domain's rules, and change there. It is declared here
// (consumer side) so this package need not import chat — which imports this
// package to attach pictures and match its errors — and the chat package
// supplies the adapter (chat.NewBlobEncryptedUploadGate), so neither the chat
// ID discriminator nor the rules are duplicated here.
type EncryptedUploadGate interface {
// CanUploadEncrypted reports whether chatID takes end-to-end encrypted
// content and userID may send it there. A chat that takes none, an
// unknown chat, or a caller who may not send in it is false with no
// error, so a refused caller learns nothing of the chat.
CanUploadEncrypted(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (bool, error)
}
2 changes: 1 addition & 1 deletion blob/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ type Blob struct {
// EncryptedFor is set when the blob's bytes are end-to-end encrypted for one
// surface (blobpb.InitiateExternalUploadRequest.end_to_end_encrypted_for),
// naming that surface as the principal its read grant will be made to — a
// DM is PrincipalForChat(chat), the only surface today. It is pinned at
// chat is PrincipalForChat(chat), the only surface today. It is pinned at
// reservation and immutable. The server cannot read such a blob, so it
// derives no metadata or renditions from it, never moderates it, grants the
// principal read access in the step that makes it READY (see
Expand Down
44 changes: 23 additions & 21 deletions blob/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ type Server struct {
access AccessStore
resolver PrincipalResolver

// dms gates end-to-end encrypted uploads: one is reserved only for a DM the
// caller is a member of (see initiateEncryptedUpload).
dms DMMembership
// encryptedUploads gates end-to-end encrypted uploads: one is reserved
// only for a chat that takes encrypted content from the caller (see
// initiateEncryptedUpload).
encryptedUploads EncryptedUploadGate

blobpb.UnimplementedBlobStorageServer
}
Expand All @@ -48,17 +49,17 @@ func NewServer(
storage ObjectStorage,
access AccessStore,
resolver PrincipalResolver,
dms DMMembership,
encryptedUploads EncryptedUploadGate,
) *Server {
return &Server{
log: log,
authz: authz,
accounts: accounts,
blobs: blobs,
storage: storage,
access: access,
resolver: resolver,
dms: dms,
log: log,
authz: authz,
accounts: accounts,
blobs: blobs,
storage: storage,
access: access,
resolver: resolver,
encryptedUploads: encryptedUploads,
}
}

Expand Down Expand Up @@ -111,7 +112,7 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat
}

// An end-to-end encrypted upload is its own contract — an opaque type, its
// own size ceiling, and a DM it is pinned to — so it is reserved on its own
// own size ceiling, and a chat it is pinned to — so it is reserved on its own
// path. An ordinary upload has no surface named.
if chatID := req.GetChat(); chatID != nil {
return s.initiateEncryptedUpload(ctx, log, owner, chatID, req)
Expand Down Expand Up @@ -160,15 +161,16 @@ func (s *Server) InitiateExternalUpload(ctx context.Context, req *blobpb.Initiat
})
}

// initiateEncryptedUpload reserves an end-to-end encrypted blob for a DM
// initiateEncryptedUpload reserves an end-to-end encrypted blob for a chat
// (blobpb.InitiateExternalUploadRequest.end_to_end_encrypted_for). The server
// cannot read the bytes, so the contract it pins is the one it can hold the
// upload to: the opaque type (UNSUPPORTED_TYPE otherwise), the encrypted size
// ceiling (TOO_LARGE otherwise; both policy-driven, so they echo the policy
// version), and a DM the caller is a member of (DENIED otherwise — a group, an
// unknown chat, and a non-member are indistinguishable, so a caller learns
// nothing about a chat they are not in). The cheap checks run first; the
// membership read runs only for a request that is otherwise acceptable.
// version), and a chat that takes encrypted content from the caller (see
// EncryptedUploadGate; DENIED otherwise — a chat that takes none, an unknown
// chat, and a caller who may not send are indistinguishable, so a caller
// learns nothing about a chat they are not in). The cheap checks run first;
// the gate's reads run only for a request that is otherwise acceptable.
//
// Reservation pins the blob to the chat as the principal its grant will be made
// to (Blob.EncryptedFor = PrincipalForChat) but grants nothing: the read grant
Expand Down Expand Up @@ -201,12 +203,12 @@ func (s *Server) initiateEncryptedUpload(ctx context.Context, log *zap.Logger, o
}, nil
}

isDMMember, err := s.dms.IsDMMember(ctx, chatID, owner)
allowed, err := s.encryptedUploads.CanUploadEncrypted(ctx, chatID, owner)
if err != nil {
log.Warn("Failed to check DM membership", zap.Error(err))
log.Warn("Failed to check encrypted upload gate", zap.Error(err))
return nil, status.Error(codes.Internal, "failed to initiate upload")
}
if !isDMMember {
if !allowed {
return &blobpb.InitiateExternalUploadResponse{Result: blobpb.InitiateExternalUploadResponse_DENIED}, nil
}

Expand Down
72 changes: 56 additions & 16 deletions blob/tests/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -918,14 +918,48 @@ func testEncryptedUpload(t *testing.T, accounts account.Store, blobs blob.Store,
require.Equal(t, blobpb.InitiateExternalUploadResponse_OK, resp.Result)
})

t.Run("a group chat is denied", func(t *testing.T) {
t.Run("a chat that takes no encrypted content is denied", func(t *testing.T) {
groupID := &commonpb.ChatId{Value: dmID.Value[:16]}
resolver.joinDM(groupID, senderID) // membership does not help: it is not a DM
resolver.join(groupID, senderID) // membership does not help: the gate refuses the chat
resp := initiateEncrypted(t, h, sender, groupID, blob.EncryptedMimeType, uint64(len(ciphertext)))
require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, resp.Result)
require.Nil(t, resp.PolicyVersion)
})

t.Run("a keyed private group's member reserves a blob pinned to the group", func(t *testing.T) {
// The pipeline is surface-agnostic: a group the gate admits is pinned,
// finalized and granted exactly as a DM is, and read by its members
// through the chat context.
groupID := &commonpb.ChatId{Value: dmID.Value[16:]}
_, outsider := registerUser(t, accounts)
resolver.joinKeyedPrivateGroup(groupID, senderID)
resolver.joinKeyedPrivateGroup(groupID, recipientID)

resp := initiateEncrypted(t, h, sender, groupID, blob.EncryptedMimeType, uint64(len(ciphertext)))
require.Equal(t, blobpb.InitiateExternalUploadResponse_OK, resp.Result)
record, err := blobs.GetByID(context.Background(), resp.BlobId)
require.NoError(t, err)
require.Equal(t, blob.PrincipalForChat(groupID), *record.EncryptedFor)

upload(resp.UploadTarget, ciphertext)
require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_PROCESSING, completeResponse(t, h, sender, resp.BlobId).Status)
h.drain(t)
require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_READY, completeResponse(t, h, sender, resp.BlobId).Status)
granted, err := access.HasGrant(context.Background(), resp.BlobId, blob.PrincipalForChat(groupID), blob.PermissionRead)
require.NoError(t, err)
require.True(t, granted)

got := getBlobs(t, h, recipient, []*blobpb.BlobId{resp.BlobId}, &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: groupID}})
require.Len(t, got, 1)
require.Equal(t, blobpb.BlobStatus_BLOB_STATUS_READY, got[0].Status)
require.NotNil(t, got[0].Metadata.GetEncrypted())
require.Empty(t, getBlobs(t, h, outsider, []*blobpb.BlobId{resp.BlobId}, &blobpb.AccessContext{Scope: &blobpb.AccessContext_Chat{Chat: groupID}}))

// The DM's member has no business with the group's upload.
denied := initiateEncrypted(t, h, outsider, groupID, blob.EncryptedMimeType, uint64(len(ciphertext)))
require.Equal(t, blobpb.InitiateExternalUploadResponse_DENIED, denied.Result)
})

t.Run("a non-member is denied", func(t *testing.T) {
_, outsider := registerUser(t, accounts)
resp := initiateEncrypted(t, h, outsider, dmID, blob.EncryptedMimeType, uint64(len(ciphertext)))
Expand Down Expand Up @@ -1161,34 +1195,40 @@ func makePNGWithExif(t *testing.T, width, height int) []byte {

// fakeResolver is a controllable blob.PrincipalResolver for the server suite: a
// (principal, user) pair resolves as covered only after allow records it. It
// doubles as the suite's blob.DMMembership: joinDM records a user as a member
// of a DM, which both admits their encrypted uploads for it and — as the
// production ChatResolver would — covers them for the chat's grants.
// doubles as the suite's blob.EncryptedUploadGate: joinDM records a user as a
// member of a DM, and joinKeyedPrivateGroup as a member of a private group
// that has its key, which both admits their encrypted uploads for the chat
// and — as the production ChatResolver would — covers them for its grants. A
// member of any other chat is covered by its grants (join) and not admitted
// to upload for it, as the production gate refuses a public group's member.
type fakeResolver struct {
covered map[string]bool
dmMembers map[string]bool
uploaders map[string]bool
}

func newFakeResolver() *fakeResolver {
return &fakeResolver{covered: make(map[string]bool), dmMembers: make(map[string]bool)}
return &fakeResolver{covered: make(map[string]bool), uploaders: make(map[string]bool)}
}

func (r *fakeResolver) allow(principal blob.Principal, user *commonpb.UserId) {
r.covered[resolverKey(principal, user)] = true
}

func (r *fakeResolver) joinDM(chatID *commonpb.ChatId, user *commonpb.UserId) {
r.dmMembers[resolverKey(blob.PrincipalForChat(chatID), user)] = true
func (r *fakeResolver) join(chatID *commonpb.ChatId, user *commonpb.UserId) {
r.allow(blob.PrincipalForChat(chatID), user)
}

func (r *fakeResolver) IsDMMember(_ context.Context, chatID *commonpb.ChatId, user *commonpb.UserId) (bool, error) {
// The production adapter refuses a group ID before reading membership; the
// fake mirrors that so the suite exercises the same shape.
if len(chatID.GetValue()) != 32 {
return false, nil
}
return r.dmMembers[resolverKey(blob.PrincipalForChat(chatID), user)], nil
func (r *fakeResolver) joinDM(chatID *commonpb.ChatId, user *commonpb.UserId) {
r.uploaders[resolverKey(blob.PrincipalForChat(chatID), user)] = true
r.join(chatID, user)
}

func (r *fakeResolver) joinKeyedPrivateGroup(chatID *commonpb.ChatId, user *commonpb.UserId) {
r.joinDM(chatID, user)
}

func (r *fakeResolver) CanUploadEncrypted(_ context.Context, chatID *commonpb.ChatId, user *commonpb.UserId) (bool, error) {
return r.uploaders[resolverKey(blob.PrincipalForChat(chatID), user)], nil
}

func (r *fakeResolver) Covers(ctx context.Context, principal blob.Principal, user *commonpb.UserId) (bool, error) {
Expand Down
Loading
Loading