diff --git a/CHANGELOG.md b/CHANGELOG.md index da10b24..71b04a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,87 @@ called out explicitly even when nothing else did. release notes, so a version with no entry here does not release. Write the entry in the same PR that syncs the contract, while the diff is still in front of you. +## 0.12.0 + +Synced to [`flipcash2-protobuf-api@9ebf55fe`](https://github.com/code-payments/flipcash2-protobuf-api/commit/9ebf55fef834c1a47ae995ae22cad28d79080f2c), +picking up [#117](https://github.com/code-payments/flipcash2-protobuf-api/pull/117) through +[#122](https://github.com/code-payments/flipcash2-protobuf-api/pull/122). `blob.v1`, `chat.v1`, +`messaging.v1` and `push.v1` moved. + +Most of this release is end-to-end encryption for DMs: an encrypted content type, encrypted blob +uploads for DM media, and a per-chat flag for migrating DMs over. It is additive on the wire, but +one existing Swift accessor is gone and pushes can now arrive without the message, so the upgrade is +not free on iOS. Both are under Upgrading. + +### Added + +End-to-end encrypted DMs, in `messaging.v1`: + +- `EncryptedContent`, a new `Content.type` case (`encrypted = 7`), carrying `scheme` (field 1), + a 24-byte `nonce` (field 2) and `ciphertext` (field 3, up to 17408 bytes including the 16-byte + tag). The plaintext is a serialized `Content` holding a `TextContent`, a `MediaContent`, or a + `ReplyContent` of either. `EncryptedContent.Scheme` has `UNKNOWN = 0` and + `X25519_XCHACHA20POLY1305 = 1`. The proto comment on `EncryptedContent` specifies the key + derivation, AAD and blob format in full; follow it rather than this summary. +- `SendMessageResponse.Result.ENCRYPTION_NOT_ALLOWED = 2` and + `EditMessageResponse.Result.ENCRYPTION_NOT_ALLOWED = 5`, returned when `EncryptedContent` is sent + to a chat that is not a DM. + +Encrypted blobs, in `blob.v1`: + +- `InitiateExternalUploadRequest.end_to_end_encrypted_for`, a oneof whose only case is + `chat = 4` (`common.v1.ChatId`). The caller must be a member of that DM. `mime_type` must be + `application/octet-stream`, and the server checks only the size. +- `EncryptedBlobMetadata`, a new empty message and a new `BlobMetadata.kind` case + (`encrypted = 5`), marking a blob uploaded that way. +- `UploadPolicy.encrypted` (field 4) and the new `EncryptedConstraints`, with `max_size_bytes` + (field 1, the only enforced limit) and advisory `image` bounds (field 2). Unset when the caller + may not upload encrypted blobs. + +Chat metadata, in `chat.v1.Metadata`: + +- `creator` (field 13, `common.v1.UserId`), set for group chats only. +- `use_e2ee` (field 100, `bool`), true when clients should send new content in this DM as + `EncryptedContent`. Always false for group chats. It is transitional and will be deprecated once + E2EE launches. The Swift accessor is `useE2Ee`. + +Push, in `push.v1.ChatMetadata`: + +- `message_id` (field 5, `messaging.v1.MessageId`), sent in place of the full message when the + message would push the payload over the 4KB FCM/APNs limit. + +### Changed + +- `ChatMetadata.message` (field 3) now sits inside a new `message_ref` oneof alongside + `message_id`. The field number and type are unchanged, so this is wire-compatible. In Swift the + generated `hasMessage` and `clearMessage()` are gone; read `messageRef` instead. Kotlin keeps + `hasMessage()` and gains `getMessageRefCase()`. +- `ChatMetadata.sending_user_id` is no longer deprecated. It is set whether the push carries the + message or only its ID. +- `Chat.GetChat` documents an unauthenticated read: with `auth` unset, the caller gets the chat's + public view, and `view_mode` must be `REDACTED`. Any other mode, or a DM, is `DENIED`. No field + changed. + +### Upgrading + +**A chat push may no longer carry the message.** This applies to every client, including one that +never upgrades: the server now omits the message from a push for a long message and sends +`message_id` instead, which an older client sees as `message` simply unset. The push's title and +body are still set, so the notification can be shown either way. A client that needs the message, +to decrypt it or to store it for a muted chat, fetches it with `Messaging.GetMessage` using +`message_id` and the chat ID from `Payload.navigation`. + +**`EncryptedContent` needs handling before a DM turns on `use_e2ee`.** The server cannot read, +moderate or preview it. A client that cannot decrypt a message, or decrypts a type outside the +allowed three, renders it as unsupported rather than failing. + +### Unchanged + +Nothing was renumbered. Both new `Result` cases are appended after the last existing case (`DENIED` +and `CONFLICT` respectively), so no positional `rawValue` mapping shifts. `Content.encrypted` and +`BlobMetadata.encrypted` are appended to their oneofs, and every new field takes a free number. No +service, RPC, message or enum case was removed. Everything else in the diff is comments. + ## 0.11.0 Synced to [`flipcash2-protobuf-api@4ccbbe43`](https://github.com/code-payments/flipcash2-protobuf-api/commit/4ccbbe43197ec6cc2d7a3f0681a73f799cdcfb46), diff --git a/Sources/Flipcash2ClientProtocol/ContractInfo.swift b/Sources/Flipcash2ClientProtocol/ContractInfo.swift index d2385d6..f4af0b7 100644 --- a/Sources/Flipcash2ClientProtocol/ContractInfo.swift +++ b/Sources/Flipcash2ClientProtocol/ContractInfo.swift @@ -2,7 +2,7 @@ public enum Flipcash2ContractInfo { public static let version = "0.11.0-dev" - public static let protoCommit = "4ccbbe43197ec6cc2d7a3f0681a73f799cdcfb46" + public static let protoCommit = "9ebf55fef834c1a47ae995ae22cad28d79080f2c" public static var isLocal: Bool { protoCommit == localSentinel } diff --git a/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.grpc.swift b/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.grpc.swift index 2a52880..3393336 100644 --- a/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.grpc.swift +++ b/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.grpc.swift @@ -98,7 +98,10 @@ extension Flipcash_Blob_V1_BlobStorage { /// > BlobStorage manages direct-to-storage uploads and authorized, time-limited reads /// > of the bytes behind MediaItem renditions (and other blobs). Clients upload bytes /// > straight to object storage via a presigned target — the server never proxies - /// > them — and all blob metadata is server-derived from the stored bytes. + /// > them — and all blob metadata is server-derived from the stored bytes. The + /// > exception is end-to-end encrypted blobs (see + /// > InitiateExternalUploadRequest.end_to_end_encrypted_for), whose bytes the + /// > server cannot read. public protocol ClientProtocol: Sendable { /// Call the "GetUploadPolicy" method. /// @@ -133,7 +136,8 @@ extension Flipcash_Blob_V1_BlobStorage { /// > /// > InitiateExternalUpload reserves a BlobId and returns a short-lived presigned /// > target the client uploads the bytes to directly. Clients only ever upload - /// > ORIGINALs; the server derives any additional renditions itself. + /// > ORIGINALs; the server derives any additional renditions itself, except + /// > for end-to-end encrypted blobs, which have none. /// /// - Parameters: /// - request: A request containing a single `Flipcash_Blob_V1_InitiateExternalUploadRequest` message. @@ -219,7 +223,10 @@ extension Flipcash_Blob_V1_BlobStorage { /// > BlobStorage manages direct-to-storage uploads and authorized, time-limited reads /// > of the bytes behind MediaItem renditions (and other blobs). Clients upload bytes /// > straight to object storage via a presigned target — the server never proxies - /// > them — and all blob metadata is server-derived from the stored bytes. + /// > them — and all blob metadata is server-derived from the stored bytes. The + /// > exception is end-to-end encrypted blobs (see + /// > InitiateExternalUploadRequest.end_to_end_encrypted_for), whose bytes the + /// > server cannot read. public struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { private let client: GRPCCore.GRPCClient @@ -275,7 +282,8 @@ extension Flipcash_Blob_V1_BlobStorage { /// > /// > InitiateExternalUpload reserves a BlobId and returns a short-lived presigned /// > target the client uploads the bytes to directly. Clients only ever upload - /// > ORIGINALs; the server derives any additional renditions itself. + /// > ORIGINALs; the server derives any additional renditions itself, except + /// > for end-to-end encrypted blobs, which have none. /// /// - Parameters: /// - request: A request containing a single `Flipcash_Blob_V1_InitiateExternalUploadRequest` message. @@ -426,7 +434,8 @@ extension Flipcash_Blob_V1_BlobStorage.ClientProtocol { /// > /// > InitiateExternalUpload reserves a BlobId and returns a short-lived presigned /// > target the client uploads the bytes to directly. Clients only ever upload - /// > ORIGINALs; the server derives any additional renditions itself. + /// > ORIGINALs; the server derives any additional renditions itself, except + /// > for end-to-end encrypted blobs, which have none. /// /// - Parameters: /// - request: A request containing a single `Flipcash_Blob_V1_InitiateExternalUploadRequest` message. @@ -565,7 +574,8 @@ extension Flipcash_Blob_V1_BlobStorage.ClientProtocol { /// > /// > InitiateExternalUpload reserves a BlobId and returns a short-lived presigned /// > target the client uploads the bytes to directly. Clients only ever upload - /// > ORIGINALs; the server derives any additional renditions itself. + /// > ORIGINALs; the server derives any additional renditions itself, except + /// > for end-to-end encrypted blobs, which have none. /// /// - Parameters: /// - message: request message to send. diff --git a/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.pb.swift b/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.pb.swift index 555e34e..a5ce241 100644 --- a/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.pb.swift +++ b/Sources/Flipcash2ClientProtocol/blob_v1_blob_storage_service.pb.swift @@ -123,8 +123,59 @@ public struct Flipcash_Blob_V1_InitiateExternalUploadRequest: Sendable { /// content-length-range so storage rejects an upload that exceeds it. public var sizeBytes: UInt64 = 0 + /// Set when the bytes are end-to-end encrypted, naming the surface they are + /// encrypted for. Unset for an ordinary upload. mime_type must be + /// "application/octet-stream", or the upload is denied with + /// UNSUPPORTED_TYPE. size_bytes is the size of the whole encrypted blob and + /// is checked against UploadPolicy.encrypted. + /// + /// The server cannot read the bytes, so it checks only their size. It + /// derives no metadata or renditions, does not moderate, and does not check + /// for privacy metadata. The blob can be referenced only from the surface + /// named here, and is rejected anywhere else (unencrypted MediaContent, + /// profile or chat pictures). + public var endToEndEncryptedFor: Flipcash_Blob_V1_InitiateExternalUploadRequest.OneOf_EndToEndEncryptedFor? = nil + + /// The bytes are encrypted for this DM, as described in + /// messaging.v1.EncryptedContent: the 24-byte nonce followed by the + /// ciphertext and its 16-byte tag. The caller must be a member of the + /// chat and the chat must be a DM, or the upload is DENIED. Once READY, + /// the blob is granted to the chat, so the other member can read it + /// through AccessContext.chat, and it can be referenced only from + /// EncryptedContent in that chat. + public var chat: Flipcash_Common_V1_ChatId { + get { + if case .chat(let v)? = endToEndEncryptedFor {return v} + return Flipcash_Common_V1_ChatId() + } + set {endToEndEncryptedFor = .chat(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() + /// Set when the bytes are end-to-end encrypted, naming the surface they are + /// encrypted for. Unset for an ordinary upload. mime_type must be + /// "application/octet-stream", or the upload is denied with + /// UNSUPPORTED_TYPE. size_bytes is the size of the whole encrypted blob and + /// is checked against UploadPolicy.encrypted. + /// + /// The server cannot read the bytes, so it checks only their size. It + /// derives no metadata or renditions, does not moderate, and does not check + /// for privacy metadata. The blob can be referenced only from the surface + /// named here, and is rejected anywhere else (unencrypted MediaContent, + /// profile or chat pictures). + public enum OneOf_EndToEndEncryptedFor: Equatable, Sendable { + /// The bytes are encrypted for this DM, as described in + /// messaging.v1.EncryptedContent: the 24-byte nonce followed by the + /// ciphertext and its 16-byte tag. The caller must be a member of the + /// chat and the chat must be a DM, or the upload is DENIED. Once READY, + /// the blob is granted to the chat, so the other member can read it + /// through AccessContext.chat, and it can be referenced only from + /// EncryptedContent in that chat. + case chat(Flipcash_Common_V1_ChatId) + + } + public init() {} fileprivate var _auth: Flipcash_Common_V1_Auth? = nil @@ -526,7 +577,7 @@ extension Flipcash_Blob_V1_GetUploadPolicyResponse.Result: SwiftProtobuf._ProtoN extension Flipcash_Blob_V1_InitiateExternalUploadRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".InitiateExternalUploadRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}auth\0\u{3}mime_type\0\u{3}size_bytes\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}auth\0\u{3}mime_type\0\u{3}size_bytes\0\u{1}chat\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -537,6 +588,19 @@ extension Flipcash_Blob_V1_InitiateExternalUploadRequest: SwiftProtobuf.Message, case 1: try { try decoder.decodeSingularMessageField(value: &self._auth) }() case 2: try { try decoder.decodeSingularStringField(value: &self.mimeType) }() case 3: try { try decoder.decodeSingularUInt64Field(value: &self.sizeBytes) }() + case 4: try { + var v: Flipcash_Common_V1_ChatId? + var hadOneofValue = false + if let current = self.endToEndEncryptedFor { + hadOneofValue = true + if case .chat(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.endToEndEncryptedFor = .chat(v) + } + }() default: break } } @@ -556,6 +620,9 @@ extension Flipcash_Blob_V1_InitiateExternalUploadRequest: SwiftProtobuf.Message, if self.sizeBytes != 0 { try visitor.visitSingularUInt64Field(value: self.sizeBytes, fieldNumber: 3) } + try { if case .chat(let v)? = self.endToEndEncryptedFor { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -563,6 +630,7 @@ extension Flipcash_Blob_V1_InitiateExternalUploadRequest: SwiftProtobuf.Message, if lhs._auth != rhs._auth {return false} if lhs.mimeType != rhs.mimeType {return false} if lhs.sizeBytes != rhs.sizeBytes {return false} + if lhs.endToEndEncryptedFor != rhs.endToEndEncryptedFor {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift index 5ea1904..9d64457 100644 --- a/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/blob_v1_model.pb.swift @@ -32,7 +32,7 @@ public enum Flipcash_Blob_V1_BlobStatus: SwiftProtobuf.Enum, Swift.CaseIterable /// bytes present; deriving metadata / transcoding / moderating case processing // = 2 - /// available; metadata populated and moderation passed + /// available; metadata populated and moderation passed (encrypted: size checked) case ready // = 3 /// failed validation or moderation; not servable @@ -78,7 +78,8 @@ public enum Flipcash_Blob_V1_BlobStatus: SwiftProtobuf.Enum, Swift.CaseIterable /// Why a blob failed finalization, after its bytes were uploaded. Distinct from /// the pre-upload denials in InitiateExternalUploadResponse.Result, which reject -/// before any bytes are stored. +/// before any bytes are stored. An end-to-end encrypted blob is only ever +/// rejected with TOO_LARGE or INTERNAL, since the server cannot read its bytes. public enum Flipcash_Blob_V1_RejectionReason: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case unknown // = 0 @@ -239,9 +240,11 @@ public struct Flipcash_Blob_V1_BlobBatch: Sendable { public init() {} } -/// Server-authoritative metadata describing a stored blob. Never set by clients. -/// With the exception of download_url, every field is intrinsic to the stored -/// bytes and immutable, derived once by the server. +/// Server-authoritative metadata describing a stored blob. Never set by clients, +/// except inside messaging.v1.EncryptedContent, where the sender sets it to +/// describe the plaintext of an end-to-end encrypted blob and the server never +/// sees it. With the exception of download_url, every field is intrinsic to the +/// stored bytes and immutable, derived once by the server. public struct Flipcash_Blob_V1_BlobMetadata: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -272,9 +275,9 @@ public struct Flipcash_Blob_V1_BlobMetadata: Sendable { public mutating func clearDownloadURL() {self._downloadURL = nil} /// Kind-specific metadata the server derived from the bytes. Exactly one - /// variant is set for a recognized media kind; left unset for opaque blobs. - /// Only images are supported today; video/audio/etc. will be added as new - /// variants. + /// variant is set for a recognized media kind or an end-to-end encrypted + /// blob; left unset for opaque blobs. Only images are supported today; + /// video/audio/etc. will be added as new variants. public var kind: Flipcash_Blob_V1_BlobMetadata.OneOf_Kind? = nil public var image: Flipcash_Blob_V1_ImageMetadata { @@ -285,14 +288,23 @@ public struct Flipcash_Blob_V1_BlobMetadata: Sendable { set {kind = .image(newValue)} } + public var encrypted: Flipcash_Blob_V1_EncryptedBlobMetadata { + get { + if case .encrypted(let v)? = kind {return v} + return Flipcash_Blob_V1_EncryptedBlobMetadata() + } + set {kind = .encrypted(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() /// Kind-specific metadata the server derived from the bytes. Exactly one - /// variant is set for a recognized media kind; left unset for opaque blobs. - /// Only images are supported today; video/audio/etc. will be added as new - /// variants. + /// variant is set for a recognized media kind or an end-to-end encrypted + /// blob; left unset for opaque blobs. Only images are supported today; + /// video/audio/etc. will be added as new variants. public enum OneOf_Kind: Equatable, Sendable { case image(Flipcash_Blob_V1_ImageMetadata) + case encrypted(Flipcash_Blob_V1_EncryptedBlobMetadata) } @@ -301,6 +313,22 @@ public struct Flipcash_Blob_V1_BlobMetadata: Sendable { fileprivate var _downloadURL: Flipcash_Blob_V1_DownloadUrl? = nil } +/// Marks a blob uploaded with +/// InitiateExternalUploadRequest.end_to_end_encrypted_for. Its BlobMetadata +/// describes the encrypted bytes: mime_type is "application/octet-stream" and +/// size_bytes is the size of the encrypted blob. The plaintext's type, size and +/// image metadata are set by the sender inside the messaging.v1.EncryptedContent +/// that references the blob, and clients render from those instead. +public struct Flipcash_Blob_V1_EncryptedBlobMetadata: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + /// Intrinsic descriptors for a still image. public struct Flipcash_Blob_V1_ImageMetadata: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the @@ -337,6 +365,10 @@ public struct Flipcash_Blob_V1_Media: Sendable { /// exactly one ORIGINAL rendition (its blob_id); the server fills that /// rendition's metadata and appends any derived renditions (e.g. a /// downscaled DISPLAY and a THUMBNAIL). + /// + /// Inside messaging.v1.EncryptedContent, the server never sees the media: + /// the sender supplies the single ORIGINAL rendition with its metadata + /// already set, and there are no derived renditions. public var renditions: [Flipcash_Blob_V1_Rendition] = [] public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -370,6 +402,9 @@ public struct Flipcash_Blob_V1_Rendition: Sendable { /// /// If unavailable at the time the media is retrieved, the client can use /// GetBlobs to query for the blob metadata. + /// + /// Inside messaging.v1.EncryptedContent, the sender sets this to describe + /// the plaintext, without a download_url; see BlobMetadata. public var blob: Flipcash_Blob_V1_BlobMetadata { get {return _blob ?? Flipcash_Blob_V1_BlobMetadata()} set {_blob = newValue} @@ -473,12 +508,54 @@ public struct Flipcash_Blob_V1_UploadPolicy: Sendable { /// upload whose type matches no entry is not accepted. public var mimeTypeConstraints: [Flipcash_Blob_V1_MimeTypeConstraints] = [] + /// Constraints on end-to-end encrypted uploads (see + /// InitiateExternalUploadRequest.end_to_end_encrypted_for), which are + /// governed by this instead of mime_type_constraints. Unset when the caller + /// may not upload encrypted blobs. + public var encrypted: Flipcash_Blob_V1_EncryptedConstraints { + get {return _encrypted ?? Flipcash_Blob_V1_EncryptedConstraints()} + set {_encrypted = newValue} + } + /// Returns true if `encrypted` has been explicitly set. + public var hasEncrypted: Bool {return self._encrypted != nil} + /// Clears the value of `encrypted`. Subsequent reads from it will return its default value. + public mutating func clearEncrypted() {self._encrypted = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} fileprivate var _version: Flipcash_Blob_V1_PolicyVersion? = nil fileprivate var _ttl: SwiftProtobuf.Google_Protobuf_Duration? = nil + fileprivate var _encrypted: Flipcash_Blob_V1_EncryptedConstraints? = nil +} + +/// Upload constraints for end-to-end encrypted blobs. +public struct Flipcash_Blob_V1_EncryptedConstraints: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// Hard ceiling on the encrypted blob's byte size, including the 24-byte + /// nonce and 16-byte tag. This is the only constraint the server enforces. + public var maxSizeBytes: UInt64 = 0 + + /// Bounds the sender should downscale an image to before encrypting it. The + /// server cannot read the bytes, so these are advisory. + public var image: Flipcash_Blob_V1_ImageConstraints { + get {return _image ?? Flipcash_Blob_V1_ImageConstraints()} + set {_image = newValue} + } + /// Returns true if `image` has been explicitly set. + public var hasImage: Bool {return self._image != nil} + /// Clears the value of `image`. Subsequent reads from it will return its default value. + public mutating func clearImage() {self._image = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _image: Flipcash_Blob_V1_ImageConstraints? = nil } /// Opaque generation token identifying a snapshot of an UploadPolicy. Compared @@ -718,6 +795,9 @@ public struct Flipcash_Blob_V1_AccessContext: Sendable { /// The caller is accessing these blobs from within this chat. Authorized /// iff the caller is a member of the chat and the blob was shared into it. + /// An end-to-end encrypted blob is shared into the chat it was uploaded + /// for (InitiateExternalUploadRequest.end_to_end_encrypted_for) once it + /// is READY, not by the message that references it. public var chat: Flipcash_Common_V1_ChatId { get { if case .chat(let v)? = scope {return v} @@ -758,6 +838,9 @@ public struct Flipcash_Blob_V1_AccessContext: Sendable { public enum OneOf_Scope: Equatable, Sendable { /// The caller is accessing these blobs from within this chat. Authorized /// iff the caller is a member of the chat and the blob was shared into it. + /// An end-to-end encrypted blob is shared into the chat it was uploaded + /// for (InitiateExternalUploadRequest.end_to_end_encrypted_for) once it + /// is READY, not by the message that references it. case chat(Flipcash_Common_V1_ChatId) /// The caller is accessing these blobs from this user's public profile. /// Authorized iff the blob is a rendition of that user's CURRENT profile @@ -931,7 +1014,7 @@ extension Flipcash_Blob_V1_BlobBatch: SwiftProtobuf.Message, SwiftProtobuf._Mess extension Flipcash_Blob_V1_BlobMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".BlobMetadata" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}mime_type\0\u{3}size_bytes\0\u{3}download_url\0\u{1}image\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}mime_type\0\u{3}size_bytes\0\u{3}download_url\0\u{1}image\0\u{1}encrypted\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -955,6 +1038,19 @@ extension Flipcash_Blob_V1_BlobMetadata: SwiftProtobuf.Message, SwiftProtobuf._M self.kind = .image(v) } }() + case 5: try { + var v: Flipcash_Blob_V1_EncryptedBlobMetadata? + var hadOneofValue = false + if let current = self.kind { + hadOneofValue = true + if case .encrypted(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.kind = .encrypted(v) + } + }() default: break } } @@ -974,9 +1070,17 @@ extension Flipcash_Blob_V1_BlobMetadata: SwiftProtobuf.Message, SwiftProtobuf._M try { if let v = self._downloadURL { try visitor.visitSingularMessageField(value: v, fieldNumber: 3) } }() - try { if case .image(let v)? = self.kind { + switch self.kind { + case .image?: try { + guard case .image(let v)? = self.kind else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 4) - } }() + }() + case .encrypted?: try { + guard case .encrypted(let v)? = self.kind else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() + case nil: break + } try unknownFields.traverse(visitor: &visitor) } @@ -990,6 +1094,25 @@ extension Flipcash_Blob_V1_BlobMetadata: SwiftProtobuf.Message, SwiftProtobuf._M } } +extension Flipcash_Blob_V1_EncryptedBlobMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".EncryptedBlobMetadata" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Blob_V1_EncryptedBlobMetadata, rhs: Flipcash_Blob_V1_EncryptedBlobMetadata) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + extension Flipcash_Blob_V1_ImageMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ImageMetadata" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}width\0\u{1}height\0\u{1}blurhash\0") @@ -1110,7 +1233,7 @@ extension Flipcash_Blob_V1_Rendition.Role: SwiftProtobuf._ProtoNameProviding { extension Flipcash_Blob_V1_UploadPolicy: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".UploadPolicy" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{1}ttl\0\u{3}mime_type_constraints\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{1}ttl\0\u{3}mime_type_constraints\0\u{1}encrypted\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1121,6 +1244,7 @@ extension Flipcash_Blob_V1_UploadPolicy: SwiftProtobuf.Message, SwiftProtobuf._M case 1: try { try decoder.decodeSingularMessageField(value: &self._version) }() case 2: try { try decoder.decodeSingularMessageField(value: &self._ttl) }() case 3: try { try decoder.decodeRepeatedMessageField(value: &self.mimeTypeConstraints) }() + case 4: try { try decoder.decodeSingularMessageField(value: &self._encrypted) }() default: break } } @@ -1140,6 +1264,9 @@ extension Flipcash_Blob_V1_UploadPolicy: SwiftProtobuf.Message, SwiftProtobuf._M if !self.mimeTypeConstraints.isEmpty { try visitor.visitRepeatedMessageField(value: self.mimeTypeConstraints, fieldNumber: 3) } + try { if let v = self._encrypted { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -1147,6 +1274,46 @@ extension Flipcash_Blob_V1_UploadPolicy: SwiftProtobuf.Message, SwiftProtobuf._M if lhs._version != rhs._version {return false} if lhs._ttl != rhs._ttl {return false} if lhs.mimeTypeConstraints != rhs.mimeTypeConstraints {return false} + if lhs._encrypted != rhs._encrypted {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Flipcash_Blob_V1_EncryptedConstraints: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".EncryptedConstraints" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}max_size_bytes\0\u{1}image\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt64Field(value: &self.maxSizeBytes) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._image) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if self.maxSizeBytes != 0 { + try visitor.visitSingularUInt64Field(value: self.maxSizeBytes, fieldNumber: 1) + } + try { if let v = self._image { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Blob_V1_EncryptedConstraints, rhs: Flipcash_Blob_V1_EncryptedConstraints) -> Bool { + if lhs.maxSizeBytes != rhs.maxSizeBytes {return false} + if lhs._image != rhs._image {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } diff --git a/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.grpc.swift b/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.grpc.swift index a5ae346..8ae8d48 100644 --- a/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.grpc.swift +++ b/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.grpc.swift @@ -176,6 +176,12 @@ extension Flipcash_Chat_V1_Chat { /// > Source IDL Documentation: /// > /// > GetChat returns the metadata for a specific chat + /// > + /// > Auth is optional. An unauthenticated caller gets the chat's public view: + /// > the same group record a registered non-member previewing it receives, + /// > with view_mode REDACTED and none of the per-viewer fields (is_hidden, + /// > viewer_state). An unauthenticated read under any other view_mode, or of + /// > a DM, is DENIED. /// /// - Parameters: /// - request: A request containing a single `Flipcash_Chat_V1_GetChatRequest` message. @@ -517,6 +523,12 @@ extension Flipcash_Chat_V1_Chat { /// > Source IDL Documentation: /// > /// > GetChat returns the metadata for a specific chat + /// > + /// > Auth is optional. An unauthenticated caller gets the chat's public view: + /// > the same group record a registered non-member previewing it receives, + /// > with view_mode REDACTED and none of the per-viewer fields (is_hidden, + /// > viewer_state). An unauthenticated read under any other view_mode, or of + /// > a DM, is DENIED. /// /// - Parameters: /// - request: A request containing a single `Flipcash_Chat_V1_GetChatRequest` message. @@ -956,6 +968,12 @@ extension Flipcash_Chat_V1_Chat.ClientProtocol { /// > Source IDL Documentation: /// > /// > GetChat returns the metadata for a specific chat + /// > + /// > Auth is optional. An unauthenticated caller gets the chat's public view: + /// > the same group record a registered non-member previewing it receives, + /// > with view_mode REDACTED and none of the per-viewer fields (is_hidden, + /// > viewer_state). An unauthenticated read under any other view_mode, or of + /// > a DM, is DENIED. /// /// - Parameters: /// - request: A request containing a single `Flipcash_Chat_V1_GetChatRequest` message. @@ -1344,6 +1362,12 @@ extension Flipcash_Chat_V1_Chat.ClientProtocol { /// > Source IDL Documentation: /// > /// > GetChat returns the metadata for a specific chat + /// > + /// > Auth is optional. An unauthenticated caller gets the chat's public view: + /// > the same group record a registered non-member previewing it receives, + /// > with view_mode REDACTED and none of the per-viewer fields (is_hidden, + /// > viewer_state). An unauthenticated read under any other view_mode, or of + /// > a DM, is DENIED. /// /// - Parameters: /// - message: request message to send. diff --git a/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift b/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift index f265280..8145c31 100644 --- a/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift +++ b/Sources/Flipcash2ClientProtocol/chat_v1_chat_service.pb.swift @@ -42,8 +42,12 @@ public struct Flipcash_Chat_V1_GetChatRequest: Sendable { /// withheld from a viewer who may not read the chat under it. Unset (FULL) /// is the pre-redaction contract: messaging state for a viewer who may /// read the chat in full, the bare record for anyone else. + /// + /// Must be REDACTED when auth is unset; any other mode is DENIED. public var viewMode: Flipcash_Messaging_V1_ViewMode = .full + /// Optional. Unset requests the chat's public view (see Chat.GetChat), + /// which requires view_mode REDACTED. public var auth: Flipcash_Common_V1_Auth { get {return _auth ?? Flipcash_Common_V1_Auth()} set {_auth = newValue} diff --git a/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift index 318522d..542b0f1 100644 --- a/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/chat_v1_model.pb.swift @@ -185,6 +185,33 @@ public struct Flipcash_Chat_V1_Metadata: @unchecked Sendable { /// Clears the value of `viewerState`. Subsequent reads from it will return its default value. public mutating func clearViewerState() {_uniqueStorage()._viewerState = nil} + /// The user that created this chat. Set only for group chats. + public var creator: Flipcash_Common_V1_UserId { + get {return _storage._creator ?? Flipcash_Common_V1_UserId()} + set {_uniqueStorage()._creator = newValue} + } + /// Returns true if `creator` has been explicitly set. + public var hasCreator: Bool {return _storage._creator != nil} + /// Clears the value of `creator`. Subsequent reads from it will return its default value. + public mutating func clearCreator() {_uniqueStorage()._creator = nil} + + /// Whether messages in this chat are end-to-end encrypted (see + /// messaging.v1.EncryptedContent). Only supported for DMs (CONTACT_DM or + /// TIP_DM); always false for group chats. + /// + /// Used to migrate DMs to E2EE: when true, clients send all new content in + /// the chat as EncryptedContent. When false, clients send content in the + /// clear. Existing messages are not re-encrypted, so a chat may hold a mix + /// of both. + /// + /// This is a transitional flag. Once E2EE has launched, clients should + /// always end-to-end encrypt DMs regardless of this value, and it will be + /// deprecated. + public var useE2Ee: Bool { + get {return _storage._useE2Ee} + set {_uniqueStorage()._useE2Ee = newValue} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} @@ -925,7 +952,7 @@ extension Flipcash_Chat_V1_ChatType: SwiftProtobuf._ProtoNameProviding { extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Metadata" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}chat_id\0\u{1}type\0\u{1}members\0\u{3}last_message\0\u{3}last_activity\0\u{3}latest_event_sequence\0\u{3}is_hidden\0\u{1}title\0\u{1}picture\0\u{3}roster_summary\0\u{1}rules\0\u{3}viewer_state\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}chat_id\0\u{1}type\0\u{1}members\0\u{3}last_message\0\u{3}last_activity\0\u{3}latest_event_sequence\0\u{3}is_hidden\0\u{1}title\0\u{1}picture\0\u{3}roster_summary\0\u{1}rules\0\u{3}viewer_state\0\u{1}creator\0\u{4}W\u{1}use_e2ee\0") fileprivate class _StorageClass { var _chatID: Flipcash_Common_V1_ChatId? = nil @@ -940,6 +967,8 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa var _rosterSummary: Flipcash_Chat_V1_RosterSummary? = nil var _rules: Flipcash_Chat_V1_Rules? = nil var _viewerState: Flipcash_Chat_V1_ViewerState? = nil + var _creator: Flipcash_Common_V1_UserId? = nil + var _useE2Ee: Bool = false // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -962,6 +991,8 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa _rosterSummary = source._rosterSummary _rules = source._rules _viewerState = source._viewerState + _creator = source._creator + _useE2Ee = source._useE2Ee } } @@ -992,6 +1023,8 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa case 10: try { try decoder.decodeSingularMessageField(value: &_storage._rosterSummary) }() case 11: try { try decoder.decodeSingularMessageField(value: &_storage._rules) }() case 12: try { try decoder.decodeSingularMessageField(value: &_storage._viewerState) }() + case 13: try { try decoder.decodeSingularMessageField(value: &_storage._creator) }() + case 100: try { try decoder.decodeSingularBoolField(value: &_storage._useE2Ee) }() default: break } } @@ -1040,6 +1073,12 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa try { if let v = _storage._viewerState { try visitor.visitSingularMessageField(value: v, fieldNumber: 12) } }() + try { if let v = _storage._creator { + try visitor.visitSingularMessageField(value: v, fieldNumber: 13) + } }() + if _storage._useE2Ee != false { + try visitor.visitSingularBoolField(value: _storage._useE2Ee, fieldNumber: 100) + } } try unknownFields.traverse(visitor: &visitor) } @@ -1061,6 +1100,8 @@ extension Flipcash_Chat_V1_Metadata: SwiftProtobuf.Message, SwiftProtobuf._Messa if _storage._rosterSummary != rhs_storage._rosterSummary {return false} if _storage._rules != rhs_storage._rules {return false} if _storage._viewerState != rhs_storage._viewerState {return false} + if _storage._creator != rhs_storage._creator {return false} + if _storage._useE2Ee != rhs_storage._useE2Ee {return false} return true } if !storagesAreEqual {return false} diff --git a/Sources/Flipcash2ClientProtocol/messaging_v1_messaging_service.pb.swift b/Sources/Flipcash2ClientProtocol/messaging_v1_messaging_service.pb.swift index 3ff609d..68cacc0 100644 --- a/Sources/Flipcash2ClientProtocol/messaging_v1_messaging_service.pb.swift +++ b/Sources/Flipcash2ClientProtocol/messaging_v1_messaging_service.pb.swift @@ -407,6 +407,7 @@ public struct Flipcash_Messaging_V1_SendMessageRequest: Sendable { /// - TextContent /// - ReplyContent /// - MediaContent + /// - EncryptedContent, in DMs only public var content: [Flipcash_Messaging_V1_Content] = [] /// Client-generated idempotency token for this send. Used to dedup retried @@ -463,6 +464,9 @@ public struct Flipcash_Messaging_V1_SendMessageResponse: Sendable { public typealias RawValue = Int case ok // = 0 case denied // = 1 + + /// The content is EncryptedContent and the chat is not a DM. + case encryptionNotAllowed // = 2 case UNRECOGNIZED(Int) public init() { @@ -473,6 +477,7 @@ public struct Flipcash_Messaging_V1_SendMessageResponse: Sendable { switch rawValue { case 0: self = .ok case 1: self = .denied + case 2: self = .encryptionNotAllowed default: self = .UNRECOGNIZED(rawValue) } } @@ -481,6 +486,7 @@ public struct Flipcash_Messaging_V1_SendMessageResponse: Sendable { switch self { case .ok: return 0 case .denied: return 1 + case .encryptionNotAllowed: return 2 case .UNRECOGNIZED(let i): return i } } @@ -489,6 +495,7 @@ public struct Flipcash_Messaging_V1_SendMessageResponse: Sendable { public static let allCases: [Flipcash_Messaging_V1_SendMessageResponse.Result] = [ .ok, .denied, + .encryptionNotAllowed, ] } @@ -525,6 +532,7 @@ public struct Flipcash_Messaging_V1_EditMessageRequest: Sendable { /// - TextContent /// - ReplyContent /// - MediaContent + /// - EncryptedContent, in DMs only public var content: [Flipcash_Messaging_V1_Content] = [] /// Required optimistic-concurrency guard: the message's event_sequence as the @@ -584,6 +592,9 @@ public struct Flipcash_Messaging_V1_EditMessageResponse: Sendable { /// edit/delete won). The edit was not applied; `message` carries the /// current state for the client to reconcile against and retry. case conflict // = 4 + + /// The content is EncryptedContent and the chat is not a DM. + case encryptionNotAllowed // = 5 case UNRECOGNIZED(Int) public init() { @@ -597,6 +608,7 @@ public struct Flipcash_Messaging_V1_EditMessageResponse: Sendable { case 2: self = .messageNotFound case 3: self = .cannotEdit case 4: self = .conflict + case 5: self = .encryptionNotAllowed default: self = .UNRECOGNIZED(rawValue) } } @@ -608,6 +620,7 @@ public struct Flipcash_Messaging_V1_EditMessageResponse: Sendable { case .messageNotFound: return 2 case .cannotEdit: return 3 case .conflict: return 4 + case .encryptionNotAllowed: return 5 case .UNRECOGNIZED(let i): return i } } @@ -619,6 +632,7 @@ public struct Flipcash_Messaging_V1_EditMessageResponse: Sendable { .messageNotFound, .cannotEdit, .conflict, + .encryptionNotAllowed, ] } @@ -1944,7 +1958,7 @@ extension Flipcash_Messaging_V1_SendMessageResponse: SwiftProtobuf.Message, Swif } extension Flipcash_Messaging_V1_SendMessageResponse.Result: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0OK\0\u{1}DENIED\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0OK\0\u{1}DENIED\0\u{1}ENCRYPTION_NOT_ALLOWED\0") } extension Flipcash_Messaging_V1_EditMessageRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { @@ -2041,7 +2055,7 @@ extension Flipcash_Messaging_V1_EditMessageResponse: SwiftProtobuf.Message, Swif } extension Flipcash_Messaging_V1_EditMessageResponse.Result: SwiftProtobuf._ProtoNameProviding { - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0OK\0\u{1}DENIED\0\u{1}MESSAGE_NOT_FOUND\0\u{1}CANNOT_EDIT\0\u{1}CONFLICT\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0OK\0\u{1}DENIED\0\u{1}MESSAGE_NOT_FOUND\0\u{1}CANNOT_EDIT\0\u{1}CONFLICT\0\u{1}ENCRYPTION_NOT_ALLOWED\0") } extension Flipcash_Messaging_V1_DeleteMessageRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { diff --git a/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift index cc25341..30deba4 100644 --- a/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/messaging_v1_model.pb.swift @@ -327,6 +327,14 @@ public struct Flipcash_Messaging_V1_Content: Sendable { set {type = .deleted(newValue)} } + public var encrypted: Flipcash_Messaging_V1_EncryptedContent { + get { + if case .encrypted(let v)? = type {return v} + return Flipcash_Messaging_V1_EncryptedContent() + } + set {type = .encrypted(newValue)} + } + public var unknownFields = SwiftProtobuf.UnknownStorage() public enum OneOf_Type: Equatable, Sendable { @@ -336,6 +344,7 @@ public struct Flipcash_Messaging_V1_Content: Sendable { case media(Flipcash_Messaging_V1_MediaContent) case system(Flipcash_Messaging_V1_SystemContent) case deleted(Flipcash_Messaging_V1_DeletedContent) + case encrypted(Flipcash_Messaging_V1_EncryptedContent) } @@ -468,6 +477,8 @@ public struct Flipcash_Messaging_V1_MediaContent: Sendable { /// /// On SendMessage the client supplies exactly one ORIGINAL rendition per /// item; the server fills its metadata and appends the derived renditions. + /// Inside EncryptedContent, the sender sets the ORIGINAL's metadata itself + /// and there are no derived renditions; see EncryptedContent. public var items: [Flipcash_Blob_V1_Media] = [] /// Optional caption rendered alongside the media @@ -543,6 +554,179 @@ public struct Flipcash_Messaging_V1_DeletedContent: Sendable { fileprivate var _deletedBy: Flipcash_Common_V1_UserId? = nil } +/// End-to-end encrypted content, sent only in DMs (chat.v1.ChatType +/// CONTACT_DM or TIP_DM). SendMessage and EditMessage reject it in a group +/// chat. The server stores and relays the ciphertext as-is and cannot read it, +/// so it cannot moderate it, render a push preview from it, or produce a +/// placeholder for it. +/// +/// The plaintext is a serialized Content message. The allowed plaintext types +/// are: +/// - TextContent +/// - MediaContent, as described under "Media" below +/// - ReplyContent, whose own content is a TextContent or a MediaContent +/// The server cannot enforce this. A client that decrypts any other type, or +/// cannot decrypt the payload at all, renders the message as unsupported. A +/// decrypted Content never itself holds EncryptedContent. +/// +/// Encryption is between the Ed25519 public keys the two DM members registered +/// their accounts with, which each member already knows. Neither key is carried +/// in the message: the sender is Message.sender_id and the recipient is the +/// other member. For scheme X25519_XCHACHA20POLY1305: +/// +/// 1. Convert keys to X25519. The sender converts its Ed25519 private key to +/// an X25519 private key, and the recipient's Ed25519 public key to an +/// X25519 public key, using the standard birational map (libsodium's +/// crypto_sign_ed25519_sk_to_curve25519 and +/// crypto_sign_ed25519_pk_to_curve25519). The recipient does the same with +/// the roles reversed. Reject a public key that is not a valid point, or +/// that converts to a low-order X25519 point. +/// +/// 2. Compute the shared secret: ss = X25519(own_x25519_priv, peer_x25519_pub). +/// Abort if ss is all zeros. +/// +/// 3. Derive the chat key with HKDF-SHA256 (RFC 5869): +/// salt = min(pk_a, pk_b) || max(pk_a, pk_b) +/// ikm = ss +/// info = "flipcash-dm-e2ee-v1" || chat_id +/// L = 32 +/// pk_a and pk_b are the two members' 32-byte Ed25519 public keys, ordered +/// bytewise so that both members derive the same key, and chat_id is the +/// raw bytes of common.v1.ChatId.value. The key depends only on the two +/// keys and the chat, so it can be derived once per chat and cached. +/// +/// 4. Encrypt with XChaCha20-Poly1305 (the IETF construction in libsodium's +/// crypto_aead_xchacha20poly1305_ietf_encrypt): +/// key = the chat key from step 3 +/// nonce = 24 fresh random bytes, set in `nonce` below +/// plaintext = the serialized Content +/// aad = "flipcash-dm-e2ee-v1" || chat_id +/// || sender_pk || recipient_pk +/// The ciphertext, with its 16-byte Poly1305 tag appended, is set in +/// `ciphertext` below. Both directions use the same key, which is safe +/// because the nonce is random and 24 bytes long. Never reuse a nonce, and +/// do not substitute a 12-byte-nonce AEAD. +/// +/// sender_pk and recipient_pk are the 32-byte Ed25519 public keys of +/// Message.sender_id and of the other member. The recipient decrypts with the +/// key from step 3 and the same aad. Both keys are bound into the aad in +/// sender-then-recipient order, so a ciphertext cannot be replayed as if the +/// other member sent it, or moved to another chat. +/// +/// Because both members derive the same key, the sender can also decrypt its +/// own messages, including on its other devices. Nothing binds the ciphertext +/// to its MessageId, so a ciphertext re-posted in the same chat by the server +/// decrypts as a valid message there. +/// +/// This scheme has no forward secrecy: anyone who later obtains either +/// member's private key can decrypt every message in the chat, past and +/// future. A scheme with ephemeral keys or a ratchet can be added as a new +/// Scheme value without changing this message. +/// +/// Media +/// +/// A MediaContent plaintext references blobs the sender uploaded for this chat +/// with blob.v1.InitiateExternalUploadRequest.end_to_end_encrypted_for. The +/// scheme also determines the format of every blob the plaintext references. +/// Clients never take the format from the blob's server-side metadata, which +/// the server could alter. For scheme X25519_XCHACHA20POLY1305, each blob is +/// encrypted with the chat key from step 3, using its own aad so that a blob +/// can never be passed off as a message ciphertext, or the reverse: +/// nonce = 24 fresh random bytes +/// aad = "flipcash-dm-e2ee-blob-v1" || chat_id +/// || sender_pk || recipient_pk || blob_id +/// blob = nonce || XChaCha20-Poly1305(chat key, nonce, image bytes, aad) +/// blob_id is the raw bytes of blob.v1.BlobId.value, which is known from +/// InitiateExternalUpload before the bytes are uploaded. Binding it stops the +/// server from serving one blob's bytes in place of another's. sender_pk is the +/// key of the member who uploaded the blob, which is always Message.sender_id. +/// +/// The server never sees the image, so it cannot derive renditions or +/// metadata, strip privacy metadata, or moderate. Before encrypting, the sender +/// downscales the image, strips privacy metadata such as EXIF and location, +/// and computes its dimensions and blurhash. It waits for the blob to be READY +/// before sending the message. +/// +/// Each Media item carries exactly one ORIGINAL rendition. Its blob_id is set, +/// and its BlobMetadata is set by the sender to describe the plaintext image: +/// mime_type, the plaintext size_bytes, and ImageMetadata. download_url is left +/// unset. Only image MIME types are allowed; a recipient renders any other as +/// unsupported. +/// +/// Decrypting proves the sender wrote this metadata, but nothing checks it +/// against the image. A recipient validates it with the blob.v1.BlobMetadata +/// rules after decrypting, renders the message as unsupported if it is invalid, +/// and treats the decoded image as authoritative: it uses the image's actual +/// dimensions, and renders an image that fails to decode or whose decrypted +/// length differs from size_bytes as unsupported. +/// +/// To display the image, the recipient: +/// 1. Calls blob.v1.GetBlobs with AccessContext.chat to mint a download_url. +/// The metadata GetBlobs returns describes the ciphertext, so it is used +/// only for the URL. A blob still PROCESSING is retried later. +/// 2. Downloads the blob, splits off the 24-byte nonce, and decrypts it with +/// the chat key and the aad above. +/// 3. Renders the image using the metadata from the plaintext, showing the +/// blurhash until the download completes. +/// A blob that is missing or fails to decrypt is rendered as unsupported. +public struct Flipcash_Messaging_V1_EncryptedContent: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// The encryption scheme used, which determines how every other field, and + /// every blob the plaintext references, is interpreted. Clients render an + /// unrecognized scheme as unsupported. + public var scheme: Flipcash_Messaging_V1_EncryptedContent.Scheme = .unknown + + /// The random XChaCha20-Poly1305 nonce, unique per encryption. + public var nonce: Data = Data() + + /// The encrypted, serialized Content with the 16-byte Poly1305 tag + /// appended. The upper bound fits a MediaContent with a maximum-length + /// caption (4096 characters of up to 4 bytes each) wrapped in a + /// ReplyContent, plus the tag. + public var ciphertext: Data = Data() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public enum Scheme: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case unknown // = 0 + case x25519Xchacha20Poly1305 // = 1 + case UNRECOGNIZED(Int) + + public init() { + self = .unknown + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .unknown + case 1: self = .x25519Xchacha20Poly1305 + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .unknown: return 0 + case .x25519Xchacha20Poly1305: return 1 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Flipcash_Messaging_V1_EncryptedContent.Scheme] = [ + .unknown, + .x25519Xchacha20Poly1305, + ] + + } + + public init() {} +} + /// Emoji identifies an emoji used in a reaction. The value is a unicode emoji /// sequence — a single grapheme cluster, which may include modifiers such as a /// skin-tone selector or ZWJ joins — or a custom emoji identifier where @@ -1333,7 +1517,7 @@ extension Flipcash_Messaging_V1_Message: SwiftProtobuf.Message, SwiftProtobuf._M extension Flipcash_Messaging_V1_Content: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Content" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}text\0\u{1}cash\0\u{1}reply\0\u{1}media\0\u{1}system\0\u{1}deleted\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}text\0\u{1}cash\0\u{1}reply\0\u{1}media\0\u{1}system\0\u{1}deleted\0\u{1}encrypted\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -1419,6 +1603,19 @@ extension Flipcash_Messaging_V1_Content: SwiftProtobuf.Message, SwiftProtobuf._M self.type = .deleted(v) } }() + case 7: try { + var v: Flipcash_Messaging_V1_EncryptedContent? + var hadOneofValue = false + if let current = self.type { + hadOneofValue = true + if case .encrypted(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.type = .encrypted(v) + } + }() default: break } } @@ -1454,6 +1651,10 @@ extension Flipcash_Messaging_V1_Content: SwiftProtobuf.Message, SwiftProtobuf._M guard case .deleted(let v)? = self.type else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 6) }() + case .encrypted?: try { + guard case .encrypted(let v)? = self.type else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -1691,6 +1892,50 @@ extension Flipcash_Messaging_V1_DeletedContent: SwiftProtobuf.Message, SwiftProt } } +extension Flipcash_Messaging_V1_EncryptedContent: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".EncryptedContent" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}scheme\0\u{1}nonce\0\u{1}ciphertext\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.scheme) }() + case 2: try { try decoder.decodeSingularBytesField(value: &self.nonce) }() + case 3: try { try decoder.decodeSingularBytesField(value: &self.ciphertext) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.scheme != .unknown { + try visitor.visitSingularEnumField(value: self.scheme, fieldNumber: 1) + } + if !self.nonce.isEmpty { + try visitor.visitSingularBytesField(value: self.nonce, fieldNumber: 2) + } + if !self.ciphertext.isEmpty { + try visitor.visitSingularBytesField(value: self.ciphertext, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Flipcash_Messaging_V1_EncryptedContent, rhs: Flipcash_Messaging_V1_EncryptedContent) -> Bool { + if lhs.scheme != rhs.scheme {return false} + if lhs.nonce != rhs.nonce {return false} + if lhs.ciphertext != rhs.ciphertext {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Flipcash_Messaging_V1_EncryptedContent.Scheme: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}X25519_XCHACHA20POLY1305\0") +} + extension Flipcash_Messaging_V1_Emoji: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Emoji" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}value\0") diff --git a/Sources/Flipcash2ClientProtocol/push_v1_model.pb.swift b/Sources/Flipcash2ClientProtocol/push_v1_model.pb.swift index db38832..577dfd0 100644 --- a/Sources/Flipcash2ClientProtocol/push_v1_model.pb.swift +++ b/Sources/Flipcash2ClientProtocol/push_v1_model.pb.swift @@ -225,12 +225,12 @@ public struct Flipcash_Push_V1_ChatMetadata: Sendable { // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. - /// The user ID that sent a chat message + /// The user ID that sent a chat message. Set whether the push carries the + /// full message or only its ID, so a push without the message can still be + /// attributed to its sender. /// /// Note: This will not be set for system messages OR for notifications that /// don't relate to a user - /// - /// Deprecated: Infer from message instead public var sendingUserID: Flipcash_Common_V1_UserId { get {return _sendingUserID ?? Flipcash_Common_V1_UserId()} set {_sendingUserID = newValue} @@ -243,15 +243,34 @@ public struct Flipcash_Push_V1_ChatMetadata: Sendable { /// The type of chat public var type: Flipcash_Chat_V1_ChatType = .unknown - /// The chat message that was sent, if the push is for a message + /// The chat message that was sent, if the push is for a message. Neither is + /// set for a chat push that isn't for a message. The push's title and body + /// are set either way, so the notification can be presented without the + /// message. + public var messageRef: Flipcash_Push_V1_ChatMetadata.OneOf_MessageRef? = nil + + /// The full message, when it fits in the push. public var message: Flipcash_Messaging_V1_Message { - get {return _message ?? Flipcash_Messaging_V1_Message()} - set {_message = newValue} + get { + if case .message(let v)? = messageRef {return v} + return Flipcash_Messaging_V1_Message() + } + set {messageRef = .message(newValue)} + } + + /// Only the message's ID, when the full message would put the push over + /// the push provider's payload size limit (4KB for both FCM and APNs), + /// which a long message can reach. A client that needs the message + /// (e.g. to decrypt EncryptedContent, or to store the message for a + /// muted chat) fetches it with Messaging.GetMessage, using this ID and + /// the chat ID in Payload.navigation. + public var messageID: Flipcash_Messaging_V1_MessageId { + get { + if case .messageID(let v)? = messageRef {return v} + return Flipcash_Messaging_V1_MessageId() + } + set {messageRef = .messageID(newValue)} } - /// Returns true if `message` has been explicitly set. - public var hasMessage: Bool {return self._message != nil} - /// Clears the value of `message`. Subsequent reads from it will return its default value. - public mutating func clearMessage() {self._message = nil} /// Whether the recipient had this chat muted when the push was sent. /// The push is still delivered so the client can store the message, @@ -260,10 +279,26 @@ public struct Flipcash_Push_V1_ChatMetadata: Sendable { public var unknownFields = SwiftProtobuf.UnknownStorage() + /// The chat message that was sent, if the push is for a message. Neither is + /// set for a chat push that isn't for a message. The push's title and body + /// are set either way, so the notification can be presented without the + /// message. + public enum OneOf_MessageRef: Equatable, Sendable { + /// The full message, when it fits in the push. + case message(Flipcash_Messaging_V1_Message) + /// Only the message's ID, when the full message would put the push over + /// the push provider's payload size limit (4KB for both FCM and APNs), + /// which a long message can reach. A client that needs the message + /// (e.g. to decrypt EncryptedContent, or to store the message for a + /// muted chat) fetches it with Messaging.GetMessage, using this ID and + /// the chat ID in Payload.navigation. + case messageID(Flipcash_Messaging_V1_MessageId) + + } + public init() {} fileprivate var _sendingUserID: Flipcash_Common_V1_UserId? = nil - fileprivate var _message: Flipcash_Messaging_V1_Message? = nil } // MARK: - Code below here is support for the SwiftProtobuf runtime. @@ -469,7 +504,7 @@ extension Flipcash_Push_V1_Navigation: SwiftProtobuf.Message, SwiftProtobuf._Mes extension Flipcash_Push_V1_ChatMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ChatMetadata" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}sending_user_id\0\u{1}type\0\u{1}message\0\u{1}muted\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}sending_user_id\0\u{1}type\0\u{1}message\0\u{1}muted\0\u{3}message_id\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -479,8 +514,33 @@ extension Flipcash_Push_V1_ChatMetadata: SwiftProtobuf.Message, SwiftProtobuf._M switch fieldNumber { case 1: try { try decoder.decodeSingularMessageField(value: &self._sendingUserID) }() case 2: try { try decoder.decodeSingularEnumField(value: &self.type) }() - case 3: try { try decoder.decodeSingularMessageField(value: &self._message) }() + case 3: try { + var v: Flipcash_Messaging_V1_Message? + var hadOneofValue = false + if let current = self.messageRef { + hadOneofValue = true + if case .message(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.messageRef = .message(v) + } + }() case 4: try { try decoder.decodeSingularBoolField(value: &self.muted) }() + case 5: try { + var v: Flipcash_Messaging_V1_MessageId? + var hadOneofValue = false + if let current = self.messageRef { + hadOneofValue = true + if case .messageID(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.messageRef = .messageID(v) + } + }() default: break } } @@ -497,19 +557,22 @@ extension Flipcash_Push_V1_ChatMetadata: SwiftProtobuf.Message, SwiftProtobuf._M if self.type != .unknown { try visitor.visitSingularEnumField(value: self.type, fieldNumber: 2) } - try { if let v = self._message { + try { if case .message(let v)? = self.messageRef { try visitor.visitSingularMessageField(value: v, fieldNumber: 3) } }() if self.muted != false { try visitor.visitSingularBoolField(value: self.muted, fieldNumber: 4) } + try { if case .messageID(let v)? = self.messageRef { + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Flipcash_Push_V1_ChatMetadata, rhs: Flipcash_Push_V1_ChatMetadata) -> Bool { if lhs._sendingUserID != rhs._sendingUserID {return false} if lhs.type != rhs.type {return false} - if lhs._message != rhs._message {return false} + if lhs.messageRef != rhs.messageRef {return false} if lhs.muted != rhs.muted {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true diff --git a/flipcash2.lock b/flipcash2.lock index 7520150..7b4d43a 100644 --- a/flipcash2.lock +++ b/flipcash2.lock @@ -1,4 +1,4 @@ # Pinned upstream contract. Regenerate with scripts/sync-protos.sh . upstream: code-payments/flipcash2-protobuf-api -commit: 4ccbbe43197ec6cc2d7a3f0681a73f799cdcfb46 -subject: Add EditChat RPC (#116) +commit: 9ebf55fef834c1a47ae995ae22cad28d79080f2c +subject: Add use_e2ee to chat Metadata (#122) diff --git a/proto/blob/v1/blob_storage_service.proto b/proto/blob/v1/blob_storage_service.proto index 7e64f23..4852db7 100644 --- a/proto/blob/v1/blob_storage_service.proto +++ b/proto/blob/v1/blob_storage_service.proto @@ -13,7 +13,10 @@ import "validate/validate.proto"; // BlobStorage manages direct-to-storage uploads and authorized, time-limited reads // of the bytes behind MediaItem renditions (and other blobs). Clients upload bytes // straight to object storage via a presigned target — the server never proxies -// them — and all blob metadata is server-derived from the stored bytes. +// them — and all blob metadata is server-derived from the stored bytes. The +// exception is end-to-end encrypted blobs (see +// InitiateExternalUploadRequest.end_to_end_encrypted_for), whose bytes the +// server cannot read. service BlobStorage { // GetUploadPolicy returns the current upload constraints — which MIME types // are accepted and the per-type ceilings the server enforces — so the client @@ -24,7 +27,8 @@ service BlobStorage { // InitiateExternalUpload reserves a BlobId and returns a short-lived presigned // target the client uploads the bytes to directly. Clients only ever upload - // ORIGINALs; the server derives any additional renditions itself. + // ORIGINALs; the server derives any additional renditions itself, except + // for end-to-end encrypted blobs, which have none. rpc InitiateExternalUpload(InitiateExternalUploadRequest) returns (InitiateExternalUploadResponse); // CompleteExternalUpload is an ADVISORY signal that the client finished uploading, @@ -73,6 +77,28 @@ message InitiateExternalUploadRequest { // Declared byte size. The server bakes it into the policy's // content-length-range so storage rejects an upload that exceeds it. uint64 size_bytes = 3 [(validate.rules).uint64.gte = 1]; + + // Set when the bytes are end-to-end encrypted, naming the surface they are + // encrypted for. Unset for an ordinary upload. mime_type must be + // "application/octet-stream", or the upload is denied with + // UNSUPPORTED_TYPE. size_bytes is the size of the whole encrypted blob and + // is checked against UploadPolicy.encrypted. + // + // The server cannot read the bytes, so it checks only their size. It + // derives no metadata or renditions, does not moderate, and does not check + // for privacy metadata. The blob can be referenced only from the surface + // named here, and is rejected anywhere else (unencrypted MediaContent, + // profile or chat pictures). + oneof end_to_end_encrypted_for { + // The bytes are encrypted for this DM, as described in + // messaging.v1.EncryptedContent: the 24-byte nonce followed by the + // ciphertext and its 16-byte tag. The caller must be a member of the + // chat and the chat must be a DM, or the upload is DENIED. Once READY, + // the blob is granted to the chat, so the other member can read it + // through AccessContext.chat, and it can be referenced only from + // EncryptedContent in that chat. + common.v1.ChatId chat = 4; + } } message InitiateExternalUploadResponse { diff --git a/proto/blob/v1/model.proto b/proto/blob/v1/model.proto index 98de2c8..dd8cb20 100644 --- a/proto/blob/v1/model.proto +++ b/proto/blob/v1/model.proto @@ -34,7 +34,7 @@ enum BlobStatus { BLOB_STATUS_UNKNOWN = 0; BLOB_STATUS_PENDING = 1; // reserved; awaiting the client's upload BLOB_STATUS_PROCESSING = 2; // bytes present; deriving metadata / transcoding / moderating - BLOB_STATUS_READY = 3; // available; metadata populated and moderation passed + BLOB_STATUS_READY = 3; // available; metadata populated and moderation passed (encrypted: size checked) BLOB_STATUS_REJECTED = 4; // failed validation or moderation; not servable } @@ -61,9 +61,11 @@ message BlobBatch { }]; } -// Server-authoritative metadata describing a stored blob. Never set by clients. -// With the exception of download_url, every field is intrinsic to the stored -// bytes and immutable, derived once by the server. +// Server-authoritative metadata describing a stored blob. Never set by clients, +// except inside messaging.v1.EncryptedContent, where the sender sets it to +// describe the plaintext of an end-to-end encrypted blob and the server never +// sees it. With the exception of download_url, every field is intrinsic to the +// stored bytes and immutable, derived once by the server. message BlobMetadata { // MIME type (e.g. "image/jpeg"). string mime_type = 1 [(validate.rules).string = { @@ -86,14 +88,23 @@ message BlobMetadata { DownloadUrl download_url = 3; // Kind-specific metadata the server derived from the bytes. Exactly one - // variant is set for a recognized media kind; left unset for opaque blobs. - // Only images are supported today; video/audio/etc. will be added as new - // variants. + // variant is set for a recognized media kind or an end-to-end encrypted + // blob; left unset for opaque blobs. Only images are supported today; + // video/audio/etc. will be added as new variants. oneof kind { - ImageMetadata image = 4; + ImageMetadata image = 4; + EncryptedBlobMetadata encrypted = 5; } } +// Marks a blob uploaded with +// InitiateExternalUploadRequest.end_to_end_encrypted_for. Its BlobMetadata +// describes the encrypted bytes: mime_type is "application/octet-stream" and +// size_bytes is the size of the encrypted blob. The plaintext's type, size and +// image metadata are set by the sender inside the messaging.v1.EncryptedContent +// that references the blob, and clients render from those instead. +message EncryptedBlobMetadata {} + // Intrinsic descriptors for a still image. message ImageMetadata { // Pixel dimensions, for reserving layout before the bytes arrive. @@ -117,6 +128,10 @@ message Media { // exactly one ORIGINAL rendition (its blob_id); the server fills that // rendition's metadata and appends any derived renditions (e.g. a // downscaled DISPLAY and a THUMBNAIL). + // + // Inside messaging.v1.EncryptedContent, the server never sees the media: + // the sender supplies the single ORIGINAL rendition with its metadata + // already set, and there are no derived renditions. repeated Rendition renditions = 1 [(validate.rules).repeated = { min_items: 1 }]; @@ -145,6 +160,9 @@ message Rendition { // // If unavailable at the time the media is retrieved, the client can use // GetBlobs to query for the blob metadata. + // + // Inside messaging.v1.EncryptedContent, the sender sets this to describe + // the plaintext, without a download_url; see BlobMetadata. BlobMetadata blob = 3; } @@ -170,6 +188,23 @@ message UploadPolicy { min_items: 1 max_items: 1024 }]; + + // Constraints on end-to-end encrypted uploads (see + // InitiateExternalUploadRequest.end_to_end_encrypted_for), which are + // governed by this instead of mime_type_constraints. Unset when the caller + // may not upload encrypted blobs. + EncryptedConstraints encrypted = 4; +} + +// Upload constraints for end-to-end encrypted blobs. +message EncryptedConstraints { + // Hard ceiling on the encrypted blob's byte size, including the 24-byte + // nonce and 16-byte tag. This is the only constraint the server enforces. + uint64 max_size_bytes = 1 [(validate.rules).uint64.gte = 1]; + + // Bounds the sender should downscale an image to before encrypting it. The + // server cannot read the bytes, so these are advisory. + ImageConstraints image = 2; } // Opaque generation token identifying a snapshot of an UploadPolicy. Compared @@ -289,7 +324,8 @@ message RejectionMetadata { // Why a blob failed finalization, after its bytes were uploaded. Distinct from // the pre-upload denials in InitiateExternalUploadResponse.Result, which reject -// before any bytes are stored. +// before any bytes are stored. An end-to-end encrypted blob is only ever +// rejected with TOO_LARGE or INTERNAL, since the server cannot read its bytes. enum RejectionReason { REJECTION_REASON_UNKNOWN = 0; REJECTION_REASON_MODERATION = 1; // tripped content moderation; see flagged_category @@ -319,6 +355,9 @@ message AccessContext { // The caller is accessing these blobs from within this chat. Authorized // iff the caller is a member of the chat and the blob was shared into it. + // An end-to-end encrypted blob is shared into the chat it was uploaded + // for (InitiateExternalUploadRequest.end_to_end_encrypted_for) once it + // is READY, not by the message that references it. common.v1.ChatId chat = 1 [(validate.rules).message.required = true]; // The caller is accessing these blobs from this user's public profile. diff --git a/proto/chat/v1/chat_service.proto b/proto/chat/v1/chat_service.proto index c532205..9f2234e 100644 --- a/proto/chat/v1/chat_service.proto +++ b/proto/chat/v1/chat_service.proto @@ -15,6 +15,12 @@ import "validate/validate.proto"; service Chat { // GetChat returns the metadata for a specific chat + // + // Auth is optional. An unauthenticated caller gets the chat's public view: + // the same group record a registered non-member previewing it receives, + // with view_mode REDACTED and none of the per-viewer fields (is_hidden, + // viewer_state). An unauthenticated read under any other view_mode, or of + // a DM, is DENIED. rpc GetChat(GetChatRequest) returns (GetChatResponse); // GetDmChatFeed gets the set of DM chats for an owner account using @@ -150,10 +156,14 @@ message GetChatRequest { // withheld from a viewer who may not read the chat under it. Unset (FULL) // is the pre-redaction contract: messaging state for a viewer who may // read the chat in full, the bare record for anyone else. + // + // Must be REDACTED when auth is unset; any other mode is DENIED. messaging.v1.ViewMode view_mode = 2 [(validate.rules).enum = { in: [0, 1, 2] // FULL, FULL_OR_REDACTED, REDACTED }]; + // Optional. Unset requests the chat's public view (see Chat.GetChat), + // which requires view_mode REDACTED. common.v1.Auth auth = 10; } diff --git a/proto/chat/v1/model.proto b/proto/chat/v1/model.proto index 5e52d1d..31c277a 100644 --- a/proto/chat/v1/model.proto +++ b/proto/chat/v1/model.proto @@ -78,6 +78,23 @@ message Metadata { // Per-viewer chat state, including what the viewer may do in the chat. // Absent when the chat holds nothing about them; always set for a member. ViewerState viewer_state = 12; + + // The user that created this chat. Set only for group chats. + common.v1.UserId creator = 13; + + // Whether messages in this chat are end-to-end encrypted (see + // messaging.v1.EncryptedContent). Only supported for DMs (CONTACT_DM or + // TIP_DM); always false for group chats. + // + // Used to migrate DMs to E2EE: when true, clients send all new content in + // the chat as EncryptedContent. When false, clients send content in the + // clear. Existing messages are not re-encrypted, so a chat may hold a mix + // of both. + // + // This is a transitional flag. Once E2EE has launched, clients should + // always end-to-end encrypt DMs regardless of this value, and it will be + // deprecated. + bool use_e2ee = 100; } // Rules define the requirements a user must satisfy to participate in a chat. diff --git a/proto/messaging/v1/messaging_service.proto b/proto/messaging/v1/messaging_service.proto index 3b99992..bbdd99a 100644 --- a/proto/messaging/v1/messaging_service.proto +++ b/proto/messaging/v1/messaging_service.proto @@ -237,6 +237,7 @@ message SendMessageRequest { // - TextContent // - ReplyContent // - MediaContent + // - EncryptedContent, in DMs only repeated Content content = 2 [(validate.rules).repeated = { min_items: 1 max_items: 1 @@ -253,8 +254,10 @@ message SendMessageRequest { message SendMessageResponse { Result result = 1; enum Result { - OK = 0; - DENIED = 1; + OK = 0; + DENIED = 1; + // The content is EncryptedContent and the chat is not a DM. + ENCRYPTION_NOT_ALLOWED = 2; } // The chat message that was sent if the RPC was succesful, which includes @@ -271,6 +274,7 @@ message EditMessageRequest { // - TextContent // - ReplyContent // - MediaContent + // - EncryptedContent, in DMs only repeated Content content = 3 [(validate.rules).repeated = { min_items: 1 max_items: 1 @@ -290,14 +294,16 @@ message EditMessageRequest { message EditMessageResponse { Result result = 1; enum Result { - OK = 0; - DENIED = 1; - MESSAGE_NOT_FOUND = 2; - CANNOT_EDIT = 3; + OK = 0; + DENIED = 1; + MESSAGE_NOT_FOUND = 2; + CANNOT_EDIT = 3; // The message changed since expected_event_sequence (a concurrent // edit/delete won). The edit was not applied; `message` carries the // current state for the client to reconcile against and retry. - CONFLICT = 4; + CONFLICT = 4; + // The content is EncryptedContent and the chat is not a DM. + ENCRYPTION_NOT_ALLOWED = 5; } // On OK, the updated materialized message (advanced event_sequence, diff --git a/proto/messaging/v1/model.proto b/proto/messaging/v1/model.proto index d104d1b..aa940ca 100644 --- a/proto/messaging/v1/model.proto +++ b/proto/messaging/v1/model.proto @@ -174,12 +174,13 @@ message Content { oneof type { option (validate.required) = true; - TextContent text = 1; - CashContent cash = 2; - ReplyContent reply = 3; - MediaContent media = 4; - SystemContent system = 5; - DeletedContent deleted = 6; + TextContent text = 1; + CashContent cash = 2; + ReplyContent reply = 3; + MediaContent media = 4; + SystemContent system = 5; + DeletedContent deleted = 6; + EncryptedContent encrypted = 7; } } @@ -234,6 +235,8 @@ message MediaContent { // // On SendMessage the client supplies exactly one ORIGINAL rendition per // item; the server fills its metadata and appends the derived renditions. + // Inside EncryptedContent, the sender sets the ORIGINAL's metadata itself + // and there are no derived renditions; see EncryptedContent. repeated blob.v1.Media items = 1 [(validate.rules).repeated = { min_items: 1 max_items: 1 @@ -271,6 +274,149 @@ message DeletedContent { common.v1.UserId deleted_by = 2; } +// End-to-end encrypted content, sent only in DMs (chat.v1.ChatType +// CONTACT_DM or TIP_DM). SendMessage and EditMessage reject it in a group +// chat. The server stores and relays the ciphertext as-is and cannot read it, +// so it cannot moderate it, render a push preview from it, or produce a +// placeholder for it. +// +// The plaintext is a serialized Content message. The allowed plaintext types +// are: +// - TextContent +// - MediaContent, as described under "Media" below +// - ReplyContent, whose own content is a TextContent or a MediaContent +// The server cannot enforce this. A client that decrypts any other type, or +// cannot decrypt the payload at all, renders the message as unsupported. A +// decrypted Content never itself holds EncryptedContent. +// +// Encryption is between the Ed25519 public keys the two DM members registered +// their accounts with, which each member already knows. Neither key is carried +// in the message: the sender is Message.sender_id and the recipient is the +// other member. For scheme X25519_XCHACHA20POLY1305: +// +// 1. Convert keys to X25519. The sender converts its Ed25519 private key to +// an X25519 private key, and the recipient's Ed25519 public key to an +// X25519 public key, using the standard birational map (libsodium's +// crypto_sign_ed25519_sk_to_curve25519 and +// crypto_sign_ed25519_pk_to_curve25519). The recipient does the same with +// the roles reversed. Reject a public key that is not a valid point, or +// that converts to a low-order X25519 point. +// +// 2. Compute the shared secret: ss = X25519(own_x25519_priv, peer_x25519_pub). +// Abort if ss is all zeros. +// +// 3. Derive the chat key with HKDF-SHA256 (RFC 5869): +// salt = min(pk_a, pk_b) || max(pk_a, pk_b) +// ikm = ss +// info = "flipcash-dm-e2ee-v1" || chat_id +// L = 32 +// pk_a and pk_b are the two members' 32-byte Ed25519 public keys, ordered +// bytewise so that both members derive the same key, and chat_id is the +// raw bytes of common.v1.ChatId.value. The key depends only on the two +// keys and the chat, so it can be derived once per chat and cached. +// +// 4. Encrypt with XChaCha20-Poly1305 (the IETF construction in libsodium's +// crypto_aead_xchacha20poly1305_ietf_encrypt): +// key = the chat key from step 3 +// nonce = 24 fresh random bytes, set in `nonce` below +// plaintext = the serialized Content +// aad = "flipcash-dm-e2ee-v1" || chat_id +// || sender_pk || recipient_pk +// The ciphertext, with its 16-byte Poly1305 tag appended, is set in +// `ciphertext` below. Both directions use the same key, which is safe +// because the nonce is random and 24 bytes long. Never reuse a nonce, and +// do not substitute a 12-byte-nonce AEAD. +// +// sender_pk and recipient_pk are the 32-byte Ed25519 public keys of +// Message.sender_id and of the other member. The recipient decrypts with the +// key from step 3 and the same aad. Both keys are bound into the aad in +// sender-then-recipient order, so a ciphertext cannot be replayed as if the +// other member sent it, or moved to another chat. +// +// Because both members derive the same key, the sender can also decrypt its +// own messages, including on its other devices. Nothing binds the ciphertext +// to its MessageId, so a ciphertext re-posted in the same chat by the server +// decrypts as a valid message there. +// +// This scheme has no forward secrecy: anyone who later obtains either +// member's private key can decrypt every message in the chat, past and +// future. A scheme with ephemeral keys or a ratchet can be added as a new +// Scheme value without changing this message. +// +// Media +// +// A MediaContent plaintext references blobs the sender uploaded for this chat +// with blob.v1.InitiateExternalUploadRequest.end_to_end_encrypted_for. The +// scheme also determines the format of every blob the plaintext references. +// Clients never take the format from the blob's server-side metadata, which +// the server could alter. For scheme X25519_XCHACHA20POLY1305, each blob is +// encrypted with the chat key from step 3, using its own aad so that a blob +// can never be passed off as a message ciphertext, or the reverse: +// nonce = 24 fresh random bytes +// aad = "flipcash-dm-e2ee-blob-v1" || chat_id +// || sender_pk || recipient_pk || blob_id +// blob = nonce || XChaCha20-Poly1305(chat key, nonce, image bytes, aad) +// blob_id is the raw bytes of blob.v1.BlobId.value, which is known from +// InitiateExternalUpload before the bytes are uploaded. Binding it stops the +// server from serving one blob's bytes in place of another's. sender_pk is the +// key of the member who uploaded the blob, which is always Message.sender_id. +// +// The server never sees the image, so it cannot derive renditions or +// metadata, strip privacy metadata, or moderate. Before encrypting, the sender +// downscales the image, strips privacy metadata such as EXIF and location, +// and computes its dimensions and blurhash. It waits for the blob to be READY +// before sending the message. +// +// Each Media item carries exactly one ORIGINAL rendition. Its blob_id is set, +// and its BlobMetadata is set by the sender to describe the plaintext image: +// mime_type, the plaintext size_bytes, and ImageMetadata. download_url is left +// unset. Only image MIME types are allowed; a recipient renders any other as +// unsupported. +// +// Decrypting proves the sender wrote this metadata, but nothing checks it +// against the image. A recipient validates it with the blob.v1.BlobMetadata +// rules after decrypting, renders the message as unsupported if it is invalid, +// and treats the decoded image as authoritative: it uses the image's actual +// dimensions, and renders an image that fails to decode or whose decrypted +// length differs from size_bytes as unsupported. +// +// To display the image, the recipient: +// 1. Calls blob.v1.GetBlobs with AccessContext.chat to mint a download_url. +// The metadata GetBlobs returns describes the ciphertext, so it is used +// only for the URL. A blob still PROCESSING is retried later. +// 2. Downloads the blob, splits off the 24-byte nonce, and decrypts it with +// the chat key and the aad above. +// 3. Renders the image using the metadata from the plaintext, showing the +// blurhash until the download completes. +// A blob that is missing or fails to decrypt is rendered as unsupported. +message EncryptedContent { + // The encryption scheme used, which determines how every other field, and + // every blob the plaintext references, is interpreted. Clients render an + // unrecognized scheme as unsupported. + Scheme scheme = 1 [(validate.rules).enum = { + in: [1] // X25519_XCHACHA20POLY1305 + }]; + enum Scheme { + UNKNOWN = 0; + X25519_XCHACHA20POLY1305 = 1; + } + + // The random XChaCha20-Poly1305 nonce, unique per encryption. + bytes nonce = 2 [(validate.rules).bytes = { + min_len: 24 + max_len: 24 + }]; + + // The encrypted, serialized Content with the 16-byte Poly1305 tag + // appended. The upper bound fits a MediaContent with a maximum-length + // caption (4096 characters of up to 4 bytes each) wrapped in a + // ReplyContent, plus the tag. + bytes ciphertext = 3 [(validate.rules).bytes = { + min_len: 17 + max_len: 17408 + }]; +} + // Emoji identifies an emoji used in a reaction. The value is a unicode emoji // sequence — a single grapheme cluster, which may include modifiers such as a // skin-tone selector or ZWJ joins — or a custom emoji identifier where diff --git a/proto/push/v1/model.proto b/proto/push/v1/model.proto index 29df45e..b2cab48 100644 --- a/proto/push/v1/model.proto +++ b/proto/push/v1/model.proto @@ -67,12 +67,12 @@ message Navigation { } // Additional metadata provided for chat pushes message ChatMetadata { - // The user ID that sent a chat message + // The user ID that sent a chat message. Set whether the push carries the + // full message or only its ID, so a push without the message can still be + // attributed to its sender. // // Note: This will not be set for system messages OR for notifications that // don't relate to a user - // - // Deprecated: Infer from message instead common.v1.UserId sending_user_id = 1; // The type of chat @@ -80,8 +80,22 @@ message ChatMetadata { not_in: [0] // UNKNOWN }]; - // The chat message that was sent, if the push is for a message - messaging.v1.Message message = 3; + // The chat message that was sent, if the push is for a message. Neither is + // set for a chat push that isn't for a message. The push's title and body + // are set either way, so the notification can be presented without the + // message. + oneof message_ref { + // The full message, when it fits in the push. + messaging.v1.Message message = 3; + + // Only the message's ID, when the full message would put the push over + // the push provider's payload size limit (4KB for both FCM and APNs), + // which a long message can reach. A client that needs the message + // (e.g. to decrypt EncryptedContent, or to store the message for a + // muted chat) fetches it with Messaging.GetMessage, using this ID and + // the chat ID in Payload.navigation. + messaging.v1.MessageId message_id = 5; + } // Whether the recipient had this chat muted when the push was sent. // The push is still delivered so the client can store the message,