test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/ single-use — every assertion runs the production code path. 21 tests green, tsc clean. node_modules via symlinks (gitignored); no npm install needed.
This commit is contained in:
141
e2e/harness/dpop.ts
Normal file
141
e2e/harness/dpop.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* P5 signing-key setup + capability-token / DPoP bundle builder. These wire the REAL
|
||||
* relay-auth issue + DPoP primitives. `resetVerifyKeyForTest`, `resetDpopCacheForTest`,
|
||||
* `buildDpopProof`, `jwkThumbprint`, and the WebCrypto Ed25519 helpers are TEST-ONLY / internal,
|
||||
* so they are deep-imported from `relay-auth/src/...` (relay-auth has no `exports` map, so subpath
|
||||
* imports resolve through the e2e node_modules symlink).
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { issueCapabilityToken, type DpopContext } from 'relay-auth'
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import {
|
||||
configureVerifyKey,
|
||||
resetVerifyKeyForTest,
|
||||
} from 'relay-auth/src/config/keys.js'
|
||||
import {
|
||||
generateEd25519KeyPair,
|
||||
exportEd25519PublicRaw,
|
||||
} from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { buildDpopProof, resetDpopCacheForTest } from 'relay-auth/src/capability/verify.js'
|
||||
import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js'
|
||||
import { principal } from './fakes.js'
|
||||
|
||||
export { resetDpopCacheForTest, jwkThumbprint }
|
||||
|
||||
export interface P5Key {
|
||||
readonly signingKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
}
|
||||
|
||||
/** Configure the P5 verifying key globally and return the matching private signing key. */
|
||||
export async function setupP5SigningKey(): Promise<P5Key> {
|
||||
resetVerifyKeyForTest()
|
||||
const pair = await generateEd25519KeyPair()
|
||||
configureVerifyKey(pair.publicKey)
|
||||
const publicRaw = await exportEd25519PublicRaw(pair.publicKey)
|
||||
return { signingKey: pair.privateKey, publicRaw }
|
||||
}
|
||||
|
||||
export interface Ephemeral {
|
||||
readonly privateKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
}
|
||||
|
||||
export async function makeEphemeral(): Promise<Ephemeral> {
|
||||
const pair = await generateEd25519KeyPair()
|
||||
return { privateKey: pair.privateKey, publicRaw: await exportEd25519PublicRaw(pair.publicKey) }
|
||||
}
|
||||
|
||||
export interface IssueOpts {
|
||||
readonly accountId: string
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly rights?: readonly CapabilityRight[]
|
||||
readonly ttl?: number
|
||||
readonly now: number
|
||||
readonly htu?: string
|
||||
readonly htm?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A capability token bundled with a matching DPoP proof. `newDpop(at?)` mints a FRESH DPoP proof
|
||||
* (new jti) bound to the SAME ephemeral key — needed to re-present the same token under a distinct
|
||||
* DPoP (single-use jti / revocation tests) without tripping the DPoP replay cache.
|
||||
*/
|
||||
export interface CapBundle {
|
||||
readonly raw: string
|
||||
readonly dpop: DpopContext
|
||||
readonly newDpop: (at?: number) => Promise<DpopContext>
|
||||
}
|
||||
|
||||
export async function issueCapBundle(signingKey: CryptoKey, o: IssueOpts): Promise<CapBundle> {
|
||||
const eph = await makeEphemeral()
|
||||
const cnfJkt = await jwkThumbprint(eph.publicRaw)
|
||||
const raw = await issueCapabilityToken(
|
||||
{
|
||||
principal: principal(o.accountId),
|
||||
aud: o.aud,
|
||||
host: o.host,
|
||||
rights: o.rights ?? ['attach'],
|
||||
ttlSeconds: o.ttl ?? 45,
|
||||
cnfJkt,
|
||||
},
|
||||
signingKey,
|
||||
o.now,
|
||||
)
|
||||
const htu = o.htu ?? `https://${o.aud}/ws`
|
||||
const htm = o.htm ?? 'GET'
|
||||
const newDpop = async (at: number = o.now): Promise<DpopContext> => ({
|
||||
proofJws: await buildDpopProof(eph.privateKey, eph.publicRaw, {
|
||||
htu,
|
||||
htm,
|
||||
jti: randomUUID(),
|
||||
iat: at,
|
||||
}),
|
||||
htu,
|
||||
htm,
|
||||
})
|
||||
return { raw, dpop: await newDpop(o.now), newDpop }
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 crafting: a capability whose `cnf.jkt` is the thumbprint of a NON-32-byte blob, plus a DPoP
|
||||
* proof whose `header.jwk.x` decodes to that same blob. The thumbprint check therefore PASSES and
|
||||
* execution reaches `importEd25519PublicRaw(blob)`, which throws on the bad length — exercising the
|
||||
* fail-safe (must resolve to false, never reject).
|
||||
*/
|
||||
export interface MalformedOpts {
|
||||
readonly accountId: string
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly now: number
|
||||
readonly blobLen?: number
|
||||
}
|
||||
|
||||
export async function craftMalformedDpopBundle(
|
||||
signingKey: CryptoKey,
|
||||
o: MalformedOpts,
|
||||
): Promise<{ raw: string; dpop: DpopContext }> {
|
||||
const blob = new Uint8Array(o.blobLen ?? 16).fill(7)
|
||||
const cnfJkt = await jwkThumbprint(blob) // 43-char base64url regardless of blob length
|
||||
const raw = await issueCapabilityToken(
|
||||
{
|
||||
principal: principal(o.accountId),
|
||||
aud: o.aud,
|
||||
host: o.host,
|
||||
rights: ['attach'],
|
||||
ttlSeconds: 45,
|
||||
cnfJkt,
|
||||
},
|
||||
signingKey,
|
||||
o.now,
|
||||
)
|
||||
const htu = `https://${o.aud}/ws`
|
||||
const enc = (x: unknown): string =>
|
||||
encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(x)))
|
||||
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(blob) } })
|
||||
const p = enc({ htu, htm: 'GET', jti: randomUUID(), iat: o.now })
|
||||
const s = encodeBase64UrlBytes(new Uint8Array(64)) // arbitrary signature bytes
|
||||
return { raw, dpop: { proofJws: `${h}.${p}.${s}`, htu, htm: 'GET' } }
|
||||
}
|
||||
132
e2e/harness/fakes.ts
Normal file
132
e2e/harness/fakes.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* In-memory port fakes — the ONLY seams that stand in for the true I/O boundaries P1/P3 own
|
||||
* (host registry / session registry / revocation store / token buckets / audit sink / revocation
|
||||
* bus). Every security decision still runs through the REAL relay-auth exports; these fakes only
|
||||
* supply storage. Adapted from `relay-auth/test/_helpers.ts` (same shapes, bare import paths).
|
||||
*/
|
||||
import type { HostRecord, KillSignal, RevocationBus } from 'relay-contracts'
|
||||
import type {
|
||||
AuthenticatedPrincipal,
|
||||
AuditEvent,
|
||||
AuditSink,
|
||||
HostRegistryPort,
|
||||
SessionRegistryPort,
|
||||
RevocationStore,
|
||||
TokenBucketStore,
|
||||
} from 'relay-auth'
|
||||
|
||||
/** An authenticated principal (the ONLY source of accountId, INV3). */
|
||||
export function principal(
|
||||
accountId: string,
|
||||
overrides: Partial<AuthenticatedPrincipal> = {},
|
||||
): AuthenticatedPrincipal {
|
||||
return {
|
||||
kind: 'human',
|
||||
accountId,
|
||||
principalId: 'cred-' + accountId,
|
||||
amr: ['passkey'],
|
||||
authAt: 1000,
|
||||
stepUpAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal online HostRecord for the authz registry (agentPubkey here is unused by authz). */
|
||||
export function makeHostRecord(
|
||||
accountId: string,
|
||||
hostId: string,
|
||||
status: HostRecord['status'] = 'online',
|
||||
): HostRecord {
|
||||
return {
|
||||
hostId,
|
||||
accountId,
|
||||
subdomain: 'sub-' + hostId,
|
||||
agentPubkey: new Uint8Array(32),
|
||||
enrollFpr: 'fpr-' + hostId,
|
||||
status,
|
||||
lastSeen: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
revokedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeHostRegistry(hosts: readonly HostRecord[]): HostRegistryPort {
|
||||
const map = new Map(hosts.map((h) => [h.hostId, h]))
|
||||
return { getById: async (id) => map.get(id) ?? null }
|
||||
}
|
||||
|
||||
export interface MutableSessionRegistry extends SessionRegistryPort {
|
||||
add(entry: { sessionId: string; hostId: string; accountId: string }): void
|
||||
}
|
||||
|
||||
export function fakeSessionRegistry(
|
||||
seed: readonly { sessionId: string; hostId: string; accountId: string }[] = [],
|
||||
): MutableSessionRegistry {
|
||||
const map = new Map(seed.map((s) => [s.sessionId, { hostId: s.hostId, accountId: s.accountId }]))
|
||||
return {
|
||||
getById: async (id) => map.get(id) ?? null,
|
||||
add: (e) => {
|
||||
map.set(e.sessionId, { hostId: e.hostId, accountId: e.accountId })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface RevocationFake extends RevocationStore {
|
||||
readonly revoked: Set<string>
|
||||
readonly consumed: Set<string>
|
||||
}
|
||||
|
||||
export function fakeRevocationStore(): RevocationFake {
|
||||
const revoked = new Set<string>()
|
||||
const consumed = new Set<string>()
|
||||
return {
|
||||
revoked,
|
||||
consumed,
|
||||
isRevoked: async (jti) => revoked.has(jti),
|
||||
revokeJti: async (jti) => {
|
||||
revoked.add(jti)
|
||||
},
|
||||
consumeOnce: async (jti) => {
|
||||
if (consumed.has(jti)) return false
|
||||
consumed.add(jti)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface TokenBucketFake extends TokenBucketStore {
|
||||
readonly blocked: Set<string>
|
||||
readonly calls: string[]
|
||||
}
|
||||
|
||||
/** Token bucket that always allows unless a key is added to `blocked`. */
|
||||
export function fakeTokenBucket(): TokenBucketFake {
|
||||
const blocked = new Set<string>()
|
||||
const calls: string[] = []
|
||||
return {
|
||||
blocked,
|
||||
calls,
|
||||
take: async (key) => {
|
||||
calls.push(key)
|
||||
return !blocked.has(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditFake extends AuditSink {
|
||||
readonly events: AuditEvent[]
|
||||
}
|
||||
|
||||
export function fakeAuditSink(): AuditFake {
|
||||
const events: AuditEvent[] = []
|
||||
return { events, append: async (e) => void events.push(e) }
|
||||
}
|
||||
|
||||
export interface RevocationBusFake extends RevocationBus {
|
||||
readonly published: KillSignal[]
|
||||
}
|
||||
|
||||
export function fakeRevocationBus(): RevocationBusFake {
|
||||
const published: KillSignal[] = []
|
||||
return { published, publish: async (s) => void published.push(s) }
|
||||
}
|
||||
34
e2e/harness/spy.ts
Normal file
34
e2e/harness/spy.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* RelaySpy — the untrusted-relay / attacker vantage. A passthrough that records every ciphertext
|
||||
* byte it forwards (exactly what a malicious relay can see). INV2: a known plaintext marker must
|
||||
* NEVER appear in `captured`. Mirrors the `relay-e2e/test/integration.test.ts` spy.
|
||||
*/
|
||||
import { MAX_FRAME_BYTES } from 'relay-e2e'
|
||||
|
||||
export class RelaySpy {
|
||||
readonly captured: Uint8Array[] = []
|
||||
|
||||
/** Forward a sealed payload, recording a copy of the ciphertext the relay observes. */
|
||||
forward(payload: Uint8Array): Uint8Array {
|
||||
if (payload.length > MAX_FRAME_BYTES) {
|
||||
throw new Error(`payload exceeds MAX_FRAME_BYTES (${payload.length} > ${MAX_FRAME_BYTES})`)
|
||||
}
|
||||
this.captured.push(payload.slice())
|
||||
return payload
|
||||
}
|
||||
|
||||
/** Latin1 concatenation of everything the relay saw — for substring canary scans. */
|
||||
snapshot(): string {
|
||||
return this.captured.map((b) => Buffer.from(b).toString('latin1')).join(' ')
|
||||
}
|
||||
|
||||
/** True if `marker` appears in ANY captured frame (utf8 or latin1) — INV2 tripwire. */
|
||||
contains(marker: string): boolean {
|
||||
if (this.snapshot().includes(marker)) return true
|
||||
return this.captured.some(
|
||||
(b) =>
|
||||
Buffer.from(b).toString('utf8').includes(marker) ||
|
||||
Buffer.from(b).toString('latin1').includes(marker),
|
||||
)
|
||||
}
|
||||
}
|
||||
332
e2e/harness/world.ts
Normal file
332
e2e/harness/world.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* buildRelayWorld() — composes the REAL relay-auth (P5) + relay-e2e (P4) + agent (P2 replay)
|
||||
* exports through the in-memory seams in ./fakes and exposes a clean flow API plus the untrusted
|
||||
* "RelaySpy" attacker vantage. Only the true I/O boundaries are faked; every security check is the
|
||||
* production code path. Pass an explicit `now` everywhere (no Date.now in assertions).
|
||||
*
|
||||
* The host identity used for the §4.4 handshake transcript signature is a real WebCrypto Ed25519
|
||||
* key (relay-auth's own crypto, deep-imported) so the harness needs no @noble import of its own and
|
||||
* no relay-e2e deep import (relay-e2e's `exports` map blocks subpaths).
|
||||
*/
|
||||
import { randomUUID, randomBytes } from 'node:crypto'
|
||||
import type { AeadAlg, E2EEnvelope, E2ESession, HostHello, HostRecord } from 'relay-contracts'
|
||||
import {
|
||||
createClientHandshake,
|
||||
createHostHandshake,
|
||||
createE2ESession,
|
||||
MemoryDevicePinStore,
|
||||
deriveContentKey,
|
||||
sealReplayFrame,
|
||||
openReplayCiphertext,
|
||||
encodeEnvelope,
|
||||
} from 'relay-e2e'
|
||||
import {
|
||||
onUpgrade,
|
||||
onReattach,
|
||||
revoke,
|
||||
revokeToken,
|
||||
signDeviceAuthProof,
|
||||
verifyDeviceProof,
|
||||
type AuthzOutcome,
|
||||
type EnforceDeps,
|
||||
type UpgradeContext,
|
||||
type StepUpPolicy,
|
||||
type RevocationScope,
|
||||
} from 'relay-auth'
|
||||
import {
|
||||
generateEd25519KeyPair,
|
||||
exportEd25519PublicRaw,
|
||||
importEd25519PublicRaw,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
} from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { createReplaySealer, type ReplaySealer } from 'agent'
|
||||
import {
|
||||
fakeAuditSink,
|
||||
fakeHostRegistry,
|
||||
fakeRevocationBus,
|
||||
fakeRevocationStore,
|
||||
fakeSessionRegistry,
|
||||
fakeTokenBucket,
|
||||
makeHostRecord,
|
||||
principal,
|
||||
type AuditFake,
|
||||
type MutableSessionRegistry,
|
||||
type RevocationBusFake,
|
||||
type RevocationFake,
|
||||
type TokenBucketFake,
|
||||
} from './fakes.js'
|
||||
import {
|
||||
craftMalformedDpopBundle,
|
||||
issueCapBundle,
|
||||
resetDpopCacheForTest,
|
||||
setupP5SigningKey,
|
||||
type CapBundle,
|
||||
} from './dpop.js'
|
||||
import { RelaySpy } from './spy.js'
|
||||
|
||||
export const DEFAULT_NOW = 1_700_000_000
|
||||
const REPLAY_ALG: AeadAlg = 'xchacha20-poly1305'
|
||||
|
||||
export const NO_STEP_UP: StepUpPolicy = {
|
||||
required: false,
|
||||
maxAgeSeconds: Number.MAX_SAFE_INTEGER,
|
||||
requiredMethod: 'passkey',
|
||||
}
|
||||
export const STRICT_PASSKEY: StepUpPolicy = {
|
||||
required: true,
|
||||
maxAgeSeconds: 300,
|
||||
requiredMethod: 'passkey',
|
||||
}
|
||||
|
||||
/** A seeded host: authz identity (hostId/accountId/aud) + §4.4 e2e identity + replay secret. */
|
||||
export interface HostFixture {
|
||||
readonly label: string
|
||||
readonly hostId: string
|
||||
readonly accountId: string
|
||||
readonly aud: string
|
||||
readonly origin: string
|
||||
readonly agentPubkey: Uint8Array
|
||||
readonly hostContentSecret: Uint8Array
|
||||
readonly replayAlg: AeadAlg
|
||||
/** WebCrypto private key backing the handshake transcript signer. */
|
||||
readonly signingPriv: CryptoKey
|
||||
}
|
||||
|
||||
async function makeHostFixture(label: string, accountId: string): Promise<HostFixture> {
|
||||
const kp = await generateEd25519KeyPair()
|
||||
const agentPubkey = await exportEd25519PublicRaw(kp.publicKey)
|
||||
const sub = `${label}.term.example.com`
|
||||
return {
|
||||
label,
|
||||
hostId: randomUUID(),
|
||||
accountId,
|
||||
aud: sub,
|
||||
origin: `https://${sub}`,
|
||||
agentPubkey,
|
||||
hostContentSecret: new Uint8Array(randomBytes(32)),
|
||||
replayAlg: REPLAY_ALG,
|
||||
signingPriv: kp.privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpgradeArgs {
|
||||
readonly raw: string
|
||||
readonly dpop: UpgradeContext['dpop']
|
||||
readonly host: string
|
||||
readonly aud: string
|
||||
readonly origin: string
|
||||
readonly now: number
|
||||
readonly principal?: UpgradeContext['principal']
|
||||
readonly requiredRight?: UpgradeContext['requiredRight']
|
||||
readonly activeSessionCount?: number
|
||||
readonly remoteAddrHash?: string
|
||||
}
|
||||
|
||||
export interface ReattachArgs extends UpgradeArgs {
|
||||
readonly sessionId: string
|
||||
}
|
||||
|
||||
export interface HandshakeOpts {
|
||||
readonly host?: HostFixture
|
||||
readonly now?: number
|
||||
/** MITM: pubkey the client verifies host_hello against (default = the real agentPubkey). */
|
||||
readonly verifyAgainstPubkey?: Uint8Array
|
||||
/** MITM: mutate the host_hello the client receives (tampered transcript / hostEphPub). */
|
||||
readonly tamperHostHello?: (h: HostHello) => HostHello
|
||||
}
|
||||
|
||||
export interface EstablishedSessions {
|
||||
readonly client: E2ESession
|
||||
readonly host: E2ESession
|
||||
readonly spy: RelaySpy
|
||||
readonly agentPubkey: Uint8Array
|
||||
}
|
||||
|
||||
export interface RelayWorld {
|
||||
readonly now: number
|
||||
readonly signingKey: CryptoKey
|
||||
readonly publicRaw: Uint8Array
|
||||
readonly hostA: HostFixture
|
||||
readonly hostB: HostFixture
|
||||
readonly allowedOrigins: readonly string[]
|
||||
readonly deps: EnforceDeps
|
||||
readonly audit: AuditFake
|
||||
readonly revocation: RevocationFake
|
||||
readonly buckets: TokenBucketFake
|
||||
readonly bus: RevocationBusFake
|
||||
readonly sessions: MutableSessionRegistry
|
||||
principal(accountId: string, over?: Parameters<typeof principal>[1]): ReturnType<typeof principal>
|
||||
setStepUpPolicy(policy: StepUpPolicy): void
|
||||
issueCap(o: {
|
||||
accountId: string
|
||||
host: string
|
||||
aud: string
|
||||
rights?: readonly UpgradeContext['requiredRight'][]
|
||||
ttl?: number
|
||||
now?: number
|
||||
}): Promise<CapBundle>
|
||||
craftMalformedDpop(o: {
|
||||
accountId: string
|
||||
host: string
|
||||
aud: string
|
||||
now?: number
|
||||
blobLen?: number
|
||||
}): Promise<{ raw: string; dpop: UpgradeContext['dpop'] }>
|
||||
upgrade(a: UpgradeArgs): Promise<AuthzOutcome>
|
||||
reattach(a: ReattachArgs): Promise<AuthzOutcome>
|
||||
addSession(e: { sessionId: string; hostId: string; accountId: string }): void
|
||||
establishSession(opts?: HandshakeOpts): Promise<EstablishedSessions>
|
||||
newReplaySealer(sessionId: string, host?: HostFixture): ReplaySealer
|
||||
/** Browser-side re-derivation + open of a replay frame (throws on AEAD failure / wrong epoch). */
|
||||
openReplay(sessionId: string, epoch: string, env: E2EEnvelope, host?: HostFixture): Uint8Array
|
||||
revokeToken(jti: string, exp?: number): Promise<void>
|
||||
revoke(scope: RevocationScope): Promise<void>
|
||||
}
|
||||
|
||||
export async function buildRelayWorld(now: number = DEFAULT_NOW): Promise<RelayWorld> {
|
||||
const { signingKey, publicRaw } = await setupP5SigningKey()
|
||||
resetDpopCacheForTest()
|
||||
|
||||
const hostA = await makeHostFixture('alice', 'acct-A')
|
||||
const hostB = await makeHostFixture('bob', 'acct-B')
|
||||
|
||||
const hostRecords: HostRecord[] = [
|
||||
makeHostRecord(hostA.accountId, hostA.hostId),
|
||||
makeHostRecord(hostB.accountId, hostB.hostId),
|
||||
]
|
||||
const hosts = fakeHostRegistry(hostRecords)
|
||||
const sessions = fakeSessionRegistry([])
|
||||
const revocation = fakeRevocationStore()
|
||||
const buckets = fakeTokenBucket()
|
||||
const audit = fakeAuditSink()
|
||||
const bus = fakeRevocationBus()
|
||||
const allowedOrigins = [hostA.origin, hostB.origin]
|
||||
|
||||
let stepUpPolicy: StepUpPolicy = NO_STEP_UP
|
||||
const deps: EnforceDeps = {
|
||||
hosts,
|
||||
sessions,
|
||||
revocation,
|
||||
buckets,
|
||||
audit,
|
||||
stepUpPolicyFor: () => stepUpPolicy,
|
||||
}
|
||||
|
||||
function ctxFor(a: UpgradeArgs): UpgradeContext {
|
||||
return {
|
||||
capabilityRaw: a.raw,
|
||||
originHeader: a.origin,
|
||||
expectedAud: a.aud,
|
||||
requestedHostId: a.host,
|
||||
requiredRight: a.requiredRight ?? 'attach',
|
||||
remoteAddrHash: a.remoteAddrHash ?? 'ip-hash',
|
||||
activeSessionCount: a.activeSessionCount ?? 0,
|
||||
dpop: a.dpop,
|
||||
principal: a.principal ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const replayCrypto = { deriveContentKey, sealReplayFrame }
|
||||
|
||||
return {
|
||||
now,
|
||||
signingKey,
|
||||
publicRaw,
|
||||
hostA,
|
||||
hostB,
|
||||
allowedOrigins,
|
||||
deps,
|
||||
audit,
|
||||
revocation,
|
||||
buckets,
|
||||
bus,
|
||||
sessions,
|
||||
principal,
|
||||
setStepUpPolicy(policy) {
|
||||
stepUpPolicy = policy
|
||||
},
|
||||
issueCap(o) {
|
||||
return issueCapBundle(signingKey, {
|
||||
accountId: o.accountId,
|
||||
host: o.host,
|
||||
aud: o.aud,
|
||||
rights: o.rights,
|
||||
ttl: o.ttl,
|
||||
now: o.now ?? now,
|
||||
})
|
||||
},
|
||||
craftMalformedDpop(o) {
|
||||
return craftMalformedDpopBundle(signingKey, {
|
||||
accountId: o.accountId,
|
||||
host: o.host,
|
||||
aud: o.aud,
|
||||
now: o.now ?? now,
|
||||
blobLen: o.blobLen,
|
||||
})
|
||||
},
|
||||
upgrade(a) {
|
||||
return onUpgrade(ctxFor(a), deps, allowedOrigins, a.now)
|
||||
},
|
||||
reattach(a) {
|
||||
return onReattach({ ...ctxFor(a), sessionId: a.sessionId }, deps, allowedOrigins, a.now)
|
||||
},
|
||||
addSession(e) {
|
||||
sessions.add(e)
|
||||
},
|
||||
async establishSession(opts: HandshakeOpts = {}): Promise<EstablishedSessions> {
|
||||
const h = opts.host ?? hostA
|
||||
const hsNow = opts.now ?? now
|
||||
const client = createClientHandshake({
|
||||
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
|
||||
deviceAuthProofProvider: {
|
||||
proofFor: (_hostId, binding) =>
|
||||
signDeviceAuthProof(principal(h.accountId), binding, signingKey, hsNow),
|
||||
},
|
||||
verifier: {
|
||||
verify: async (pub, transcript, sig) =>
|
||||
verifyEd25519(await importEd25519PublicRaw(pub), sig, transcript),
|
||||
},
|
||||
pinStore: new MemoryDevicePinStore(),
|
||||
hostId: h.hostId,
|
||||
})
|
||||
const host = createHostHandshake({
|
||||
signer: { sign: (transcript) => signEd25519(h.signingPriv, transcript) },
|
||||
agentPubkey: h.agentPubkey,
|
||||
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
|
||||
verifyDeviceProof: (proof, binding) => verifyDeviceProof(proof, binding, hsNow),
|
||||
})
|
||||
const clientHello = await client.start()
|
||||
const rawHostHello = await host.onClientHello(clientHello)
|
||||
const hostHello = opts.tamperHostHello ? opts.tamperHostHello(rawHostHello) : rawHostHello
|
||||
const clientResult = await client.onHostHello(
|
||||
hostHello,
|
||||
opts.verifyAgainstPubkey ?? h.agentPubkey,
|
||||
)
|
||||
return {
|
||||
client: createE2ESession('client', clientResult),
|
||||
host: createE2ESession('host', host.result!),
|
||||
spy: new RelaySpy(),
|
||||
agentPubkey: h.agentPubkey,
|
||||
}
|
||||
},
|
||||
newReplaySealer(sessionId, host = hostA) {
|
||||
return createReplaySealer(host.hostContentSecret, sessionId, host.replayAlg, replayCrypto)
|
||||
},
|
||||
openReplay(sessionId, epoch, env, host = hostA) {
|
||||
const key = deriveContentKey({
|
||||
hostContentSecret: host.hostContentSecret,
|
||||
sessionId,
|
||||
alg: host.replayAlg,
|
||||
epoch,
|
||||
})
|
||||
return openReplayCiphertext(key, encodeEnvelope(env))
|
||||
},
|
||||
async revokeToken(jti, exp = now + 60) {
|
||||
await revokeToken(jti, exp, revocation)
|
||||
},
|
||||
async revoke(scope) {
|
||||
await revoke(scope, revocation, hosts, bus, now)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user