mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance send functionality with improved key handling and decryption, update UI components for better user experience
This commit is contained in:
+48
-9
@@ -195,6 +195,14 @@ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|||||||
return diff === 0;
|
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<void> {
|
async function setSendPassword(send: Send, password: string | null): Promise<void> {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
send.passwordHash = null;
|
send.passwordHash = null;
|
||||||
@@ -206,6 +214,16 @@ async function setSendPassword(send: Send, password: string | null): Promise<voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Official client behavior: request.password already contains PBKDF2 hash (base64).
|
||||||
|
// Keep it as-is to remain interoperable.
|
||||||
|
if (isLikelyHashB64(password)) {
|
||||||
|
send.passwordHash = password.trim();
|
||||||
|
send.passwordSalt = null;
|
||||||
|
send.passwordIterations = null;
|
||||||
|
send.authType = SendAuthType.Password;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(64));
|
const salt = crypto.getRandomValues(new Uint8Array(64));
|
||||||
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
|
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
|
||||||
|
|
||||||
@@ -216,10 +234,15 @@ async function setSendPassword(send: Send, password: string | null): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
|
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
|
||||||
if (!send.passwordHash || !send.passwordSalt || !send.passwordIterations) {
|
if (!send.passwordHash) {
|
||||||
return false;
|
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 salt = base64UrlDecode(send.passwordSalt);
|
||||||
const expected = base64UrlDecode(send.passwordHash);
|
const expected = base64UrlDecode(send.passwordHash);
|
||||||
if (!salt || !expected) return false;
|
if (!salt || !expected) return false;
|
||||||
@@ -360,14 +383,30 @@ async function validatePublicSendAccess(send: Send, body: unknown): Promise<Resp
|
|||||||
if (!send.passwordHash) return null;
|
if (!send.passwordHash) return null;
|
||||||
|
|
||||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
if (typeof passwordRaw.value !== 'string') {
|
const passwordHashB64Raw = getAliasedProp(body, [
|
||||||
return errorResponse('Password not provided', 401);
|
'password_hash_b64',
|
||||||
}
|
'passwordHashB64',
|
||||||
|
'passwordHash',
|
||||||
|
'password_hash',
|
||||||
|
]);
|
||||||
|
|
||||||
const validPassword = await verifySendPassword(send, passwordRaw.value);
|
let validPassword = false;
|
||||||
if (!validPassword) {
|
if (send.passwordSalt && send.passwordIterations) {
|
||||||
return errorResponse('Invalid password', 400);
|
if (typeof passwordRaw.value !== 'string') {
|
||||||
|
return errorResponse('Password not provided', 401);
|
||||||
|
}
|
||||||
|
validPassword = await verifySendPassword(send, passwordRaw.value);
|
||||||
|
} else {
|
||||||
|
const candidate =
|
||||||
|
typeof passwordHashB64Raw.value === 'string'
|
||||||
|
? passwordHashB64Raw.value
|
||||||
|
: typeof passwordRaw.value === 'string'
|
||||||
|
? passwordRaw.value
|
||||||
|
: '';
|
||||||
|
if (!candidate) return errorResponse('Password not provided', 401);
|
||||||
|
validPassword = verifySendPasswordHashB64(send, candidate);
|
||||||
}
|
}
|
||||||
|
if (!validPassword) return errorResponse('Invalid password', 400);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1153,12 +1192,12 @@ export async function handleDownloadSendFile(
|
|||||||
|
|
||||||
export async function issueSendAccessToken(
|
export async function issueSendAccessToken(
|
||||||
env: Env,
|
env: Env,
|
||||||
sendId: string,
|
sendIdOrAccessId: string,
|
||||||
passwordHashB64?: string | null,
|
passwordHashB64?: string | null,
|
||||||
password?: string | null
|
password?: string | null
|
||||||
): Promise<{ token: string } | { error: Response }> {
|
): Promise<{ token: string } | { error: Response }> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const send = await storage.getSend(sendId);
|
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||||
|
|
||||||
if (!send || !isSendAvailable(send)) {
|
if (!send || !isSendAvailable(send)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+39
-13
@@ -47,7 +47,7 @@ import {
|
|||||||
updateProfile,
|
updateProfile,
|
||||||
verifyMasterPassword,
|
verifyMasterPassword,
|
||||||
} from '@/lib/api';
|
} 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';
|
import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||||
|
|
||||||
interface PendingTotp {
|
interface PendingTotp {
|
||||||
@@ -56,6 +56,21 @@ interface PendingTotp {
|
|||||||
masterKey: Uint8Array;
|
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() {
|
export default function App() {
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const [phase, setPhase] = useState<AppPhase>('loading');
|
const [phase, setPhase] = useState<AppPhase>('loading');
|
||||||
@@ -460,14 +475,20 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
if (send.key) {
|
if (send.key) {
|
||||||
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
|
const sendKeyRaw = await decryptBw(send.key, encKey, macKey);
|
||||||
const sendEnc = sendKeyRaw.slice(0, 32);
|
const derived = await deriveSendKeyParts(sendKeyRaw);
|
||||||
const sendMac = sendKeyRaw.slice(32, 64);
|
nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac);
|
||||||
nextSend.decName = await decryptField(send.name || '', sendEnc, sendMac);
|
nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac);
|
||||||
nextSend.decNotes = await decryptField(send.notes || '', sendEnc, sendMac);
|
nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac);
|
||||||
nextSend.decText = await decryptField(send.text?.text || '', sendEnc, sendMac);
|
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!);
|
const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!);
|
||||||
nextSend.decShareKey = shareKey;
|
nextSend.decShareKey = shareKey;
|
||||||
nextSend.shareUrl = `${window.location.origin}/send/${send.accessId}/${shareKey}`;
|
nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey);
|
||||||
} else {
|
} else {
|
||||||
nextSend.decName = '';
|
nextSend.decName = '';
|
||||||
nextSend.decNotes = '';
|
nextSend.decNotes = '';
|
||||||
@@ -637,7 +658,7 @@ export default function App() {
|
|||||||
await sendsQuery.refetch();
|
await sendsQuery.refetch();
|
||||||
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) {
|
||||||
const keyPart = await buildSendShareKey(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);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
}
|
}
|
||||||
pushToast('success', 'Send created');
|
pushToast('success', 'Send created');
|
||||||
@@ -654,7 +675,7 @@ export default function App() {
|
|||||||
await sendsQuery.refetch();
|
await sendsQuery.refetch();
|
||||||
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) {
|
||||||
const keyPart = await buildSendShareKey(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);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
}
|
}
|
||||||
pushToast('success', 'Send updated');
|
pushToast('success', 'Send updated');
|
||||||
@@ -709,11 +730,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||||
if (phase === 'app' && location === '/') navigate('/vault');
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||||
}, [phase, location, navigate]);
|
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) {
|
if (publicSendMatch) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend } from '@/lib/api';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api';
|
||||||
|
|
||||||
interface PublicSendPageProps {
|
interface PublicSendPageProps {
|
||||||
accessId: string;
|
accessId: string;
|
||||||
@@ -19,7 +19,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await accessPublicSend(props.accessId, pass);
|
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
|
||||||
if (!props.keyPart) {
|
if (!props.keyPart) {
|
||||||
setError('This link is missing decryption key.');
|
setError('This link is missing decryption key.');
|
||||||
setSendData(null);
|
setSendData(null);
|
||||||
@@ -48,10 +48,22 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
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);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) throw new Error('Download failed');
|
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 obj = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = obj;
|
a.href = obj;
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyAccessUrl(send: Send): void {
|
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);
|
void navigator.clipboard.writeText(url);
|
||||||
props.onNotify('success', 'Link copied');
|
props.onNotify('success', 'Link copied');
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-30
@@ -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 {
|
import type {
|
||||||
AdminInvite,
|
AdminInvite,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
@@ -673,19 +673,29 @@ function base64UrlToBytes(value: string): Uint8Array {
|
|||||||
return base64ToBytes(padded);
|
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<string> {
|
async function parseErrorMessage(resp: Response, fallback: string): Promise<string> {
|
||||||
const body = await parseJson<TokenError>(resp);
|
const body = await parseJson<TokenError>(resp);
|
||||||
return body?.error_description || body?.error || fallback;
|
return body?.error_description || body?.error || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSendKeyParts(sendKeyBytes: Uint8Array): { enc: Uint8Array; mac: Uint8Array } {
|
async function toSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||||
if (sendKeyBytes.length >= 64) {
|
// Legacy compatibility: early NodeWarden builds stored a full 64-byte key material.
|
||||||
return { enc: sendKeyBytes.slice(0, 32), mac: sendKeyBytes.slice(32, 64) };
|
if (sendKeyMaterial.length >= 64) {
|
||||||
|
return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) };
|
||||||
}
|
}
|
||||||
const merged = new Uint8Array(64);
|
// Official behavior: send URL key is seed material; derive 64-byte key via HKDF.
|
||||||
merged.set(sendKeyBytes.slice(0, 32), 0);
|
const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64);
|
||||||
merged.set(sendKeyBytes.slice(0, 32), 32);
|
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
||||||
return { enc: merged.slice(0, 32), mac: merged.slice(32, 64) };
|
}
|
||||||
|
|
||||||
|
async function hashSendPasswordB64(password: string, sendKeyMaterial: Uint8Array): Promise<string> {
|
||||||
|
const hash = await pbkdf2(password, sendKeyMaterial, SEND_PASSWORD_ITERATIONS, 32);
|
||||||
|
return bytesToBase64(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMaxAccessCountRaw(value: string): number | null {
|
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');
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
const userEnc = base64ToBytes(session.symEncKey);
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
const userMac = base64ToBytes(session.symMacKey);
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
const sendKeyRaw = crypto.getRandomValues(new Uint8Array(64));
|
const sendKeyMaterial = crypto.getRandomValues(new Uint8Array(SEND_KEY_SEED_BYTES));
|
||||||
const sendKeyForUser = await encryptBw(sendKeyRaw, userEnc, userMac);
|
const sendKeyForUser = await encryptBw(sendKeyMaterial, userEnc, userMac);
|
||||||
const sendKey = toSendKeyParts(sendKeyRaw);
|
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||||
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
||||||
const notesCipher = await encryptTextValue(draft.notes || '', 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 expirationIso = toIsoDateFromDays(draft.expirationDays, false);
|
||||||
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
|
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
|
||||||
const password = String(draft.password || '');
|
const password = String(draft.password || '');
|
||||||
|
const passwordHash = password ? await hashSendPasswordB64(password, sendKeyMaterial) : null;
|
||||||
|
|
||||||
if (draft.type === 'text') {
|
if (draft.type === 'text') {
|
||||||
const text = String(draft.text || '').trim();
|
const text = String(draft.text || '').trim();
|
||||||
@@ -730,7 +741,7 @@ export async function createSend(
|
|||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
maxAccessCount,
|
maxAccessCount,
|
||||||
password: password || null,
|
password: passwordHash,
|
||||||
hideEmail: false,
|
hideEmail: false,
|
||||||
disabled: !!draft.disabled,
|
disabled: !!draft.disabled,
|
||||||
deletionDate: deletionIso,
|
deletionDate: deletionIso,
|
||||||
@@ -749,6 +760,10 @@ export async function createSend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!draft.file) throw new Error('File is required');
|
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', {
|
const fileResp = await authedFetch('/api/sends/file/v2', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -759,11 +774,11 @@ export async function createSend(
|
|||||||
notes: notesCipher,
|
notes: notesCipher,
|
||||||
key: sendKeyForUser,
|
key: sendKeyForUser,
|
||||||
file: {
|
file: {
|
||||||
fileName: draft.file.name,
|
fileName: fileNameCipher,
|
||||||
},
|
},
|
||||||
fileLength: draft.file.size,
|
fileLength: encryptedFileBytes.byteLength,
|
||||||
maxAccessCount,
|
maxAccessCount,
|
||||||
password: password || null,
|
password: passwordHash,
|
||||||
hideEmail: false,
|
hideEmail: false,
|
||||||
disabled: !!draft.disabled,
|
disabled: !!draft.disabled,
|
||||||
deletionDate: deletionIso,
|
deletionDate: deletionIso,
|
||||||
@@ -772,20 +787,20 @@ export async function createSend(
|
|||||||
});
|
});
|
||||||
if (!fileResp.ok) throw new Error(await parseErrorMessage(fileResp, 'Create file send failed'));
|
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;
|
const uploadUrl = uploadInfo?.url;
|
||||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||||
|
|
||||||
const formData = new FormData();
|
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, {
|
const uploadResp = await authedFetch(uploadUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed'));
|
if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed'));
|
||||||
const fileBody = await parseJson<{ sendResponse?: Send }>(fileResp);
|
if (!uploadInfo?.sendResponse?.id) throw new Error('Create file send failed');
|
||||||
if (!fileBody?.sendResponse?.id) throw new Error('Create file send failed');
|
return uploadInfo.sendResponse;
|
||||||
return fileBody.sendResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSend(
|
export async function updateSend(
|
||||||
@@ -798,8 +813,8 @@ export async function updateSend(
|
|||||||
if (!send.key) throw new Error('Send key unavailable');
|
if (!send.key) throw new Error('Send key unavailable');
|
||||||
const userEnc = base64ToBytes(session.symEncKey);
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
const userMac = base64ToBytes(session.symMacKey);
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
const sendKeyRaw = await decryptBw(send.key, userEnc, userMac);
|
const sendKeyMaterial = await decryptBw(send.key, userEnc, userMac);
|
||||||
const sendKey = toSendKeyParts(sendKeyRaw);
|
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||||
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
||||||
const notesCipher = await encryptTextValue(draft.notes || '', 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 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 = {
|
const payload = {
|
||||||
id: send.id,
|
id: send.id,
|
||||||
type: draft.type === 'file' ? 1 : 0,
|
type: draft.type === 'file' ? 1 : 0,
|
||||||
@@ -824,7 +842,7 @@ export async function updateSend(
|
|||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
maxAccessCount,
|
maxAccessCount,
|
||||||
password: String(draft.password || '') || null,
|
password: passwordHash,
|
||||||
hideEmail: false,
|
hideEmail: false,
|
||||||
disabled: !!draft.disabled,
|
disabled: !!draft.disabled,
|
||||||
deletionDate: deletionIso,
|
deletionDate: deletionIso,
|
||||||
@@ -850,8 +868,29 @@ export async function deleteSend(
|
|||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accessPublicSend(accessId: string, password?: string): Promise<any> {
|
async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> {
|
||||||
const payload = password ? { password } : {};
|
const payload: Record<string, unknown> = {};
|
||||||
|
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<any> {
|
||||||
|
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
||||||
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -866,8 +905,8 @@ export async function accessPublicSend(accessId: string, password?: string): Pro
|
|||||||
return (await parseJson<any>(resp)) || null;
|
return (await parseJson<any>(resp)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function accessPublicSendFile(sendId: string, fileId: string, password?: string): Promise<string> {
|
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
|
||||||
const payload = password ? { password } : {};
|
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
||||||
const resp = await fetch(`/api/sends/${encodeURIComponent(sendId)}/access/file/${encodeURIComponent(fileId)}`, {
|
const resp = await fetch(`/api/sends/${encodeURIComponent(sendId)}/access/file/${encodeURIComponent(fileId)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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<any> {
|
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
|
||||||
const sendKeyRaw = base64UrlToBytes(urlSafeKey);
|
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
|
||||||
const sendKey = toSendKeyParts(sendKeyRaw);
|
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||||
const out: any = { ...accessData };
|
const out: any = { ...accessData };
|
||||||
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
||||||
if (accessData?.text?.text) {
|
if (accessData?.text?.text) {
|
||||||
@@ -902,8 +941,18 @@ export async function decryptPublicSend(accessData: any, urlSafeKey: string): Pr
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function decryptPublicSendFileBytes(
|
||||||
|
encryptedBytes: ArrayBuffer | Uint8Array,
|
||||||
|
urlSafeKey: string
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
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<string> {
|
export function buildSendShareKey(sendKeyEncrypted: string, userEncB64: string, userMacB64: string): Promise<string> {
|
||||||
const userEnc = base64ToBytes(userEncB64);
|
const userEnc = base64ToBytes(userEncB64);
|
||||||
const userMac = base64ToBytes(userMacB64);
|
const userMac = base64ToBytes(userMacB64);
|
||||||
return decryptBw(sendKeyEncrypted, userEnc, userMac).then((raw) => bytesToBase64Url(raw));
|
return decryptBw(sendKeyEncrypted, userEnc, userMac).then((keyMaterial) => bytesToBase64Url(keyMaterial));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ export async function hkdfExpand(prk: Uint8Array, info: string, length: number):
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hkdf(
|
||||||
|
ikm: Uint8Array,
|
||||||
|
salt: string | Uint8Array,
|
||||||
|
info: string | Uint8Array,
|
||||||
|
outputByteSize: number
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
|
||||||
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
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)));
|
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)));
|
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<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<string> {
|
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
const cipher = await encryptAesCbc(data, encKey, iv);
|
const cipher = await encryptAesCbc(data, encKey, iv);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.public-send-page {
|
.public-send-page {
|
||||||
min-height: 100vh;
|
min-height: 80vh;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user