Encrypt Nextcloud passwords at rest, never return them via API
Passwords are encrypted with AES-256-GCM before being written to users.json. The GET /api/users/:token endpoint no longer returns the password field, so the settings form asks users to re-enter only when changing it. Legacy plaintext passwords in users.json are decrypted transparently and re-encrypted on next save. Requires a new ENCRYPTION_KEY env var (64-char hex, 32 bytes). Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
611a4a6da3
commit
0b26fd86ad
3 files changed
+55
-10
No files matched your search
@@ -19,15 +19,22 @@ Example filename:
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
Generate an encryption key (required — used to encrypt Nextcloud passwords at rest):
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
```
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```env
|
||||
ENCRYPTION_KEY=paste-your-generated-key-here
|
||||
ADMIN_PASSWORD=choose-a-strong-password
|
||||
PORT=3000
|
||||
# USERS_FILE=users.json # optional, defaults to users.json
|
||||
```
|
||||
|
||||
`ADMIN_PASSWORD` is required to create new accounts via the web UI. Without it, registration is disabled (existing users can still update their settings).
|
||||
`ENCRYPTION_KEY` is required — the server will not start without it. `ADMIN_PASSWORD` is required to create new accounts via the web UI; without it, registration is disabled (existing users can still update their settings via their token URL).
|
||||
|
||||
### 2. Run
|
||||
|
||||
|
||||
+4
-1
@@ -312,9 +312,12 @@
|
||||
|
||||
document.getElementById('nextcloudUrl').value = user.nextcloudUrl
|
||||
document.getElementById('username').value = user.username
|
||||
document.getElementById('password').value = user.password
|
||||
document.getElementById('folder').value = user.folder
|
||||
|
||||
const pwField = document.getElementById('password')
|
||||
pwField.placeholder = 'Leave blank to keep your current password'
|
||||
pwField.required = false
|
||||
|
||||
document.getElementById('adminSection').classList.add('hidden')
|
||||
document.getElementById('submitBtn').textContent = 'Update settings'
|
||||
document.getElementById('subtitle').textContent = 'Update your integration settings'
|
||||
|
||||
@@ -2,12 +2,43 @@ import 'dotenv/config'
|
||||
import express from 'express'
|
||||
import axios from 'axios'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'crypto'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// --- Encryption setup ---
|
||||
|
||||
const ENCRYPTION_KEY_HEX = process.env.ENCRYPTION_KEY
|
||||
if (!ENCRYPTION_KEY_HEX || ENCRYPTION_KEY_HEX.length !== 64) {
|
||||
console.error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes).')
|
||||
console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"')
|
||||
process.exit(1)
|
||||
}
|
||||
const ENCRYPTION_KEY = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
|
||||
|
||||
function encryptPassword(plaintext) {
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv)
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const authTag = cipher.getAuthTag()
|
||||
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`
|
||||
}
|
||||
|
||||
function decryptPassword(stored) {
|
||||
if (!stored.startsWith('enc:')) {
|
||||
// Legacy plaintext — transparently supported until next save
|
||||
return stored
|
||||
}
|
||||
const [, ivHex, authTagHex, encryptedHex] = stored.split(':')
|
||||
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, Buffer.from(ivHex, 'hex'))
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
|
||||
return decipher.update(Buffer.from(encryptedHex, 'hex')) + decipher.final('utf8')
|
||||
}
|
||||
|
||||
// --- App setup ---
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(express.urlencoded({ extended: false }))
|
||||
@@ -59,7 +90,7 @@ app.post('/api/users', (req, res) => {
|
||||
users[token] = {
|
||||
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
|
||||
username,
|
||||
password,
|
||||
password: encryptPassword(password),
|
||||
folder: folder || 'AudioPen'
|
||||
}
|
||||
saveUsers()
|
||||
@@ -69,24 +100,28 @@ app.post('/api/users', (req, res) => {
|
||||
res.json({ token, webhookUrl })
|
||||
})
|
||||
|
||||
// Returns settings without the password
|
||||
app.get('/api/users/:token', (req, res) => {
|
||||
const user = users[req.params.token]
|
||||
if (!user) return res.status(404).json({ error: 'Not found.' })
|
||||
res.json(user)
|
||||
const { password: _omit, ...safeUser } = user
|
||||
res.json(safeUser)
|
||||
})
|
||||
|
||||
// Password is optional — omit to keep the existing one
|
||||
app.put('/api/users/:token', (req, res) => {
|
||||
if (!users[req.params.token]) return res.status(404).json({ error: 'Not found.' })
|
||||
const existing = users[req.params.token]
|
||||
if (!existing) return res.status(404).json({ error: 'Not found.' })
|
||||
|
||||
const { nextcloudUrl, username, password, folder } = req.body
|
||||
if (!nextcloudUrl || !username || !password) {
|
||||
return res.status(400).json({ error: 'nextcloudUrl, username, and password are required.' })
|
||||
if (!nextcloudUrl || !username) {
|
||||
return res.status(400).json({ error: 'nextcloudUrl and username are required.' })
|
||||
}
|
||||
|
||||
users[req.params.token] = {
|
||||
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
|
||||
username,
|
||||
password,
|
||||
password: password ? encryptPassword(password) : existing.password,
|
||||
folder: folder || 'AudioPen'
|
||||
}
|
||||
saveUsers()
|
||||
@@ -156,7 +191,7 @@ ${transcript}
|
||||
await axios.put(webdavUrl, markdown, {
|
||||
auth: {
|
||||
username: user.username,
|
||||
password: user.password
|
||||
password: decryptPassword(user.password)
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown'
|
||||
|
||||
Reference in new issue
Block a user