fcmrecv

package module
v0.0.0-...-d067719 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 4, 2026 License: MIT Imports: 19 Imported by: 0

README

firebase_recv

A receive-only Firebase Cloud Messaging (FCM) web-push client for Go. It registers itself as a push receiver, holds open the connection to Google's message server, and decrypts the data messages that come in.

It does not send pushes and it is not a wrapper around the Firebase Admin SDK. There is no way to use this to deliver a notification to someone else. If you need to send, look elsewhere. This is the listening half only, the same thing a browser does when a site asks to subscribe to web push.

Install

go get github.com/cpz/firebase_recv

Needs Go 1.24 or newer. It relies on crypto/hkdf and crypto/ecdh from the standard library, both of which landed in 1.24, so older toolchains will not build it.

Usage

Fill in a Config from your Firebase web app settings, hand it to New, register once, then Listen. Messages arrive through the OnMessage callback, the Messages() channel, or both.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	fcmrecv "github.com/cpz/firebase_recv"
)

func main() {
	log := slog.New(slog.NewTextHandler(os.Stderr, nil))

	cfg := fcmrecv.Config{
		APIKey:            os.Getenv("FCM_API_KEY"),    // "AIza...your-web-api-key"
		ProjectID:         os.Getenv("FCM_PROJECT_ID"), // "your-project-id"
		AppID:             os.Getenv("FCM_APP_ID"),     // "1:000000000000:android:0000000000000000"
		MessagingSenderID: os.Getenv("FCM_SENDER_ID"),  // "000000000000"
	}

	// FileStore persists the registration so you do not re-register on every run.
	store := &fcmrecv.FileStore{Path: "fcm-creds.json"}

	client, err := fcmrecv.New(cfg,
		fcmrecv.WithLogger(log),
		fcmrecv.WithCredentialStore(store),
		fcmrecv.OnMessage(func(msg fcmrecv.Message) error {
			fmt.Printf("pid=%s data=%v\n", msg.PersistentID, msg.Data)
			return nil
		}),
	)
	if err != nil {
		log.Error("init", "err", err)
		os.Exit(1)
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	creds, err := client.Register(ctx)
	if err != nil {
		log.Error("register", "err", err)
		os.Exit(1)
	}
	// creds.FCMToken() is the token a sender needs to target this receiver.
	fmt.Println("registered, token tail:", lastFour(creds.FCMToken()))

	// Listen blocks until ctx is cancelled. It reconnects on its own across drops.
	if err := client.Listen(ctx); err != nil {
		log.Error("listen", "err", err)
		os.Exit(1)
	}
}

func lastFour(s string) string {
	if len(s) <= 4 {
		return s
	}
	return s[len(s)-4:]
}

If you would rather pull messages than be called back, drop the OnMessage option and range over client.Messages() in your own goroutine. The channel is buffered and drops the oldest message when a slow consumer lets it fill, so the read loop is never blocked by your handler. A panic inside OnMessage is caught and turned into an error.

There is a runnable version of this in examples/listen. It reads everything from environment variables.

Config

New returns an error unless APIKey, ProjectID, and AppID are set.

  • APIKey: the Firebase web API key (the AIza... value).
  • ProjectID: the Firebase project id, used to build the installations and registration URLs.
  • AppID: the Firebase app id, the 1:NNN:android:... string. This is not the sender id and the two are easy to mix up.
  • MessagingSenderID: kept on the credentials for completeness. It is never put on the wire, so leaving it empty changes nothing.
  • BundleID: optional. Defaults to receiver.push.com. It becomes part of the GCM subtype.
  • VAPIDKey: optional application server key. Defaults to the standard GCM server key. See the note below about what that default does to the registration.

Credentials are secrets

The credential file the FileStore writes is sensitive. It holds the ECDH private key, the auth secret, the GCM security token, and the FCM/installation tokens in plaintext JSON. Anyone with that file can impersonate the receiver and read its messages. Keep it out of version control, off shared disks, and locked down (FileStore writes it 0600, but that only helps so much).

The example's output path, examples/listen/fcm-creds.json, is already in .gitignore so a stray test run cannot commit it. If you point the store somewhere else, gitignore that path too.

Content encodings

Inbound payloads use one of the two Web Push content encodings and the library picks the right one per message:

  • aesgcm, the older draft-03 scheme. The salt and the sender's ephemeral key ride in separate crypto-key and encryption headers. This is what FCM sends today.
  • aes128gcm, RFC 8188 records keyed per RFC 8291. The salt and key are packed into a binary header at the front of the ciphertext, so there are no side headers.

Protocol notes

Two phases. Registration is a short sequence of HTTPS calls to Google hosts: a check-in to android.clients.google.com, a GCM register3, a Firebase installation, and the FCM web-push registration. After that the client opens one long-lived TLS connection to mtalk.google.com:5228 and speaks MCS, which is length-delimited protobuf stanzas (login, heartbeats, data messages, acks).

A few things that bite anyone reimplementing this from scratch:

  • The very first MCS packet on a fresh connection is prefixed with a single version byte (41; the server still tolerates the legacy 38). Every stanza after that has no such prefix, and the server sends its own version byte back before its first stanza.
  • The key material in the registration is base64url with the padding stripped. Standard base64 will not round-trip. The Firebase installation id (FID) is the one exception: it is standard base64 and can contain /, which is why the refresh URL path-escapes it.
  • When VAPIDKey is the default GCM server key, the FCM registration leaves applicationPubKey out of the body entirely. Send the default key as an explicit applicationPubKey and the registration is rejected. Only a real, non-default VAPID key gets included.

Cross-check against the Python implementation

scripts/crossvalidate.py checks the decryption against the firebase-messaging Python library on identical ciphertext. It encrypts a synthetic payload once, decrypts it with both, and compares the SHA-256 of the two plaintexts. The only thing it prints is the two hashes and PASS or FAIL. No tokens, FIDs, or payload bytes ever reach stdout.

py -3.12 scripts/crossvalidate.py

Run it from the repo root. It needs no Firebase project and no env vars.

Credits

This is a Go port. The behavior was pinned against existing work:

  • firebase-messaging (Python), used as the reference for what correct output looks like.
  • crow-misia/go-push-receiver and crow-misia/http-ece, Go prior art for the same protocol and the same encodings.
  • The Chromium MCS and check-in protobufs, which are BSD-licensed. Their original copyright header is kept in the .proto files under proto/.

License

MIT. See LICENSE.

Documentation

Overview

Package fcmrecv registers a process as a Firebase Cloud Messaging (FCM) web-push client and receives FCM data messages over Google's MCS protocol. It is the device-side receiver, not the admin/send SDK.

Index

Constants

View Source
const (
	GCMServerKey    = "BDOU99-h67HcA6JeFXHbSNMu7e2yNNu3RzoMj8TM4W88jITfq7ZmPvIM1Iv-4_l2LxQcYwhqby2xGpWwzjfAnG4"
	ChromeVersion   = "94.0.4606.51"
	SDKVersion      = "w:0.6.6"
	AuthVersion     = "FIS_v2"
	GCMRegisterApp  = "org.chromium.linux"
	DefaultBundleID = "receiver.push.com"

	MCSHost    = "mtalk.google.com"
	MCSPort    = "5228"
	MCSVersion = 41 // raw byte; server also accepts legacy 38

	HostCheckin   = "https://android.clients.google.com/checkin"
	HostRegister3 = "https://android.clients.google.com/c2dm/register3"
	HostFIS       = "https://firebaseinstallations.googleapis.com/v1/projects/%s/installations"
	HostFCMReg    = "https://fcmregistrations.googleapis.com/v1/projects/%s/registrations"
)
View Source
const (
	TagHeartbeatPing     = 0
	TagHeartbeatAck      = 1
	TagLoginRequest      = 2
	TagLoginResponse     = 3
	TagClose             = 4
	TagIqStanza          = 7
	TagDataMessageStanza = 8
	TagStreamErrorStanza = 10
)

MCS stanza tags (ordinal, not proto field numbers)

View Source
const (
	SelectiveAckExtensionID = 12
	StreamAckExtensionID    = 13
)

Variables

This section is empty.

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

func New

func New(cfg Config, opts ...Option) (*Client, error)

func (*Client) Listen

func (c *Client) Listen(ctx context.Context) error

Listen connects, logs in, and delivers messages until ctx is cancelled, reconnecting with quadratic backoff on a reset up to the retry limit.

func (*Client) Messages

func (c *Client) Messages() <-chan Message

func (*Client) Register

func (c *Client) Register(ctx context.Context) (*Credentials, error)

Register loads or mints credentials. With a valid stored install token it does a check-in only and reuses everything; near expiry it tries an install-token refresh and otherwise falls back to a full re-registration.

func (*Client) Started

func (c *Client) Started() bool

func (*Client) State

func (c *Client) State() RunState

type Config

type Config struct {
	APIKey            string
	ProjectID         string
	AppID             string
	MessagingSenderID string // stored for completeness; never sent on the wire
	BundleID          string // optional; default "receiver.push.com"
	VAPIDKey          string // optional; default = GCMServerKey
}

type ConfigSnapshot

type ConfigSnapshot struct {
	BundleID  string `json:"bundle_id"`
	ProjectID string `json:"project_id"`
	VAPIDKey  string `json:"vapid_key"`
}

type CredentialStore

type CredentialStore interface {
	Load(ctx context.Context) (*Credentials, error)
	Save(ctx context.Context, c *Credentials) error
}

type Credentials

type Credentials struct {
	Keys          KeyMaterial    `json:"keys"`
	GCM           GCMIdentity    `json:"gcm"`
	FCM           FCMRecord      `json:"fcm"`
	Config        ConfigSnapshot `json:"config"`
	PersistentIDs []string       `json:"persistent_ids,omitempty"`
}

func (Credentials) FCMToken

func (c Credentials) FCMToken() string

func (Credentials) LogValue

func (c Credentials) LogValue() slog.Value

type FCMRecord

type FCMRecord struct {
	Registration map[string]any     `json:"registration"`
	Installation InstallationRecord `json:"installation"`
}

func (FCMRecord) LogValue

func (f FCMRecord) LogValue() slog.Value

type FileStore

type FileStore struct{ Path string }

func (*FileStore) Load

func (s *FileStore) Load(ctx context.Context) (*Credentials, error)

func (*FileStore) Save

func (s *FileStore) Save(ctx context.Context, c *Credentials) error

type GCMIdentity

type GCMIdentity struct {
	Token         string `json:"token"`
	AppID         string `json:"app_id"`
	AndroidID     uint64 `json:"android_id"`
	SecurityToken uint64 `json:"security_token"`
}

func (GCMIdentity) LogValue

func (g GCMIdentity) LogValue() slog.Value

type InstallationRecord

type InstallationRecord struct {
	Token        string `json:"token"`
	ExpiresIn    int    `json:"expires_in"`
	RefreshToken string `json:"refresh_token"`
	FID          string `json:"fid"`
	CreatedAt    int64  `json:"created_at"`
}

func (InstallationRecord) LogValue

func (r InstallationRecord) LogValue() slog.Value

type KeyMaterial

type KeyMaterial struct {
	Public  string `json:"public"`
	Private string `json:"private"`
	Secret  string `json:"secret"`
}

func (KeyMaterial) LogValue

func (k KeyMaterial) LogValue() slog.Value

type Message

type Message struct {
	PersistentID string
	AppData      map[string]string
	Data         map[string]any
	Raw          []byte
	Context      any
}

type Option

type Option func(*Client)

func OnCredentialsUpdated

func OnCredentialsUpdated(fn func(*Credentials)) Option

func OnMessage

func OnMessage(fn func(Message) error) Option

func WithAbortOnSequentialErrors

func WithAbortOnSequentialErrors(n int) Option

func WithAckTimeout

func WithAckTimeout(d time.Duration) Option

func WithBackoffBase

func WithBackoffBase(d time.Duration) Option

func WithCallbackContext

func WithCallbackContext(v any) Option

func WithConnectionRetry

func WithConnectionRetry(n int) Option

func WithCredentialStore

func WithCredentialStore(s CredentialStore) Option

func WithHTTPTimeout

func WithHTTPTimeout(d time.Duration) Option

func WithHeartbeat

func WithHeartbeat(server, client time.Duration) Option

func WithLogWarnLimit

func WithLogWarnLimit(n int) Option

func WithLogger

func WithLogger(l *slog.Logger) Option

func WithMonitorInterval

func WithMonitorInterval(d time.Duration) Option

func WithResetInterval

func WithResetInterval(d time.Duration) Option

func WithSeededPersistentIDs

func WithSeededPersistentIDs(ids []string) Option

func WithSelectiveAcks

func WithSelectiveAcks(b bool) Option

func WithVerboseDebug

func WithVerboseDebug(b bool) Option

type RunState

type RunState int
const (
	StateCreated RunState = iota
	StateStartingTasks
	StateStartingConnection
	StateStartingLogin
	StateStarted
	StateResetting
	StateStopping
	StateStopped
)

type Tunables

type Tunables struct {
	ServerHeartbeat  time.Duration
	ClientHeartbeat  time.Duration
	HeartbeatAckTO   time.Duration
	SelectiveAcks    bool
	ConnectionRetry  int
	BackoffBase      time.Duration
	ResetInterval    time.Duration
	MonitorInterval  time.Duration
	AbortOnSeqErrors int
	LogWarnLimit     int
	VerboseDebug     bool
	HTTPTimeout      time.Duration
}

Directories

Path Synopsis
examples
listen command
internal
checkin
Package checkin performs the GCM device check-in.
Package checkin performs the GCM device check-in.
ece
Package ece decrypts Web Push payloads.
Package ece decrypts Web Push payloads.
keys
Package keys generates and reloads the P-256 ECDH key material and the 16-byte Web Push auth secret.
Package keys generates and reloads the P-256 ECDH key material and the 16-byte Web Push auth secret.
mcs
Package mcs implements the MCS framing, login, heartbeat and reconnect loop.
Package mcs implements the MCS framing, login, heartbeat and reconnect loop.
pb
Package pb holds the generated MCS / GCM check-in protobuf bindings.
Package pb holds the generated MCS / GCM check-in protobuf bindings.
register
Package register implements GCM register, Firebase Installations, and FCM web-push registration.
Package register implements GCM register, Firebase Installations, and FCM web-push registration.
scripts
gohelper command
gohelper is a thin stdin-to-stdout shim used by crossvalidate.py.
gohelper is a thin stdin-to-stdout shim used by crossvalidate.py.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL