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
|
### 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
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in new issue
Block a user