diff --git a/src/components/post-card.tsx b/src/components/post-card.tsx index 42e86f0..b30286b 100644 --- a/src/components/post-card.tsx +++ b/src/components/post-card.tsx @@ -35,7 +35,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from './ui/dropdown-menu'; -import { memo, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { usePlausible } from '@/hooks/use-plausible'; import { useBlueskyStore } from '@/lib/bluesky/store'; @@ -207,6 +207,80 @@ const PostDropdownMenu = ({ post, setTranslatedText }: { post: BSkyPost; setTran ); }; +async function createEncryptedPost(content: string) { + // Generate encryption key and IV + const key = await crypto.subtle.generateKey({ name: 'AES-CBC', length: 256 }, true, ['encrypt', 'decrypt']); + + const iv = crypto.getRandomValues(new Uint8Array(16)); + const encoder = new TextEncoder(); + const data = encoder.encode(content); + + // Encrypt content + const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, data); + + // Export key material + const rawKey = await crypto.subtle.exportKey('raw', key); + const keyString = btoa(String.fromCharCode(...new Uint8Array(rawKey))); + + // Combine IV and ciphertext + const combined = new Uint8Array(iv.byteLength + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.byteLength); + + return { + record: { + text: '', // Empty public text + encryption: { + type: 'AES-CBC', + key: keyString, + }, + encryptedText: btoa(String.fromCharCode(...combined)), + }, + }; +} + +window.createEncryptedPost = createEncryptedPost; + +async function decryptPrivatePost(post: BSkyPost) { + if (!post.record.encryption || !post.record.encryptedText) { + return post.record.text; + } + + const { key } = post.record.encryption; + const encryptedText = post.record.encryptedText; + + try { + // Decode base64 key + const keyData = new Uint8Array(Array.from(atob(key), (c) => c.charCodeAt(0))); + + // Import key (without length parameter) + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyData, + { name: post.record.encryption.type }, // Removed length + false, + ['decrypt'], + ); + + // Decode base64 payload + const encryptedData = new Uint8Array(Array.from(atob(encryptedText), (c) => c.charCodeAt(0))); + + // Extract IV and ciphertext with proper buffer boundaries + const iv = new Uint8Array(encryptedData.buffer, 0, 16); + const data = new Uint8Array(encryptedData.buffer, 16); + + // Decrypt + const decryptedData = await crypto.subtle.decrypt({ name: post.record.encryption.type, iv }, cryptoKey, data); + + return new TextDecoder().decode(decryptedData); + } catch (error) { + console.error('Decryption failed:', error); + return 'decryption failed'; + } +} + +window.decryptPrivatePost = decryptPrivatePost; + type PostCardInnerProps = { post: BSkyPost; context?: string; @@ -225,6 +299,14 @@ function PostCardInner({ post, context, className, parent = false }: PostCardInn const navigate = useNavigate(); const [translatedText, setTranslatedText] = useState(null); const { moderation } = usePostLabels({ agent, post }); + const isPrivatePost = !!post.record.encryptedText; + const [postText, setPostText] = useState(isPrivatePost ? 'decrypting private post...' : post.record.text); + + useEffect(() => { + if (isPrivatePost) { + decryptPrivatePost(post).then(setPostText); + } + }, [isPrivatePost, post]); const handleLike = (event: React.MouseEvent) => { event.preventDefault(); @@ -330,9 +412,9 @@ function PostCardInner({ post, context, className, parent = false }: PostCardInn ) : post.record.facets ? ( - + ) : ( - + )}

diff --git a/src/lib/bluesky/types/bsky-post.ts b/src/lib/bluesky/types/bsky-post.ts index 7a1f7cf..a836cac 100644 --- a/src/lib/bluesky/types/bsky-post.ts +++ b/src/lib/bluesky/types/bsky-post.ts @@ -29,6 +29,33 @@ export const BSkyPost = Type.Object({ }), ), text: Type.String(), + encryptedText: Type.Optional(Type.String()), + encryption: Type.Optional( + Type.Object({ + key: Type.String(), + type: Type.String(), + encoding: Type.String(), + }), + ), + acl: Type.Optional( + Type.Object({ + user: Type.Array( + Type.Object({ + did: Type.String(), + permission: Type.Object({ + read: Type.Boolean(), + interact: Type.Object({ + reply: Type.Boolean(), + like: Type.Boolean(), + repost: Type.Boolean(), + quote: Type.Boolean(), + comment: Type.Boolean(), + }), + }), + }), + ), + }), + ), }), embed: Type.Optional(BSkyPostEmbed), replyCount: Type.Number(),