From 8481e2756ea9c9f7dbb959c91d35b3cee8f83564 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 1 Mar 2026 07:10:30 +0800 Subject: [PATCH] feat: enhance send functionality with improved key handling and decryption, update UI components for better user experience --- src/handlers/sends.ts | 57 ++++++++++-- webapp/src/App.tsx | 52 ++++++++--- webapp/src/components/PublicSendPage.tsx | 20 ++++- webapp/src/components/SendsPage.tsx | 2 +- webapp/src/lib/api.ts | 109 ++++++++++++++++------- webapp/src/lib/crypto.ts | 43 +++++++++ webapp/src/styles.css | 2 +- 7 files changed, 227 insertions(+), 58 deletions(-) diff --git a/src/handlers/sends.ts b/src/handlers/sends.ts index f33ce69..ee51132 100644 --- a/src/handlers/sends.ts +++ b/src/handlers/sends.ts @@ -195,6 +195,14 @@ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { return diff === 0; } +function isLikelyHashB64(value: string): boolean { + const raw = String(value || '').trim(); + if (!raw) return false; + if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false; + const decoded = base64UrlDecode(raw); + return !!decoded && decoded.length === 32; +} + async function setSendPassword(send: Send, password: string | null): Promise { if (!password) { send.passwordHash = null; @@ -206,6 +214,16 @@ async function setSendPassword(send: Send, password: string | null): Promise { - if (!send.passwordHash || !send.passwordSalt || !send.passwordIterations) { + if (!send.passwordHash) { return false; } + // Official client behavior: password is already a hash in base64. + if (!send.passwordSalt || !send.passwordIterations) { + return verifySendPasswordHashB64(send, password); + } + const salt = base64UrlDecode(send.passwordSalt); const expected = base64UrlDecode(send.passwordHash); if (!salt || !expected) return false; @@ -360,14 +383,30 @@ async function validatePublicSendAccess(send: Send, body: unknown): Promise { const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); + const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId); if (!send || !isSendAvailable(send)) { return { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0fba9c2..8f16a64 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -47,7 +47,7 @@ import { updateProfile, verifyMasterPassword, } from '@/lib/api'; -import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; +import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto'; import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; interface PendingTotp { @@ -56,6 +56,21 @@ interface PendingTotp { masterKey: Uint8Array; } +const SEND_KEY_SALT = 'bitwarden-send'; +const SEND_KEY_PURPOSE = 'send'; + +function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string { + return `${origin}/#/send/${accessId}/${keyPart}`; +} + +async function deriveSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> { + if (sendKeyMaterial.length >= 64) { + return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) }; + } + const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64); + return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) }; +} + export default function App() { const [location, navigate] = useLocation(); const [phase, setPhase] = useState('loading'); @@ -460,14 +475,20 @@ export default function App() { try { if (send.key) { const sendKeyRaw = await decryptBw(send.key, encKey, macKey); - const sendEnc = sendKeyRaw.slice(0, 32); - const sendMac = sendKeyRaw.slice(32, 64); - nextSend.decName = await decryptField(send.name || '', sendEnc, sendMac); - nextSend.decNotes = await decryptField(send.notes || '', sendEnc, sendMac); - nextSend.decText = await decryptField(send.text?.text || '', sendEnc, sendMac); + const derived = await deriveSendKeyParts(sendKeyRaw); + nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); + nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); + nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); + if (send.file?.fileName) { + const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); + nextSend.file = { + ...(send.file || {}), + fileName: decFileName || send.file.fileName, + }; + } const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); nextSend.decShareKey = shareKey; - nextSend.shareUrl = `${window.location.origin}/send/${send.accessId}/${shareKey}`; + nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); } else { nextSend.decName = ''; nextSend.decNotes = ''; @@ -637,7 +658,7 @@ export default function App() { await sendsQuery.refetch(); if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); - const shareUrl = `${window.location.origin}/send/${created.accessId}/${keyPart}`; + const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart); await navigator.clipboard.writeText(shareUrl); } pushToast('success', 'Send created'); @@ -654,7 +675,7 @@ export default function App() { await sendsQuery.refetch(); if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); - const shareUrl = `${window.location.origin}/send/${updated.accessId}/${keyPart}`; + const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart); await navigator.clipboard.writeText(shareUrl); } pushToast('success', 'Send updated'); @@ -709,11 +730,16 @@ export default function App() { } } - useEffect(() => { - if (phase === 'app' && location === '/') navigate('/vault'); - }, [phase, location, navigate]); + const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; + const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; + const effectiveLocation = hashPath.startsWith('/send/') ? hashPath : location; + const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); + const isPublicSendRoute = !!publicSendMatch; + + useEffect(() => { + if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); + }, [phase, location, isPublicSendRoute, navigate]); - const publicSendMatch = location.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); if (publicSendMatch) { return ( <> diff --git a/webapp/src/components/PublicSendPage.tsx b/webapp/src/components/PublicSendPage.tsx index 87072b6..1ad42ae 100644 --- a/webapp/src/components/PublicSendPage.tsx +++ b/webapp/src/components/PublicSendPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'preact/hooks'; import { Download, Eye, Lock } from 'lucide-preact'; -import { accessPublicSend, accessPublicSendFile, decryptPublicSend } from '@/lib/api'; +import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api'; interface PublicSendPageProps { accessId: string; @@ -19,7 +19,7 @@ export default function PublicSendPage(props: PublicSendPageProps) { setBusy(true); setError(''); try { - const data = await accessPublicSend(props.accessId, pass); + const data = await accessPublicSend(props.accessId, props.keyPart, pass); if (!props.keyPart) { setError('This link is missing decryption key.'); setSendData(null); @@ -48,10 +48,22 @@ export default function PublicSendPage(props: PublicSendPageProps) { setBusy(true); setError(''); try { - const url = await accessPublicSendFile(sendData.id, sendData.file.id, password || undefined); + const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined); const resp = await fetch(url); if (!resp.ok) throw new Error('Download failed'); - const blob = await resp.blob(); + const encryptedBytes = await resp.arrayBuffer(); + let blob: Blob; + if (props.keyPart) { + try { + const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart); + blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' }); + } catch { + // Legacy compatibility: early web-created file sends uploaded plaintext bytes. + blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); + } + } else { + blob = new Blob([encryptedBytes], { type: 'application/octet-stream' }); + } const obj = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = obj; diff --git a/webapp/src/components/SendsPage.tsx b/webapp/src/components/SendsPage.tsx index 6ddb54d..e8733c8 100644 --- a/webapp/src/components/SendsPage.tsx +++ b/webapp/src/components/SendsPage.tsx @@ -169,7 +169,7 @@ export default function SendsPage(props: SendsPageProps) { } function copyAccessUrl(send: Send): void { - const url = send.shareUrl || `${window.location.origin}/send/${send.accessId}`; + const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`; void navigator.clipboard.writeText(url); props.onNotify('success', 'Link copied'); } diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index f47354d..f962c08 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -1,4 +1,4 @@ -import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto'; +import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, hkdfExpand, pbkdf2 } from './crypto'; import type { AdminInvite, AdminUser, @@ -673,19 +673,29 @@ function base64UrlToBytes(value: string): Uint8Array { return base64ToBytes(padded); } +const SEND_KEY_SALT = 'bitwarden-send'; +const SEND_KEY_PURPOSE = 'send'; +const SEND_KEY_SEED_BYTES = 16; +const SEND_PASSWORD_ITERATIONS = 100000; + async function parseErrorMessage(resp: Response, fallback: string): Promise { const body = await parseJson(resp); return body?.error_description || body?.error || fallback; } -function toSendKeyParts(sendKeyBytes: Uint8Array): { enc: Uint8Array; mac: Uint8Array } { - if (sendKeyBytes.length >= 64) { - return { enc: sendKeyBytes.slice(0, 32), mac: sendKeyBytes.slice(32, 64) }; +async function toSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> { + // Legacy compatibility: early NodeWarden builds stored a full 64-byte key material. + if (sendKeyMaterial.length >= 64) { + return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) }; } - const merged = new Uint8Array(64); - merged.set(sendKeyBytes.slice(0, 32), 0); - merged.set(sendKeyBytes.slice(0, 32), 32); - return { enc: merged.slice(0, 32), mac: merged.slice(32, 64) }; + // Official behavior: send URL key is seed material; derive 64-byte key via HKDF. + const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64); + return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) }; +} + +async function hashSendPasswordB64(password: string, sendKeyMaterial: Uint8Array): Promise { + const hash = await pbkdf2(password, sendKeyMaterial, SEND_PASSWORD_ITERATIONS, 32); + return bytesToBase64(hash); } function parseMaxAccessCountRaw(value: string): number | null { @@ -704,9 +714,9 @@ export async function createSend( if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const userEnc = base64ToBytes(session.symEncKey); const userMac = base64ToBytes(session.symMacKey); - const sendKeyRaw = crypto.getRandomValues(new Uint8Array(64)); - const sendKeyForUser = await encryptBw(sendKeyRaw, userEnc, userMac); - const sendKey = toSendKeyParts(sendKeyRaw); + const sendKeyMaterial = crypto.getRandomValues(new Uint8Array(SEND_KEY_SEED_BYTES)); + const sendKeyForUser = await encryptBw(sendKeyMaterial, userEnc, userMac); + const sendKey = await toSendKeyParts(sendKeyMaterial); const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac); const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac); @@ -714,6 +724,7 @@ export async function createSend( const expirationIso = toIsoDateFromDays(draft.expirationDays, false); const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount); const password = String(draft.password || ''); + const passwordHash = password ? await hashSendPasswordB64(password, sendKeyMaterial) : null; if (draft.type === 'text') { const text = String(draft.text || '').trim(); @@ -730,7 +741,7 @@ export async function createSend( hidden: false, }, maxAccessCount, - password: password || null, + password: passwordHash, hideEmail: false, disabled: !!draft.disabled, deletionDate: deletionIso, @@ -749,6 +760,10 @@ export async function createSend( } if (!draft.file) throw new Error('File is required'); + const fileNameCipher = await encryptTextValue(draft.file.name, sendKey.enc, sendKey.mac); + if (!fileNameCipher) throw new Error('Invalid file name'); + const plainFileBytes = new Uint8Array(await draft.file.arrayBuffer()); + const encryptedFileBytes = await encryptBwFileData(plainFileBytes, sendKey.enc, sendKey.mac); const fileResp = await authedFetch('/api/sends/file/v2', { method: 'POST', @@ -759,11 +774,11 @@ export async function createSend( notes: notesCipher, key: sendKeyForUser, file: { - fileName: draft.file.name, + fileName: fileNameCipher, }, - fileLength: draft.file.size, + fileLength: encryptedFileBytes.byteLength, maxAccessCount, - password: password || null, + password: passwordHash, hideEmail: false, disabled: !!draft.disabled, deletionDate: deletionIso, @@ -772,20 +787,20 @@ export async function createSend( }); if (!fileResp.ok) throw new Error(await parseErrorMessage(fileResp, 'Create file send failed')); - const uploadInfo = await parseJson<{ url?: string }>(fileResp); + const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send }>(fileResp); const uploadUrl = uploadInfo?.url; if (!uploadUrl) throw new Error('Create file send failed: missing upload URL'); const formData = new FormData(); - formData.set('data', draft.file, draft.file.name); + const encryptedBlob = new Blob([encryptedFileBytes as unknown as BlobPart], { type: 'application/octet-stream' }); + formData.set('data', encryptedBlob, fileNameCipher); const uploadResp = await authedFetch(uploadUrl, { method: 'POST', body: formData, }); if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed')); - const fileBody = await parseJson<{ sendResponse?: Send }>(fileResp); - if (!fileBody?.sendResponse?.id) throw new Error('Create file send failed'); - return fileBody.sendResponse; + if (!uploadInfo?.sendResponse?.id) throw new Error('Create file send failed'); + return uploadInfo.sendResponse; } export async function updateSend( @@ -798,8 +813,8 @@ export async function updateSend( if (!send.key) throw new Error('Send key unavailable'); const userEnc = base64ToBytes(session.symEncKey); const userMac = base64ToBytes(session.symMacKey); - const sendKeyRaw = await decryptBw(send.key, userEnc, userMac); - const sendKey = toSendKeyParts(sendKeyRaw); + const sendKeyMaterial = await decryptBw(send.key, userEnc, userMac); + const sendKey = await toSendKeyParts(sendKeyMaterial); const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac); const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac); @@ -813,6 +828,9 @@ export async function updateSend( const textCipher = await encryptTextValue(String(draft.text || ''), sendKey.enc, sendKey.mac); + const passwordRaw = String(draft.password || ''); + const passwordHash = passwordRaw ? await hashSendPasswordB64(passwordRaw, sendKeyMaterial) : null; + const payload = { id: send.id, type: draft.type === 'file' ? 1 : 0, @@ -824,7 +842,7 @@ export async function updateSend( hidden: false, }, maxAccessCount, - password: String(draft.password || '') || null, + password: passwordHash, hideEmail: false, disabled: !!draft.disabled, deletionDate: deletionIso, @@ -850,8 +868,29 @@ export async function deleteSend( if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed')); } -export async function accessPublicSend(accessId: string, password?: string): Promise { - const payload = password ? { password } : {}; +async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise> { + const payload: Record = {}; + const plainPassword = String(password || '').trim(); + if (!plainPassword) return payload; + payload.password = plainPassword; + + // Official clients send a PBKDF2 hash bound to send key material. + if (keyPart) { + try { + const sendKeyMaterial = base64UrlToBytes(keyPart); + const passwordHashB64 = await hashSendPasswordB64(plainPassword, sendKeyMaterial); + payload.passwordHash = passwordHashB64; + payload.password_hash_b64 = passwordHashB64; + payload.passwordHashB64 = passwordHashB64; + } catch { + // Fallback to plain password for legacy compatibility. + } + } + return payload; +} + +export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise { + const payload = await buildPublicSendAccessPayload(password, keyPart); const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -866,8 +905,8 @@ export async function accessPublicSend(accessId: string, password?: string): Pro return (await parseJson(resp)) || null; } -export async function accessPublicSendFile(sendId: string, fileId: string, password?: string): Promise { - const payload = password ? { password } : {}; +export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise { + const payload = await buildPublicSendAccessPayload(password, keyPart); const resp = await fetch(`/api/sends/${encodeURIComponent(sendId)}/access/file/${encodeURIComponent(fileId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -885,8 +924,8 @@ export async function accessPublicSendFile(sendId: string, fileId: string, passw } export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise { - const sendKeyRaw = base64UrlToBytes(urlSafeKey); - const sendKey = toSendKeyParts(sendKeyRaw); + const sendKeyMaterial = base64UrlToBytes(urlSafeKey); + const sendKey = await toSendKeyParts(sendKeyMaterial); const out: any = { ...accessData }; out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac); if (accessData?.text?.text) { @@ -902,8 +941,18 @@ export async function decryptPublicSend(accessData: any, urlSafeKey: string): Pr return out; } +export async function decryptPublicSendFileBytes( + encryptedBytes: ArrayBuffer | Uint8Array, + urlSafeKey: string +): Promise { + const sendKeyMaterial = base64UrlToBytes(urlSafeKey); + const sendKey = await toSendKeyParts(sendKeyMaterial); + const encrypted = encryptedBytes instanceof Uint8Array ? encryptedBytes : new Uint8Array(encryptedBytes); + return decryptBwFileData(encrypted, sendKey.enc, sendKey.mac); +} + export function buildSendShareKey(sendKeyEncrypted: string, userEncB64: string, userMacB64: string): Promise { const userEnc = base64ToBytes(userEncB64); const userMac = base64ToBytes(userMacB64); - return decryptBw(sendKeyEncrypted, userEnc, userMac).then((raw) => bytesToBase64Url(raw)); + return decryptBw(sendKeyEncrypted, userEnc, userMac).then((keyMaterial) => bytesToBase64Url(keyMaterial)); } diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index ee5eaa5..19ff694 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -62,6 +62,25 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number): return result; } +export async function hkdf( + ikm: Uint8Array, + salt: string | Uint8Array, + info: string | Uint8Array, + outputByteSize: number +): Promise { + const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt; + const infoBytes = typeof info === 'string' ? new TextEncoder().encode(info) : info; + const params: HkdfParams = { + name: 'HKDF', + salt: toBufferSource(saltBytes), + info: toBufferSource(infoBytes), + hash: 'SHA-256', + }; + const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8); + return new Uint8Array(bits); +} + async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise { const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes))); @@ -77,6 +96,30 @@ async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data))); } +export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise { + const iv = crypto.getRandomValues(new Uint8Array(16)); + const cipher = await encryptAesCbc(data, encKey, iv); + const mac = await hmacSha256(macKey, concatBytes(iv, cipher)); + const out = new Uint8Array(1 + iv.length + mac.length + cipher.length); + out[0] = 2; // EncryptionType.AesCbc256_HmacSha256_B64 + out.set(iv, 1); + out.set(mac, 1 + iv.length); + out.set(cipher, 1 + iv.length + mac.length); + return out; +} + +export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise { + if (!encrypted || encrypted.length < 1 + 16 + 32 + 1) throw new Error('Invalid encrypted file data'); + const encType = encrypted[0]; + if (encType !== 2) throw new Error('Unsupported file encryption type'); + const iv = encrypted.slice(1, 17); + const mac = encrypted.slice(17, 49); + const cipher = encrypted.slice(49); + const expected = await hmacSha256(macKey, concatBytes(iv, cipher)); + if (bytesToBase64(expected) !== bytesToBase64(mac)) throw new Error('MAC mismatch'); + return decryptAesCbc(cipher, encKey, iv); +} + export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise { const iv = crypto.getRandomValues(new Uint8Array(16)); const cipher = await encryptAesCbc(data, encKey, iv); diff --git a/webapp/src/styles.css b/webapp/src/styles.css index f9354bb..a6a461f 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -46,7 +46,7 @@ html { } .public-send-page { - min-height: 100vh; + min-height: 80vh; align-items: center; justify-items: center; }