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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Miakapp/Miakapp-V3
ref: abee78b80383f0e7c900dbcaede3741095f08c35
ref: cc3bcd70fdb4b058f990ca2607693a2043faebaf
path: .contract/miakapp-v3
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
Expand Down Expand Up @@ -48,7 +48,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Miakapp/MiakAPI
ref: 5c26eaa830015d94f53bf05fbbb0f5ebda6d290f
ref: a798a746847ba3d5c16128a08b33353269e770a4
path: miakapi
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
Expand Down Expand Up @@ -82,13 +82,13 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Miakapp/Miakapp-V3
ref: 259173ca730f0763710b7c99afa163ba26e70bb2
ref: f9509c41ef1c0389623d31419372e6430a2313d9
path: .integration/miakapp-v3
- name: Check out the pinned MiakAPI coordinator SDK
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Miakapp/MiakAPI
ref: 5c26eaa830015d94f53bf05fbbb0f5ebda6d290f
ref: a798a746847ba3d5c16128a08b33353269e770a4
path: .integration/miakapi
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
Expand All @@ -115,7 +115,10 @@ jobs:
run: |
bun install --frozen-lockfile
bun run build
- name: Run the Home Key to relay authentication integration
- name: Install the pinned Chromium runtime
working-directory: .integration/miakapi
run: ./node_modules/.bin/playwright install --with-deps chromium
- name: Run the coordinator and browser relay authentication integration
run: >-
./scripts/check-platform-integration.sh
"$GITHUB_WORKSPACE/.integration/miakapp-v3"
Expand Down
107 changes: 62 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@ connects home coordinators, authenticated users, and future CLI sessions without
persisting Firebase credentials, Home Keys, push credentials, or product
business logic.

The standalone binary validates short-lived coordinator and CLI access tokens
against the configured control-plane JWKS and validates human sessions against
Firebase public certificates. It holds no verification secret and never calls
the platform with authenticated authority. The relay remains an implementation
preview until deployment admission limits and the staging relay integration gate
have passed; do not deploy it as a production replacement yet.
The standalone binary validates short-lived coordinator, CLI, and browser-user
access tokens against the configured control-plane JWKS. Firebase Auth and App
Check credentials remain on the browser-to-control-plane HTTPS boundary and are
never relay credentials. The relay holds no verification secret and never calls
the platform with authenticated authority. It remains an implementation preview
until deployment admission limits and the staging relay integration gate have
passed; do not deploy it as a production replacement yet.

## Contract

The wire contract is owned by Miakapp-V3
[`RFC 0001`](https://github.com/Miakapp/Miakapp-V3/blob/b927789691dd8cdebc91b673853cdc6711fe057d/docs/rfcs/0001-wire-protocol.md).
This module pins the independent canonical Go codec at that immutable commit.
It does not copy frame definitions into this repository.
[`RFC 0001`](https://github.com/Miakapp/Miakapp-V3/blob/b927789691dd8cdebc91b673853cdc6711fe057d/docs/rfcs/0001-wire-protocol.md),
and this module pins the canonical Go codec at that immutable commit. Relay
credential profiles are owned by
[`RFC 0004`](https://github.com/Miakapp/Miakapp-V3/blob/cc3bcd70fdb4b058f990ca2607693a2043faebaf/docs/rfcs/0004-platform-control-plane.md),
with the independent Go verifier pinned at that second commit. This repository
does not copy either contract.

The implemented vertical slice includes:

Expand Down Expand Up @@ -71,15 +75,16 @@ cd /absolute/path/to/Miakapp-Server
./scripts/check-miakapi-integration.sh /absolute/path/to/MiakAPI
```

The gate serves the MiakAPI browser fixture from loopback HTTPS, allows exactly
that page Origin, and executes it in headless Chromium against the real relay.
This smaller gate serves the MiakAPI browser fixture from loopback HTTPS, allows
exactly that page Origin, and executes it in headless Chromium against the real
relay.
It proves enrollment, initial state, one patch, one call/result and scheduled
reauthentication on one WebSocket. A second call succeeds after the original
four-second lease expires, proving the correlated renewal completed without a
reconnect. The fixture uses only synthetic tokens and emits a closed semantic
JSON result; browser traces and WebSocket frame inspection stay disabled. It
does not prove Google's live Firebase certificate path or memory isolation from
a malicious relay.
does not exercise the control-plane credential exchange or memory isolation
from a malicious relay.

Run the production authentication adapter against the canonical control-plane
vectors:
Expand All @@ -88,28 +93,35 @@ vectors:
./scripts/check-control-plane-integration.sh /absolute/path/to/Miakapp-V3
```

Run the synthetic Home Key through the real emulator control plane, MiakAPI
provider, scheduled SDK reauthentication, and production relay verifier after
building both external checkouts:
Run the synthetic Home Key and browser source credentials through the real
emulator control plane, MiakAPI providers, scheduled SDK reauthentication, and
production relay verifier after building both external checkouts:

```sh
./scripts/check-platform-integration.sh /absolute/path/to/Miakapp-V3 /absolute/path/to/MiakAPI
```

This gate uses only loopback HTTPS, the `demo-miakapp-v4` Auth and Firestore
emulators, ephemeral certificates, Home Key and control-secret files, and two
independent instances of the production verifier cache. A fake-clock probe cache
proves one shared refresh for 32 concurrent future-key tokens, the ten-second
random-`kid` abuse bound, conditional expiry revalidation, fail-closed outage
handling and bounded recovery. The real-time cache remains on the relay socket
while the SDK changes signing key during scheduled `REAUTH` without changing its
principal or reconnecting. Integration-only constructors and authenticated
loopback controls are excluded from normal relay builds; evidence records only
bounded counters and consistency booleans, never tokens or claims.

This local gate uses canonical synthetic keys. It does not claim live Cloud KMS
version rotation, Google's Firebase certificate endpoint, or public-ingress
behavior.
independent instances of the production verifier cache. It creates an unenrolled
synthetic Auth user and a signed synthetic App Check token in one private
temporary file. Because the Auth emulator emits deliberately unsigned JWTs, the
browser fixture applies a local shape adapter before the strict public provider
and restores the exact emulator token at the HTTPS boundary; production code is
not relaxed. The real control plane then issues the audience-bound user token.

A fake-clock probe cache proves one shared refresh for 32 concurrent future-key
tokens, the ten-second random-`kid` abuse bound, conditional expiry revalidation,
fail-closed outage handling, and bounded recovery. The real-time relay cache
survives signing-key rotation while a coordinator and a real Chromium browser
each complete same-socket `REAUTH`. The fixture then changes the authoritative
Home relay, and the browser carries that single new credential to a second real
relay without another exchange or overlapping sockets. It recovers state and
executes calls on both relays. Integration-only constructors and authenticated
loopback controls are excluded from normal relay builds; transient frame checks
emit only bounded counters and a source-credential-presence boolean, never
tokens, claims, or frame contents. This local gate does not claim live Cloud KMS
rotation or public-ingress behavior.

CI checks out both repositories at immutable commits so relay changes cannot
silently drift against moving protocol, authentication, or SDK dependencies.
Expand All @@ -130,7 +142,6 @@ The executable reads:
| `MIAKAPP_CONTROL_PLANE_ISSUER` | required | Exact HTTPS control-plane issuer origin |
| `MIAKAPP_CONTROL_PLANE_JWKS_URL` | required | Same-origin `/.well-known/jwks.json` endpoint |
| `MIAKAPP_RELAY_AUDIENCE` | required | Exact public `wss://.../ws` URL for this relay |
| `MIAKAPP_FIREBASE_PROJECT_ID` | required | Firebase project accepted for human sessions |
| `MIAKAPP_HANDSHAKE_TIMEOUT` | `5s` | Maximum wait for HELLO and authentication |
| `MIAKAPP_WRITE_TIMEOUT` | `5s` | One WebSocket write deadline |
| `MIAKAPP_PING_INTERVAL` | `30s` | RFC 6455 liveness interval |
Expand All @@ -151,25 +162,31 @@ identity lease; the relay independently binds that lease to the requested role,
home, coordinator name, principal ID, Home Key client ID, and expiry.
Reauthentication cannot change the established principal or switch Home Keys.

Coordinator and CLI tokens use the canonical Ed25519 profile from RFC 0004. The
relay pins one issuer, one audience and one same-origin JWKS URL. The JWKS client
accepts no redirects, bounds responses to 64 KiB and 16 exact Ed25519 keys,
requires the specified ETag and 60-second cache policy, coalesces concurrent
Coordinator, CLI, and browser-user tokens use distinct canonical Ed25519
profiles from RFC 0004. The browser profile contains exactly one relay audience,
Home ID, Firebase UID, `miakapp_role=user`, `scope=relay:user`, a maximum
five-minute lease, and an optional verified email; it contains no Home Key client
or coordinator claim. HELLO may only bind the already verified user to the same
Home. Firebase ID and App Check tokens are rejected as relay credentials.

The relay pins one issuer, one audience, and one same-origin JWKS URL. The JWKS
client accepts no redirects, bounds responses to 64 KiB and 16 exact Ed25519
keys, requires the specified ETag and 60-second cache policy, coalesces concurrent
refreshes, and permits at most one unknown-key refresh per ten seconds. Expired
caches fail closed if they cannot be refreshed.

Human sessions use Firebase ID tokens, the pinned Firebase project/issuer and
Google's fixed Secure Token certificate endpoint. Certificate lifetime follows
the endpoint's `Cache-Control` maximum age. This local verification deliberately
does not claim immediate Firebase account-disablement or token-revocation checks;
the browser must reauthenticate before its current ID-token lease expires.

Under the current RFC 0004 profile, the selected relay necessarily receives the
Firebase ID token as a reusable bearer credential and observes home traffic.
Users may therefore select only an official relay or one they explicitly trust
as completely as the Miakapp backend. Arbitrary community-relay selection must
remain disabled until the control plane can issue a short-lived credential bound
to one relay audience, home, user and role.
The selected relay still observes plaintext Home traffic and must therefore be
an official instance, self-hosted by the user, or operated by somebody the user
explicitly trusts. Self-hosting requires configuring the relay's exact public
WSS URL in the authoritative Home record and as `MIAKAPP_RELAY_AUDIENCE`; a token
issued for another relay cannot be replayed here. The relay needs only public
control-plane configuration and no Firebase or Miakapp credential.

This credential transition is intentionally fail-closed rather than dual-stack:
an old Firebase-token relay rejects the new user profile, and this relay rejects
Firebase source tokens. Roll out compatible control-plane, browser SDK, and relay
revisions together, retain the previous complete set for rollback, and do not
route browser traffic to a partially upgraded relay.

This preview is not yet the deployment admission-control boundary. Per-IP
connection limits, total-home admission, and aggregate cross-connection memory
Expand Down
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ services:
MIAKAPP_ALLOWED_ORIGINS: ${MIAKAPP_ALLOWED_ORIGINS:-}
MIAKAPP_CONTROL_PLANE_ISSUER: ${MIAKAPP_CONTROL_PLANE_ISSUER:?required}
MIAKAPP_CONTROL_PLANE_JWKS_URL: ${MIAKAPP_CONTROL_PLANE_JWKS_URL:?required}
MIAKAPP_FIREBASE_PROJECT_ID: ${MIAKAPP_FIREBASE_PROJECT_ID:?required}
MIAKAPP_LISTEN_ADDRESS: :3000
MIAKAPP_RELAY_AUDIENCE: ${MIAKAPP_RELAY_AUDIENCE:?required}
ports:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ toolchain go1.26.6

require (
github.com/coder/websocket v1.8.15
github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904115658-abee78b80383
github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904181620-cc3bcd70fdb4
github.com/miakapp/miakapp-v3/protocol/go v0.0.0-20260831114037-b927789691dd
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904115658-abee78b80383 h1:NN+AIZu7xw2ecFZxj9ysQw3133lfMyLa6FyaqakzEIY=
github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904115658-abee78b80383/go.mod h1:cQE1eJh3FwoiffiXMkCxsPyjll9nb9KE8G37mU6oFF4=
github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904181620-cc3bcd70fdb4 h1:fesdNm7llFrIjFEdR/auLe3nW0Y7PAlssVW2q8GacNQ=
github.com/miakapp/miakapp-v3/control-plane-contract/go v0.0.0-20260904181620-cc3bcd70fdb4/go.mod h1:cQE1eJh3FwoiffiXMkCxsPyjll9nb9KE8G37mU6oFF4=
github.com/miakapp/miakapp-v3/protocol/go v0.0.0-20260831114037-b927789691dd h1:QMcEzyWak+1XyEIDV7dgsw8XhF5w6Df4zHklBQSv31g=
github.com/miakapp/miakapp-v3/protocol/go v0.0.0-20260831114037-b927789691dd/go.mod h1:3AmoKsBHhQ4xYN5f5fnHd4vlk7IlrJqINQ0GBmYzHN4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
31 changes: 26 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ func (identity Identity) Principal(sessionID int64) []any {
}

// Verifier validates initial and replacement access material. Implementations
// must perform all signature, issuer, audience, expiry and revocation checks
// required by the future platform control-plane contract.
// must perform all signature, issuer, audience, expiry and profile checks
// required by the platform control-plane contract.
type Verifier interface {
Verify(context.Context, Request) (Identity, error)
}
Expand Down Expand Up @@ -102,15 +102,36 @@ func ValidateBinding(request Request, identity Identity, now time.Time) error {
if !validIdentityValue(identity.HomeID, 128) || !validIdentityValue(identity.ID, 128) {
return Failure(ErrRejected, errors.New("missing identity binding"))
}
expectedScope := ""
switch identity.Role {
case RoleUser:
expectedScope = "relay:user"
case RoleCoordinator:
expectedScope = "relay:coordinator"
case RoleCLI:
expectedScope = "relay:cli"
default:
return Failure(ErrRejected, errors.New("unsupported identity role"))
}
if _, ok := identity.Scopes[expectedScope]; !ok || len(identity.Scopes) != 1 {
return Failure(ErrRejected, errors.New("identity scope does not match its role"))
}
if identity.Role == RoleUser {
if identity.ClientID != "" {
return Failure(ErrRejected, errors.New("unexpected Home Key client binding"))
}
if request.HomeID == "" || identity.HomeID != request.HomeID {
return Failure(ErrRejected, errors.New("user home mismatch"))
}
} else if !validIdentityValue(identity.ClientID, 128) {
return Failure(ErrRejected, errors.New("missing Home Key client binding"))
}
if request.HomeID != "" && identity.HomeID != request.HomeID {
return Failure(ErrRejected, errors.New("home mismatch"))
} else {
if request.HomeID != "" && identity.HomeID != request.HomeID {
return Failure(ErrRejected, errors.New("home mismatch"))
}
if identity.VerifiedEmail != "" {
return Failure(ErrRejected, errors.New("unexpected verified email"))
}
}
if request.Role == RoleCoordinator {
if identity.CoordinatorName != request.CoordinatorName {
Expand Down
31 changes: 31 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func validIdentity() Identity {
ClientID: "client-1",
CoordinatorName: "automation",
ExpiresAt: time.Now().Add(time.Minute),
Scopes: map[string]struct{}{"relay:coordinator": {}},
}
}

Expand All @@ -35,6 +36,9 @@ func TestValidateBindingRejectsPrincipalChanges(t *testing.T) {
{name: "coordinator", mutate: func(identity *Identity) { identity.CoordinatorName = "other" }, kind: ErrRejected},
{name: "long ID", mutate: func(identity *Identity) { identity.ID = strings.Repeat("x", 129) }, kind: ErrRejected},
{name: "missing client", mutate: func(identity *Identity) { identity.ClientID = "" }, kind: ErrRejected},
{name: "missing scope", mutate: func(identity *Identity) { identity.Scopes = nil }, kind: ErrRejected},
{name: "wrong scope", mutate: func(identity *Identity) { identity.Scopes = map[string]struct{}{"relay:user": {}} }, kind: ErrRejected},
{name: "multiple scopes", mutate: func(identity *Identity) { identity.Scopes["relay:user"] = struct{}{} }, kind: ErrRejected},
{name: "control character", mutate: func(identity *Identity) { identity.ID = "home\n1" }, kind: ErrRejected},
{name: "invalid email", mutate: func(identity *Identity) { identity.VerifiedEmail = "user\[email protected]" }, kind: ErrRejected},
{name: "unrepresentable expiry", mutate: func(identity *Identity) { identity.ExpiresAt = time.UnixMilli(9_007_199_254_740_992) }, kind: ErrRejected},
Expand All @@ -52,6 +56,33 @@ func TestValidateBindingRejectsPrincipalChanges(t *testing.T) {
}
}

func TestValidateBindingRequiresTokenBoundUserHomeAndShape(t *testing.T) {
now := time.Now()
identity := Identity{
Role: RoleUser,
HomeID: "home-1",
ID: "firebase-user",
VerifiedEmail: "[email protected]",
ExpiresAt: now.Add(time.Minute),
Scopes: map[string]struct{}{"relay:user": {}},
}
if err := ValidateBinding(Request{Role: RoleUser, HomeID: "home-1"}, identity, now); err != nil {
t.Fatal(err)
}
for _, request := range []Request{
{Role: RoleUser},
{Role: RoleUser, HomeID: "home-2"},
} {
if err := ValidateBinding(request, identity, now); Kind(err) != ErrRejected {
t.Fatalf("expected user Home binding rejection, received %v", err)
}
}
identity.ClientID = "home-key-client"
if err := ValidateBinding(Request{Role: RoleUser, HomeID: "home-1"}, identity, now); Kind(err) != ErrRejected {
t.Fatalf("expected user client binding rejection, received %v", err)
}
}

func TestFailureRedactsWrappedError(t *testing.T) {
err := Failure(ErrRejected, errors.New("Bearer secret-token"))
if err.Error() != "authentication rejected" {
Expand Down
Loading