Skip to content

feat(account): gate leads with contract changes: pairing confirm signs the permissions, link_epoch on paired and unlinked, Android refresh device_counter - #194

Merged
WillCybertron merged 1 commit into
mainfrom
feat/account-gate-contract
Nov 12, 2026
Merged

WillCybertron merged 1 commit into
mainfrom
feat/account-gate-contract

Conversation

@WillCybertron

Copy link
Copy Markdown
Contributor

Day 6 review gate, Account-core part 2 of 3: the decided leads that change a contract. Do not merge before the coordinator has routed the contract delta below to iOS, Android and the products.

Pairing confirm signs the permissions (pairing.ts)

  • Before, the owner's approval covered the pairing but not the permission list, which was written from the confirm body.
  • confirmPayload now carries permissions, sorted and without duplicates. GET /v1/pairings/{id}?permissions=a,b serves the approval request for exactly those permissions. Confirm recomputes the digest from its body's list, so an approval signed for other permissions is 401 approval_invalid. The app still signs exactly what the service serves.
  • Test: pairing.test.ts M1 path. An approval read with no permissions does not confirm two. This fails without the change.

link_epoch on agent.paired and agent.unlinked (pairing.ts, agents.ts, schema.sql)

  • The agent row keeps a link counter that rises with every confirmed pairing. agent.paired carries the new link and agent.unlinked carries the link it ends.
  • Products drop either event when it is older than the link they hold. This closes retried stale events reactivating an unlinked agent.
  • Test: pairing.test.ts "gate lead: each pairing is a new link_epoch…".

Android refreshes carry a counter (binding.ts)

  • An Android refresh body carries device_counter, inside the bytes X-OE-Device-Signature signs, above the last refresh's. Otherwise 401 binding_invalid.
  • The session's binding stores the counter, so a captured refresh cannot be replayed inside the 30-second retry grace.
  • Test: binding.test.ts Android. Without a counter, or with one not above the last, the refresh is refused; this fails without the change.

Contract delta (account/openapi.yaml, exact):

@@ -682,7 +682,8 @@
         rotated token revokes the whole session (401 token_reused), except an idempotent retry within 30 seconds whose
         successor was never used: that mints a fresh successor. Clients serialize refresh calls and ask for every
         audience they need in one call. Expiry: 90 days sliding (apps), 12 hours absolute (web). Binding: iOS sends
-        X-OE-App-Attest; Android sends X-OE-Device-Signature and X-OE-Play-Integrity; the web sends the session cookie
+        X-OE-App-Attest; Android sends X-OE-Device-Signature and X-OE-Play-Integrity, and `device_counter` in the
+        signed body, above the one its last refresh sent (401 binding_invalid otherwise); the web sends the session cookie
         with Origin equal to the base URL. grant_type `login_code` (or `urn:openentity:login_code`): redeems the
         Android Apple code from /v1/auth/apple/callback; send `client`, and `attestation` on the install's first
         sign-in. Each access token is a 15-minute ES256 JWT with exactly one audience (AccessTokenClaims). Product
@@ -1463,12 +1464,20 @@
       operationId: getPairing
       tags: [Pairing]
       summary: Pairing state; once claimed, the agent, the match string and the approval request
-      description: The app polls this while the code is on screen, at most every 2 seconds.
+      description: |
+        The app polls this while the code is on screen, at most every 2 seconds. Once the owner has picked the
+        permissions, the app reads it again with `permissions`: the `approval_request` then covers them
+        (`payload.permissions`, sorted and without duplicates), and confirm must send that same list.
       x-available: day-3
       security:
         - ownerBearer: []
       parameters:
         - $ref: '#/components/parameters/IdPath'
+        - name: permissions
+          in: query
+          required: false
+          description: The permissions the owner picked, comma-separated. Absent means none.
+          schema: { type: string, maxLength: 16384 }
       responses:
         '200':
           description: The pairing.
@@ -1507,7 +1516,9 @@
       summary: Confirm a claimed pairing with Face ID (approval/2.0 over the link)
       description: |
         The owner checks that the match string matches what the agent printed, picks permissions, and signs the
-        pairing's `approval_request` (action pairing.confirm). The service then sends agent.paired to every product
+        pairing's `approval_request` read with those permissions (action pairing.confirm; the signed payload names
+        them). The body's `permissions` must be the same list, or the approval does not verify (401
+        approval_invalid). The service then sends agent.paired to every product
         (POST /v1/oe/events), which enrolls the agent there. The agent's first product keys follow on its first
         confirmed read of GET /v1/pair/{pairing_id}.
       x-available: day-3
@@ -3581,6 +3592,7 @@
           items: { $ref: '#/components/schemas/AudienceDid' }
         client: { $ref: '#/components/schemas/ClientInfo' }
         attestation: { $ref: '#/components/schemas/Attestation' }
+        device_counter: { type: integer, minimum: 1, maximum: 9007199254740991, description: 'Android refresh only: a counter the install keeps, above the last refresh''s, inside the body X-OE-Device-Signature signs.' }
       allOf:
         - if:
             properties:
@@ -4749,7 +4761,7 @@
           then: { properties: { data: { $ref: '#/components/schemas/PlanChangedData' } } }
     AgentPairedData:
       type: object
-      required: [agent_did, owner_did, permissions, framework, seq]
+      required: [agent_did, owner_did, permissions, framework, seq, link_epoch]
       properties:
         agent_did: { $ref: '#/components/schemas/AgentDid' }
         owner_did: { $ref: '#/components/schemas/OwnerDid' }
@@ -4758,6 +4770,7 @@
           items: { $ref: '#/components/schemas/Permission' }
         framework: { type: [string, 'null'], maxLength: 64, description: What the agent named at claim, e.g. openclaw. }
         seq: { type: integer, minimum: 1, description: 'The agent''s permissions counter, as on agent.permissions: apply these permissions only if newer than the ones held.' }
+        link_epoch: { type: integer, minimum: 1, description: 'This link of the agent to its owner: it rises with every confirmed pairing. Drop an agent.paired or agent.unlinked older than the link held.' }
     AgentPausedData:
       type: object
       required: [agent_did, owner_did, paused_at, seq]
@@ -4776,10 +4789,11 @@
         seq: { type: integer, minimum: 1, description: The agent's pause-state counter. Apply only if newer than the one held. }
     AgentUnlinkedData:
       type: object
-      required: [agent_did, owner_did]
+      required: [agent_did, owner_did, link_epoch]
       properties:
         agent_did: { $ref: '#/components/schemas/AgentDid' }
         owner_did: { $ref: '#/components/schemas/OwnerDid' }
+        link_epoch: { type: integer, minimum: 1, description: The link this ends (agent.paired's link_epoch). Ignore it when a newer link is held. }
     AgentPermissionsData:
       type: object
       required: [agent_did, owner_did, permissions, seq]

What each client does:

  • iOS and Android: after the owner picks permissions, read GET /v1/pairings/{id}?permissions=<comma-separated>, sign that approval_request, and confirm with the same permissions.
  • Android only: keep a per-install counter in app storage, and send device_counter, one higher each time, in every refresh body.
  • Products: store link_epoch per agent. Apply agent.paired only if its epoch is newer than or equal to the one held, and agent.unlinked only if its epoch equals the one held.

Tests: account 179 pass. scripts/verify.sh is green in the pre-push hook.

…s the permissions, link_epoch on agent.paired and agent.unlinked, Android refreshes carry a rising device_counter
@WillCybertron
WillCybertron marked this pull request as ready for review November 12, 2026 09:41
@WillCybertron
WillCybertron merged commit 4e121dc into main Nov 12, 2026
1 check passed
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@WillCybertron
WillCybertron deleted the feat/account-gate-contract branch November 12, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant