11#!/usr/bin/env node
2+ import { existsSync , mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
3+ import { dirname } from "node:path" ;
4+ import { exit } from "node:process" ;
5+
26import { Command } from "commander" ;
3- import { createWalletClient , createPublicClient , http , parseAbiItem , toHex } from "viem" ;
47import type { Abi } from "viem" ;
8+ import { createPublicClient , createWalletClient , http , parseAbiItem , toHex } from "viem" ;
59import { privateKeyToAccount } from "viem/accounts" ;
6- import { sepolia , foundry } from "viem/chains" ;
10+ import { foundry , sepolia } from "viem/chains" ;
11+
712import { importAbi } from "@/importAbi.js" ;
813import packageJson from "../package.json" with { type : "json" } ;
9- import { exit } from "node:process" ;
10- import { existsSync , mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
11- import { dirname } from "node:path" ;
12- import { b64urlToHex , jwtKid , parseJwt } from "./oidc.js" ;
14+ import { b64urlToHex , jwtKid , parseJwt , requestGithubOidcToken } from "@/oidc.js" ;
1315
1416const COMMAND_NAME = "fcf" ;
15- const COMMAND_DESCRIPTION = "FCF CLI tool."
17+ const COMMAND_DESCRIPTION = "FCF CLI tool." ;
1618const VERSION = packageJson ?. version || "v0.0.1" ;
19+
20+ const DEFAULT_LIST_BLOCK_RANGE = 50_000n ;
1721const GITHUB_ISSUER = "https://token.actions.githubusercontent.com" ;
22+
1823const REGISTER_WORKFLOW_PATH = ".github/workflows/fcf-register.yml" ;
1924const REGISTER_WORKFLOW_TEMPLATE_PATH = new URL ( "../templates/fcf-register.yml" , import . meta. url ) ;
20- const DEFAULT_LIST_BLOCK_RANGE = 50_000n ;
2125
22- function die ( err : any ) : never {
23- let error = "unknown error" ;
24- if ( err instanceof Error ) error = err . message ;
25- console . error ( `${ COMMAND_NAME } : ${ error } ; exiting.` )
26- exit ( 1 ) ;
26+ const abi = loadAbi ( ) ;
27+ const RepoRegisteredEvent = parseAbiItem (
28+ "event RepoRegistered(uint256 indexed repoId, address indexed registrant, uint64 githubOwnerId, uint64 registeredAt)"
29+ ) ;
30+
31+ function buildProgram ( ) : Command {
32+ const program = new Command ( )
33+ . name ( COMMAND_NAME )
34+ . description ( COMMAND_DESCRIPTION )
35+ . version ( VERSION ) ;
36+
37+ addInitCommand ( program ) ;
38+ addRegisterCommand ( program ) ;
39+ addKeysSyncCommand ( program ) ;
40+ addListCommand ( program ) ;
41+
42+ return program ;
2743}
2844
29- // import abi -> created by forge build
30- let abi : Abi | null = null ;
31- try {
32- abi = importAbi ( ) ;
33- } catch ( err ) {
34- die ( err ) ;
45+ // spawns the github action workflow file that runs the `register` command
46+ function addInitCommand ( program : Command ) : void {
47+ program
48+ . command ( "init" )
49+ . option ( "--force" , "overwrite existing workflow" )
50+ . action ( ( opts ) => {
51+ if ( existsSync ( REGISTER_WORKFLOW_PATH ) && ! opts . force ) {
52+ die ( new Error ( `${ REGISTER_WORKFLOW_PATH } already exists` ) ) ;
53+ }
54+
55+ mkdirSync ( dirname ( REGISTER_WORKFLOW_PATH ) , { recursive : true } ) ;
56+ writeFileSync ( REGISTER_WORKFLOW_PATH , readRegisterWorkflowTemplate ( ) ) ;
57+ console . log ( `created: ${ REGISTER_WORKFLOW_PATH } ` ) ;
58+ } ) ;
3559}
3660
37- const RepoRegisteredEvent = parseAbiItem (
38- "event RepoRegistered(uint256 indexed repoId, address indexed registrant, uint64 githubOwnerId, uint64 registeredAt)"
39- ) ;
61+ // registers the RIK of a repository on-chain -> meant to be run in a github action in the target repo
62+ function addRegisterCommand ( program : Command ) : void {
63+ program
64+ . command ( "register" )
65+ . option ( "--oidc-token <token>" , "GitHub's OIDC repository token" )
66+ // needs to be removed in prod or be a default
67+ . requiredOption ( "--contract <addr>" , "deployed RIK address" )
68+ . action ( async ( opts ) => {
69+ const { account, publicClient, walletClient } = clients ( ) ;
70+ const oidcToken = opts . oidcToken ??
71+ await ( async ( ) => {
72+ try { return await requestGithubOidcToken ( account . address . toLowerCase ( ) ) } catch ( err ) { die ( err ) ; }
73+ } ) ( ) ;
74+ const jwt = parseRequiredJwt ( oidcToken ) ;
75+
76+ const expectedAud = account . address . toLowerCase ( ) ;
77+ if ( jwt . payload . aud ?. toLowerCase ( ) !== expectedAud ) {
78+ die ( new Error ( `aud mismatch: want ${ expectedAud } , got ${ jwt . payload . aud } ` ) ) ;
79+ }
4080
41- const program = new Command ( ) ;
81+ const repoId = BigInt ( jwt . payload . repository_id ) ;
82+ const ownerId = BigInt ( jwt . payload . repository_owner_id ) ;
83+ if ( ! jwt . header . kid ) die ( new Error ( "jwt kid missing" ) ) ;
4284
43- program . name ( COMMAND_NAME )
44- . description ( COMMAND_DESCRIPTION )
45- . version ( VERSION ) ;
85+ const hash = await walletClient . writeContract ( {
86+ address : opts . contract as `0x${ string } ` ,
87+ abi,
88+ functionName : "register" ,
89+ args : [
90+ jwtKid ( jwt . header . kid ) ,
91+ toHex ( jwt . headerB64 ) ,
92+ toHex ( jwt . payloadB64 ) ,
93+ b64urlToHex ( jwt . signatureB64 ) ,
94+ repoId ,
95+ ownerId ,
96+ ] ,
97+ account,
98+ } ) ;
99+ const r = await publicClient . waitForTransactionReceipt ( { hash } ) ;
100+ console . log ( `registered: ${ hash } status=${ r . status } ` ) ;
101+ } ) ;
102+ }
103+
104+ // only the contract deployer can run this -> periodically adds valid Github public signing keys
105+ function addKeysSyncCommand ( program : Command ) : void {
106+ program
107+ . command ( "keys" )
108+ . command ( "sync" )
109+ // needs to be removed in prod or be a default
110+ . requiredOption ( "--contract <addr>" , "deployed RIK address" )
111+ . action ( async ( opts ) => {
112+ const { account, publicClient, walletClient } = clients ( ) ;
113+ const config = await fetchJson ( `${ GITHUB_ISSUER } /.well-known/openid-configuration` ) ;
114+ const jwks = await fetchJson ( config . jwks_uri ) ;
115+
116+ for ( const key of jwks . keys ?? [ ] ) {
117+ if ( key . kty !== "RSA" || ! key . kid || ! key . n || ! key . e ) continue ;
118+
119+ const kid = jwtKid ( key . kid ) ;
120+ const hash = await walletClient . writeContract ( {
121+ address : opts . contract as `0x${ string } ` ,
122+ abi,
123+ functionName : "addKey" ,
124+ args : [ kid , b64urlToHex ( key . n ) , b64urlToHex ( key . e ) ] ,
125+ account,
126+ } ) ;
127+ const r = await publicClient . waitForTransactionReceipt ( { hash } ) ;
128+ console . log ( `key synced: ${ key . kid } kid=${ kid } status=${ r . status } ` ) ;
129+ }
130+ } ) ;
131+ }
132+
133+ function addListCommand ( program : Command ) : void {
134+ program
135+ . command ( "list" )
136+ // needs to be removed in prod or be a default
137+ . requiredOption ( "--contract <addr>" , "deployed RIK address" )
138+ . option ( "--from-block <n>" , "starting block" )
139+ . action ( async ( opts ) => {
140+ const { publicClient } = clients ( ) ;
141+ const fromBlock = opts . fromBlock
142+ ? BigInt ( opts . fromBlock )
143+ : await publicClient . getBlockNumber ( ) - DEFAULT_LIST_BLOCK_RANGE ;
144+
145+ const logs = await publicClient . getLogs ( {
146+ address : opts . contract as `0x${ string } ` ,
147+ event : RepoRegisteredEvent ,
148+ fromBlock,
149+ toBlock : "latest" ,
150+ } ) ;
151+
152+ for ( const l of logs ) {
153+ const { repoId, registrant, githubOwnerId, registeredAt } = l . args ;
154+ console . log (
155+ `repo=${ repoId } ownerId=${ githubOwnerId } registrant=${ registrant } at=${ registeredAt } `
156+ ) ;
157+ }
158+ } ) ;
159+ }
46160
47161function clients ( ) {
48- const pk = process . env . PRIVATE_KEY as `0x${ string } ` ;
49- const rpc = process . env . RPC_URL ?? "http://127.0.0.1:8545" ;
50- const chain = rpc . includes ( "sepolia" ) ? sepolia : foundry ;
51- const account = privateKeyToAccount ( pk ) ;
162+ const privateKey = process . env . PRIVATE_KEY as `0x${ string } ` ;
163+ const rpcUrl = process . env . RPC_URL ?? "http://127.0.0.1:8545" ;
164+ const chain = rpcUrl . includes ( "sepolia" ) ? sepolia : foundry ;
165+ const account = privateKeyToAccount ( privateKey ) ;
52166
53167 return {
54- wallet : createWalletClient ( { account, chain, transport : http ( rpc ) } ) ,
55- publicC : createPublicClient ( { chain, transport : http ( rpc ) } ) ,
56168 account,
169+ publicClient : createPublicClient ( { chain, transport : http ( rpcUrl ) } ) ,
170+ walletClient : createWalletClient ( { account, chain, transport : http ( rpcUrl ) } ) ,
57171 } ;
58-
59- }
60-
61- async function requestGithubOidcToken ( audience : string ) : Promise < string > {
62- const requestUrl = process . env . ACTIONS_ID_TOKEN_REQUEST_URL ;
63- const requestToken = process . env . ACTIONS_ID_TOKEN_REQUEST_TOKEN ;
64- if ( ! requestUrl || ! requestToken ) die ( new Error ( "GitHub OIDC env vars not found" ) ) ;
65-
66- const separator = requestUrl . includes ( "?" ) ? "&" : "?" ;
67- // include aud in payload -> aud == eth address of signer
68- const res = await fetch ( `${ requestUrl } ${ separator } audience=${ encodeURIComponent ( audience ) } ` , {
69- headers : { authorization : `bearer ${ requestToken } ` } ,
70- } ) ;
71- if ( ! res . ok ) die ( new Error ( `failed to fetch GitHub OIDC token: ${ res . status } ` ) ) ;
72-
73- const body = await res . json ( ) as { value ? : string } ;
74- if ( ! body . value ) die ( new Error ( "GitHub OIDC response did not contain token" ) ) ;
75- return body . value ;
76172}
77173
78174async function fetchJson ( url : string ) : Promise < any > {
@@ -81,119 +177,44 @@ async function fetchJson(url: string): Promise<any> {
81177 return res . json ( ) ;
82178}
83179
84- program
85- . command ( "init" )
86- . option ( "--force" , "overwrite existing workflow" )
87- . action ( ( opts ) => {
88- if ( existsSync ( REGISTER_WORKFLOW_PATH ) && ! opts . force ) {
89- die ( new Error ( `${ REGISTER_WORKFLOW_PATH } already exists` ) ) ;
90- }
91-
92- let registerWorkflow : string ;
93- try {
94- registerWorkflow = readFileSync ( REGISTER_WORKFLOW_TEMPLATE_PATH , "utf8" ) ;
95- } catch ( err ) {
96- die ( err ) ;
97- }
98-
99- mkdirSync ( dirname ( REGISTER_WORKFLOW_PATH ) , { recursive : true } ) ;
100- writeFileSync ( REGISTER_WORKFLOW_PATH , registerWorkflow ) ;
101- console . log ( `created: ${ REGISTER_WORKFLOW_PATH } ` ) ;
102- } ) ;
103-
104- program
105- . command ( "register" )
106- . option ( "--oidc-token <token>" , "GitHub's OIDC repository token" )
107- // needs to be removed in prod or be a default
108- . requiredOption ( "--contract <addr>" , "deployed RIK address" )
109- . action ( async ( opts ) => {
110- const { wallet, publicC, account } = clients ( ) ;
111- const oidcToken = opts . oidcToken ?? await requestGithubOidcToken ( account . address . toLowerCase ( ) ) ;
112-
113- const jwt = ( ( ) => {
114- try {
115- return parseJwt ( oidcToken ) ;
116- } catch ( err ) {
117- die ( err ) ;
118- }
119- } ) ( ) ;
120-
121- const expectedAud = account . address . toLowerCase ( ) ;
122- if ( jwt . payload . aud ?. toLowerCase ( ) !== expectedAud ) {
123- die ( new Error ( `aud mismatch: want ${ expectedAud } , got ${ jwt . payload . aud } ` ) ) ;
124- }
125- const repoId = BigInt ( jwt . payload . repository_id ) ;
126- const ownerId = BigInt ( jwt . payload . repository_owner_id ) ;
127- if ( ! jwt . header . kid ) die ( new Error ( "jwt kid missing" ) ) ;
128-
129- const hash = await wallet . writeContract ( {
130- address : opts . contract as `0x${ string } ` ,
131- abi,
132- functionName : "register" ,
133- args : [
134- jwtKid ( jwt . header . kid ) ,
135- toHex ( jwt . headerB64 ) ,
136- toHex ( jwt . payloadB64 ) ,
137- b64urlToHex ( jwt . signatureB64 ) ,
138- repoId ,
139- ownerId ,
140- ] ,
141- account,
142- } ) ;
143- const r = await publicC . waitForTransactionReceipt ( { hash } ) ;
144- console . log ( `registered: ${ hash } status=${ r . status } ` ) ;
145- } ) ;
146-
147- program
148- . command ( "keys" )
149- . command ( "sync" )
150- // needs to be removed in prod or be a default
151- . requiredOption ( "--contract <addr>" , "deployed RIK address" )
152- . action ( async ( opts ) => {
153- const { wallet, publicC, account } = clients ( ) ;
154- const config = await fetchJson ( `${ GITHUB_ISSUER } /.well-known/openid-configuration` ) ;
155- const jwks = await fetchJson ( config . jwks_uri ) ;
156-
157- for ( const key of jwks . keys ?? [ ] ) {
158- if ( key . kty !== "RSA" || ! key . kid || ! key . n || ! key . e ) continue ;
159-
160- const kid = jwtKid ( key . kid ) ;
161- const hash = await wallet . writeContract ( {
162- address : opts . contract as `0x${ string } ` ,
163- abi,
164- functionName : "addKey" ,
165- args : [ kid , b64urlToHex ( key . n ) , b64urlToHex ( key . e ) ] ,
166- account,
167- } ) ;
168- const r = await publicC . waitForTransactionReceipt ( { hash } ) ;
169- console . log ( `key synced: ${ key . kid } kid=${ kid } status=${ r . status } ` ) ;
170- }
171- } ) ;
172-
173- program
174- . command ( "list" )
175- // needs to be removed in prod or be a default
176- . requiredOption ( "--contract <addr>" , "deployed RIK address" )
177- . option ( "--from-block <n>" , "starting block" )
178- . action ( async ( opts ) => {
179- const { publicC } = clients ( ) ;
180-
181- // by default looks only in the last DEFAULT_LIST_BLOCK_RANGE blocks
182- if ( ! opts . fromBlock ) opts . fromBlock = await publicC . getBlockNumber ( ) - DEFAULT_LIST_BLOCK_RANGE ;
183-
184- const logs = await publicC . getLogs ( {
185- address : opts . contract as `0x${ string } ` ,
186- event : RepoRegisteredEvent ,
187- fromBlock : BigInt ( opts . fromBlock ) ,
188- toBlock : "latest" ,
189- } ) ;
190-
191- for ( const l of logs ) {
192- const { repoId, registrant, githubOwnerId, registeredAt} = l . args ;
193- console . log (
194- `repo=${ repoId } ownerId=${ githubOwnerId } registrant=${ registrant } at=${ registeredAt } `
195- ) ;
196- }
197- } ) ;
180+ async function requestRequiredGithubOidcToken ( audience : string ) : Promise < string > {
181+ try {
182+ return await requestGithubOidcToken ( audience) ;
183+ } catch ( err ) {
184+ die ( err ) ;
185+ }
186+ }
187+
188+ function parseRequiredJwt ( token : string ) : ReturnType < typeof parseJwt > {
189+ try {
190+ return parseJwt ( token ) ;
191+ } catch ( err ) {
192+ die ( err ) ;
193+ }
194+ }
195+
196+ function readRegisterWorkflowTemplate ( ) : string {
197+ try {
198+ return readFileSync ( REGISTER_WORKFLOW_TEMPLATE_PATH , "utf8" ) ;
199+ } catch ( err ) {
200+ die ( err ) ;
201+ }
202+ }
203+
204+ function loadAbi ( ) : Abi {
205+ try {
206+ return importAbi ( ) ;
207+ } catch ( err ) {
208+ die ( err ) ;
209+ }
210+ }
211+
212+ function die ( err : any ) : never {
213+ let error = "unknown error" ;
214+ if ( err instanceof Error ) error = err . message ;
215+ console . error ( `${ COMMAND_NAME } : ${ error } ; exiting.` ) ;
216+ exit ( 1 ) ;
217+ }
198218
219+ const program = buildProgram ( ) ;
199220program . parseAsync ( process . argv ) ;
0 commit comments