release(v4.10.0): cross-host approval routing primitives in @shade/sdk
Some checks failed
Test / test (push) Has been cancelled

Builds on V4.9's encrypted profile blob: ships the canonical
profile-blob schema (hosts/clients/trustedApproverFingerprints) and
the build/sign/verify trio for proxy-approval frames. Headless servers
can now route a `linkRequest` to a trusted-approver phone, verify the
phone's Ed25519 signature against the fresh profile blob, and complete
pairing without a GUI host being available.

Length-prefixed binary signing payload so any platform (Kotlin, Swift,
Go) can produce byte-identical signing input from test vectors. No
relay or transport changes — entirely SDK-level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 17:09:59 +02:00
parent 80c410f518
commit 1bd7037a6d
29 changed files with 1479 additions and 25 deletions

View File

@@ -0,0 +1,552 @@
import { describe, test, expect } from 'bun:test';
import {
emptyCanonicalProfile,
parseCanonicalProfile,
serializeCanonicalProfile,
upsertHost,
upsertClient,
removeClient,
setTrustedApprover,
isTrustedApprover,
findClientByFingerprint,
findClientByAddress,
buildApprovalRequest,
signProxyApproval,
verifyProxyApproval,
canonicalApprovalSigningBytes,
DEFAULT_APPROVAL_DOMAIN,
} from '../src/index.js';
import type {
CanonicalProfileBlob,
ProfileClientEntry,
ProfileHostEntry,
ApprovalRequestFrame,
} from '../src/index.js';
import { SubtleCryptoProvider, ed25519PublicKeyFromSeed } from '@shade/crypto-web';
import { ValidationError } from '@shade/core';
const crypto = new SubtleCryptoProvider();
function randBytes(n: number): Uint8Array {
const buf = new Uint8Array(n);
globalThis.crypto.getRandomValues(buf);
return buf;
}
function bytesToHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) s += bytes[i]!.toString(16).padStart(2, '0');
return s;
}
function makeClient(opts: {
name: string;
trusted?: boolean;
}): { entry: ProfileClientEntry; signingSeed: Uint8Array } {
const signingSeed = randBytes(32);
const pubkey = ed25519PublicKeyFromSeed(signingSeed);
const fingerprint = `fp-${opts.name}-${bytesToHex(pubkey).slice(0, 8)}`;
const entry: ProfileClientEntry = {
address: `device:${opts.name}`,
identityPublicKey: bytesToHex(pubkey),
identityFingerprint: fingerprint,
name: opts.name,
kind: 'mobile',
addedAt: 1_700_000_000_000,
};
if (opts.trusted) entry.trustedApprover = true;
return { entry, signingSeed };
}
function makeHost(): ProfileHostEntry {
return {
address: 'device:host-server',
name: 'Server',
kind: 'server',
addedAt: 1_700_000_000_000,
};
}
describe('Canonical profile schema', () => {
test('emptyCanonicalProfile is well-formed and round-trips', () => {
const blob = emptyCanonicalProfile(123);
expect(blob.version).toBe(1);
expect(blob.hosts).toEqual([]);
expect(blob.clients).toEqual([]);
expect(blob.trustedApproverFingerprints).toEqual([]);
expect(blob.updatedAt).toBe(123);
const bytes = serializeCanonicalProfile(blob);
const parsed = parseCanonicalProfile(bytes);
expect(parsed).toEqual(blob);
});
test('upsertHost replaces by address and bumps updatedAt', () => {
let blob = emptyCanonicalProfile(0);
const host = makeHost();
blob = upsertHost(blob, host, 100);
expect(blob.hosts).toEqual([host]);
expect(blob.updatedAt).toBe(100);
const renamed = { ...host, name: 'Server (renamed)' };
blob = upsertHost(blob, renamed, 200);
expect(blob.hosts).toHaveLength(1);
expect(blob.hosts[0]!.name).toBe('Server (renamed)');
expect(blob.updatedAt).toBe(200);
});
test('upsertClient denormalizes trustedApproverFingerprints', () => {
let blob = emptyCanonicalProfile(0);
const a = makeClient({ name: 'phone-a', trusted: true });
const b = makeClient({ name: 'phone-b', trusted: false });
blob = upsertClient(blob, a.entry, 100);
blob = upsertClient(blob, b.entry, 200);
expect(blob.clients).toHaveLength(2);
expect(blob.trustedApproverFingerprints).toEqual([a.entry.identityFingerprint]);
expect(isTrustedApprover(blob, a.entry.identityFingerprint)).toBe(true);
expect(isTrustedApprover(blob, b.entry.identityFingerprint)).toBe(false);
});
test('setTrustedApprover toggles the flag and the denormalized list', () => {
let blob = emptyCanonicalProfile(0);
const c = makeClient({ name: 'phone', trusted: false });
blob = upsertClient(blob, c.entry);
expect(blob.trustedApproverFingerprints).toEqual([]);
blob = setTrustedApprover(blob, c.entry.identityFingerprint, true, 100);
expect(blob.trustedApproverFingerprints).toEqual([c.entry.identityFingerprint]);
expect(blob.clients[0]!.trustedApprover).toBe(true);
expect(blob.updatedAt).toBe(100);
blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 200);
expect(blob.trustedApproverFingerprints).toEqual([]);
expect(blob.clients[0]!.trustedApprover).toBeUndefined();
expect(blob.updatedAt).toBe(200);
// No-op toggle to existing state returns the same blob.
const before = blob;
blob = setTrustedApprover(blob, c.entry.identityFingerprint, false, 999);
expect(blob).toBe(before);
});
test('removeClient cleans up trustedApproverFingerprints', () => {
let blob = emptyCanonicalProfile(0);
const c = makeClient({ name: 'phone', trusted: true });
blob = upsertClient(blob, c.entry);
expect(blob.trustedApproverFingerprints).toHaveLength(1);
blob = removeClient(blob, c.entry.identityFingerprint);
expect(blob.clients).toEqual([]);
expect(blob.trustedApproverFingerprints).toEqual([]);
});
test('findClientByFingerprint and findClientByAddress', () => {
let blob = emptyCanonicalProfile(0);
const c = makeClient({ name: 'phone' });
blob = upsertClient(blob, c.entry);
expect(findClientByFingerprint(blob, c.entry.identityFingerprint)?.address).toBe(
c.entry.address,
);
expect(findClientByAddress(blob, c.entry.address)?.identityFingerprint).toBe(
c.entry.identityFingerprint,
);
expect(findClientByFingerprint(blob, 'unknown')).toBeNull();
expect(findClientByAddress(blob, 'unknown')).toBeNull();
});
test('parseCanonicalProfile rejects malformed input', () => {
expect(() => parseCanonicalProfile('not json')).toThrow(ValidationError);
expect(() => parseCanonicalProfile('[]')).toThrow(ValidationError);
expect(() => parseCanonicalProfile('{"version":2}')).toThrow(ValidationError);
expect(() =>
parseCanonicalProfile(
JSON.stringify({
version: 1,
clients: [{ address: 'x', name: 'x', kind: 'm', addedAt: 0 }],
}),
),
).toThrow(ValidationError); // missing identityPublicKey
expect(() =>
parseCanonicalProfile(
JSON.stringify({
version: 1,
clients: [
{
address: 'x',
name: 'x',
kind: 'm',
addedAt: 0,
identityPublicKey: 'NOTHEX',
identityFingerprint: 'x',
},
],
}),
),
).toThrow(ValidationError); // identityPublicKey not 64 hex
});
test('parsed blob is fully equal to the input via JSON round-trip', () => {
let blob = emptyCanonicalProfile(1);
const host = makeHost();
const c = makeClient({ name: 'phone', trusted: true });
blob = upsertHost(blob, host, 2);
blob = upsertClient(blob, c.entry, 3);
blob.signedBy = 'aabbccdd';
const bytes = serializeCanonicalProfile(blob);
const parsed = parseCanonicalProfile(bytes);
expect(parsed).toEqual(blob);
});
});
describe('Approval signing payload', () => {
test('canonicalApprovalSigningBytes is deterministic', () => {
const a = canonicalApprovalSigningBytes({
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'approve',
});
const b = canonicalApprovalSigningBytes({
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'approve',
});
expect(Buffer.from(a).toString('hex')).toBe(Buffer.from(b).toString('hex'));
});
test('different decision produces different signing bytes', () => {
const base = {
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: 'h',
requestingDeviceFingerprint: 'r',
};
const approveBytes = canonicalApprovalSigningBytes({ ...base, decision: 'approve' });
const rejectBytes = canonicalApprovalSigningBytes({ ...base, decision: 'reject' });
expect(Buffer.from(approveBytes).toString('hex')).not.toBe(
Buffer.from(rejectBytes).toString('hex'),
);
});
test('different domain produces different signing bytes', () => {
const a = canonicalApprovalSigningBytes({
domain: 'shade-link-approve-v1',
requestId: 'r',
hostFingerprint: 'h',
requestingDeviceFingerprint: 'd',
decision: 'approve',
});
const b = canonicalApprovalSigningBytes({
domain: 'prism-link-approve-v1',
requestId: 'r',
hostFingerprint: 'h',
requestingDeviceFingerprint: 'd',
decision: 'approve',
});
expect(Buffer.from(a).toString('hex')).not.toBe(Buffer.from(b).toString('hex'));
});
});
describe('Build / sign / verify proxy approval', () => {
function buildScenario() {
const phone = makeClient({ name: 'phone', trusted: true });
let profile = emptyCanonicalProfile(0);
profile = upsertHost(profile, makeHost());
profile = upsertClient(profile, phone.entry);
const request = buildApprovalRequest({
hostAddress: 'device:host-server',
hostFingerprint: 'host-fp-12345',
requestingDevice: {
fingerprint: 'cafe-laptop-fp-67890',
deviceName: 'cafe-laptop',
userAgent: 'Mozilla/5.0',
ipHint: '203.0.113.7',
},
crypto,
});
return { phone, profile, request };
}
test('happy path: signed approve verifies', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
expect(approval.kind).toBe('linkApproveByProxy');
expect(approval.requestId).toBe(request.requestId);
expect(approval.signature.length).toBe(128); // 64-byte sig as hex
const verdict = await verifyProxyApproval({
request,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(true);
if (verdict.ok) {
expect(verdict.approver.address).toBe(phone.entry.address);
}
});
test('happy path: signed reject verifies', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'reject',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const verdict = await verifyProxyApproval({ request, approval, profile, crypto });
expect(verdict.ok).toBe(true);
});
test('replay against a different request fails (request-id-mismatch)', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const otherRequest: ApprovalRequestFrame = {
...request,
requestId: 'ffffffffffffffffffffffffffffffff',
};
const verdict = await verifyProxyApproval({
request: otherRequest,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('request-id-mismatch');
});
test('decision tampered after signing fails (bad-signature)', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const tampered = { ...approval, decision: 'reject' as const };
const verdict = await verifyProxyApproval({
request,
approval: tampered,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
});
test('host fingerprint substitution fails (bad-signature)', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
// Verifier sees the same approval but a different host fingerprint
// (simulates an attacker forwarding an approval to a different host).
const swappedRequest: ApprovalRequestFrame = {
...request,
hostFingerprint: 'evil-host-fp',
};
const verdict = await verifyProxyApproval({
request: swappedRequest,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
});
test('domain mismatch is rejected before signature check', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const tampered = { ...approval, domain: 'prism-link-approve-v1' };
const verdict = await verifyProxyApproval({
request,
approval: tampered,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('domain-mismatch');
});
test('unknown approver fingerprint fails', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const lying = { ...approval, approverFingerprint: 'no-such-fingerprint' };
const verdict = await verifyProxyApproval({
request,
approval: lying,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('unknown-approver');
});
test('revoked approver (trustedApprover off) fails with not-trusted', async () => {
const { phone, profile: original, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
// Simulate a revoke: workstation toggles the trustedApprover flag off
// and PUTs the new blob; host re-fetches before verifying.
const revoked = setTrustedApprover(
original,
phone.entry.identityFingerprint,
false,
);
const verdict = await verifyProxyApproval({
request,
approval,
profile: revoked,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('not-trusted');
});
test('expired request is rejected', async () => {
const { phone, profile, request } = buildScenario();
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
const verdict = await verifyProxyApproval({
request,
approval,
profile,
crypto,
now: () => request.expiresAt + 1,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('expired');
});
test('signature with the wrong key fails', async () => {
const { phone, profile, request } = buildScenario();
const wrongSeed = randBytes(32);
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint, // claim phone
approverSigningKey: wrongSeed, // but sign with someone else's key
crypto,
});
const verdict = await verifyProxyApproval({
request,
approval,
profile,
crypto,
});
expect(verdict.ok).toBe(false);
if (!verdict.ok) expect(verdict.reason).toBe('bad-signature');
});
test('default domain is `shade-link-approve-v1`', () => {
const r = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
expect(r.domain).toBe(DEFAULT_APPROVAL_DOMAIN);
expect(DEFAULT_APPROVAL_DOMAIN).toBe('shade-link-approve-v1');
});
test('custom domain (e.g. `prism-link-approve-v1`) survives round-trip', async () => {
const { phone, profile } = buildScenario();
const request = buildApprovalRequest({
hostAddress: 'device:host-server',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
domain: 'prism-link-approve-v1',
});
const approval = await signProxyApproval({
request,
decision: 'approve',
approverFingerprint: phone.entry.identityFingerprint,
approverSigningKey: phone.signingSeed,
crypto,
});
expect(approval.domain).toBe('prism-link-approve-v1');
const verdict = await verifyProxyApproval({ request, approval, profile, crypto });
expect(verdict.ok).toBe(true);
});
test('requestId is 32 lowercase hex chars (128 bits)', () => {
const r = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
expect(/^[0-9a-f]{32}$/.test(r.requestId)).toBe(true);
});
test('two consecutive builds produce distinct requestIds', () => {
const a = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
const b = buildApprovalRequest({
hostAddress: 'h',
hostFingerprint: 'h',
requestingDevice: { fingerprint: 'r' },
crypto,
});
expect(a.requestId).not.toBe(b.requestId);
});
});