release(v4.10.0): cross-host approval routing primitives in @shade/sdk
Some checks failed
Test / test (push) Has been cancelled
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:
552
packages/shade-sdk/tests/approval.test.ts
Normal file
552
packages/shade-sdk/tests/approval.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user