F\(^\star \) is a general-purpose programming language aimed at program verification. It puts together the automation of an SMT-backed deductive verification tool with the expressive power of a language with full-spectrum dependent types. Briefly, it is a functional, higher-order, effectful, dependently typed language, with syntax loosely based on OCaml. F\(^\star \) supports refinement types and Hoare-style specifications, computing VCs of computations via a type-level weakest precondition (WP) calculus packed within Dijkstra monads [57]. F\(^\star \)’s effect system is also user-extensible [1]. Using it, one can model or embed imperative programming in styles ranging from ML to C [55] and assembly [35]. After verification, F\(^\star \) programs can be extracted to efficient OCaml or F# code. A first-order fragment of F\(^\star \), called Low\(^\star \), can also be extracted to C via the KreMLin compiler [55].
This paper introduces Meta-F\(^\star \), a metaprogramming framework for F\(^\star \) that allows users to safely customize and extend F\(^\star \) in many ways. For instance, Meta-F\(^\star \) can be used to preprocess or solve proof obligations; synthesize F\(^\star \) expressions; generate top-level definitions; and resolve implicit arguments in user-defined ways, enabling non-trivial extensions. This paper primarily discusses the first two features. Technically, none of these features deeply increase the expressive power of F\(^\star \), since one could manually program in F\(^\star \) terms that can now be metaprogrammed. However, as we will see shortly, manually programming terms and their proofs can be so prohibitively costly as to be practically infeasible.
Meta-F\(^\star \) is similar to other tactic frameworks, such as Coq’s [29] or Lean’s [30], in presenting a set of goals to the programmer, providing commands to break them down, allowing to inspect and build abstract syntax, etc. In this paper, we mostly detail the characteristics where Meta-F\(^\star \) differs from other engines.
This section presents Meta-F\(^\star \) informally, displaying its usage through case studies. We present any necessary F\(^\star \) background as needed.
2.1 Tactics for Individual Assertions and Partial Canonicalization
Non-linear arithmetic reasoning is crucially needed for the verification of optimized, low-level cryptographic primitives [18, 64], an important use case for F\(^\star \) [13] and other verification frameworks, including those that rely on SMT solving alone (e.g., Dafny [47]) as well as those that rely exclusively on tactic-based proofs (e.g., FiatCrypto [32]). While both styles have demonstrated significant successes, we make a case for a middle ground, leveraging the SMT solver for the parts of a VC where it is effective, and using tactics only where it is not.
We focus on Poly1305 [11], a widely-used cryptographic MAC that computes a series of integer multiplications and additions modulo a large prime number \(p = 2^{130} - 5\). Implementations of the Poly1305 multiplication and mod operations are carefully hand-optimized to represent 130-bit numbers in terms of smaller 32-bit or 64-bit registers, using clever tricks; proving their correctness requires reasoning about long sequences of additions and multiplications.
Previously: Guiding SMT Solvers by Manually Applying Lemmas. Prior proofs of correctness of Poly1305 and other cryptographic primitives using SMT-based program verifiers, including F\(^\star \) [64] and Dafny [18], use a combination of SMT automation and manual application of lemmas. On the plus side, SMT solvers are excellent at linear arithmetic, so these proofs delegate all associativity-commutativity (AC) reasoning about addition to SMT. Non-linear arithmetic in SMT solvers, even just AC-rewriting and distributivity, are, however, inefficient and unreliable—so much so that the prior efforts above (and other works too [40, 41]) simply turn off support for non-linear arithmetic in the solver, in order not to degrade verification performance across the board due to poor interaction of theories. Instead, users need to explicitly invoke lemmas.Footnote 1
For instance, here is a statement and proof of a lemma about Poly1305 in F\(^\star \). The property and its proof do not really matter; the lines marked “
” do. In this particular proof, working around the solver’s inability to effectively reason about non-linear arithmetic, the programmer has spelled out basic facts about distributivity of multiplication and addition, by calling the library lemma
, in order to guide the solver towards the proof. (Below,
and
represent \(2^{44}\) and \(2^{88}\) respectively)
Even at this relatively small scale, needing to explicitly instantiate the distributivity lemma is verbose and error prone. Even worse, the user is blind while doing so: the program text does not display the current set of available facts nor the final goal. Proofs at this level of abstraction are painfully detailed in some aspects, yet also heavily reliant on the SMT solver to fill in the aspects of the proof that are missing.
Given enough time, the solver can sometimes find a proof without the additional hints, but this is usually rare and dependent on context, and almost never robust. In this particular example we find by varying Z3’s random seed that, in an isolated setting, the lemma is proven automatically about 32% of the time. The numbers are much worse for more complex proofs, and where the context contains many facts, making this style quickly spiral out of control. For example, a proof of one of the main lemmas in Poly1305,
, requires 41 steps of rewriting for associativity-commutativity of multiplication, and distributivity of addition and multiplication—making the proof much too long to show here.
SMT and Tactics in Meta-F\({^{\star }}\mathbf{.}\) The listing below shows the statement and proof of
in Meta-F\(^\star \), of which the lemma above was previously only a small part. Again, the specific property proven is not particularly relevant to our discussion. But, this time, the proof contains just two steps.
First, we call a single lemma about modular addition from F\(^\star \)’s standard library. Then, we assert an equality annotated with a tactic (
). Instead of encoding the assertion as-is to the SMT solver, it is preprocessed by the
tactic. The tactic is presented with the asserted equality as its goal, in an environment containing not only all variables in scope but also hypotheses for the precondition of
and the postcondition of the
call (otherwise, the assertion could not be proven). The tactic will then canonicalize the sides of the equality, but notably only “up to” linear arithmetic conversions. Rather than fully canonicalizing the terms, the tactic just rewrites them into a sum-of-products canonical form, leaving all the remaining work to the SMT solver, which can then easily and robustly discharge the goal using linear arithmetic only.
This tactic works over terms in the commutative semiring of integers (
) using proof-by-reflection [12, 20, 36, 38]. Internally, it is composed of a simpler, also proof-by-reflection based tactic
that works over monoids, which is then “stacked” on itself to build
. The basic idea of proof-by-reflection is to reduce most of the proof burden to mechanical computation, obtaining much more efficient proofs compared to repeatedly applying lemmas. For
, we begin with a type for monoids, a small AST representing monoid values, and a denotation for expressions back into the monoid type.
To canonicalize an
, it is first converted to a list of operands (
) and then reflected back to the monoid (
). The process is proven correct, in the particular case of equalities, by the
lemma.
At this stage, if the goal is
, we require two monoidal expressions
and
such that
and
. They are constructed by the tactic
by inspecting the syntax of the goal, using Meta-F\(^\star \)’s reflection capabilities (detailed ahead in Sect. 3.3). We have no way to prove once and for all that the expressions built by
correctly denote the terms, but this fact can be proven automatically at each application of the tactic, by simple unification. The tactic then applies the lemma
, and the goal is changed to
. Finally, by normalization, each side will be canonicalized by running
and
.
The
tactic follows a similar approach, and is similar to existing reflective tactics for other proof assistants [9, 38], except that it only canonicalizes up to linear arithmetic, as explained above. The full VC for
contains many other facts, e.g., that
is non-zero so the division is well-defined and that the postcondition does indeed hold. These obligations remain in a “skeleton” VC that is also easily proven by Z3. This proof is much easier for the programmer to write and much more robust, as detailed ahead in Sect. 6.1. The proof of Poly1305’s other main lemma,
, is also similarly well automated.
Tactic Proofs Without SMT. Of course, one can verify
in Coq, following the same conceptual proof used in Meta-F\(^\star \), but relying on tactics only. Our proof (included in the appendix) is 27 lines long, two of which involve the use of Coq’s
tactic (similar to our
tactic) and
tactic for solving formulas in Presburger arithmetic. The remaining 25 lines include steps to destruct the propositional structure of terms, rewrite by equalities, enriching the context to enable automatic modulo rewriting (Coq does not fully automatically recognize equality modulo p as an equivalence relation compatible with arithmetic operators). While a mature proof assistant like Coq has libraries and tools to ease this kind of manipulation, it can still be verbose.
In contrast, in Meta-F\(^\star \) all of these mundane parts of a proof are simply dispatched to the SMT solver, which decides linear arithmetic efficiently, beyond the quantifier-free Presburger fragment supported by tactics like
, handles congruence closure natively, etc.
2.2 Tactics for Entire VCs and Separation Logic
A different way to invoke Meta-F\(^\star \) is over an entire VC. While the exact shape of VCs is hard to predict, users with some experience can write tactics that find and solve particular sub-assertions within a VC, or simply massage them into shapes better suited for the SMT solver. We illustrate the idea on proofs for heap-manipulating programs.
One verification method that has eluded F\(^\star \) until now is separation logic, the main reason being that the pervasive “frame rule” requires instantiating existentially quantified heap variables, which is a challenge for SMT solvers, and simply too tedious for users. With Meta-F\(^\star \), one can do better. We have written a (proof-of-concept) embedding of separation logic and a tactic (
) that performs heap frame inference automatically.
The approach we follow consists of designing the WP specifications for primitive stateful actions so as to make their footprint syntactically evident. The tactic then descends through VCs until it finds an existential for heaps arising from the frame rule. Then, by solving an equality between heap expressions (which requires canonicalization, for which we use a variant of
targeting commutative monoids) the tactic finds the frames and instantiates the existentials. Notably, as opposed to other tactic frameworks for separation logic [4, 45, 49, 51], this is all our tactic does before dispatching to the SMT solver, which can now be effective over the instantiated VC.
We now provide some detail on the framework. Below, ‘
’ represents the empty heap, ‘\(\bullet \)’ is the separating conjunction and ‘
’ is the heaplet with the single reference
set to value
.Footnote 2 Our development distinguishes between a “heap” and its “memory” for technical reasons, but we will treat the two as equivalent here. Further,
is a predicate discriminating valid heaps (as in [52]), i.e., those built from separating conjunctions of actually disjoint heaps.
We first define the type of WPs and present the WP for the frame rule:
Intuitively,
behaves as the postcondition
“framed” by
, i.e.,
holds when the two heaps
and
are disjoint and
holds over the result value
and the conjoined heaps. Then,
takes a postcondition
and initial heap
, and requires that
can be split into disjoint subheaps
(the footprint) and
(the frame), such that the postcondition
, when properly framed, holds over the footprint.
In order to provide specifications for primitive actions we start in small-footprint style. For instance, below is the WP for reading a reference:
We then insert framing wrappers around such small-footprint WPs when exposing the corresponding stateful actions to the programmer, e.g.,
To verify code written in such style, we annotate the corresponding programs to have their VCs processed by
. For instance, for the
function below, the tactic successfully finds the frames for the four occurrences of the frame rule and greatly reduces the solver’s work. Even in this simple example, not performing such instantiation would cause the solver to fail.
The
tactic: (1) uses syntax inspection to unfold and traverse the goal until it reaches a
—say, the one for
; (2) inspects
’s first explicit argument (here
) to compute the references the current command requires (here
); (3) uses unification variables to build a memory expression describing the required framing of input memory (here
) and instantiates the existentials of
with these unification variables; (4) builds a goal that equates this memory expression with
’s third argument (here
); and (5) uses a commutative monoids tactic (similar to Sect. 2.1) with the heap algebra (
,
) to canonicalize the equality and sort the heaplets. Next, it can solve for the unification variables component-wise, instantiating
to
and
, and then proceed to the next
.
In general, after frames are instantiated, the SMT solver can efficiently prove the remaining assertions, such as the obligations about heap definedness. Thus, with relatively little effort, Meta-F\(^\star \) brings an (albeit simple version of a) widely used yet previously out-of-scope program logic (i.e., separation logic) into F\(^\star \). To the best of our knowledge, the ability to script separation logic into an SMT-based program verifier, without any primitive support, is unique.
2.3 Metaprogramming Verified Low-Level Parsers and Serializers
Above, we used Meta-F\(^\star \) to manipulate VCs for user-written code. Here, we focus instead on generating verified code automatically. We loosely refer to the previous setting as using “tactics”, and to the current one as “metaprogramming”. In most ITPs, tactics and metaprogramming are not distinguished; however in a program verifier like F\(^\star \), where some proofs are not materialized at all (Sect. 4.1), proving VCs of existing terms is distinct from generating new terms.
Metaprogramming in F\(^\star \) involves programmatically generating a (potentially effectful) term (e.g., by constructing its syntax and instructing F\(^\star \) how to type-check it) and processing any VCs that arise via tactics. When applicable (e.g., when working in a domain-specific language), metaprogramming verified code can substantially reduce, or even eliminate, the burden of manual proofs.
We illustrate this by automating the generation of parsers and serializers from a type definition. Of course, this is a routine task in many mainstream metaprogramming frameworks (e.g., Template Haskell, camlp4, etc). The novelty here is that we produce imperative parsers and serializers extracted to C, with proofs that they are memory safe, functionally correct, and mutually inverse. This section is slightly simplified, more detail can be found the appendix.
We proceed in several stages. First, we program a library of pure, high-level parser and serializer combinators, proven to be (partial) mutual inverses of each other. A
for a type
is represented as a function possibly returning a
along with the amount of input bytes consumed. The type of a
for a given
contains a refinementFootnote 3 stating that
is an inverse of the serializer. A
is a dependent record of a parser and an associated serializer.
Basic combinators in the library include constructs for parsing and serializing base values and pairs, such as the following:
Next, we define low-level versions of these combinators, which work over mutable arrays instead of byte sequences. These combinators are coded in the Low\(^\star \) subset of F\(^\star \) (and so can be extracted to C) and are proven to both be memory-safe and respect their high-level variants. The type for low-level parsers,
, denotes an imperative function that reads from an array of bytes and returns a
, behaving as the specificational parser
. Conversely, a
writes into an array of bytes, behaving as
.
Given such a library, we would like to build verified, mutually inverse, low-level parsers and serializers for specific data formats. The task is mechanical, yet overwhelmingly tedious by hand, with many auxiliary proof obligations of a predictable structure: a perfect candidate for metaprogramming.
Deriving Specifications from a Type Definition. Consider the following F\(^\star \) type, representing lists of exactly 18 pairs of bytes.
The first component of our metaprogram is
, which generates parser and serializer specifications from a type definition.
The syntax
is the way to call Meta-F\(^\star \) for code generation. Meta-F\(^\star \) will run the metaprogram
and, if successful, replace the underscore by the result. In this case, the
inspects the syntax of the
type (Sect. 3.3) and produces the package below (
and
are sequencing combinators):
Deriving Low-Level Implementations that Match Specifications. From this pair of specifications, we can automatically generate Low\(^\star \) implementations for them:
which will produce the following low-level implementations:
For simple types like the one above, the generated code is fairly simple. However, for more complex types, using the combinator library comes with non-trivial proof obligations. For example, even for a simple enumeration,
, the parser specification is as follows:
We represent
with
and
with
. The parser first parses a “bounded” byte, with only two values. The
combinator then expects functions between the bounded byte and the datatype being parsed (
), which must be proven to be mutual inverses. This proof is conceptually easy, but for large enumerations nested deep within the structure of other types, it is notoriously hard for SMT solvers. Since the proof is inherently computational, a proof that destructs the inductive type into its cases and then normalizes is much more natural. With our metaprogram, we can produce the term and then discharge these proof obligations with a tactic on the spot, eliminating them from the final VC. We also explore simply tweaking the SMT context, again via a tactic, with good results. A quantitative evaluation is provided in Sect. 6.2.