Commit graph

239 commits

Author SHA1 Message Date
Heimdall
e8edd88efc Specify answer collector record and consent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:30:45 +00:00
fefd2ddf4a Merge pull request 'Live-Postgres tests: exercise the pending-link methods; add a standing live run' (#4) from heimdall/live-pg-exercise-pending-links into main 2026-11-06 00:32:56 +00:00
Heimdall
575be9cf49 Add scripts/test-live-pg.sh: run the suite against a throwaway Postgres
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 23:30:57 +00:00
Heimdall
70fd6396f4 Exercise the pending-link and findAuthCompletion store methods in the live-Postgres privilege tests
The live suite asserts that exerciseStore calls every PostgresStore method and
that dropping any one table privilege makes it fail with 42501. createPendingLink,
consumePendingLink and findAuthCompletion landed without a call there, so the
coverage assertion failed, and with no operation touching pending_links the
privilege test saw no error when SELECT on it was revoked. Both failures were
invisible because the live files skip without HEIMDALL_TEST_PG_ADMIN_URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 23:30:23 +00:00
aa2edd3826 Merge pull request 'r2: bind OAuth completion to the launching browser, one use per state; gate connect starts' (#3) from heimdall/r2-state-binding into main 2026-11-05 17:16:39 +00:00
Heimdall
f1ddc2fffc Refuse an unbound or replayed state with a page back to the app, and keep use records through the skew
Browsers (Accept: text/html) refused for state_not_bound, state_already_launched
or state_already_used get the handoff page linking to return_to, re-checked
against the app's allowed origins; other callers keep the JSON code. A callback
with no binding denies the attempt its signed state names (pending only).
The memory store prunes a use record at exp + 30 s, as the state itself is
accepted that long; the schema comment says the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 17:16:38 +00:00
Heimdall
b541e094eb Merge heimdall-binds-completion (r2 state binding) into main's link and Google work; gate connect starts that carry a connection
Launch hop, signed per-flow cookie and one-use claims are merged with main's
link-initiator binding and Google OIDC (secrets now derived at launch).
A connect start carrying a connection target now needs the app's own secret.
Google, link and managed-credential tests go through the launch helper.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 12:46:05 +00:00
fd67d94aa3 Merge pull request 'Google OIDC provider: link-only, sub-keyed, PKCE and nonce' (#2) from heimdall/google-oidc-provider into main 2026-11-05 10:58:37 +00:00
heimdall
9123f2def0 Merge main (link-initiator binding) and reconcile the Google link with it
One initiator field (initiator_account_id) replaces link_account_id: a bearer
names it for requires_link providers, the authenticated app for the rest. One
refusal class (LinkRefusal, 403) replaces AccountLinkRefused. Provider count in
the verse test is 8.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:58:02 +00:00
da70416ebe Merge pull request 'Bind link mode to its initiator and redeem to its target' (#1) from heimdall/link-initiator-target-binding into main 2026-11-05 10:57:53 +00:00
Heimdall
c829429cd5 Google link is two-phase; callback evaluates all linked identities
The callback records a typed pending link (short TTL, single use) and
writes no linked identity; POST /v1/apps/:appSlug/links/confirm with the
Heimdall access token of the initiating account attaches it. A victim who
completes an attacker's link URL attaches nothing. The sign-in callback
also evaluates the account's other linked identities with the start-time
policy, so a Discord-origin member returning via Google keeps app_access.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 17:15:33 +00:00
Heimdall
8e870d20a0 Google OIDC provider: link-only, sub-keyed, PKCE and nonce
Google is a Heimdall provider behind Bifrost, with no direct upstream.
Scopes openid email profile; the callback verifies the id_token
(RS256 against Google's keys, iss, aud, exp, iat, nonce) and requires
a verified email. The identity key is sub, never the email.

PKCE verifier and nonce are derived from the state jti and Heimdall's
signing key, so nothing is stored between start and callback.

Google never mints an account. mode=link needs a Heimdall access token
for the app and attaches to that account only; sign_in signs in an
identity already linked; an unknown sub is refused, and a sub held by
another account is never moved. The six existing providers still mint
on first login.

Admitted to the bifrost profile only. The Idunn recipe declares the
Google client id and secret file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 16:10:23 +00:00
Heimdall
84c54fbb5e Refuse a connection target on sign_in start
Connection targets ride only on connect and link starts, which carry
x-heimdall-app-secret. A sign_in start is open to any browser; with a
connection it now returns 400 connection_not_allowed_in_sign_in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:30:22 +00:00
Heimdall
0f2e294fcb Pin completion expiry at redeem: 410 at and after expiresAt, for any caller and target
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:50:34 +00:00
Heimdall
9f5a0e6938 Bind link mode to its initiator and redeem to its target
Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:38:51 +00:00
Meta Vens
5146c722af Boot test: fail on a logged publication fault, and require presence at startup
A silent early return in the startup publication would otherwise pass, the 10 s
pulse loop filling in the frames later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:50:37 +02:00
Meta Vens
1a36175f3a Presence authority test: the Rust fixture's target is the fixture's own, not heimdall
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:49:23 +02:00
Meta Vens
c044155855 Heimdall opens its Idunn presence authority once, before it listens
index.ts declared the presence sequence below the startup try, so the bundle's
hoisted var was undefined at the first publish; decodePrivateIdentity refused the
Rust owner's integer-array key fields; both descriptors were re-read every pulse and
returned nothing after the first. openPresenceAuthority now reads the bundle facts and
each descriptor once (whole, from offset 0, then closed), owns the one publisher
sequence, and signs through them; a launch-contract fault throws before anything
listens, so the process exits non-zero. decodePrivateIdentity takes the shape of its
source and names the failed check, never a value. openActivationSigner and the
per-pulse descriptor reads are deleted.

tests/idunn-boot.test.ts boots the bundle under the Idunn launch contract and checks
the signed warming frames and the fail-fast exits. The descriptor, sequence and
generation tests now use the Rust-written fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:48:08 +02:00
Meta Vens
c0d30e8081 Test fixtures: the Rust owner's Idunn runtime records, and one fake Idunn RUDP endpoint
tests/fixtures/idunn-runtime holds expected.cc, activation.cc, the activation seed,
the presence identity credential and the machine-id it is bound to, copied from
CultLib 217a9480 (Rust-written; test keys only). fake-idunn-rudp.ts is the accept
server idunn-rudp-health.test.ts built inline, now keeping a session per remote and
every delivered frame, so a boot test can receive several pulses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:48:02 +02:00
Meta Vens
e8548614c8 Generation test: bundle the generation process so a uid that cannot read the checkout can run it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:26:13 +02:00
Meta Vens
55732a901d Presence: the publisher sequence is in-process, the state root keeps no owner-only file, an Idunn generation never self-enrolls
nextPublisherSequence read and rewrote <dataRoot>/runtime-presence-sequence at
mode 0600, which the next generation (a fresh DynamicUser uid in the shared
state group) cannot open. Odin compares publisher_sequence only within one
activation and Idunn mints a new activation per start, so PublisherSequence
derives it from the observation clock, never below the previous value plus
one, as Muninn does. openProviderHealthIdentity under
GAMECULT_IDUNN_RUNTIME_BUNDLE now refuses when the presence descriptor is
absent instead of enrolling an identity Idunn does not trust.

Adds the cross-generation test (root only) and the sequence, no-dataRoot and
missing-descriptor tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:25:11 +02:00
Meta Vens
25d43d1121 Merge security/fastify-upgrade: fastify 5.12.7 advisories
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:06:25 +02:00
Meta Vens
899ea0b8fe Heimdall: fastify 5.8.5 to 5.12.7, find-my-way 9.5.0 to 9.10.0 (clears 5 High advisories)
GHSA-p68q-wchp-6fh7 authentication bypass via malformed URLs, header and request validation bypass, request body replacement, find-my-way HTTP/2 DoS. pnpm audit --prod: 8 vulnerabilities before, none after.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:00:47 +02:00
Meta Vens
c15fb97706 Test: malformed URL must not reach a sibling prefix's not-found handler (GHSA-p68q-wchp-6fh7)
Red on fastify 5.8.5, the live lockfile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:00:46 +02:00
Meta Vens
4185e657bb Tests: state time windows to the second, requested_scopes shapes, the launch route's validation, the phases each route claims
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:25:52 +02:00
Meta Vens
5076e04950 Docs: the launch hop, the binding cookie and the callback refusals
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:18 +02:00
Meta Vens
3def0fb426 OAuth states complete once, only in the browser that launched them: a launch hop sets a signed binding cookie, launch and callback each consume the state, and the callback checks expiry
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:17 +02:00
Meta Vens
14e0cde26c Store: claimOAuthStateUse claims one use of a signed OAuth state per (jti, phase), on both stores and an INSERT-only table
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:16 +02:00
Meta Vens
607c2b0a20 Depend on @fastify/cookie for the OAuth binding cookie
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:16 +02:00
Meta Vens
c1dfe15dd5 Merge readme-front-door: README opens with value, problem, entry points, live versus planned 2026-10-22 20:04:06 +02:00
Meta Vens
c7cf78bbbc README: front door with value, problem, entry points, live versus planned
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:03:50 +02:00
Meta Vens
ece6ba1d62 Tests: a state completes once and only in the browser that launched it; the gate holds for every mode, provider and handoff (red against the callback that accepts any signed state)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:04:10 +02:00
Meta Vens
9fd152feff Tests: a foreign app's caller cannot start a Bifrost flow, with no policy attached
Kills the surviving mutant on the caller.appSlug comparison in startOAuthFlow: over HTTP a foreign secret never resolves to a caller, so only a direct call reaches that arm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:24:42 +02:00
Meta Vens
ed964201d4 Docs: startRequiresAppCaller, and Heimdall authenticates while Bifrost issues identity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
7e17a0e952 Start: a profile with startRequiresAppCaller refuses unauthenticated starts; set on Bifrost
Bifrost's browser binding is the secrecy of a nonce carried readable in the signed state, so only Bifrost may mint a Bifrost state. The gate sits in startOAuthFlow beside the entitlementPolicy caller check, so HTTP and the private command plane share it. Follows the red tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:14:37 +02:00
Meta Vens
6d43eacd84 Tests: apps that bind browser completion refuse unauthenticated starts (red; does not pass yet)
The replay of finding known-nonce-login-csrf at the HTTP surface, plus authenticated, cross-app and public-app cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:13:02 +02:00
Meta Vens
5952cce8a0 State: live d51fa6b proven by a human Twitch sign-in; Joystick consent remains
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:03:17 +02:00
Meta Vens
30a1ace6fd State: close eureka/joystick-h1 as accepted (merged, PR #1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:34 +02:00
Meta Vens
df05570ef6 State: Joystick H1 merged and live at d51fa6b; follow-ups recorded by id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:56:25 +02:00
Meta Vens
d51fa6b999
Merge pull request #1 from GameCult/eureka/joystick-h1
Joystick.tv provider and serialized managed-credential refresh (cut H1)
2026-10-19 17:47:39 +02:00
Meta Vens
778eabaedc Docs: map managed credential custody, the callback mint and the open follow-ups
architecture.md gains a Managed credential custody section: the single
refresh owner, the store-port lock, per-process single flight, typed
revoked and refresh-failed answers, the provider descriptor token fields
and the one token request, the mode x role gates, custody-only
completions, verification layer and follow-ups by id. The spotify finding
now matches the removed spiverse profile; JOYSTICK joins the provider env
lists. implementation-plan and service-contract lose two stale lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:49:34 +02:00
Meta Vens
6db84bfae8 Pin the year boundary, the typed exchange failure and the start refusal's code
Closes StrykerJS survivors on the diff: the cap at exactly 365 days against a
second over, the rotating-exchange failure's typed code (and that a
non-rotating provider is unaffected), and the start refusal's error word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:45:35 +02:00
Meta Vens
1d856ebd40 Code exchange: a rotating provider's grant without a refresh token fails the callback
Joystick rotates refresh tokens, so a code-exchange answer whose refresh_token
is absent or empty would store a credential the first refresh revokes. The
shared exchange now throws a typed ProviderResponseError before the identity
is resolved or anything is written. The residual-risk comment on the in-flight
refresh map cites its follow-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:35:11 +02:00
Meta Vens
4e312828c3 Pin the handoff strip: a custody-only backend_callback delivery carries no refresh token
Delivers a joystick connect through a backend_callback handoff and checks the
posted body has no refreshToken or refresh and empty capabilities, so the
handoff spreading `issued` instead of `delivered` fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:34:44 +02:00
Meta Vens
09ea233592 Token answers: an expires_in beyond a year is unusable, so the default lifetime applies
A value like 3e11 overflowed the Date the expiry is stored as and surfaced
as an untyped error. usableExpiresIn now caps a usable value at 365 days;
the refresh path is pinned on memory and live Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:34:43 +02:00
Meta Vens
e1ac84fb70 Joystick custody needs a creator binding, at start and at the callback
callbackMint now takes the connection: custody_only is a connect for a
managed-connection-only provider bound to connection.kind creator with a
targetId. Anything else is refused at start with creator_connection_required
before any redirect, and at the callback from the signed state before the
code exchange. The streampixels profile note says what Heimdall guarantees
and records the S1 rule that SP's redeem treats a non-creator Joystick
completion as an error, never a sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:34:24 +02:00
Meta Vens
26f264877a Pin the hybrid capability listing a viewer sign-in carries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:04:12 +02:00
Meta Vens
a911da93d8 Claims: facts are the one owner of a capability-free claim
Every shared capability needs a non-empty anyOf, so an empty fact set already
yields no capabilities; the explicit empty list beside it was a second owner
no test could tell apart from the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:34:41 +02:00
Meta Vens
655fcc7d22 Credential rotate decisions carry a required expiry
Refresh and exchange answers now type as ExpiringTokenSet, and a rotate
decision requires tokenExpiresAt, so neither store has a no-expiry rule left
to mutate: a NULL expiry can only be written by a revoke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:29:10 +02:00
Meta Vens
32208cc8e2 Pin the callback's refusal of a signed state the provider has no role for
Re-signs a joystick connect state as sign_in and link with the app's own key
and shows the callback refuses before the code exchange and writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:27:28 +02:00