Skip to content

Host ISO 18013-5 proximity in the SDK instead of piping GATT - #31

Draft
leifj wants to merge 7 commits into
mainfrom
feat/proximity-via-sdk
Draft

leifj wants to merge 7 commits into
mainfrom
feat/proximity-via-sdk

Conversation

@leifj

@leifj leifj commented Sep 10, 2026

Copy link
Copy Markdown

What it does

The wrapper exposed Bluetooth as eight bridge methods and let the page drive the mdoc protocol over them. That put device engagement, session keys, the session transcript, chunking and device-response assembly in TypeScript, duplicated the BLE stack across this app, the iOS app and the page, and — the part worth pausing on — meant the page never authenticated the reader. It extracted no certificate chain, verified nothing, and asked nothing about trust. It presented to whatever connected.

This replaces the byte pipe with a session hosted by the SDK, whose proximity stack has been through real-reader interop.

before   reader ──BLE──▶ wrapper ──byte[] as JSON──▶ page (runs the protocol)
after    reader ──BLE──▶ SDK session ──ask/answer──▶ page (consent, signature)

The contract the page has to implement

Two methods to call, four handlers to register. Payloads are UTF-8 JSON in base64 in both directions.

// start a session; resolves with the engagement to put in a QR code
const { mdocUri, mode } = await nativeWrapper.proximityStart({ mode: 'peripheral' })
await nativeWrapper.proximityStop()

// the session then asks the page for what only the page has
nativeWrapper.onRequest('proximity.credentials', async () => [ /* StoredCredential[] */ ])
nativeWrapper.onRequest('proximity.consent',     async ({ docType, requestedClaims, families, readerTrust }) =>
                                                        ({ approved: true, credentialId }))
nativeWrapper.onRequest('proximity.sign',        async ({ credentialId, disclosedClaims, sessionTranscript }) =>
                                                        ({ deviceResponse: base64 }))
nativeWrapper.onRequest('proximity.readerTrust', async ({ x5chain }) => ({ trusted, reason, entityName }))
nativeWrapper.onRequest('proximity.step',        ({ step }) => { /* progress */ })
nativeWrapper.onRequest('proximity.complete',    ({ success }) => { /* terminal */ })

proximity.credentials returns the SDK's StoredCredential shape, which is the same shape the encrypted container already holds, so the page is handing over what it already has rather than converting.

Review points

Ordered by how much I would like an opinion.

  1. proximity.readerTrust is still a callback, and the reason is narrower than this PR first claimed. The original comment said an offline session could not make a trust decision at all. Wrong about the SDK: RICAL for readers and VICAL for issuers are both there, with the fail-closed-versus-fall-back distinction. What is true is that the local evaluator is private to SirosWallet, which a flow-free composition does not construct, and the reachable remote half wants a backend base URL the page owns. A composition gap, one small SDK addition from closing. Corrected in the code comment.

  2. Which BLE role should be the default? The engagement offers both and this starts one, defaulting to peripheral server. The page historically drove central client. Peripheral server is the role the SDK has the most exposure on. Easy to change; I would rather it be a decision than a default.

  3. Eligibility filtering stays in the page. filterEligible is the identity function here, because consumption policy and presentation history live in the page's wallet state. That means the page must not return an exhausted instance from proximity.credentials. Reasonable, or should the policy move too?

  4. The old resolve path is left as it is. It interpolates its argument into a single-quoted JavaScript string with no escaping, which is why one caller emitted \n by hand to survive the trip. WebAuthn is its last user and its fields are base64url already, so it works. New code uses base64 JSON. Worth a follow-up rather than widening this diff.

  5. The authenticated-channel cost. Resolved. All five native crates are on Maven Central now, so the repository block and the CI token pair are removed. Consuming this wrapper needs only mavenCentral(), verified by a build with every credential removed.

What was verified

ktlintCheck pass
testDebugUnitTest pass
assembleDebug pass
assembleDebugAndroidTest pass
CI on this branch pass, including connected tests
resolves with no credentials at all yes, since the crates reached Central
a build tested on a device yes, by @yimyitbarek
on a phone, against a reader not done

CI going green is worth one note beyond the tick: it means the SDK resolves and the app builds against keystore + credentials alone, without the wallet facade and therefore without the engine. That composition was inferred from the module graph before this branch; now it is demonstrated in a real build.

No hardware run against a reader, so the session itself is compile-and-lint verified only, and it cannot be exercised end to end until the page registers the handlers.

One thing worth noting from doing it: no NFC service is declared here, because the SDK's own library manifest already contributes MdocHostApduService, its intent filter and its AID group. My first attempt declared it and the manifest merger rejected the duplicate. So NFC static handover arrives by adding the dependency.

What comes out

Removed Lines
bluetooth/ — client, server, characteristics, extensions 1,069
debug/ — the two GATT printers 317
eight bluetooth* bridge methods, two mode accessors, shim wrappers ~130

Net 806 lines fewer, and one implementation instead of three.

🤖 Generated with Claude Code

leifj and others added 6 commits September 10, 2026 12:47
The wrapper exposed Bluetooth as eight bridge methods and let the page run
the mdoc protocol over them. That put device engagement, session keys, the
transcript, chunking and response assembly in TypeScript, and it meant the
page never authenticated the reader: no certificate chain was extracted,
nothing was verified, nothing was asked about trust.

This replaces the byte pipe with a session. The page starts one and gets an
engagement URI back for its QR code; the SDK runs engagement, both BLE
roles, NFC static handover, session establishment, reader authentication
and device-response assembly, and calls back into the page for the three
things the page still owns: which credentials it will present, the user's
consent, and a signature from a key that never leaves it.

Two pieces:

  JsCallHost   calls from native into the page and waits. Correlation ids,
               timeouts, cancellation, and failing everything outstanding
               when a page commit replaces the handler registry. Payloads
               are UTF-8 JSON in base64 both ways, so quoting, newlines and
               U+2028/U+2029 stop being hazards and binary needs no second
               encoding. The older resolve path, which interpolates into a
               single-quoted string with no escaping, is left alone;
               WebAuthn is its last user.

  ProximityBridge  wiring only. Turns the SDK's seven injected lambdas into
               bridge calls and back.

Removed: bluetooth/ (client, server, characteristics, extensions), the two
debug GATT printers, the eight bluetooth* bridge methods and the two mode
accessors, and their shim wrappers. No NFC service is declared here because
the SDK's own library manifest contributes it.

Net 806 lines fewer, and one implementation instead of three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SDK is published to GitHub Packages, which is per-repository and
authenticated, so reading it from this repo needs a token that can see
another repository's packages. The default GITHUB_TOKEN cannot, which is
why this run failed with the message the settings block now prints.

Uses the same actor and secret the release workflow already uses. If the
SDK moves to a channel with anonymous reads, both of these go away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SDK is moving to Maven Central for 0.15.0, which needs no repository
block and no token. Both places that exist only because the current
channel is authenticated now say so and name the version, so bumping the
catalog pin is enough to find them: the repository block in
settings.gradle.kts and the actor/token pair in push.yml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.14.0 is the first SDK release on Maven Central, so the SDK itself now
resolves from mavenCentral(). Publishing it there turned out to be
necessary but not sufficient: its own native dependencies are not on
Central, and a build without credentials fails on all five.

  org.siros:siros-wscd-manager      org.siros:zk-cred-vega
  org.siros:zk-cred-longfellow      org.siros:zk-cred-bbs
  org.siros:siros-dc-matcher

GitHub Packages serves every package in the org through any
repository-scoped URL the token can read, which is why one block covered
these before and still does. So the repository block and the workflow
token stay, but they now say what they are actually for and name the real
removal condition: those five reaching Central, not the SDK.

Verified: a build with GITHUB_ACTOR/GITHUB_TOKEN unset and gpr.* blanked
fails on exactly those five and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s not enough

The README framed these as FaceTec-only and optional, and said CI needs no
additional secrets. Both are now wrong. Every build needs them, because
the SDK's native dependencies are on GitHub Packages, and the first CI run
on this branch failed for exactly the reason the README said would not
happen: the default GITHUB_TOKEN cannot read another repository's
packages.

Also records which kind of PAT works. A classic token with read:packages
sees the whole organisation whatever repository URL Gradle asks for. A
fine-grained token scopes by the repository that owns the package, so it
needs all five crate repositories selected, and picking only
siros-sdk-kotlin is the intuitive choice that does not work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tladesignz
tladesignz self-requested a review September 15, 2026 14:28
@s-jairl

s-jairl commented Sep 15, 2026

Copy link
Copy Markdown

As regards to review question 1, this approach seems reasonable - me and @smncd were discussing if this "offline trust validation" (checking if a certificate is signed by a trusted CA) should be performed by the frontend, but considering that the SDK likely already wanna provide this functionality for other users and that this is primarily applicable to offline usecases, the SDK validation is likely the best until the day we pickup WebBLE again.

Comment thread .github/workflows/push.yml Outdated
@tladesignz
tladesignz requested a review from smncd September 15, 2026 15:47
@tladesignz

Copy link
Copy Markdown
Collaborator

@smncd and @yimyitbarek should have a look at this too, I guess since it affects them very much, too, AFAIU.

@leifj
leifj marked this pull request as ready for review September 18, 2026 21:29
@leifj

leifj commented Sep 18, 2026

Copy link
Copy Markdown
Author

@yimyitbarek told me he tested a build for this and it works.

@smncd

smncd commented Sep 18, 2026

Copy link
Copy Markdown
Member

I skimmed this and I have some thoughts about the bridge/interface. Will elaborate on Monday :)

Both from review.

The SDK's five native crates are on Maven Central now, so the repository
block and the CI token pair come out. A build with GITHUB_ACTOR and
GITHUB_TOKEN unset and gpr.* blanked now passes all four checks, which is
the thing the workaround existed to avoid failing.

The reader-trust comment claimed an offline session could not make a trust
decision at all. That was wrong about the SDK: RICAL for readers and VICAL
for issuers are both there, with the fail-closed-versus-fall-back
distinction. What is true is narrower, and the comment now says it. The
local evaluator is private to SirosWallet, which a flow-free composition
does not construct; the remote half is reachable through
BackendApiClient.evaluateTrust but wants a shaped AuthZEN body and a
backend base URL the page owns the conversation with. A composition gap,
one small SDK addition from being native.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDco921kM3P6Btpdc3HiGe
@leifj
leifj marked this pull request as draft September 19, 2026 08:43
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.

5 participants