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); }); });