Skip to content

Commit

Permalink
refactor(server): Verify code signature on startup
Browse files Browse the repository at this point in the history
Drop self-signed manifest, as it brings little information
if it can't be verified.
  • Loading branch information
franky47 committed Dec 19, 2022
1 parent 69771f1 commit a5f6e42
Show file tree
Hide file tree
Showing 2 changed files with 97 additions and 96 deletions.
59 changes: 56 additions & 3 deletions config/semantic-release/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,64 @@ export async function success(_: any, context: any) {
return
}
const repoRoot = path.resolve(ctx.data.cwd, '../..')
const { gitTag, name, version } = ctx.data.nextRelease
const nameWithoutVersion = name.replace(`@${version}`, '')
const summaryLine = `- [\`${gitTag}\`](https://www.npmjs.com/package/${nameWithoutVersion}/v/${version}) \n`
const { gitTag, version } = ctx.data.nextRelease
const name = gitTag.replace(`@${version}`, '')
const sceau = JSON.parse(
await fs.readFile(path.resolve(ctx.data.cwd, 'sceau.json'), {
encoding: 'utf8',
})
)
const packageJson = JSON.parse(
await fs.readFile(path.resolve(ctx.data.cwd, 'package.json'), {
encoding: 'utf8',
})
)
const depsText = [
renderDependencies(packageJson.dependencies ?? {}, 'Dependencies'),
renderDependencies(packageJson.peerDependencies ?? {}, 'Peer dependencies'),
renderDependencies(
packageJson.devDependencies ?? {},
'Development dependencies'
),
]
.filter(Boolean)
.join('\n')

const summaryLine = `### [\`${gitTag}\`](https://www.npmjs.com/package/${name}/v/${version})
${depsText}
<details>
<summary>🔏 Code signature</summary>
\`\`\`json
${JSON.stringify(sceau, null, 2)}
\`\`\`
</details>
`
await fs.appendFile(
path.resolve(repoRoot, 'GITHUB_STEP_SUMMARY_PACKAGES'),
summaryLine
)
}

function renderDependencies(input: Record<string, string>, heading: string) {
const deps = Object.fromEntries(
Object.entries(input).filter(
([packageName, version]) =>
packageName.startsWith('@socialgouv/e2esdk-') &&
version !== '0.0.0-internal'
)
)
return Object.keys(deps).length > 0
? `#### ${heading}
| Package | Version |
|:------- |:------- |
${Object.entries(deps)
.map(([name, version]) => `| \`${name}\` | \`${version}\` |`)
.join('\n')}
`
: null
}
134 changes: 41 additions & 93 deletions packages/server/src/routes/info.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,23 @@
import {
multipartSignature,
numberToUint32LE,
Sodium,
} from '@socialgouv/e2esdk-crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { sceauSchema, SCEAU_FILE_NAME, verify } from 'sceau'
import { z } from 'zod'
import { zodToJsonSchema } from 'zod-to-json-schema'
import { env } from '../env.js'
import type { App } from '../types'

export const prefixOverride = ''

const manifestEntry = z.object({
path: z.string(),
hash: z.string(),
signature: z.string(),
sizeBytes: z.number(),
})

const infoResponseBody = z.object({
version: z.string(),
release: z.string(),
buildURL: z.string(),
deploymentURL: z.string(),
signaturePublicKey: z.string(),
manifestSignature: z.string(),
manifest: z.array(manifestEntry).optional(),
})
type InfoResponseBody = z.infer<typeof infoResponseBody>

const querystring = z.object({
manifest: z.literal('true').optional().describe('Show extended manifest'),
})

type ManifestEntry = z.infer<typeof manifestEntry>

async function readVersion() {
const packageJsonPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
Expand All @@ -50,109 +31,76 @@ async function readVersion() {
}
}

export default async function infoRoutes(app: App) {
const buildDir = path.resolve(
// --

async function verifyCodeSignature(app: App) {
const rootDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../'
'../..'
)
const version = await readVersion()
const signaturePrivateKey = app.sodium.from_base64(env.SIGNATURE_PRIVATE_KEY)
const manifest = await generateManifest(
const sceauFilePath = path.resolve(rootDir, SCEAU_FILE_NAME)
const sceauFileContents = await fs
.readFile(sceauFilePath, { encoding: 'utf8' })
.catch(error => {
app.log.fatal({ msg: 'Failed to read code signature file', error })
process.exit(1)
})
const sceau = sceauSchema.parse(JSON.parse(sceauFileContents))
const result = await verify(
app.sodium,
signaturePrivateKey,
buildDir
sceau,
rootDir,
app.sodium.from_hex(sceau.publicKey)
)
const manifestSignature = app.sodium.to_base64(
multipartSignature(
app.sodium,
signaturePrivateKey,
...manifest.map(entry => app.sodium.from_base64(entry.hash))
)
)
app.sodium.memzero(signaturePrivateKey)
if (result.outcome === 'failure') {
app.log.fatal({
msg: 'Invalid code signature',
manifestErrors: result.manifestErrors,
signatureVerified: result.signatureVerified,
})
process.exit(0)
}
app.log.info({
msg: 'Code signature verified',
signedOn: result.timestamp,
sources: result.sourceURL,
build: result.buildURL,
})
}

// --

export default async function infoRoutes(app: App) {
if (env.NODE_ENV === 'production') {
await verifyCodeSignature(app)
}
const version = await readVersion()
const serverInfo: InfoResponseBody = {
version,
release: env.RELEASE_TAG,
buildURL: env.BUILD_URL,
deploymentURL: env.DEPLOYMENT_URL,
signaturePublicKey: env.SIGNATURE_PUBLIC_KEY,
manifestSignature,
}
app.log.info({
msg: 'Server info',
...serverInfo,
manifest: env.NODE_ENV === 'production' || env.DEBUG ? manifest : undefined,
})

app.get<{
Reply: z.infer<typeof infoResponseBody>
Querystring: z.infer<typeof querystring>
}>(
'/',
{
schema: {
summary: 'Get server info',
querystring: zodToJsonSchema(querystring),
response: {
200: zodToJsonSchema(infoResponseBody),
},
},
},
async function getServerInfo(req, res) {
const body = {
...serverInfo,
manifest: req.query.manifest === 'true' ? manifest : undefined,
}
return res.send(body)
return res.send(serverInfo)
}
)
}

// --

async function* walk(dir: string): AsyncGenerator<string> {
const dirents = await fs.readdir(dir, { withFileTypes: true })
for (const dirent of dirents) {
const res = path.resolve(dir, dirent.name)
if (dirent.isDirectory()) {
yield* walk(res)
} else {
yield res
}
}
}

async function generateManifest(
sodium: Sodium,
privateKey: Uint8Array,
basePath: string
) {
const manifest: ManifestEntry[] = []
for await (const filePath of walk(basePath)) {
if (filePath.endsWith('.js.map')) {
continue // Ignore sourcemaps
}
const buffer = await fs.readFile(filePath, { encoding: null })
const hash = sodium.crypto_generichash(
sodium.crypto_generichash_BYTES,
buffer
)
const signature = sodium.to_base64(
multipartSignature(
sodium,
privateKey,
sodium.from_string(filePath),
numberToUint32LE(buffer.byteLength),
hash
)
)
manifest.push({
path: filePath,
hash: sodium.to_base64(hash),
signature,
sizeBytes: buffer.byteLength,
})
}
return manifest
}

0 comments on commit a5f6e42

Please sign in to comment.