import type { CollectExpiredInput, CollectExpiredResult } from './retention'; /** * Device assertion envelope, canonical signing bytes, and verifier * (plan `device-assertion-broker`, P3 ①-⑤). * * A device assertion is a short-lived, audience-scoped statement a *paired * device* makes about itself — "this device, paired to this product against * this server, wants to talk to `` for the next two minutes" — signed * with the same Ed25519 device identity key `attestation.ts`'s device proof * uses. A sibling CLI installed alongside the daemon presents one to the host's * cloud, which exchanges it for a product session. It is NOT a request-bound * proof (`attestation.ts`) and the two must never be interchangeable; see the * domain prefix below. * * Four decisions this file encodes, none of them re-litigable here: * * 1. **A custom JSON signing envelope, not JWS.** The domain prefix has to be * *inside* the signed bytes. JWS puts its type tag in a header that no * verifier is required to check, which is a fail-open shape: a token minted * for one purpose verifies for another as long as the key matches. So this * clones the mechanism `attestation.ts` already froze — RFC 8785-subset * canonicalization (`canonicalizeJson`, imported, never re-implemented) plus * a golden fixture pinning the exact bytes. * 2. **What the claims deliberately do NOT carry.** No `devicePublicKey`: a * verifier must resolve the key from its own device directory by `deviceId`, * or the envelope becomes self-authenticating. No caller identity: every * process under the same UID can reach the control socket, so a "who asked" * field would be synthesized authority, not evidence. No `keyId`: there is * no key-rotation story for this envelope yet, and a field nothing populates * honestly is a structure that invites a verifier to trust it. * 3. **`audience` is a single string, never an array.** A multi-audience token * forces every verifier to agree on the same containment rule; one string * compared with `===` cannot be got wrong. * 4. **The verifier cannot forget the revocation check.** {@link * verifyDeviceAssertion} takes `revoked` as a REQUIRED dependency, so a * caller that never looked the device row up does not compile. The daemon's * own local checks (see `@byok-sdk/client`'s `assertion.issue`) are only half * of revocation; the other half is this recheck at exchange time, and no * documentation may claim the daemon satisfies "synchronous invalidation" * on its own. * * Like `attestation.ts`, this module is crypto-free: signature verification is * an injected port (`DeviceAssertionVerifier`), because core must load on * Workers and `node:crypto`/WebCrypto disagree about key handling. */ import { z } from 'zod'; import { type JsonObject } from './attestation'; import type { DevicePrincipal } from './principals'; import { type TenantId } from './tenant'; /** Envelope schema id, self-consistent with the domain prefix below. */ export declare const DEVICE_ASSERTION_SCHEMA_ID = "byok-device-assertion-v1"; /** * Domain separation prefix, prepended to the canonical claim bytes before * signing. * * Must remain mutually NON-PREFIX with the other three things this same Ed25519 * device key signs — `byok-nonce-v1\n` (challenge/token renewal, see * `@byok-sdk/client`'s `device-keys.ts`), `byok-device-proof-v1\n` * (`attestation.ts`), and `byok-task-assertion-v1\n` ({@link * TASK_ASSERTION_DOMAIN_PREFIX}) — so no signature over one domain can ever be * reinterpreted as a signature over another. * `packages/core/src/__tests__/device-assertion.test.ts` asserts the property * across the three core-owned prefixes and * `packages/client/src/__tests__/device-assertion-broker.test.ts` across all * four; those assertions are the falsifier for this whole design, not a nicety. */ export declare const DEVICE_ASSERTION_DOMAIN_PREFIX = "byok-device-assertion-v1\n"; export declare const DEVICE_ASSERTION_VERSION = 1; /** * Envelope schema id of the task-scoped assertion (contract §8.1), kept beside * the device one so the two are read — and reviewed — together. */ export declare const TASK_ASSERTION_SCHEMA_ID = "byok-task-assertion-v1"; /** * Domain separation prefix for `byok-task-assertion-v1`. * * Joins the mutually NON-PREFIX set the ONE Ed25519 device key signs under: * `byok-nonce-v1\n`, `byok-device-proof-v1\n`, * `byok-device-assertion-v1\n`, and this. Contract §8.1 requires the old and * new envelopes to be non-interchangeable, and domain separation is the half of * that which holds even when both envelopes parse: a device-lane signature can * never be reinterpreted as a task-lane one, because the signed bytes differ in * their first line. The four-way falsifier lives in * `packages/client/src/__tests__/device-assertion-broker.test.ts` (the only * suite that can import the real nonce domain); the three core-owned prefixes * are cross-checked in `packages/core/src/__tests__/device-assertion.test.ts`. */ export declare const TASK_ASSERTION_DOMAIN_PREFIX = "byok-task-assertion-v1\n"; export declare const TASK_ASSERTION_VERSION = 1; /** Signature algorithms this envelope version admits. */ export declare const DEVICE_ASSERTION_ALGORITHMS: readonly ['ed25519']; export type DeviceAssertionAlgorithm = (typeof DEVICE_ASSERTION_ALGORITHMS)[number]; /** * Default assertion lifetime. Short on purpose: the daemon keeps no `jti` * ledger (it is not on the verification path and could not stop a real replay * anyway), so a narrow expiry window plus a burn-on-use verifier is the whole * replay story. */ export declare const DEVICE_ASSERTION_DEFAULT_TTL_MS = 120000; /** * Hard ceiling on the lifetime, enforced at BOTH ends: a daemon refuses to be * configured above it, and {@link verifyDeviceAssertion} refuses an envelope * whose own `issuedAt`→`expiresAt` span exceeds it regardless of who minted it. */ export declare const DEVICE_ASSERTION_MAX_TTL_MS = 300000; /** Bound on the `audience` claim, in UTF-8 bytes — an allowlist entry is a short identifier, not a document. */ export declare const DEVICE_ASSERTION_AUDIENCE_MAX_BYTES = 256; /** * The signed claim set. `strictObject` with every member REQUIRED: an optional * claim is a claim a verifier may or may not see, and this envelope is small * enough that there is no honest reason for one. * * `issuer` is the paired server's origin (scheme + host + port, normalized) — * it binds the assertion to the deployment the device is actually paired * against, so an assertion minted by a device paired to a staging server cannot * be presented to production. */ export declare const DeviceAssertionClaimsSchema: z.ZodObject<{ version: z.ZodLiteral<1>; issuer: z.ZodString; productId: z.ZodString; deviceId: z.ZodString; audience: z.ZodString; jti: z.ZodString; issuedAt: z.ZodISODateTime; expiresAt: z.ZodISODateTime; }, z.core.$strict>; export type DeviceAssertionClaims = z.infer; export declare const DeviceAssertionEnvelopeV1Schema: z.ZodObject<{ schema: z.ZodLiteral<"byok-device-assertion-v1">; algorithm: z.ZodEnum<{ ed25519: "ed25519"; }>; protected: z.ZodObject<{ version: z.ZodLiteral<1>; issuer: z.ZodString; productId: z.ZodString; deviceId: z.ZodString; audience: z.ZodString; jti: z.ZodString; issuedAt: z.ZodISODateTime; expiresAt: z.ZodISODateTime; }, z.core.$strict>; signature: z.ZodString; }, z.core.$strict>; export type DeviceAssertionEnvelopeV1 = z.infer; /** * Parses an envelope fail-closed. * * @throws {ByokCoreError} code `assertion_envelope_invalid`. */ export declare function parseDeviceAssertionEnvelope(input: unknown): DeviceAssertionEnvelopeV1; /** * Projects claims into the exact JSON object that gets canonicalized. * * Built field by field rather than by spreading the parsed object — the same * discipline `deviceProofCanonicalClaims` documents. Nothing here is optional, * so there is no absent-key decision to get wrong; the explicit projection is * what keeps it that way if a field is ever added. */ export declare function deviceAssertionCanonicalClaims(claims: DeviceAssertionClaims): JsonObject; /** Canonical JSON text of the claim set, without the domain prefix. */ export declare function deviceAssertionCanonicalJson(claims: DeviceAssertionClaims): string; /** * The exact bytes a device signs and a verifier reconstructs: * `byok-device-assertion-v1\n` followed by the canonical claim JSON, UTF-8 * encoded. * * Frozen by `src/__tests__/golden/device-assertion-v1.canonical.json`. */ export declare function deviceAssertionSigningInput(claims: DeviceAssertionClaims): Uint8Array; export interface DeviceAssertionVerifyInput { readonly algorithm: DeviceAssertionAlgorithm; /** Raw public key, base64url — the JWK `x` encoding the device registry stores. */ readonly publicKey: string; readonly signature: string; readonly signingInput: Uint8Array; } /** * Injected signature verification, for the same reason `DeviceProofVerifier` * exists: core is Node-free and Workers-safe, so it answers no cryptographic * question itself. Kept separate from `DeviceProofVerifier` even though the * shapes coincide — one composition object satisfies both — because this file's * entire purpose is that the two domains never become interchangeable, and a * shared type is the first step toward a shared code path. */ export interface DeviceAssertionVerifier { verify(input: DeviceAssertionVerifyInput): Promise; } /** * The device-row fields a verification reads — the verifier's OWN directory * row, resolved by `deviceId`, never anything the envelope carried. * * Both fields together, from one lookup, are what make forgetting impossible: * the caller cannot obtain `publicKeyJwkX` without also obtaining the current * `revoked` state, because they arrive as one object from one call. */ export interface DeviceAssertionDeviceRow { /** JWK `x` of the device's registered Ed25519 public key. The ONLY key a signature is ever checked against. */ readonly publicKeyJwkX: string; /** The row's CURRENT revocation state, read in the same lookup as the key. */ readonly revoked: boolean; } /** * Everything a verification needs that is NOT in the envelope. * * The device row is supplied through a LOOKUP PORT, not as a pre-fetched * value, and that is the whole point (this is the faithful clone of * `DeviceProofVerifier`'s "core is never a second authority on device * identity" shape). `verifyDeviceAssertion` reads `deviceId` from the parsed * claims and calls `lookupDevice(deviceId)` ITSELF, so: * * - There is no way to invoke a verification without providing the means to * look the current row up — "I forgot to check revocation" cannot be * expressed, because the function does the lookup, not the caller. * - Both the public key AND the revocation state come from that one row, so a * caller cannot pass a key while claiming `revoked: false` from thin air. * - The `deviceId` handed to `lookupDevice` is the claimed one; the row it * returns is authority. A device asserting an identity it is not is caught * by the lookup missing, or by the returned row's key failing the signature. */ export interface DeviceAssertionVerifyDeps { readonly verifier: DeviceAssertionVerifier; /** * Resolve the verifier's own device row by the claimed `deviceId`. * `undefined` for an unknown device. Sync or async; awaited either way. */ readonly lookupDevice: (deviceId: string) => Promise | DeviceAssertionDeviceRow | undefined; /** Injected instant — core never reads a wall clock (`stores.ts`'s `Clock`). */ readonly now: Date; /** Bound on `issuedAt`→`expiresAt`. Defaults to (and may never exceed) {@link DEVICE_ASSERTION_MAX_TTL_MS}. */ readonly maxLifetimeMs?: number; } /** * Verifies an assertion and returns its claims, or `undefined`. * * Every rejected state collapses to `undefined` — malformed input, an unknown * or revoked device, an expired or over-long window, a bad signature — so a * route has one response for all of them and cannot accidentally leak which * check failed. That is the same shape `authenticateDeviceProof` * (`@byok-sdk/cloud`) already uses. * * The row lookup and both authority reads (key, revocation) happen INSIDE this * function — see {@link DeviceAssertionVerifyDeps}. What the caller MUST still * do afterward, and this cannot: assert `claims.audience` equals the audience * it actually serves, assert `claims.issuer`/`claims.productId` match its own * deployment, and BURN `claims.jti` so the assertion cannot be presented * twice. The daemon keeps no `jti` ledger; single use is entirely the * verifier's job. */ export declare function verifyDeviceAssertion(input: unknown, deps: DeviceAssertionVerifyDeps): Promise; /** The verifier's complete current row. Claims remain lookup keys, never authority. */ export interface DeviceAssertionAuthorityRow extends DeviceAssertionDeviceRow { readonly tenantId: TenantId; readonly productId: string; readonly deviceId: string; } /** Trusted deployment values the host compares with exact string equality. */ export interface DeviceAssertionExpectedBinding { readonly issuer: string; readonly productId: string; readonly audience: string; } /** * The envelope kinds that share one replay authority. * * A closed union with no default: contract §8.2(2) requires the replay key to * carry an envelope-kind discriminator so the same `jti` presented as a device * assertion and as a task assertion occupies two key slots and neither lane can * burn the other's. A defaulted or optional field would reintroduce exactly the * collision the discriminator exists to prevent, so every caller states its * lane and a caller that forgets does not compile. */ export type DeviceAssertionReplaySchemaId = typeof DEVICE_ASSERTION_SCHEMA_ID | typeof TASK_ASSERTION_SCHEMA_ID; /** One replay key. Every field is derived from verified claims/current authority. */ export interface DeviceAssertionReplayConsumeInput { /** Which signed envelope kind produced this key. Required; never inferred, never defaulted. */ readonly schema: DeviceAssertionReplaySchemaId; readonly tenantId: TenantId; readonly issuer: string; readonly productId: string; readonly deviceId: string; readonly audience: string; readonly jti: string; readonly expiresAt: string; } /** * Atomic single-use authority. `true` means this caller inserted the key; * `false` means it was already consumed. Operational failures throw and must * never be translated into authenticated success. */ export interface DeviceAssertionReplayAuthority { collectExpired(tenant: TenantId, input: CollectExpiredInput): Promise; consume(input: DeviceAssertionReplayConsumeInput): Promise; } export interface AuthenticateDeviceAssertionDeps { readonly verifier: DeviceAssertionVerifier; readonly lookupDevice: (deviceId: string) => Promise | DeviceAssertionAuthorityRow | undefined; readonly replay: DeviceAssertionReplayAuthority; readonly expected: DeviceAssertionExpectedBinding; readonly now: Date; readonly maxLifetimeMs?: number; } /** * What both lanes' results carry: the current-authority principal plus the * audit fields of the credential that was spent. Never a name a caller holds — * a value is always one lane or the other, and {@link AuthenticatedAssertion} * is the union to accept when a consumer genuinely serves both. */ interface AuthenticatedAssertionBase { readonly device: DevicePrincipal; readonly issuer: string; readonly audience: string; readonly jti: string; readonly issuedAt: string; readonly expiresAt: string; } /** * Audit-safe result of a consumed DEVICE assertion; no credential or signature * is retained. * * `lane` is the discriminator contract §8.1 requires the result to carry. The * two lanes have zero interchange, so a consumer that serves only one of them * has to be able to say which — and "which fields are present" is not that * statement: a structural check drifts the moment either claim set grows, and * an extension relationship would make a task credential satisfy every * device-lane type check for free. */ export interface AuthenticatedDeviceAssertion extends AuthenticatedAssertionBase { readonly lane: 'device'; } /** * Authenticate one device assertion and atomically consume its JTI. * * All invalid authentication states collapse to `undefined`. Replay-store * operational failures reject the promise, allowing a host to return an * availability error without ever degrading to signature-only acceptance. */ export declare function authenticateDeviceAssertion(input: unknown, deps: AuthenticateDeviceAssertionDeps): Promise; /** * The task-scoped assertion: a separate envelope, NOT a device assertion with * extra claims. * * Contract §8.1 fixes the property this section exists to hold: the old and new * envelopes are not interchangeable in either direction. Three independent * mechanisms carry it, and none of them is "the verifier remembers to check": * * 1. **Different signed bytes.** {@link TASK_ASSERTION_DOMAIN_PREFIX} is * non-prefix with every other domain this device key signs under, so a * signature made in one lane cannot be reinterpreted in another even if the * claim sets were made to coincide. * 2. **Different strict schemas.** `schema` is a literal and `protected` is a * `strictObject`, so a device envelope fails * {@link parseTaskAssertionEnvelope} on its missing `taskId`/`agentRef`/ * `toolsetId` and a task envelope fails * {@link parseDeviceAssertionEnvelope} on those same claims being unknown. * 3. **Different replay keys.** {@link DeviceAssertionReplayConsumeInput} * carries a required `schema` segment (§8.2(2)), so the two lanes' ledgers * are distinguishable and neither can burn the other's `jti`. * * What is deliberately NOT duplicated: `jti`/signature encodings, the audience * byte bound, {@link DEVICE_ASSERTION_MAX_TTL_MS}, the window rule and the * device-row rule are the SAME validators the device lane uses, referenced not * copied. Contract §8.1: the task lane inherits the SDK's existing limits and * does not define a second, looser set. * * The claim set carries no caller identity for the reason stated at the top of * this file — under one UID every process can reach the control socket, so a * self-reported "who asked" is synthesized authority. The task/AgentRef binding * here is authority precisely because the daemon signs it after checking its * own local registry, not because a caller sent it. */ /** Bound on each AgentRef scalar, in UTF-8 bytes. Mirrors `AGENT_REF_MAX_BYTES` in `@byok-sdk/protocol`. */ export declare const TASK_ASSERTION_AGENT_REF_MAX_BYTES = 160; /** Bound on `toolsetId`. Mirrors the `ToolsetIdSchema` bound in `@byok-sdk/protocol`. */ export declare const TASK_ASSERTION_TOOLSET_ID_MAX_LENGTH = 128; /** The frozen offer's Agent identity. Same shape and same rules as the SDK's `AgentRefSchema`. */ export declare const TaskAssertionAgentRefSchema: z.ZodObject<{ agentId: z.ZodString; profileRevision: z.ZodString; }, z.core.$strict>; export type TaskAssertionAgentRef = z.infer; /** * The signed task claim set: the device claim set plus the three bindings that * make the assertion task-scoped, every member required for the same reason the * device claims are. */ export declare const TaskAssertionClaimsSchema: z.ZodObject<{ version: z.ZodLiteral<1>; issuer: z.ZodString; productId: z.ZodString; deviceId: z.ZodString; audience: z.ZodString; jti: z.ZodString; issuedAt: z.ZodISODateTime; expiresAt: z.ZodISODateTime; taskId: z.ZodString; agentRef: z.ZodObject<{ agentId: z.ZodString; profileRevision: z.ZodString; }, z.core.$strict>; toolsetId: z.ZodString; }, z.core.$strict>; export type TaskAssertionClaims = z.infer; export declare const TaskAssertionEnvelopeV1Schema: z.ZodObject<{ schema: z.ZodLiteral<"byok-task-assertion-v1">; algorithm: z.ZodEnum<{ ed25519: "ed25519"; }>; protected: z.ZodObject<{ version: z.ZodLiteral<1>; issuer: z.ZodString; productId: z.ZodString; deviceId: z.ZodString; audience: z.ZodString; jti: z.ZodString; issuedAt: z.ZodISODateTime; expiresAt: z.ZodISODateTime; taskId: z.ZodString; agentRef: z.ZodObject<{ agentId: z.ZodString; profileRevision: z.ZodString; }, z.core.$strict>; toolsetId: z.ZodString; }, z.core.$strict>; signature: z.ZodString; }, z.core.$strict>; export type TaskAssertionEnvelopeV1 = z.infer; /** * Parses a task envelope fail-closed. A device assertion is not a degraded task * assertion and is rejected here, not tolerated (§8.1: task lane zero fallback). * * @throws {ByokCoreError} code `assertion_envelope_invalid`. */ export declare function parseTaskAssertionEnvelope(input: unknown): TaskAssertionEnvelopeV1; /** * Projects task claims into the exact JSON object that gets canonicalized. * * Built field by field, like the device projection, and covering EVERY claim — * contract §8.1 requires the canonical projection to include all added claims, * so an unsigned `taskId`, `agentRef` or `toolsetId` would be a claim a verifier * reads but no signature covers. */ export declare function taskAssertionCanonicalClaims(claims: TaskAssertionClaims): JsonObject; /** Canonical JSON text of the task claim set, without the domain prefix. */ export declare function taskAssertionCanonicalJson(claims: TaskAssertionClaims): string; /** * The exact bytes a daemon signs and a verifier reconstructs: * `byok-task-assertion-v1\n` followed by the canonical claim JSON, UTF-8 * encoded. * * Frozen by `src/__tests__/golden/task-assertion-v1.canonical.json`. */ export declare function taskAssertionSigningInput(claims: TaskAssertionClaims): Uint8Array; /** * Verifies a task assertion and returns its claims, or `undefined`. * * The twin of {@link verifyDeviceAssertion}, with the same * collapse-to-`undefined` discipline, the same injected row lookup, and the * same window and ceiling rules ({@link assertionWindowAdmits}, * {@link resolveMaxLifetimeMs}) — and a schema this function will not widen: a * `byok-device-assertion-v1` envelope is rejected at the parse step. * * What the caller MUST still do, exactly as in the device lane: compare * `issuer`/`productId`/`audience` against its own deployment, burn the `jti` * under this envelope's `schema` segment, and check that `taskId`/`agentRef`/ * `toolsetId` belong to the frozen offer (§8.2(2)) — the last of which is Host * authority that core cannot hold. */ export declare function verifyTaskAssertion(input: unknown, deps: DeviceAssertionVerifyDeps): Promise; /** * Audit-safe result of a consumed TASK assertion; no credential or signature is * retained. * * Deliberately NOT an extension of {@link AuthenticatedDeviceAssertion}. It was * one, and that was the same mistake in the type system that §8.1 forbids on * the wire: an extension is assignable to the thing it extends, so every * consumer written for device authority would have accepted a task credential * silently, with no fallback ever written down. The two results share * {@link AuthenticatedAssertionBase} and differ in `lane`, so a consumer that * must accept exactly one of them compares one field, and a consumer that must * accept either says so with {@link AuthenticatedAssertion}. */ export interface AuthenticatedTaskAssertion extends AuthenticatedAssertionBase { readonly lane: 'task'; readonly taskId: string; readonly agentRef: TaskAssertionAgentRef; readonly toolsetId: string; } /** * Either lane's result, discriminated by `lane`. * * For the consumer that genuinely serves both (an audit projection, a ledger * writer). A consumer that serves ONE lane must name that lane's type instead — * this union exists so "both" is stated on purpose, not reached by accident. */ export type AuthenticatedAssertion = AuthenticatedDeviceAssertion | AuthenticatedTaskAssertion; /** * Authenticate one task assertion and atomically consume its JTI under the * `byok-task-assertion-v1` replay segment. * * Identical in shape and failure discipline to * {@link authenticateDeviceAssertion}: every invalid state collapses to * `undefined`, and a replay-store operational failure REJECTS rather than * degrading to signature-only acceptance (§8.2(2): an unavailable replay store * fails, it does not downgrade authentication). * * Consumption is the last step, after every binding comparison, so a rejected * assertion never burns a key — the "reject before side effects" ordering AC11 * requires. The frozen-offer check (`taskId`/`agentRef`/`toolsetId` belong to * this Execution) is deliberately NOT here: that authority lives in the Host, * and a core function that accepted it as an argument would be inviting a * caller to synthesize it. */ export declare function authenticateTaskAssertion(input: unknown, deps: AuthenticateDeviceAssertionDeps): Promise; export {};