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:
nickandClaude Sonnet 4.6 committed 2026-06-18 17:38:27 -07:00
1 parent 611a4a6da3
commit 0b26fd86ad
3 files changed
+55 -10

No files matched your search

+8 -1
View File
@@ -19,15 +19,22 @@ Example filename:
### 1. Configure environment ### 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: Create a `.env` file in the project root:
```env ```env
ENCRYPTION_KEY=paste-your-generated-key-here
ADMIN_PASSWORD=choose-a-strong-password ADMIN_PASSWORD=choose-a-strong-password
PORT=3000 PORT=3000
# USERS_FILE=users.json # optional, defaults to users.json # 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 ### 2. Run
+4 -1
View File
@@ -312,9 +312,12 @@
document.getElementById('nextcloudUrl').value = user.nextcloudUrl document.getElementById('nextcloudUrl').value = user.nextcloudUrl
document.getElementById('username').value = user.username document.getElementById('username').value = user.username
document.getElementById('password').value = user.password
document.getElementById('folder').value = user.folder 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('adminSection').classList.add('hidden')
document.getElementById('submitBtn').textContent = 'Update settings' document.getElementById('submitBtn').textContent = 'Update settings'
document.getElementById('subtitle').textContent = 'Update your integration settings' document.getElementById('subtitle').textContent = 'Update your integration settings'
+43 -8
View File
@@ -2,12 +2,43 @@ import 'dotenv/config'
import express from 'express' import express from 'express'
import axios from 'axios' import axios from 'axios'
import { readFileSync, writeFileSync } from 'fs' import { readFileSync, writeFileSync } from 'fs'
import { randomUUID } from 'crypto' import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'crypto'
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.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() const app = express()
app.use(express.urlencoded({ extended: false })) app.use(express.urlencoded({ extended: false }))
@@ -59,7 +90,7 @@ app.post('/api/users', (req, res) => {
users[token] = { users[token] = {
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''), nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
username, username,
password, password: encryptPassword(password),
folder: folder || 'AudioPen' folder: folder || 'AudioPen'
} }
saveUsers() saveUsers()
@@ -69,24 +100,28 @@ app.post('/api/users', (req, res) => {
res.json({ token, webhookUrl }) res.json({ token, webhookUrl })
}) })
// Returns settings without the password
app.get('/api/users/:token', (req, res) => { app.get('/api/users/:token', (req, res) => {
const user = users[req.params.token] const user = users[req.params.token]
if (!user) return res.status(404).json({ error: 'Not found.' }) 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) => { 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 const { nextcloudUrl, username, password, folder } = req.body
if (!nextcloudUrl || !username || !password) { if (!nextcloudUrl || !username) {
return res.status(400).json({ error: 'nextcloudUrl, username, and password are required.' }) return res.status(400).json({ error: 'nextcloudUrl and username are required.' })
} }
users[req.params.token] = { users[req.params.token] = {
nextcloudUrl: nextcloudUrl.replace(/\/$/, ''), nextcloudUrl: nextcloudUrl.replace(/\/$/, ''),
username, username,
password, password: password ? encryptPassword(password) : existing.password,
folder: folder || 'AudioPen' folder: folder || 'AudioPen'
} }
saveUsers() saveUsers()
@@ -156,7 +191,7 @@ ${transcript}
await axios.put(webdavUrl, markdown, { await axios.put(webdavUrl, markdown, {
auth: { auth: {
username: user.username, username: user.username,
password: user.password password: decryptPassword(user.password)
}, },
headers: { headers: {
'Content-Type': 'text/markdown' 'Content-Type': 'text/markdown'