Skip to content

Commit

Permalink
feat: Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 committed Dec 12, 2022
1 parent 5d42ee7 commit 4d8b9cf
Show file tree
Hide file tree
Showing 10 changed files with 4,993 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github: [franky47]
liberapay: francoisbest
12 changes: 12 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: CI/CD

on:
push:
pull_request:
types: [opened, edited, reopened, synchronize]

jobs:
ci-cd:
name: CI/CD
uses: 47ng/workflows/.github/workflows/node-ci-cd.yml@main
secrets: inherit
80 changes: 80 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"name": "sceau",
"version": "0.0.0-semantically-released",
"description": "Code signing for NPM packages",
"license": "MIT",
"author": {
"name": "François Best",
"url": "https://francoisbest.com",
"email": "npm.sceau@francoisbest.com"
},
"repository": {
"type": "git",
"url": "https://github.com/47ng/sceau"
},
"keywords": [
"code signing"
],
"publishConfig": {
"access": "public"
},
"files": [
"/dist"
],
"type": "module",
"sideEffects": false,
"module": "./dist/lib.js",
"types": "./dist/lib.d.ts",
"bin": "./dist/cli.js",
"exports": {
".": {
"import": "./dist/lib.js",
"types": "./dist/lib.d.ts"
}
},
"tsup": {
"entry": [
"src/cli.ts",
"src/lib.ts"
],
"format": [
"esm"
],
"treeshake": true
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup --clean --dts",
"typecheck": "tsc",
"test": "jest --color",
"ci": "run-s typecheck build"
},
"dependencies": {
"@npmcli/arborist": "^6.1.5",
"libsodium-wrappers": "^0.7.10",
"npm-packlist": "^7.0.4",
"zod": "3.20.0-beta.0",
"zx": "^7.1.1"
},
"devDependencies": {
"@swc/core": "^1.3.22",
"@swc/jest": "^0.2.24",
"@types/jest": "^29.2.4",
"@types/libsodium-wrappers": "^0.7.10",
"@types/node": "^18.11.13",
"@types/npm-packlist": "^3.0.0",
"@types/npmcli__arborist": "^5.6.0",
"jest": "^29.3.1",
"npm-run-all": "^4.1.5",
"ts-jest": "^29.0.3",
"tsup": "^6.5.0",
"typescript": "^4.9.4"
},
"prettier": {
"arrowParens": "avoid",
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}
}
55 changes: 55 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env zx

import { z } from 'zod'
import 'zx/globals'
import { initializeSodium } from './crypto/sodium'
import { generate, hexStringSchema } from './lib'

const DEFAULT_URL = 'unknown://local'

const envSchema = z.object({
SCEAU_PRIVATE_KEY: hexStringSchema(64).optional(),
SCEAU_PUBLIC_KEY: hexStringSchema(32).optional(),
// todo: Detect build & source URLs based on environment (GitHub Actions)
SCEAU_BUILD_URL: z.string().url().optional().default(DEFAULT_URL),
SCEAU_SOURCE_URL: z.string().url().optional().default(DEFAULT_URL),
})

const env = envSchema.parse(process.env)
const sodium = await initializeSodium()

if (argv._[0] === 'keygen') {
if (argv.seed && !hexStringSchema(64).safeParse(argv.seed).success) {
console.error(
'Error: private key seed should be a 64 byte hex string (128 characters)'
)
process.exit(1)
}
const keypair = argv.seed
? sodium.crypto_sign_seed_keypair(sodium.from_hex(argv.seed), 'hex')
: sodium.crypto_sign_keypair('hex')
console.log(`SCEAU_PRIVATE_KEY=${keypair.privateKey}`)
if (argv.pub) {
console.log(`SCEAU_PUBLIC_KEY=${keypair.publicKey}`)
}
}

if (argv._[0] === 'generate') {
const buildURL = argv.build ?? env.SCEAU_BUILD_URL
const sourceURL = argv.src ?? env.SCEAU_SOURCE_URL
const privateKey = argv.privateKey ?? env.SCEAU_PRIVATE_KEY
const packageDir = argv.packageDir ?? process.cwd()
const sceau = await generate(sodium, {
packageDir,
privateKey,
buildURL,
sourceURL,
})
if (argv.addToPackageJson) {
const packageJsonPath = path.resolve(packageDir, 'package.json')
const packageJson = await fs.readJSON(packageJsonPath)
packageJson.sceau = sceau
await fs.writeJSON(packageJsonPath, packageJson, { spaces: 2 })
}
console.log(JSON.stringify(sceau, null, Boolean(argv.pretty) ? 2 : 0))
}
6 changes: 6 additions & 0 deletions src/crypto/codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function numberToUint32LE(input: number) {
const buffer = new ArrayBuffer(4)
const u32 = new Uint32Array(buffer)
u32[0] = input
return new Uint8Array(buffer)
}
69 changes: 69 additions & 0 deletions src/crypto/signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { numberToUint32LE } from './codec'
import type { Sodium } from './sodium'

export function generateSignatureKeyPair(sodium: Sodium) {
return sodium.crypto_sign_keypair()
}

/**
* Calculate a multipart detached signature of multiple elements.
*
* Use `verifyMultipartSignature` for verification.
*
* Algorithm: Ed25519ph, with prepended manifest (see `generateManifest`)
*
* @param privateKey Signature private key
* @param items Buffers to include in the calculation
*/
export function multipartSignature(
sodium: Sodium,
privateKey: Uint8Array,
...items: Uint8Array[]
) {
const state = assembleMultipartSignatureState(sodium, items)
return sodium.crypto_sign_final_create(state, privateKey)
}

/**
* Verify integrity and provenance of a set of items, by verifying the signature
* of the hash of those items.
*
* @param publicKey Signature public key
* @param signature As returned by `multipartSignature`
* @param items Items to verify
*/
export function verifyMultipartSignature(
sodium: Sodium,
publicKey: Uint8Array,
signature: Uint8Array,
...items: Uint8Array[]
) {
const state = assembleMultipartSignatureState(sodium, items)
return sodium.crypto_sign_final_verify(state, signature, publicKey)
}

// Internal --

export function assembleMultipartSignatureState(
sodium: Sodium,
items: Uint8Array[]
) {
const state = sodium.crypto_sign_init()
// Include a representation of the structure of the input items (manifest)
// as the first element, to prevent canonicalisation attacks:
const manifest = generateSignatureManifest(items)
sodium.crypto_sign_update(state, manifest)
sodium.memzero(manifest)
// Then add each item to the internal hash
items.forEach(item => sodium.crypto_sign_update(state, item))
return state
}

function generateSignatureManifest(items: Uint8Array[]) {
const manifest = new Uint8Array(4 + items.length * 4)
manifest.set(numberToUint32LE(items.length))
items.forEach((item, index) => {
manifest.set(numberToUint32LE(item.byteLength), 4 + index * 4)
})
return manifest
}
7 changes: 7 additions & 0 deletions src/crypto/sodium.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function initializeSodium() {
const sodium = (await import('libsodium-wrappers')).default
await sodium.ready
return sodium
}

export type Sodium = Awaited<ReturnType<typeof initializeSodium>>
123 changes: 123 additions & 0 deletions src/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Arborist from '@npmcli/arborist'
import fs from 'node:fs/promises'
import packlist from 'npm-packlist'
import { z } from 'zod'
import { numberToUint32LE } from './crypto/codec'
import { multipartSignature } from './crypto/signature'
import type { Sodium } from './crypto/sodium'

export const hexStringSchema = (bytes: number) =>
z
.string()
.regex(
new RegExp(`^[0-9a-f]{${bytes * 2}}$`, 'i'),
`Expecting ${bytes} bytes in hexadecimal encoding (${
bytes * 2
} characters)`
)

const signatureSchema = hexStringSchema(64)

export const manifestEntrySchema = z.object({
path: z.string(),
hash: hexStringSchema(64),
sizeBytes: z.number().int().positive(),
signature: signatureSchema,
})

export type ManifestEntry = z.infer<typeof manifestEntrySchema>

export const sceauSchema = z.object({
signature: signatureSchema,
publicKey: hexStringSchema(32),
timestamp: z.string().datetime({ precision: 3 }),
sourceURL: z.string().url(),
buildURL: z.string().url(),
manifest: z.array(manifestEntrySchema),
})

export type Sceau = z.infer<typeof sceauSchema>

async function getManifestEntry(
sodium: Sodium,
packageDir: string,
relativeFilePath: string,
privateKey: Uint8Array
): Promise<ManifestEntry> {
const filePath = path.resolve(packageDir, relativeFilePath)
const contents = await fs.readFile(filePath)
const hash = sodium.crypto_generichash(64, contents, null)
const sizeBytes = contents.byteLength
const signature = multipartSignature(
sodium,
privateKey,
sodium.from_string(filePath),
hash,
numberToUint32LE(sizeBytes)
)
return {
path: relativeFilePath,
hash: sodium.to_hex(hash),
sizeBytes: contents.byteLength,
signature: sodium.to_hex(signature),
}
}

async function getManifest(
sodium: Sodium,
packageDir: string,
privateKey: Uint8Array
) {
const arborist = new Arborist({ path: packageDir })
const tree = await arborist.loadActual()
const files = await packlist(tree)
files.sort()
return Promise.all(
files.map(filePath =>
getManifestEntry(sodium, packageDir, filePath, privateKey)
)
)
}

const sceauInputSchema = sceauSchema
.pick({
sourceURL: true,
buildURL: true,
})
.extend({
packageDir: z.string(),
privateKey: hexStringSchema(64),
})

type SceauInput = z.infer<typeof sceauInputSchema>

export async function generate(sodium: Sodium, input: SceauInput) {
const { packageDir, sourceURL, buildURL, privateKey } =
sceauInputSchema.parse(input)
const secretKey = sodium.from_hex(privateKey)
const publicKey = sodium.to_hex(secretKey.slice(32, 64))
const manifest = await getManifest(sodium, packageDir, secretKey)
const timestamp = new Date().toISOString()
const signature = multipartSignature(
sodium,
secretKey,
sodium.from_string(timestamp),
sodium.from_string(sourceURL),
sodium.from_string(buildURL),
...manifest.map(entry => sodium.from_hex(entry.hash))
)
sodium.memzero(secretKey)
const sceau: Sceau = {
signature: sodium.to_hex(signature),
publicKey,
timestamp,
sourceURL,
buildURL,
manifest,
}
return sceau
}

export async function verify(sodium: Sodium, sceau: Sceau, packageDir: string) {
// todo: Implement me
}
32 changes: 32 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
// Type checking
"strict": true,

// Modules
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
// Language & Environment
"target": "ESNext",
"lib": ["ESNext"],

// Emit
"noEmit": true,
"declaration": false,
"downlevelIteration": true,

// Interop
"allowJs": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,

// Misc
"skipLibCheck": true,
"skipDefaultLibCheck": true
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}
Loading

0 comments on commit 4d8b9cf

Please sign in to comment.