@opndev/zero-trust
v0.0.2
Published
Zero trust implemenation
Downloads
230
Readme
@opndev/zero-trust-client
A platform-agnostic client for the Adaptive Zero-Trust protocol. Establishes and maintains device trust by solving Proof-of-Work challenges, renewing tokens, and safely retrying interrupted requests without depending on any React Native or DOM API. The same client works unmodified in a React Native app and in a browser; only storage differs per platform, via a small adapter.
Why a separate package?
The core client (createTrustClient) has zero platform dependencies.
No expo-* packages, no browser globals. Storage, the PoW hash function,
and fetch are all injected. That's what makes one client usable on both
targets, but it also means the storage adapters genuinely can't live in
the same importable module as each other: the React Native adapter
imports expo-secure-store, which doesn't exist outside Expo/RN at all,
and would break module resolution for a web-only consumer that never
touches it. Each adapter is its own subpath, so you only ever resolve the
one your platform actually needs.
Installation
npm install @opndev/zero-trust-clientReact Native projects also need:
npx expo install \
expo-secure-store \
@react-native-async-storage/async-storage \
react-native-get-random-valuesQuick start
React Native
import { sha3_256 } from 'js-sha3';
import { createTrustClient } from '@opndev/zero-trust-client';
import { createSecureStoreAdapter } from
'@opndev/zero-trust-client/adapters/secure-store';
const trust = createTrustClient({
trustUri: 'https://api.example.com/api/v1/trust',
storage: createSecureStoreAdapter(),
hash: sha3_256,
validFor: [
{ prefix: 'https://api.example.com/api/v1' },
],
});
// One shared fetch for the whole app. Attaches auth for anything matching
// validFor, passes everything else through untouched.
export const authedFetch = trust.authedFetch();Web
Only the adapter import changes, everything else is identical:
import { sha3_256 } from 'js-sha3';
import { createTrustClient } from '@opndev/zero-trust-client';
import { createWebStorageAdapter } from '@opndev/zero-trust-client/adapters/web';
const trust = createTrustClient({
trustUri: 'https://api.example.com/api/v1/trust',
storage: createWebStorageAdapter(),
hash: sha3_256,
validFor: [
{ prefix: 'https://api.example.com/api/v1' },
],
});Note on the web adapter: it uses plain
localStoragefor everything, including the refresh token. Unlike the React Native adapter (which puts the refresh token inexpo-secure-store, backed by Keychain/Keystore),localStoragegives it no special protection. Anything with script execution on the page can read it, same as any other stored value. That's a known, documented gap, not an oversight; see the adapter's own doc comment for what closing it properly would require.
API
createTrustClient(options)
| Option | Required | Default | Description |
| --- | --- | --- | --- |
| trustUri | yes | — | e.g. 'https://api.example.com/api/v1/trust'. POST/PATCH go here directly; proof submission goes to `${trustUri}/proof`. |
| storage | yes | — | Adapter implementing get(key), set(key, value), remove(key) (all async). |
| hash | yes | — | Synchronous (string) => hexString. Must match whatever the server's PoW policy expects — sha3_256 from js-sha3 for the default server-side policy. |
| validFor | no | [] | Array of { prefix } — URL prefixes that authedFetch() should attach the current access token to. Merge rules from multiple sources with plain array spread ([...a, ...b]) — no separate merge mechanism needed. For a genuinely separate trust domain (different trustUri), compose two clients instead — see authedFetch's fallback argument below. |
| fetchImpl | no | global fetch | The underlying fetch used both for this client's own requests to trustUri and, wrapped with auth, for whatever authedFetch() routes to it. |
| generateId | no | crypto.randomUUID/crypto.getRandomValues | Generates attempt_id values. Throws if neither crypto source exists, rather than silently falling back to weak randomness — attempt_id needs real entropy (see SPEC.md's Security Considerations). Inject your own on a platform where neither exists (e.g. via react-native-get-random-values). |
| solveBatchSize | no | 1000 | Hash attempts per tick before yielding back to the event loop, so a slow solve doesn't block the JS thread for its full duration. |
| clockSkewMs | no | 5000 | Safety margin subtracted from the stored access token's expiry when deciding whether it's still usable. |
Returns:
{
// The main entry point. Call this before any authenticated request.
// Resolves instantly (no network) if the current token is still
// valid; otherwise runs the full renewal flow. Concurrent callers
// during a renewal share one in-flight attempt rather than racing.
// Async, resolves with an access token string.
ensureAccessToken,
// Async, resolves with the stored device_id string, or null.
getDeviceId,
// Async, clears all locally stored trust state.
reset,
// Returns a fetch-shaped function (url, init) => Promise<Response>
// that routes each request per validFor. Memoized: safe to call
// more than once, always returns the same function. See "How
// authedFetch routes requests" below.
authedFetch,
// The resolved trustUri passed in above. Read this back instead
// of duplicating the literal string anywhere else that needs it.
trustUri,
}How authedFetch() routes requests
Every request through the function authedFetch() returns goes to one of
three places:
trustUriitself (or`${trustUri}/proof`). Always passed straight to the plain underlyingfetchImpl, never wrapped with auth. This is automatic, derived fromtrustUridirectly. Not something you configure viavalidFor, and not something you can get wrong by rule ordering. Wrapping it would be circular: it's exactly whatensureAccessToken()'s own internal calls talk to.- A URL matching one of the
validForprefixes. Attaches the current access token, and on a403, retries exactly once through a forced renewal before giving up. A403is deliberately ambiguous (ordinary token expiry and actual device revocation look identical from the status code alone), so this doesn't try to diagnose which one happened.ensureAccessToken({ force: true })already knows how to get a valid token regardless, via the samerenew()flow that correctly distinguishes them internally. - Anything else passed straight through to
fallback(defaults to this client's ownfetchImplplain, unauthenticated passthrough).
const res = await authedFetch('https://api.example.com/api/v1/event/...');
const data = await res.json();Composing multiple trust domains
authedFetch(fallback?) takes an optional fallback used for its "no match"
case. Composing two genuinely separate trust domains (different trustUri,
e.g. your own API plus an unrelated third-party trust relationship) is plain
function chaining, not a separate router type each client's "no match" case
just defers to the next:
const trust = createTrustClient(
{ trustUri: config.trust, validFor: [{ prefix: config.pow }],
/* ... */ });
const trust2 = createTrustClient(
{ trustUri: config.otherTrust, validFor: [{ prefix: config.other }],
/* ... */ });
const combined = trust.authedFetch(trust2.authedFetch());
setDefaultFetchImpl(combined);trust tries its own trustUri/validFor first; anything matching neither
defers to trust2.authedFetch(), which tries its own rules the same way,
bottoming out at trust2's own plain fetchImpl if nothing matches there
either. Extends to any number of trust domains by nesting further. Each layer
already knows how to defer to the next.
authedFetch(), called with no arguments, is memoized. Safe to call more than
once, always returns the same function. A call with an explicit fallback is
a deliberate one-off composition and always builds fresh, since caching it
under the same slot as the no-arg version could return the wrong one on a later
call.
Storage adapter contract
Any object with these three async methods works. The two provided adapters are just two implementations of the same shape:
{
get(key) {}, // async — resolves with the stored string, or null
set(key, value) {}, // async
remove(key) {}, // async
}How it behaves
- A still-valid access token never touches the network.
ensureAccessToken()checks the stored expiry first. - No refresh token → start fresh. Whether or not a stale
device_idis still stored locally, a missing refresh token always goes throughPOST /trustfor a brand-new, server-generated device. There's no partial/bare-renewal path presenting nothing and presenting a stale token cost the same thing, by design (see SPEC.md). - A refresh token → the trusted renewal path, with a mandatory
attempt_id. If the app is killed mid-request, the same attempt safely resumes next launch, no matter how long the gap was, instead of generating a new one and risking a replay-detection false positive. - A demoted device (the server responds to
PATCH /trustwith a challenge instead of tokens) is handled transparently.
The full protocol semantics live in Adaptive Zero-Trust protocol. This package only implements the client side of it.
License
MIT
