diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index fdcc4c7c..724cfd49 100644 --- a/frontend/__tests__/credential-sharing.test.tsx +++ b/frontend/__tests__/credential-sharing.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { CredentialSharing } from '../src/components/credential-sharing'; import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; @@ -6,6 +7,9 @@ function renderWithProviders(ui: React.ReactElement) { return render({ui}); } +// Checksum-valid Stellar ed25519 public key (version byte 0x30, CRC16-XModem, little-endian). +const VALID_RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + describe('CredentialSharing', () => { const walletAddress = 'GABCDEF123456...'; @@ -17,7 +21,7 @@ describe('CredentialSharing', () => { it('renders share form elements', () => { renderWithProviders(); expect(screen.getByText('Recipient Wallet Address')).toBeInTheDocument(); - expect(screen.getByText('Select Vaccination Credential')).toBeInTheDocument(); + expect(screen.getByText('Select Credentials to Share')).toBeInTheDocument(); expect(screen.getByText('Proof Duration')).toBeInTheDocument(); }); @@ -46,4 +50,149 @@ describe('CredentialSharing', () => { renderWithProviders(); expect(screen.getByText('Shared Credentials')).toBeInTheDocument(); }); + + it('renders selectable credentials', () => { + renderWithProviders(); + expect(screen.getByRole('checkbox', { name: 'Share COVID-19 (Pfizer)' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Share Influenza 2025' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Share Hepatitis B' })).toBeInTheDocument(); + }); + + it('keeps share button disabled until recipient and credential are valid', () => { + renderWithProviders(); + const shareButton = screen.getAllByText('Share Vaccination Proof').find( + (el) => el.tagName === 'BUTTON' + ) as HTMLButtonElement; + expect(shareButton).toBeDisabled(); + }); + + it('shows validation error for an invalid recipient address', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const recipientInput = screen.getByLabelText('Recipient Wallet Address'); + + await user.type(recipientInput, 'not-a-stellar-address'); + + expect( + screen.getByText('Enter a valid Stellar address (starts with G, 56 characters total)') + ).toBeInTheDocument(); + }); + + it('rejects a well-formed but checksum-invalid recipient address', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const recipientInput = screen.getByLabelText('Recipient Wallet Address'); + + // 56 chars in the right alphabet, but the CRC16 checksum is wrong. + const badChecksum = 'G' + 'B'.repeat(55); + await user.type(recipientInput, badChecksum); + + expect( + screen.getByText('Enter a valid Stellar address (starts with G, 56 characters total)') + ).toBeInTheDocument(); + }); + + it('accepts a known checksum-valid Stellar public key', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const recipientInput = screen.getByLabelText('Recipient Wallet Address'); + + await user.type(recipientInput, VALID_RECIPIENT); + + expect( + screen.queryByText('Enter a valid Stellar address (starts with G, 56 characters total)') + ).not.toBeInTheDocument(); + }); + + it('opens the confirmation dialog when the form is valid', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText('Recipient Wallet Address'), VALID_RECIPIENT); + await user.click(screen.getByRole('checkbox', { name: 'Share COVID-19 (Pfizer)' })); + + const shareButton = screen.getAllByText('Share Vaccination Proof').find( + (el) => el.tagName === 'BUTTON' + ) as HTMLButtonElement; + await user.click(shareButton); + + expect(screen.getByRole('button', { name: 'Confirm Share' })).toBeInTheDocument(); + expect(screen.getByText(VALID_RECIPIENT)).toBeInTheDocument(); + }); + + describe('expiry transitions', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Keep the simulated 10% network failure out of the test. + jest.spyOn(Math, 'random').mockReturnValue(0.5); + // jsdom does not ship crypto.randomUUID. + if (!global.crypto?.randomUUID) { + let idCounter = 0; + Object.defineProperty(global.crypto, 'randomUUID', { + value: () => `test-id-${++idCounter}`, + configurable: true, + }); + } + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + async function shareOne(user: ReturnType, credentialLabel: string) { + await user.type(screen.getByLabelText('Recipient Wallet Address'), VALID_RECIPIENT); + await user.click(screen.getByRole('checkbox', { name: `Share ${credentialLabel}` })); + + const shareButton = screen.getAllByText('Share Vaccination Proof').find( + (el) => el.tagName === 'BUTTON' + ) as HTMLButtonElement; + await user.click(shareButton); + await user.click(screen.getByRole('button', { name: 'Confirm Share' })); + + // Let the simulated proof generation finish, then reset the form so the + // next share can be created cleanly. + await act(async () => { + jest.advanceTimersByTime(3000); + }); + await user.clear(screen.getByLabelText('Recipient Wallet Address')); + await user.click(screen.getByRole('checkbox', { name: `Share ${credentialLabel}` })); + } + + it( + 'flips each share to expired at its own expiry time without user interaction', + async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + + renderWithProviders(); + + // First share: 1 hour. + await user.selectOptions(screen.getByLabelText('Proof Duration'), '3600'); + await shareOne(user, 'COVID-19 (Pfizer)'); + + // Second share: 1 day. + await user.selectOptions(screen.getByLabelText('Proof Duration'), '86400'); + await shareOne(user, 'Influenza 2025'); + + expect(screen.getAllByText('Active')).toHaveLength(2); + + // Advance past the first expiry only — the hourly share flips to + // expired while the daily one stays active. + await act(async () => { + jest.advanceTimersByTime(3600 * 1000 + 1); + }); + const badges = screen.getAllByText(/^(Active|Expired)$/); + expect(badges.filter((el) => el.textContent === 'Expired')).toHaveLength(1); + expect(badges.filter((el) => el.textContent === 'Active')).toHaveLength(1); + + // Advance past the second expiry — everything is expired now. + await act(async () => { + jest.advanceTimersByTime(86400 * 1000); + }); + expect(screen.queryByText('Active')).not.toBeInTheDocument(); + expect(screen.getAllByText('Expired')).toHaveLength(2); + }, + 15000 + ); + }); }); diff --git a/frontend/__tests__/stellar-address.test.ts b/frontend/__tests__/stellar-address.test.ts new file mode 100644 index 00000000..fe87aec2 --- /dev/null +++ b/frontend/__tests__/stellar-address.test.ts @@ -0,0 +1,32 @@ +import { isValidStellarAddress } from '@/utils/stellar-address'; + +describe('isValidStellarAddress', () => { + it('accepts a checksum-valid Stellar ed25519 public key', () => { + expect( + isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF') + ).toBe(true); + }); + + it('rejects a correctly formatted key with a bad checksum', () => { + // Same valid key with the last few chars altered to corrupt the checksum. + expect( + isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + ).toBe(false); + }); + + it('rejects non-Stellar strings', () => { + expect(isValidStellarAddress('not-an-address')).toBe(false); + expect(isValidStellarAddress('')).toBe(false); + }); + + it('rejects wrong lengths', () => { + expect(isValidStellarAddress('G' + 'A'.repeat(54))).toBe(false); + expect(isValidStellarAddress('G' + 'A'.repeat(56))).toBe(false); + }); + + it('rejects lowercase characters', () => { + expect( + isValidStellarAddress('gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf') + ).toBe(false); + }); +}); diff --git a/frontend/src/components/credential-sharing.tsx b/frontend/src/components/credential-sharing.tsx index ee4b82cc..e07e73dc 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -1,37 +1,114 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Share2, Lock, Clock, X, Shield } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { AnimatedProgress, SuccessOverlay, SuccessToast } from './animations'; +import { ShareConfirmationModal } from './share-confirmation-modal'; import { useAccessibility } from '@/contexts/AccessibilityContext'; import { useCredentialOperation } from '@/hooks/useCredentialOperation'; +import { isValidStellarAddress } from '@/utils/stellar-address'; import { AlertCircle } from 'lucide-react'; interface CredentialSharingProps { walletAddress: string; } +interface SelectableCredential { + id: string; + vaccineType: string; +} + interface SharedCredential { id: string; vaccineType: string; recipient: string; expiresAt: string; + status: 'active' | 'revoked' | 'expired'; +} + +// Credentials available to share, mirroring the mock data used elsewhere in the +// vault/verification views until real credential records are wired up. +const AVAILABLE_CREDENTIALS: SelectableCredential[] = [ + { id: 'covid-pfizer', vaccineType: 'COVID-19 (Pfizer)' }, + { id: 'influenza-2025', vaccineType: 'Influenza 2025' }, + { id: 'hepatitis-b', vaccineType: 'Hepatitis B' }, +]; + +const DURATION_OPTIONS = [ + { value: '3600', label: '1 hour' }, + { value: '86400', label: '1 day' }, + { value: '604800', label: '1 week' }, + { value: '2592000', label: '1 month' }, +]; + +// Stellar public keys are 56 chars with a version byte and CRC16-XModem +// checksum; validate the checksum so a typo'd address can't receive a share. +function isValidRecipient(address: string): boolean { + return isValidStellarAddress(address); +} + +// A share is expired once its expiry time passes, regardless of the stored +// status. Derive the effective status so the list never shows stale "active". +function effectiveStatus( + share: { status: SharedCredential['status']; expiresAt: string }, + now: number +): SharedCredential['status'] { + if (share.status === 'revoked') return 'revoked'; + if (now >= new Date(share.expiresAt).getTime()) return 'expired'; + return 'active'; } export function CredentialSharing({ walletAddress }: CredentialSharingProps) { + const [recipient, setRecipient] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + const [durationSeconds, setDurationSeconds] = useState(86400); const [sharedCredentials, setSharedCredentials] = useState([]); const [shareProgress, setShareProgress] = useState(0); const [showSuccess, setShowSuccess] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [toast, setToast] = useState<{ show: boolean; title: string; description?: string }>({ show: false, title: '', }); const { announceToScreenReader } = useAccessibility(); const shareButtonRef = useRef(null); - + const statusRegionRef = useRef(null); + const { execute, error, clearError, isPending: isSharing } = useCredentialOperation(); + // Re-render active shares exactly when the nearest not-yet-expired one + // expires. Depending on `now` reschedules after every expiry transition. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const nextExpiry = sharedCredentials + .filter((s) => s.status !== 'revoked') + .map((s) => new Date(s.expiresAt).getTime()) + .filter((expiry) => expiry > now) + .sort((a, b) => a - b)[0]; + + if (nextExpiry === undefined) return; + + const timer = setTimeout(() => setNow(Date.now()), Math.max(0, nextExpiry - Date.now())); + return () => clearTimeout(timer); + }, [sharedCredentials, now]); + + const selectedCredentials = useMemo( + () => AVAILABLE_CREDENTIALS.filter((credential) => selectedIds.includes(credential.id)), + [selectedIds] + ); + + const formInvalid = !isValidRecipient(recipient) || selectedCredentials.length === 0; + + const toggleCredential = useCallback( + (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id] + ); + }, + [] + ); + const handleShare = useCallback(async () => { if (isSharing) return; clearError(); @@ -62,24 +139,42 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { setShowSuccess(true); announceToScreenReader('Proof generated successfully'); - const newShare: SharedCredential = { + const expiresAt = new Date(Date.now() + durationSeconds * 1000).toISOString(); + const newShares: SharedCredential[] = selectedCredentials.map((credential) => ({ id: crypto.randomUUID(), - vaccineType: 'COVID-19 Vaccination', - recipient: 'GABCDEF123456...', - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }; - setSharedCredentials((prev) => [...prev, newShare]); + vaccineType: credential.vaccineType, + recipient: recipient.trim(), + expiresAt, + status: 'active', + })); + setSharedCredentials((prev) => [...prev, ...newShares]); resolve(); }, 2400); }); }, { context: 'ShareCredential', }); - }, [isSharing, clearError, execute, announceToScreenReader]); + }, [isSharing, clearError, execute, announceToScreenReader, selectedCredentials, recipient, durationSeconds]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (formInvalid) return; + setIsConfirmOpen(true); + }, + [formInvalid] + ); + + const handleConfirmShare = useCallback(() => { + setIsConfirmOpen(false); + void handleShare(); + }, [handleShare]); const handleRevoke = useCallback( (id: string, vaccineType: string) => { - setSharedCredentials((prev) => prev.filter((c) => c.id !== id)); + setSharedCredentials((prev) => + prev.map((share) => (share.id === id ? { ...share, status: 'revoked' } : share)) + ); setToast({ show: true, title: 'Access Revoked', @@ -91,15 +186,12 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { [announceToScreenReader] ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent, action: () => void) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - action(); - } - }, - [] - ); + const handleKeyDown = useCallback((e: React.KeyboardEvent, action: () => void) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + action(); + } + }, []); return ( @@ -110,13 +202,7 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { {/* Share form */} Share Vaccination Proof - { - e.preventDefault(); - handleShare(); - }} - className="space-y-3 sm:space-y-4" - > + Recipient Wallet Address @@ -125,41 +211,68 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { id="recipient-address" type="text" placeholder="G..." + value={recipient} + onChange={(e) => setRecipient(e.target.value)} disabled={isSharing} aria-required="true" aria-describedby="recipient-help" + aria-invalid={recipient !== '' && !isValidRecipient(recipient)} className="w-full bg-white/10 border border-green-400 rounded-lg px-3 sm:px-4 py-3 sm:py-2 text-white placeholder-green-300 focus:outline-none focus:border-green-300 disabled:opacity-50 text-base sm:text-sm" /> Enter the Stellar wallet address of the recipient + {recipient !== '' && !isValidRecipient(recipient) && ( + + Enter a valid Stellar address (starts with G, 56 characters total) + + )} - - - Select Vaccination Credential - - - Choose a credential... - - + + + + Select Credentials to Share + + + {AVAILABLE_CREDENTIALS.map((credential) => { + const checked = selectedIds.includes(credential.id); + return ( + + toggleCredential(credential.id)} + disabled={isSharing} + className="w-4 h-4 accent-green-500" + aria-label={`Share ${credential.vaccineType}`} + /> + + {credential.vaccineType} + + ); + })} + + + Proof Duration setDurationSeconds(Number(e.target.value))} disabled={isSharing} className="w-full bg-white/10 border border-green-400 rounded-lg px-3 sm:px-4 py-3 sm:py-2 text-white focus:outline-none focus:border-green-300 disabled:opacity-50 text-base sm:text-sm" > - 1 hour - 1 day - 1 week - 1 month + {DURATION_OPTIONS.map((option) => ( + + {option.label} + + ))} @@ -200,16 +313,18 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { {isSharing ? 'Generating Proof...' : 'Share Vaccination Proof'} @@ -235,7 +350,9 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { No credentials shared yet ) : ( - sharedCredentials.map((share, index) => ( + sharedCredentials.map((share, index) => { + const status = effectiveStatus(share, now); + return ( - {share.vaccineType} + + {share.vaccineType} + {status === 'active' && ( + + Active + + )} + {status === 'revoked' && ( + + Revoked + + )} + {status === 'expired' && ( + + Expired + + )} + Shared with: {share.recipient} @@ -258,25 +392,55 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { - handleRevoke(share.id, share.vaccineType)} - onKeyDown={(e) => - handleKeyDown(e, () => handleRevoke(share.id, share.vaccineType)) - } - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} - aria-label={`Revoke access for ${share.vaccineType}`} - > - - + {status === 'active' && ( + handleRevoke(share.id, share.vaccineType)} + onKeyDown={(e) => + handleKeyDown(e, () => handleRevoke(share.id, share.vaccineType)) + } + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + aria-label={`Revoke access for ${share.vaccineType}`} + > + + + )} - )) + ); + }) )} + {/* Share confirmation dialog */} + setIsConfirmOpen(false)} + returnFocusTo={shareButtonRef.current} + postConfirmFocusRef={statusRegionRef} + /> + + {/* Status region: receives focus when the share button is disabled after confirmation. */} + + {/* Success overlay */} setShowSuccess(false)} /> diff --git a/frontend/src/components/share-confirmation-modal.tsx b/frontend/src/components/share-confirmation-modal.tsx new file mode 100644 index 00000000..22be8f22 --- /dev/null +++ b/frontend/src/components/share-confirmation-modal.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useEffect, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Share2, Shield, Clock, CheckCircle } from 'lucide-react'; +import { useAccessibility } from '@/contexts/AccessibilityContext'; +import { getFocusableElements } from '@/utils/focusManagement'; + +interface ShareSummary { + recipient: string; + credentials: { id: string; vaccineType: string }[]; + durationSeconds: number; +} + +interface ShareConfirmationModalProps { + isOpen: boolean; + summary: ShareSummary | null; + onConfirm: () => void; + onCancel: () => void; + returnFocusTo?: HTMLElement | null; + postConfirmFocusRef?: React.RefObject; +} + +const DURATION_LABELS: Record = { + 3600: '1 hour', + 86400: '1 day', + 604800: '1 week', + 2592000: '1 month', +}; + +export function ShareConfirmationModal({ + isOpen, + summary, + onConfirm, + onCancel, + returnFocusTo, + postConfirmFocusRef, +}: ShareConfirmationModalProps) { + const { announceToScreenReader } = useAccessibility(); + const dialogRef = useRef(null); + const openerRef = useRef(null); + + useEffect(() => { + if (isOpen) { + announceToScreenReader('Share confirmation dialog opened'); + // Remember the element that opened the dialog so we can restore focus + // when it closes. Fall back to the active element if no explicit opener + // was provided. + openerRef.current = returnFocusTo ?? (document.activeElement as HTMLElement | null); + // Move focus into the dialog so keyboard/screen-reader users land inside. + const focusable = dialogRef.current ? getFocusableElements(dialogRef.current) : []; + if (focusable.length > 0) { + focusable[0].focus(); + } else { + dialogRef.current?.focus(); + } + } + }, [isOpen, announceToScreenReader, returnFocusTo]); + + const focusOpenerOrFallback = useCallback(() => { + const opener = openerRef.current; + const isDisabled = + opener instanceof HTMLButtonElement || opener instanceof HTMLInputElement + ? opener.disabled + : false; + if (opener && 'focus' in opener && !isDisabled) { + opener.focus(); + } else if (postConfirmFocusRef?.current) { + postConfirmFocusRef.current.focus(); + } + }, [postConfirmFocusRef]); + + const handleCancel = useCallback(() => { + const opener = openerRef.current; + if (opener && 'focus' in opener) { + opener.focus(); + } + onCancel(); + }, [onCancel]); + + const handleConfirm = useCallback(() => { + focusOpenerOrFallback(); + onConfirm(); + }, [onConfirm, focusOpenerOrFallback]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + return; + } + // Trap Tab and Shift+Tab within the dialog while it is open. + if (e.key === 'Tab' && dialogRef.current) { + const focusable = getFocusableElements(dialogRef.current); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }, + [handleCancel] + ); + + if (!summary) return null; + + return ( + + {isOpen && ( + + {/* Backdrop */} + + + {/* Modal content */} + + {/* Header */} + + + + + + Confirm Share + + + + {/* Summary */} + + + Recipient + {summary.recipient} + + + Credentials + + {summary.credentials.map((credential) => ( + + + {credential.vaccineType} + + ))} + + + + Duration + + + {DURATION_LABELS[summary.durationSeconds] ?? `${summary.durationSeconds} seconds`} + + + + + {/* Note */} + + The recipient will be able to view these credentials for the selected duration. You can revoke + access at any time from the shared list below. + + + {/* Action buttons */} + + + Cancel + + + + Confirm Share + + + + + )} + + ); +} diff --git a/frontend/src/utils/stellar-address.ts b/frontend/src/utils/stellar-address.ts new file mode 100644 index 00000000..6b899bd3 --- /dev/null +++ b/frontend/src/utils/stellar-address.ts @@ -0,0 +1,58 @@ +// Minimal Stellar StrKey validation. +// +// Stellar public keys are base32-encoded with a version byte and a CRC16-XModem +// checksum appended. The checksum is what makes an address that merely looks +// valid actually valid — catching typos that a plain character regex misses. + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +function crc16Xmodem(bytes: number[]): number { + let crc = 0x0000; + for (const byte of bytes) { + crc ^= byte << 8; + for (let i = 0; i < 8; i++) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc & 0xffff; +} + +function decodeBase32(encoded: string): number[] | null { + const bytes: number[] = []; + let buffer = 0; + let bits = 0; + + for (const char of encoded) { + const value = BASE32_ALPHABET.indexOf(char); + if (value === -1) return null; + buffer = (buffer << 5) | value; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + + return bytes; +} + +/** + * Returns true when the string is a checksum-valid Stellar (G...) public key. + */ +export function isValidStellarAddress(address: string): boolean { + const trimmed = address.trim(); + if (!/^G[A-Z2-7]{55}$/.test(trimmed)) return false; + + const decoded = decodeBase32(trimmed); + if (!decoded || decoded.length !== 35) return false; + + const versionByte = decoded[0]; + if (versionByte !== 6 << 3) return false; // version byte for ed25519 public key + + const payload = decoded.slice(0, 33); + const checksum = decoded.slice(33); + const expected = crc16Xmodem(payload); + + // StrKey checksums are serialized little-endian. + return checksum[0] === (expected & 0xff) && checksum[1] === (expected >> 8); +}
Enter the Stellar wallet address of the recipient
+ Enter a valid Stellar address (starts with G, 56 characters total) +
No credentials shared yet
{share.vaccineType}
Shared with: {share.recipient}
@@ -258,25 +392,55 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) {
+ The recipient will be able to view these credentials for the selected duration. You can revoke + access at any time from the shared list below. +