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>
553 lines
18 KiB
TypeScript
553 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|