feat: add content encryption
This commit is contained in:
parent
d5d025e011
commit
93fb60724c
4 changed files with 190 additions and 0 deletions
131
src/components/shortcodes/ProtectedContent.astro
Normal file
131
src/components/shortcodes/ProtectedContent.astro
Normal file
|
@ -0,0 +1,131 @@
|
|||
---
|
||||
import { encrypt } from '../../plugins/encrypt';
|
||||
import {siteConfig} from "../../config";
|
||||
|
||||
interface Props {
|
||||
password?: string;
|
||||
pwEnv?: string;
|
||||
}
|
||||
|
||||
const { password: propPassword, pwEnv } = Astro.props;
|
||||
// Get password from props, environment variable, or site config
|
||||
const password = (pwEnv ? import.meta.env[pwEnv] : propPassword) || siteConfig.contentPassword || import.meta.env.CONTENT_PASSWORD || Math.random().toString();
|
||||
|
||||
// Get the slot content
|
||||
const content = await Astro.slots.render('default');
|
||||
|
||||
// Encrypt content at build time
|
||||
const { encryptedData, iv } = encrypt(content, password);
|
||||
---
|
||||
|
||||
<div class="encrypted-content" data-encrypted={encryptedData} data-iv={iv}>
|
||||
<div class="password-form">
|
||||
<p>This content is protected. Enter the password to view it:</p>
|
||||
<form>
|
||||
<input type="password" class="decrypt-password" title="password" />
|
||||
<button class="decrypt-button">Decrypt</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="content-container hidden"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div.encrypted-content {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
div.password-form {
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
input[type="password"] {
|
||||
background: var(--accent-color);
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
button {
|
||||
background-color: var(--accent-color);
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
div.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Client-side decryption logic
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const containers = document.querySelectorAll('.encrypted-content');
|
||||
|
||||
containers.forEach(container => {
|
||||
const encryptedData = container.getAttribute('data-encrypted');
|
||||
const iv = container.getAttribute('data-iv');
|
||||
const passwordInput = container.querySelector('.decrypt-password');
|
||||
const decryptButton = container.querySelector('.decrypt-button');
|
||||
const contentContainer = container.querySelector('.content-container');
|
||||
const passwordForm = container.querySelector('.password-form');
|
||||
|
||||
decryptButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const password = passwordInput.value;
|
||||
if (!password) return;
|
||||
|
||||
const content = await decrypt(encryptedData, iv, password);
|
||||
contentContainer.innerHTML = content;
|
||||
contentContainer.classList.remove('hidden');
|
||||
passwordForm.classList.add('hidden');
|
||||
} catch (error) {
|
||||
alert('Incorrect password');
|
||||
console.error('Decryption failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Allow pressing Enter to decrypt
|
||||
passwordInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
decryptButton.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function decrypt(encryptedData, iv, password) {
|
||||
// Convert base64 to array buffer
|
||||
const encryptedBytes = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0));
|
||||
const ivBytes = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
|
||||
|
||||
// Derive key from password
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBytes = encoder.encode(password);
|
||||
const keyMaterial = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
passwordBytes,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
|
||||
const key = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: ivBytes,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
const decryptedBytes = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: ivBytes },
|
||||
key,
|
||||
encryptedBytes
|
||||
);
|
||||
|
||||
// Convert array buffer to text
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decryptedBytes);
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -28,6 +28,10 @@ export const siteConfig = {
|
|||
searchEngine: 'bing', // 'google', 'duckduckgo', 'bing'(broken until M1cr0$0ft get support for it), defaults to 'google'
|
||||
// content
|
||||
displayAvatar: true, // display author avatar in the article list and info line of article page
|
||||
// encryption
|
||||
// the global password to encrypt/decrypt the content, if set, all <ProtectedContent/> without specifying a password will be encrypted with this password
|
||||
// you can use a different environment variable to set the password.
|
||||
contentPassword: import.meta.env.CONTENT_PASSWORD,
|
||||
// footer
|
||||
// yes you can write html safely here
|
||||
customFooter: '<i>I have no mouth, and I must SCREAM</i>',
|
||||
|
|
|
@ -8,6 +8,7 @@ import Callout from '/src/components/shortcodes/Callout.astro';
|
|||
import LinkCard from '/src/components/shortcodes/LinkCard.astro';
|
||||
import Spoiler from '/src/components/shortcodes/Spoiler.astro';
|
||||
import Ruby from '/src/components/shortcodes/Ruby.astro';
|
||||
import ProtectedContent from '/src/components/shortcodes/ProtectedContent.astro';
|
||||
|
||||
This article offers a sample of basic and extended Markdown formatting that can be used, also it shows how some basic HTML elements are decorated.
|
||||
## Markdown in Astro
|
||||
|
@ -100,8 +101,34 @@ You can use the `LinkCard` component to create cards that link to external resou
|
|||
Or to customize the card further with a title and description:
|
||||
<LinkCard url="https://www.bilibili.com/video/BV1PC4y1L7mq/" title="Don't check the description" description="Don't click on the link" />
|
||||
|
||||
### Protected Content
|
||||
You can use the `ProtectedContent` component to protect certain parts of your content with a password. This is useful for sharing exclusive content or information that should only be accessible to certain users.
|
||||
<Callout type="warning">
|
||||
About security:
|
||||
|
||||
Although the encrypt process is happened at build and encrypted content is not visible in the rendered HTML. But it's still not 100% secure, as the password can be stored in the source code of a document (which could be public). Use it only for not really sensitive content.
|
||||
Using a environment variable to store the password is recommended.
|
||||
</Callout>
|
||||
|
||||
<ProtectedContent password="42">
|
||||
Yes, what you input is the ultimate answer to *life*, *the universe*, and **everything**.
|
||||
</ProtectedContent>
|
||||
|
||||
```mdx
|
||||
<ProtectedContent password="42">
|
||||
Yes, what you input is the ultimate answer to *life*, *the universe*, and **everything**.
|
||||
</ProtectedContent>
|
||||
```
|
||||
|
||||
<ProtectedContent>
|
||||
And this will be encrypted with a global password, which is set in the environment variable `CONTENT_PASSWORD` or defined by `config.ts`. (config.ts takes precedence over the environment variable)
|
||||
</ProtectedContent>
|
||||
|
||||
```mdx
|
||||
<ProtectedContent>
|
||||
And this will be encrypted with a global password, which is set in the environment variable `CONTENT_PASSWORD` or defined by `config.ts`. (config.ts takes precedence over the environment variable)
|
||||
</ProtectedContent>
|
||||
```
|
||||
|
||||
## Headings
|
||||
|
||||
|
|
28
src/plugins/encrypt.ts
Normal file
28
src/plugins/encrypt.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
export function encrypt(content: string, password: string) {
|
||||
// Generate a random initialization vector
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
// Derive key from password using PBKDF2
|
||||
const key = crypto.pbkdf2Sync(password, iv, 100000, 32, 'sha256');
|
||||
|
||||
// Create cipher
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// Encrypt content
|
||||
let encryptedData = cipher.update(content, 'utf8', 'base64');
|
||||
encryptedData += cipher.final('base64');
|
||||
|
||||
// Get auth tag and append to encrypted data
|
||||
const authTag = cipher.getAuthTag();
|
||||
const encryptedBuffer = Buffer.concat([
|
||||
Buffer.from(encryptedData, 'base64'),
|
||||
authTag
|
||||
]);
|
||||
|
||||
return {
|
||||
encryptedData: encryptedBuffer.toString('base64'),
|
||||
iv: iv.toString('base64')
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue