-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
4,993 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
github: [franky47] | ||
liberapay: francoisbest |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"] | ||
} |
Oops, something went wrong.