+{/if}
+
{#if viewerImportPickerOpen}
(viewerImportPickerOpen = false)}>
@@ -6910,6 +7234,101 @@
opacity: 0.7;
}
+ :global(.tiptap-wrapper .tiptap .secret-block) {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--accent) 7%, var(--bg-secondary));
+ margin: 1em 0;
+ padding: 12px;
+ user-select: text;
+ }
+
+ :global(.secret-block-header) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 10px;
+ }
+
+ :global(.secret-block-title) {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text-primary);
+ }
+
+ :global(.secret-block-lock) {
+ flex: 0 0 auto;
+ }
+
+ :global(.secret-block-badge) {
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 11px;
+ color: var(--text-tertiary);
+ }
+
+ :global(.secret-block-form),
+ :global(.secret-block-actions) {
+ display: flex;
+ gap: 8px;
+ }
+
+ :global(.secret-block-form input) {
+ flex: 1;
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ padding: 7px 9px;
+ font: inherit;
+ }
+
+ :global(.secret-block-form input:focus) {
+ outline: none;
+ border-color: var(--accent);
+ }
+
+ :global(.secret-block-form button),
+ :global(.secret-block-actions button) {
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ padding: 7px 10px;
+ font: inherit;
+ cursor: pointer;
+ }
+
+ :global(.secret-block-form button:hover:not(:disabled)),
+ :global(.secret-block-actions button:hover) {
+ border-color: var(--accent);
+ }
+
+ :global(.secret-block-form button:disabled) {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ :global(.secret-block-error) {
+ color: #d32f2f;
+ font-size: 12px;
+ margin-top: 8px;
+ }
+
+ :global(.secret-block-plaintext) {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text-primary);
+ }
+
/* Syntax highlighting - light mode */
:global(.tiptap pre code .hljs-keyword),
:global(.tiptap pre code .hljs-selector-tag),
@@ -7087,6 +7506,59 @@
cursor: not-allowed;
}
+ .secret-modal {
+ max-width: min(720px, 92vw);
+ }
+
+ .secret-modal-text {
+ min-height: 140px;
+ }
+
+ .secret-modal-title {
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ padding: 9px 10px;
+ font: inherit;
+ outline: none;
+ }
+
+ .secret-modal-title:focus {
+ border-color: var(--accent);
+ }
+
+ .secret-modal-fields {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ .secret-modal-fields input {
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ padding: 9px 10px;
+ font: inherit;
+ outline: none;
+ }
+
+ .secret-modal-fields input:focus {
+ border-color: var(--accent);
+ }
+
+ .secret-modal-error {
+ color: #d32f2f;
+ font-size: 12px;
+ }
+
+ @media (max-width: 640px) {
+ .secret-modal-fields {
+ grid-template-columns: 1fr;
+ }
+ }
+
.viewer-banner {
display: flex;
align-items: center;
diff --git a/src/lib/utils/secrets.ts b/src/lib/utils/secrets.ts
new file mode 100644
index 0000000..198aea9
--- /dev/null
+++ b/src/lib/utils/secrets.ts
@@ -0,0 +1,150 @@
+const SECRET_VERSION = 2;
+const SECRET_CIPHER = 'AES-256-GCM';
+const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
+const SECRET_ITERATIONS = 600_000;
+const SALT_BYTES = 16;
+const NONCE_BYTES = 12;
+
+interface SecretEnvelope {
+ v: number;
+ title?: string;
+ cipher: string;
+ kdf: string;
+ iterations: number;
+ salt: string;
+ nonce: string;
+ ct: string;
+}
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+function bytesToBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary);
+}
+
+function base64ToBytes(value: string, field: string): Uint8Array {
+ const text = value.trim();
+ if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
+ throw new Error(`Invalid secret ${field}`);
+ }
+ const binary = atob(text);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+function stableBytes(bytes: Uint8Array): Uint8Array
{
+ const copy = new Uint8Array(bytes.byteLength);
+ copy.set(bytes);
+ return copy;
+}
+
+function authenticatedMetadata(envelope: SecretEnvelope): string {
+ return JSON.stringify({
+ v: envelope.v,
+ title: envelope.title ?? 'Encrypted secret',
+ cipher: envelope.cipher,
+ kdf: envelope.kdf,
+ iterations: envelope.iterations,
+ salt: envelope.salt,
+ nonce: envelope.nonce,
+ });
+}
+
+function envelopeAad(envelope: SecretEnvelope): Uint8Array {
+ return encoder.encode(authenticatedMetadata(envelope));
+}
+
+function parseEnvelope(payload: string): SecretEnvelope {
+ let envelope: SecretEnvelope;
+ try {
+ envelope = JSON.parse(payload);
+ } catch {
+ throw new Error('Invalid secret payload');
+ }
+ if (
+ envelope?.v !== SECRET_VERSION ||
+ envelope.cipher !== SECRET_CIPHER ||
+ envelope.kdf !== SECRET_KDF ||
+ !Number.isInteger(envelope.iterations) ||
+ envelope.iterations < 100_000 ||
+ envelope.iterations > 5_000_000
+ ) {
+ throw new Error('Unsupported secret payload');
+ }
+ return envelope;
+}
+
+export function readSecretTitle(payload: string): string {
+ try {
+ const parsed = JSON.parse(payload);
+ return typeof parsed?.title === 'string' && parsed.title.trim()
+ ? parsed.title.trim()
+ : 'Encrypted secret';
+ } catch {
+ return 'Encrypted secret';
+ }
+}
+
+async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise {
+ if (!passphrase) throw new Error('Passphrase is required');
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ encoder.encode(passphrase),
+ 'PBKDF2',
+ false,
+ ['deriveKey'],
+ );
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt'],
+ );
+}
+
+export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise {
+ if (!plaintext) throw new Error('Secret text is required');
+ const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
+ const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
+ const envelope: SecretEnvelope = {
+ v: SECRET_VERSION,
+ title: title.trim() || 'Encrypted secret',
+ cipher: SECRET_CIPHER,
+ kdf: SECRET_KDF,
+ iterations: SECRET_ITERATIONS,
+ salt: bytesToBase64(salt),
+ nonce: bytesToBase64(nonce),
+ ct: '',
+ };
+ const encrypted = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
+ key,
+ encoder.encode(plaintext),
+ );
+ envelope.ct = bytesToBase64(new Uint8Array(encrypted));
+ return JSON.stringify(envelope, null, 2);
+}
+
+export async function decryptSecretText(payload: string, passphrase: string): Promise {
+ try {
+ const envelope = parseEnvelope(payload);
+ const salt = base64ToBytes(envelope.salt, 'salt');
+ const nonce = base64ToBytes(envelope.nonce, 'nonce');
+ const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
+ const key = await deriveKey(passphrase, salt, envelope.iterations);
+ const plaintext = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
+ key,
+ stableBytes(ciphertext),
+ );
+ return decoder.decode(plaintext);
+ } catch {
+ throw new Error('Unable to unlock secret. Check the passphrase or payload.');
+ }
+}