feat: add shared decks and multi-account Mastodon OAuth
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const envelopeSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
keyId: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
ciphertext: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
function decodeBase64(value: string): Buffer {
|
||||
const result = Buffer.from(value, 'base64')
|
||||
if (result.toString('base64') !== value) throw new Error('Invalid encoding')
|
||||
return result
|
||||
}
|
||||
|
||||
function loadKey() {
|
||||
const path = process.env.TWITTER_LITE_CREDENTIAL_KEY_FILE
|
||||
if (!path) throw new Error('TWITTER_LITE_CREDENTIAL_KEY_FILE is required.')
|
||||
let encoded: string
|
||||
try {
|
||||
encoded = readFileSync(path, 'utf8').trim()
|
||||
} catch {
|
||||
throw new Error('Could not read TWITTER_LITE_CREDENTIAL_KEY_FILE.')
|
||||
}
|
||||
let key: Buffer
|
||||
try {
|
||||
key = decodeBase64(encoded)
|
||||
if (key.length !== 32) throw new Error('Invalid key length')
|
||||
} catch {
|
||||
throw new Error(
|
||||
'TWITTER_LITE_CREDENTIAL_KEY_FILE must contain 32 random bytes encoded as base64.',
|
||||
)
|
||||
}
|
||||
return {
|
||||
key,
|
||||
keyId: createHash('sha256').update(key).digest('hex').slice(0, 32),
|
||||
}
|
||||
}
|
||||
|
||||
function associatedData(context: string, keyId: string) {
|
||||
if (!context) throw new Error('A credential record and purpose are required.')
|
||||
return Buffer.from(
|
||||
JSON.stringify(['twitter-lite-credential', 1, keyId, context]),
|
||||
)
|
||||
}
|
||||
|
||||
/** Context must identify both record and purpose; persist the returned opaque JSON. */
|
||||
export function encryptCredential(plaintext: string, context: string): string {
|
||||
const { key, keyId } = loadKey()
|
||||
const iv = randomBytes(12)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
cipher.setAAD(associatedData(context, keyId))
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
])
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
keyId,
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
})
|
||||
}
|
||||
|
||||
export function decryptCredential(serialized: string, context: string): string {
|
||||
const { key, keyId } = loadKey()
|
||||
try {
|
||||
const envelope = envelopeSchema.parse(JSON.parse(serialized))
|
||||
if (envelope.keyId !== keyId) throw new Error('Different key')
|
||||
const iv = decodeBase64(envelope.iv)
|
||||
const tag = decodeBase64(envelope.tag)
|
||||
if (iv.length !== 12 || tag.length !== 16)
|
||||
throw new Error('Invalid envelope')
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAAD(associatedData(context, keyId))
|
||||
decipher.setAuthTag(tag)
|
||||
return Buffer.concat([
|
||||
decipher.update(decodeBase64(envelope.ciphertext)),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Credential could not be decrypted. Check the stored credential and encryption key.',
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user