From d95f2d04a434ec4822e2b5a28a73dacc2f7baf47 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 26 Sep 2026 14:05:10 +0100 Subject: [PATCH] Initial vault: single-binary post-quantum file vault Strip Fury back to its vault functionality in one `vault` command: genkey, init, add (file or directory, optional folder inside the vault), list, get, mv and rm. The hybrid FrodoKEM-640 + ML-KEM-768 encryption core is kept under internal/pqcrypt. Signing, policies, profiles, backups, SSH remotes, FIDO2, agents, provenance and compression are removed. --- .gitignore | 3 + CLAUDE.md | 44 ++ LICENSE | 674 ++++++++++++++++++ README.md | 51 ++ go.mod | 13 + go.sum | 8 + internal/format/format.go | 356 ++++++++++ internal/format/format_test.go | 90 +++ internal/format/limits_test.go | 41 ++ internal/pqcrypt/pqcrypt.go | 405 +++++++++++ internal/pqcrypt/pqcrypt_test.go | 70 ++ internal/pqcrypt/primitives.go | 111 +++ internal/pqcrypt/suite.go | 236 +++++++ internal/pqcrypt/suite_frodo_mlkem.go | 215 ++++++ internal/pqcrypt/suite_test.go | 135 ++++ internal/stream/parallel_decrypt.go | 271 ++++++++ internal/stream/parallel_encrypt.go | 192 ++++++ internal/stream/parallel_test.go | 332 +++++++++ internal/stream/stream.go | 468 +++++++++++++ internal/stream/stream_test.go | 950 ++++++++++++++++++++++++++ internal/term/term.go | 89 +++ internal/term/term_windows.go | 46 ++ keyfile.go | 126 ++++ main.go | 241 +++++++ store.go | 426 ++++++++++++ vault_test.go | 96 +++ 26 files changed, 5689 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/format/format.go create mode 100644 internal/format/format_test.go create mode 100644 internal/format/limits_test.go create mode 100644 internal/pqcrypt/pqcrypt.go create mode 100644 internal/pqcrypt/pqcrypt_test.go create mode 100644 internal/pqcrypt/primitives.go create mode 100644 internal/pqcrypt/suite.go create mode 100644 internal/pqcrypt/suite_frodo_mlkem.go create mode 100644 internal/pqcrypt/suite_test.go create mode 100644 internal/stream/parallel_decrypt.go create mode 100644 internal/stream/parallel_encrypt.go create mode 100644 internal/stream/parallel_test.go create mode 100644 internal/stream/stream.go create mode 100644 internal/stream/stream_test.go create mode 100644 internal/term/term.go create mode 100644 internal/term/term_windows.go create mode 100644 keyfile.go create mode 100644 main.go create mode 100644 store.go create mode 100644 vault_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39b0697 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vault +/vault.exe +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4dd38b7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,44 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with this repository. + +## Overview + +vault is a post-quantum encrypted file vault: one `package main` at the repo +root that builds a single `vault` binary. Module `github.com/andydixon/vault`. + +- `main.go` - CLI: `genkey`, `init`, `add`, `list`, `get`, `mv`, `rm`. +- `keyfile.go` - passphrase-protected key file (Argon2id + + XChaCha20-Poly1305 sealing a suite ID and 32-byte master seed). +- `store.go` - on-disk vault: `records/` holds fixed-size encrypted catalogue + records, `objects/` holds padded encrypted file bodies. Records a key cannot + open are skipped, not treated as errors. +- `internal/pqcrypt` - file encryption (header + STREAM ChaCha20-Poly1305), + suite registry, hybrid FrodoKEM-640 + ML-KEM-768 default suite. +- `internal/format`, `internal/stream`, `internal/term` - header encoding, + chunked payload encryption, terminal passphrase prompts. + +## Commands + +```sh +go test ./... +go build . +``` + +End-to-end check: + +```sh +export VAULT_PASSPHRASE=test VAULT_KEY=$PWD/k VAULT_DIR=$PWD/s +./vault genkey && ./vault init && ./vault add README.md docs && ./vault list +``` + +## Invariants + +- Never write a seed or private key unencrypted. +- Keep suite IDs, stanza tags, HKDF info strings and `fileKeySize` (32) stable. +- Every ciphertext's mtime is pinned to the epoch; records stay one fixed + size; objects stay bucket-padded. These hide metadata from the storage host. +- An object's plaintext starts with its own ID, binding it to its record. +- `get` publishes output only after the entire object has authenticated. +- Keep dependencies pure Go. +- Diagnostics go to standard error, lower-case, no trailing full stops. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1842cd3 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# vault + +A post-quantum encrypted file vault in a single binary. + +Files are encrypted with a hybrid FrodoKEM-640 + ML-KEM-768 key encapsulation +and ChaCha20-Poly1305. The vault directory holds only randomly named +ciphertexts. Names, sizes, modes and timestamps live inside encrypted +catalogue records of one fixed size. File bodies are padded to power-of-two +buckets. + +## Build + +```sh +go build . # produces ./vault +go test ./... +``` + +## Use + +```sh +vault genkey # create ~/.vault/key, protected by a passphrase +vault init # create ~/.vault/store +vault add report.pdf # stored as report.pdf +vault add report.pdf work/2026 # stored as work/2026/report.pdf +vault add ./photos # every regular file under photos/ +vault list # everything +vault list work # everything under work/ +vault get work/2026/report.pdf # writes ./report.pdf +vault get report.pdf -o copy.pdf +vault mv report.pdf old/report.pdf +vault rm old/report.pdf +``` + +Adding a name that already exists replaces it. `get` refuses to overwrite an +existing file and writes nothing until the whole object has authenticated. + +`--key PATH` and `--vault DIR` work with every command. They default to +`$VAULT_KEY` and `$VAULT_DIR`, then to `~/.vault/key` and `~/.vault/store`. +The passphrase is read from the terminal, or from `$VAULT_PASSPHRASE` for +scripts. + +## Limits + +- One writer at a time. Concurrent writes to the same vault are not locked. +- The storage host can see how many files exist, their size buckets and when + the vault changes. +- The key file is the only way in. Losing it or its passphrase loses the data. + +## Licence + +GPL-3.0-or-later. The cryptographic core is derived from Fury. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a228b30 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/andydixon/vault + +// Go 1.26.6 or newer is a security requirement inherited from the crypto +// library: the standard-library fixes it relies on exist only from that +// release. +go 1.26.6 + +require ( + github.com/cloudflare/circl v1.6.4 + golang.org/x/crypto v0.53.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1717718 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= +github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= diff --git a/internal/format/format.go b/internal/format/format.go new file mode 100644 index 0000000..6227b66 --- /dev/null +++ b/internal/format/format.go @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package format implements the fury file format. +package format + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" +) + +const MaxHeaderSize = 16 << 20 // 16 MiB +const MaxStanzaSize = 1 << 20 // 1 MiB + +type Header struct { + Recipients []*Stanza + MAC []byte +} + +// Stanza is the serialised recipient block used in a fury header. +type Stanza struct { + Type string + Args []string + Body []byte +} + +var b64 = base64.RawStdEncoding.Strict() + +func DecodeString(s string) ([]byte, error) { + // CR and LF are ignored by DecodeString, but we don't want any malleability. + if strings.ContainsAny(s, "\n\r") { + return nil, errors.New(`unexpected newline character`) + } + return b64.DecodeString(s) +} + +var EncodeToString = b64.EncodeToString + +const ColumnsPerLine = 64 + +const BytesPerLine = ColumnsPerLine / 4 * 3 + +// NewWrappedBase64Encoder returns a WrappedBase64Encoder that writes to dst. +func NewWrappedBase64Encoder(enc *base64.Encoding, dst io.Writer) *WrappedBase64Encoder { + w := &WrappedBase64Encoder{dst: dst} + w.enc = base64.NewEncoder(enc, WriterFunc(w.writeWrapped)) + return w +} + +type WriterFunc func(p []byte) (int, error) + +func (f WriterFunc) Write(p []byte) (int, error) { return f(p) } + +// WrappedBase64Encoder is a standard base64 encoder that inserts an LF +// character every ColumnsPerLine bytes. It does not insert a newline neither at +// the beginning nor at the end of the stream, but it ensures the last line is +// shorter than ColumnsPerLine, which means it might be empty. +type WrappedBase64Encoder struct { + enc io.WriteCloser + dst io.Writer + written int + buf bytes.Buffer +} + +func (w *WrappedBase64Encoder) Write(p []byte) (int, error) { return w.enc.Write(p) } + +func (w *WrappedBase64Encoder) Close() error { + return w.enc.Close() +} + +func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) { + if w.buf.Len() != 0 { + panic("fury: internal error: non-empty WrappedBase64Encoder.buf") + } + total := len(p) + for len(p) > 0 { + toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p)) + n, _ := w.buf.Write(p[:toWrite]) + w.written += n + p = p[n:] + if w.written%ColumnsPerLine == 0 { + w.buf.Write([]byte("\n")) + } + } + if _, err := w.buf.WriteTo(w.dst); err != nil { + // We always return n = 0 on error because it's hard to work back to the + // input length that ended up written out. Not ideal, but Write errors + // are not recoverable anyway. + return 0, err + } + return total, nil +} + +// LastLineIsEmpty returns whether the last output line was empty, either +// because no input was written, or because a multiple of BytesPerLine was. +// +// Calling LastLineIsEmpty before Close is meaningless. +func (w *WrappedBase64Encoder) LastLineIsEmpty() bool { + return w.written%ColumnsPerLine == 0 +} + +const intro = "fury-encryption.org/v1\n" + +var stanzaPrefix = []byte("->") +var footerPrefix = []byte("---") + +func (r *Stanza) Marshal(w io.Writer) error { + if _, err := w.Write(stanzaPrefix); err != nil { + return err + } + for _, a := range append([]string{r.Type}, r.Args...) { + if _, err := io.WriteString(w, " "+a); err != nil { + return err + } + } + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + ww := NewWrappedBase64Encoder(b64, w) + if _, err := ww.Write(r.Body); err != nil { + return err + } + if err := ww.Close(); err != nil { + return err + } + _, err := io.WriteString(w, "\n") + return err +} + +func (h *Header) MarshalWithoutMAC(w io.Writer) error { + if _, err := io.WriteString(w, intro); err != nil { + return err + } + for _, r := range h.Recipients { + if err := r.Marshal(w); err != nil { + return err + } + } + _, err := fmt.Fprintf(w, "%s", footerPrefix) + return err +} + +func (h *Header) Marshal(w io.Writer) error { + if err := h.MarshalWithoutMAC(w); err != nil { + return err + } + mac := b64.EncodeToString(h.MAC) + _, err := fmt.Fprintf(w, " %s\n", mac) + return err +} + +type StanzaReader struct { + r *bufio.Reader + err error + lastBytes int64 +} + +func NewStanzaReader(r *bufio.Reader) *StanzaReader { + return &StanzaReader{r: r} +} + +func (r *StanzaReader) ReadStanza() (s *Stanza, err error) { + // Read errors are unrecoverable. + if r.err != nil { + return nil, r.err + } + var bytesRead int64 + defer func() { + r.lastBytes = bytesRead + r.err = err + }() + + s = &Stanza{} + + line, err := readLimitedLine(r.r, &bytesRead, MaxStanzaSize) + if errors.Is(err, errSizeLimitExceeded) { + return nil, fmt.Errorf("stanza exceeds %d byte limit", MaxStanzaSize) + } + if err != nil { + return nil, fmt.Errorf("failed to read line: %w", err) + } + if !bytes.HasPrefix(line, stanzaPrefix) { + return nil, fmt.Errorf("malformed stanza opening line: %q", line) + } + prefix, args := splitArgs(line) + if prefix != string(stanzaPrefix) || len(args) < 1 { + return nil, fmt.Errorf("malformed stanza: %q", line) + } + for _, a := range args { + if !isValidString(a) { + return nil, fmt.Errorf("malformed stanza: %q", line) + } + } + s.Type = args[0] + s.Args = args[1:] + + for { + line, err := readLimitedLine(r.r, &bytesRead, MaxStanzaSize) + if errors.Is(err, errSizeLimitExceeded) { + return nil, fmt.Errorf("stanza exceeds %d byte limit", MaxStanzaSize) + } + if err != nil { + return nil, fmt.Errorf("failed to read line: %w", err) + } + + b, err := DecodeString(strings.TrimSuffix(string(line), "\n")) + if err != nil { + if bytes.HasPrefix(line, footerPrefix) || bytes.HasPrefix(line, stanzaPrefix) { + return nil, fmt.Errorf("malformed body line %q: stanza ended without a short line", line) + } + return nil, errorf("malformed body line %q: %v", line, err) + } + if len(b) > BytesPerLine { + return nil, errorf("malformed body line %q: too long", line) + } + s.Body = append(s.Body, b...) + if len(b) < BytesPerLine { + // A stanza body always ends with a short line. + return s, nil + } + } +} + +var errSizeLimitExceeded = errors.New("size limit exceeded") + +func readLimitedLine(r *bufio.Reader, bytesRead *int64, limit int64) ([]byte, error) { + var line []byte + for { + frag, err := r.ReadSlice('\n') + if len(frag) > 0 { + if limit > 0 && *bytesRead+int64(len(frag)) > limit { + return nil, errSizeLimitExceeded + } + *bytesRead += int64(len(frag)) + line = append(line, frag...) + } + if err == nil { + return line, nil + } + if err != bufio.ErrBufferFull { + return nil, err + } + } +} + +type ParseError struct { + err error +} + +func (e *ParseError) Error() string { + return "parsing fury header: " + e.err.Error() +} + +func (e *ParseError) Unwrap() error { + return e.err +} + +func errorf(format string, a ...any) error { + return &ParseError{fmt.Errorf(format, a...)} +} + +// Parse returns the header and a Reader that begins at the start of the +// payload. +func Parse(input io.Reader) (*Header, io.Reader, error) { + h := &Header{} + rr := bufio.NewReader(input) + var headerBytes int64 + + lineBytes, err := readLimitedLine(rr, &headerBytes, MaxHeaderSize) + line := string(lineBytes) + if errors.Is(err, errSizeLimitExceeded) { + return nil, nil, errorf("header exceeds %d byte limit", MaxHeaderSize) + } + if err == io.EOF { + return nil, nil, errorf("file is empty") + } else if err != nil { + return nil, nil, errorf("failed to read intro: %w", err) + } + if line != intro { + return nil, nil, errorf("unexpected intro: %q", line) + } + + sr := NewStanzaReader(rr) + for { + peek, err := rr.Peek(len(footerPrefix)) + if err != nil { + return nil, nil, errorf("failed to read header: %w", err) + } + + if bytes.Equal(peek, footerPrefix) { + line, err := readLimitedLine(rr, &headerBytes, MaxHeaderSize) + if errors.Is(err, errSizeLimitExceeded) { + return nil, nil, errorf("header exceeds %d byte limit", MaxHeaderSize) + } + if err != nil { + return nil, nil, fmt.Errorf("failed to read header: %w", err) + } + + prefix, args := splitArgs(line) + if prefix != string(footerPrefix) || len(args) != 1 { + return nil, nil, errorf("malformed closing line: %q", line) + } + h.MAC, err = DecodeString(args[0]) + if err != nil || len(h.MAC) != 32 { + return nil, nil, errorf("malformed closing line %q: %v", line, err) + } + break + } + + s, err := sr.ReadStanza() + if err != nil { + return nil, nil, fmt.Errorf("failed to parse header: %w", err) + } + headerBytes += sr.lastBytes + if headerBytes > MaxHeaderSize { + return nil, nil, errorf("header exceeds %d byte limit", MaxHeaderSize) + } + h.Recipients = append(h.Recipients, s) + } + + // If input is a bufio.Reader, rr might be equal to input because + // bufio.NewReader short-circuits. In this case we can just return it (and + // we would end up reading the buffer twice if we prepended the peek below). + if rr == input { + return h, rr, nil + } + // Otherwise, unwind the bufio overread and return the unbuffered input. + buf, err := rr.Peek(rr.Buffered()) + if err != nil { + return nil, nil, errorf("internal error: %v", err) + } + payload := io.MultiReader(bytes.NewReader(buf), input) + return h, payload, nil +} + +func splitArgs(line []byte) (string, []string) { + l := strings.TrimSuffix(string(line), "\n") + parts := strings.Split(l, " ") + return parts[0], parts[1:] +} + +func isValidString(s string) bool { + if len(s) == 0 { + return false + } + for _, c := range s { + if c < 33 || c > 126 { + return false + } + } + return true +} diff --git a/internal/format/format_test.go b/internal/format/format_test.go new file mode 100644 index 0000000..4924602 --- /dev/null +++ b/internal/format/format_test.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package format_test + +import ( + "bufio" + "bytes" + "io" + "testing" + + "github.com/andydixon/vault/internal/format" +) + +func TestStanzaMarshal(t *testing.T) { + s := &format.Stanza{ + Type: "test", + Args: []string{"1", "2", "3"}, + Body: nil, // empty + } + buf := &bytes.Buffer{} + s.Marshal(buf) + if exp := "-> test 1 2 3\n\n"; buf.String() != exp { + t.Errorf("wrong empty stanza encoding: expected %q, got %q", exp, buf.String()) + } + + buf.Reset() + s.Body = []byte("AAA") + s.Marshal(buf) + if exp := "-> test 1 2 3\nQUFB\n"; buf.String() != exp { + t.Errorf("wrong normal stanza encoding: expected %q, got %q", exp, buf.String()) + } + + buf.Reset() + s.Body = bytes.Repeat([]byte("A"), format.BytesPerLine) + s.Marshal(buf) + if exp := "-> test 1 2 3\nQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFB\n\n"; buf.String() != exp { + t.Errorf("wrong 64 columns stanza encoding: expected %q, got %q", exp, buf.String()) + } +} + +func FuzzMalleability(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + h, payload, err := format.Parse(bytes.NewReader(data)) + if err != nil { + if h != nil { + t.Error("h != nil on error") + } + if payload != nil { + t.Error("payload != nil on error") + } + t.Skip() + } + w := &bytes.Buffer{} + if err := h.Marshal(w); err != nil { + t.Fatal(err) + } + if _, err := io.Copy(w, payload); err != nil { + t.Fatal(err) + } + if !bytes.Equal(w.Bytes(), data) { + t.Error("Marshal output different from input") + } + }) +} + +// FuzzStanza exercises the recipient-stanza reader directly. Parse reaches it +// only through a well-formed intro line, so fuzzing it on its own covers +// stanza shapes a whole-file seed cannot easily reach. +func FuzzStanza(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + reader := format.NewStanzaReader(bufio.NewReader(bytes.NewReader(data))) + for range 64 { + stanza, err := reader.ReadStanza() + if err != nil { + if stanza != nil { + t.Fatal("stanza returned alongside an error") + } + return + } + if stanza == nil { + t.Fatal("no stanza and no error") + } + // Parser limits must hold: nothing an attacker supplies may make + // the reader materialise more than the stanza size limit. + if len(stanza.Body) > format.MaxStanzaSize { + t.Fatalf("stanza body of %d bytes exceeds the %d byte limit", len(stanza.Body), format.MaxStanzaSize) + } + } + }) +} diff --git a/internal/format/limits_test.go b/internal/format/limits_test.go new file mode 100644 index 0000000..41d30cd --- /dev/null +++ b/internal/format/limits_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package format + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +func TestStanzaReaderRejectsOversizedStanza(t *testing.T) { + var input bytes.Buffer + input.WriteString("-> test ") + input.WriteString(strings.Repeat("a", MaxStanzaSize)) + input.WriteString("\n\n") + + sr := NewStanzaReader(bufio.NewReader(&input)) + if _, err := sr.ReadStanza(); err == nil { + t.Fatal("ReadStanza accepted an oversized stanza") + } else if !strings.Contains(err.Error(), "stanza exceeds") { + t.Fatalf("ReadStanza error = %v, want stanza size limit error", err) + } +} + +func TestParseRejectsOversizedHeader(t *testing.T) { + var input bytes.Buffer + input.WriteString(intro) + for input.Len() <= MaxHeaderSize { + input.WriteString("-> test ") + input.WriteString(strings.Repeat("a", MaxStanzaSize-1024)) + input.WriteString("\n\n") + } + input.WriteString("--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n") + + if _, _, err := Parse(&input); err == nil { + t.Fatal("Parse accepted an oversized header") + } else if !strings.Contains(err.Error(), "header exceeds") { + t.Fatalf("Parse error = %v, want header size limit error", err) + } +} diff --git a/internal/pqcrypt/pqcrypt.go b/internal/pqcrypt/pqcrypt.go new file mode 100644 index 0000000..a178202 --- /dev/null +++ b/internal/pqcrypt/pqcrypt.go @@ -0,0 +1,405 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Package fury implements post-quantum file encryption. It provides a +// post-quantum-only file format, hybrid KEM key agreement, and a 256-bit file key. +// +// For most use cases, generate a [PQIdentity] with [GeneratePQIdentity], obtain +// its public [PQRecipient] with [PQIdentity.Recipient], and use the [Encrypt] +// and [Decrypt] functions. +// +// fury encrypted files are binary and not malleable. For encoding them as text, +// use the github.com/andydixon/fury/armor package. +// +// # Cryptographic agility +// +// Key agreement is provided by registered suites (see the suite registry). +// Each suite embeds its identifier in the file, so files remain decryptable as +// newer, stronger suites are added and made the default. The default suite is a +// hybrid of FrodoKEM-640 and ML-KEM-768: the file key stays secret unless both +// KEMs are broken. +// +// # Key management +// +// fury does not have a global keyring; generate dedicated keys per task. Public +// recipients are shared as "fury1..." strings (or, being large, as files), or +// referenced by a short "furyfp1..." fingerprint. Private identities are always +// encrypted, with passphrase recovery and optional external unlock slots; there +// is no API to serialise an unencrypted private key. +package pqcrypt + +import ( + "crypto/hmac" + "crypto/rand" + "errors" + "fmt" + "io" + "runtime" + "slices" + "sort" + + "github.com/andydixon/vault/internal/format" + "github.com/andydixon/vault/internal/stream" +) + +// An Identity is passed to [Decrypt] to unwrap an opaque file key from a +// recipient stanza. It can be for example a secret key like [HybridIdentity], a +// plugin, or a custom implementation. +type Identity interface { + // Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of + // the recipient stanzas match the identity, any other error will be + // considered fatal. + // + // Most fury API users won't need to interact with this method directly, and + // should instead pass [Identity] implementations to [Decrypt]. + Unwrap(stanzas []*Stanza) (fileKey []byte, err error) +} + +// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the +// recipient stanzas match the identity. +var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block") + +// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more +// recipient stanza(s). It can be for example a public key like [HybridRecipient], +// a plugin, or a custom implementation. +type Recipient interface { + // Most fury API users won't need to interact with this method directly, and + // should instead pass [Recipient] implementations to [Encrypt]. + Wrap(fileKey []byte) ([]*Stanza, error) +} + +// RecipientWithLabels can be optionally implemented by a [Recipient], in which +// case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap]. +// +// Encrypt will succeed only if the labels returned by all the recipients +// (assuming the empty set for those that don't implement RecipientWithLabels) +// are the same. +// +// This can be used to ensure a recipient is only used with other recipients +// with equivalent properties (for example by setting a "postquantum" label) or +// to ensure a recipient is always used alone (by returning a random label, for +// example to preserve its authentication properties). +type RecipientWithLabels interface { + WrapWithLabels(fileKey []byte) (s []*Stanza, labels []string, err error) +} + +// A Stanza is a section of the fury header that encapsulates the file key as +// encrypted to a specific recipient. +// +// Most fury API users won't need to interact with this type directly, and should +// instead pass [Recipient] implementations to [Encrypt] and [Identity] +// implementations to [Decrypt]. +type Stanza struct { + Type string + Args []string + Body []byte +} + +// fileKeySize is 32 bytes (256-bit) so the symmetric layer's post-quantum +// (Grover) security floor of ~128 bits matches the KEMs, rather than the 64-bit +// floor a 128-bit file key would impose. See suite.go and the payload STREAM. +const fileKeySize = 32 +const streamNonceSize = 16 + +// maxAutoWorkers bounds the automatic worker count for parallel STREAM +// chunk encryption/decryption. ChaCha20-Poly1305 is memory-bandwidth-bound +// past a modest number of cores, so an unbounded runtime.NumCPU() on very +// large machines would just add goroutine/channel overhead for no benefit. +const maxAutoWorkers = 32 + +// MaxWorkers is the largest value [Options.Workers] will accept. +// +// The limit is set by what a worker actually costs, not by taste. Each worker +// may have two chunks in flight (see the stream package's pipeline depth), and +// a chunk is 64 KiB of plaintext plus its ciphertext buffer, so the pipeline +// holds roughly 256 KiB per worker: about 256 MiB at this limit, plus 1024 +// goroutines and channels sized to match. That is already far past the point +// where throughput stops improving, and comfortably above the thread count of +// the largest machines currently shipping. A request beyond it is a +// configuration error rather than a tuning choice, so Fury rejects it instead +// of quietly attempting it or silently substituting another number. +const MaxWorkers = 1024 + +// Options configures [EncryptWithOptions] and [DecryptWithOptions] beyond +// the defaults used by [Encrypt] and [Decrypt]. +type Options struct { + // Workers controls how many goroutines seal or open STREAM chunks in + // parallel. STREAM chunk nonces are a pure counter independent of chunk + // content, so parallelising this work never changes the resulting + // ciphertext or plaintext, only how fast it's produced. + // + // Valid values are 0 to [MaxWorkers]. Zero (the default) means + // automatic: runtime.NumCPU(), capped at a value past which + // ChaCha20-Poly1305 stops getting faster. One forces the sequential + // code path. A negative value, or one above MaxWorkers, is rejected. + Workers int +} + +// ErrInvalidWorkers is returned when [Options.Workers] is outside 0 to +// [MaxWorkers]. +var ErrInvalidWorkers = errors.New("fury: worker count out of range") + +// Validate reports whether the options are usable. Every entry point that +// accepts [Options] calls it, so an out-of-range worker count is refused by +// the library itself and not only by the command-line tool. +func (o Options) Validate() error { + if o.Workers < 0 { + return fmt.Errorf("%w: %d is negative", ErrInvalidWorkers, o.Workers) + } + if o.Workers > MaxWorkers { + return fmt.Errorf("%w: %d exceeds the maximum of %d", ErrInvalidWorkers, o.Workers, MaxWorkers) + } + return nil +} + +func (o Options) workers() int { + if o.Workers > 0 { + return o.Workers + } + if n := runtime.NumCPU(); n < maxAutoWorkers { + return n + } + return maxAutoWorkers +} + +// encryptHdr wraps a file key to every recipient and authenticates the result. +// +// INVARIANT (no silent downgrade). All recipients must return identical label +// sets. A recipient that does not implement [RecipientWithLabels] contributes +// the empty set, so it can never be mixed with a "postquantum" recipient. Any +// future recipient type with weaker properties fails closed rather than +// quietly lowering the security of the whole file. +func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) { + if len(recipients) == 0 { + return nil, errors.New("no recipients specified") + } + + hdr := &format.Header{} + var labels []string + for i, r := range recipients { + stanzas, l, err := wrapWithLabels(r, fileKey) + if err != nil { + return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err) + } + sort.Strings(l) + if i == 0 { + labels = l + } else if !slices.Equal(labels, l) { + return nil, incompatibleLabelsError(labels, l) + } + for _, s := range stanzas { + hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) + } + } + if mac, err := headerMAC(fileKey, hdr); err != nil { + return nil, fmt.Errorf("failed to compute header MAC: %v", err) + } else { + hdr.MAC = mac + } + return hdr, nil +} + +// Encrypt encrypts a file to one or more recipients. Every recipient will be +// able to decrypt the file. +// +// Writes to the returned WriteCloser are encrypted and written to dst as a fury +// file. The caller must call Close on the WriteCloser when done for the last +// chunk to be encrypted and flushed to dst. +// +// The payload is sealed using multiple goroutines automatically; see +// [EncryptWithOptions] to control that. +func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { + return EncryptWithOptions(dst, Options{}, recipients...) +} + +// EncryptWithOptions is like [Encrypt], with explicit [Options]. +// +// INVARIANT (fresh file key). Every call generates a new 32-byte file key +// from crypto/rand. Nothing derives a file key from the plaintext, the +// recipients or a counter, so no two encryptions share one. +func EncryptWithOptions(dst io.Writer, opts Options, recipients ...Recipient) (io.WriteCloser, error) { + if err := opts.Validate(); err != nil { + return nil, err + } + fileKey := make([]byte, fileKeySize) + if _, err := rand.Read(fileKey); err != nil { + return nil, fmt.Errorf("failed to generate file key: %w", err) + } + defer wipe(fileKey) + + hdr, err := encryptHdr(fileKey, recipients...) + if err != nil { + return nil, err + } + if err := hdr.Marshal(dst); err != nil { + return nil, fmt.Errorf("failed to write header: %w", err) + } + + nonce := make([]byte, streamNonceSize) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("failed to generate nonce: %w", err) + } + if _, err := dst.Write(nonce); err != nil { + return nil, fmt.Errorf("failed to write nonce: %w", err) + } + + key := streamKey(fileKey, nonce) + w, err := stream.NewParallelEncryptWriter(key, dst, opts.workers()) + wipe(key) + return w, err +} + +func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) { + if r, ok := r.(RecipientWithLabels); ok { + return r.WrapWithLabels(fileKey) + } + s, err = r.Wrap(fileKey) + return +} + +func incompatibleLabelsError(l1, l2 []string) error { + hasPQ1 := slices.Contains(l1, "postquantum") + hasPQ2 := slices.Contains(l2, "postquantum") + if hasPQ1 != hasPQ2 { + return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers") + } + return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2) +} + +// NoIdentityMatchError is returned by [Decrypt] when none of the supplied +// identities match the encrypted file. +type NoIdentityMatchError struct { + // Errors is a slice of all the errors returned to Decrypt by the Unwrap + // calls it made. They all wrap [ErrIncorrectIdentity]. + Errors []error + // StanzaTypes are the first argument of each recipient stanza in the + // encrypted file's header. + StanzaTypes []string +} + +func (e *NoIdentityMatchError) Error() string { + if len(e.Errors) == 1 { + return "identity did not match any of the recipients: " + e.Errors[0].Error() + } + return "no identity matched any of the recipients" +} + +// Decrypt decrypts a file encrypted to one or more identities. +// All identities will be tried until one successfully decrypts the file. +// Native, non-interactive identities are tried before any other identities. +// +// Decrypt returns a Reader reading the decrypted plaintext of the fury file read +// from src. If no identity matches the encrypted file, the returned error will +// be of type [NoIdentityMatchError]. +// +// The payload is opened using multiple goroutines automatically; see +// [DecryptWithOptions] to control that. +func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) { + return DecryptWithOptions(src, Options{}, identities...) +} + +// DecryptWithOptions is like [Decrypt], with explicit [Options]. +func DecryptWithOptions(src io.Reader, opts Options, identities ...Identity) (io.Reader, error) { + if err := opts.Validate(); err != nil { + return nil, err + } + hdr, payload, err := format.Parse(src) + if err != nil { + return nil, fmt.Errorf("failed to read header: %w", err) + } + + fileKey, err := decryptHdr(hdr, identities...) + if err != nil { + return nil, err + } + defer wipe(fileKey) + + nonce := make([]byte, streamNonceSize) + if _, err := io.ReadFull(payload, nonce); err != nil { + return nil, fmt.Errorf("failed to read nonce: %w", err) + } + + key := streamKey(fileKey, nonce) + r, err := stream.NewParallelDecryptReader(key, payload, opts.workers()) + wipe(key) + return r, err +} + +func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) { + if len(identities) == 0 { + return nil, errors.New("no identities specified") + } + slices.SortStableFunc(identities, func(a, b Identity) int { + var aIsNative, bIsNative bool + switch a.(type) { + case *PQIdentity: + aIsNative = true + } + switch b.(type) { + case *PQIdentity: + bIsNative = true + } + if aIsNative && !bIsNative { + return -1 + } + if !aIsNative && bIsNative { + return 1 + } + return 0 + }) + + stanzas := make([]*Stanza, 0, len(hdr.Recipients)) + errNoMatch := &NoIdentityMatchError{} + for _, s := range hdr.Recipients { + errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type) + stanzas = append(stanzas, (*Stanza)(s)) + } + var fileKey []byte + // The unwrapped key is copied out below; clear this copy on every exit + // path so it does not outlive the call. A closure is required: fileKey is + // assigned inside the loop, and a bare defer would capture today's nil. + defer func() { wipe(fileKey) }() + for _, id := range identities { + var err error + fileKey, err = id.Unwrap(stanzas) + if errors.Is(err, ErrIncorrectIdentity) { + errNoMatch.Errors = append(errNoMatch.Errors, err) + continue + } + if err != nil { + return nil, err + } + + break + } + if fileKey == nil { + return nil, errNoMatch + } + + if mac, err := headerMAC(fileKey, hdr); err != nil { + return nil, fmt.Errorf("failed to compute header MAC: %v", err) + } else if !hmac.Equal(mac, hdr.MAC) { + return nil, errors.New("bad header MAC") + } + + return append([]byte(nil), fileKey...), nil +} + +// multiUnwrap is a helper that implements Identity.Unwrap in terms of a +// function that unwraps a single recipient stanza. +func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byte, error) { + for _, s := range stanzas { + fileKey, err := unwrap(s) + if errors.Is(err, ErrIncorrectIdentity) { + // If we ever start returning something interesting wrapping + // ErrIncorrectIdentity, we should let it make its way up through + // Decrypt into NoIdentityMatchError.Errors. + continue + } + if err != nil { + return nil, err + } + return fileKey, nil + } + return nil, ErrIncorrectIdentity +} diff --git a/internal/pqcrypt/pqcrypt_test.go b/internal/pqcrypt/pqcrypt_test.go new file mode 100644 index 0000000..8165458 --- /dev/null +++ b/internal/pqcrypt/pqcrypt_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package pqcrypt + +import ( + "bytes" + "crypto/rand" + "fmt" + "io" + "testing" +) + +// TestEncryptWithOptionsRoundTrip exercises the parallel STREAM path end to +// end, at a size spanning many chunks, across a few explicit worker counts +// (including 1, which forces the original sequential code path). +func TestEncryptWithOptionsRoundTrip(t *testing.T) { + id, err := GeneratePQIdentity() + if err != nil { + t.Fatal(err) + } + r := id.Recipient() + + plaintext := make([]byte, 4<<20) // 4 MiB: many STREAM chunks + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + for _, workers := range []int{1, 2, 8} { + t.Run(fmt.Sprintf("workers=%d", workers), func(t *testing.T) { + buf := &bytes.Buffer{} + w, err := EncryptWithOptions(buf, Options{Workers: workers}, r) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + out, err := DecryptWithOptions(bytes.NewReader(buf.Bytes()), Options{Workers: workers}, id) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("round-trip mismatch") + } + }) + } +} + +func TestOptionsWorkers(t *testing.T) { + if got := (Options{}).workers(); got <= 0 { + t.Fatalf("default workers = %d, want > 0", got) + } + if got := (Options{Workers: 3}).workers(); got != 3 { + t.Fatalf("explicit workers = %d, want 3", got) + } + // An explicit request is honoured even above the automatic cap; only the + // zero-value automatic path is capped. + if got := (Options{Workers: 1000}).workers(); got != 1000 { + t.Fatalf("explicit workers = %d, want 1000", got) + } +} diff --git a/internal/pqcrypt/primitives.go b/internal/pqcrypt/primitives.go new file mode 100644 index 0000000..902929c --- /dev/null +++ b/internal/pqcrypt/primitives.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package pqcrypt + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "io" + + "github.com/andydixon/vault/internal/format" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// aeadEncrypt encrypts a message with a one-time key. +// +// INVARIANT (fixed nonce). The all-zero nonce below is safe only because +// every key reaching this function is a fresh, unique, single-use derived +// KEK. It is not a shortcut and must not be reused anywhere that condition +// does not hold: repeating a (key, nonce) pair under ChaCha20-Poly1305 +// destroys both confidentiality and authenticity. +// +// The condition holds today because aeadEncrypt has exactly one caller, the +// default suite's wrap, whose KEK is +// HKDF-SHA-256(ss1 || ss2, info = label || ct1 || ct2). Both KEM +// encapsulations are performed freshly for each wrap, so ss1, ss2, ct1 and +// ct2 differ for every call even when the same file key is wrapped to the +// same recipient twice. Any new caller must establish the same property, or +// carry its own nonce. +// +// For the code that encrypts the actual payload, which uses a counter nonce +// rather than a fixed one, see the +// github.com/andydixon/vault/internal/stream package. +func aeadEncrypt(key, plaintext []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + nonce := make([]byte, chacha20poly1305.NonceSize) + return aead.Seal(nil, nonce, plaintext, nil), nil +} + +var errIncorrectCiphertextSize = errors.New("encrypted value has unexpected length") + +// aeadDecrypt decrypts a message of an expected fixed size. +// +// The message size is limited to mitigate multi-key attacks, where a ciphertext +// can be crafted that decrypts successfully under multiple keys. Short +// ciphertexts can only target two keys, which has limited impact. +func aeadDecrypt(key []byte, size int, ciphertext []byte) ([]byte, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + if len(ciphertext) != size+aead.Overhead() { + return nil, errIncorrectCiphertextSize + } + nonce := make([]byte, chacha20poly1305.NonceSize) + return aead.Open(nil, nonce, ciphertext, nil) +} + +// headerMAC computes the header authentication tag. +// +// INVARIANT (header authentication). The MAC covers the complete header +// except the MAC line itself: the intro line, every recipient stanza in +// order, and the footer prefix. Nothing that a decryptor acts upon is left +// unauthenticated, so a stanza cannot be added, removed, reordered or edited +// without detection by anyone who can recover the file key. +// +// INVARIANT (domain separation). The MAC key and the payload key are both +// HKDF-SHA-256 expansions of the same file key under distinct info strings +// ("header" and "payload"), so neither can be substituted for the other. +func headerMAC(fileKey []byte, hdr *format.Header) ([]byte, error) { + h := hkdf.New(sha256.New, fileKey, nil, []byte("header")) + hmacKey := make([]byte, 32) + if _, err := io.ReadFull(h, hmacKey); err != nil { + wipe(hmacKey) + return nil, err + } + // The MAC key is derived per call and is of no use once the hash is + // keyed, so it does not need to outlive this function. + defer wipe(hmacKey) + hh := hmac.New(sha256.New, hmacKey) + if err := hdr.MarshalWithoutMAC(hh); err != nil { + return nil, err + } + return hh.Sum(nil), nil +} + +// streamKey derives the payload key. +// +// INVARIANT (payload key uniqueness). The 16-byte file nonce is fresh +// randomness written into every file and used here as the HKDF salt, so two +// encryptions never share a payload key even if a file key were somehow +// repeated. Combined with the STREAM counter nonce, no (key, nonce) pair is +// ever reused. +func streamKey(fileKey, nonce []byte) []byte { + h := hkdf.New(sha256.New, fileKey, nonce, []byte("payload")) + streamKey := make([]byte, chacha20poly1305.KeySize) + if _, err := io.ReadFull(h, streamKey); err != nil { + panic("fury: internal error: failed to read from HKDF: " + err.Error()) + } + return streamKey +} diff --git a/internal/pqcrypt/suite.go b/internal/pqcrypt/suite.go new file mode 100644 index 0000000..5aa6ea5 --- /dev/null +++ b/internal/pqcrypt/suite.go @@ -0,0 +1,236 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package pqcrypt + +import ( + "crypto/rand" + "errors" + "fmt" +) + +// A pqSuite is a registered post-quantum KEM suite. Each suite wraps the file +// key to a recipient and unwraps it with the corresponding identity. Suites are +// self-describing: their stanza tag is written into the header, so a file made +// with one suite keeps decrypting even after newer suites are added and made the +// default. This is the "use the best now, add better later, still decrypt older" +// property. +// +// Recipient public keys and identity private keys are opaque byte slices whose +// layout is defined by the suite. Identities are derived deterministically from +// a compact master seed so they can be stored and passphrase-wrapped cheaply. +type pqSuite interface { + // id is a stable numeric identifier embedded in recipient encodings. + id() uint16 + // tag is the header stanza Type for this suite. + tag() string + // rank orders suites when picking a default; higher wins. + rank() int + // seedSize is the length of the master seed accepted by deriveKeypair. + seedSize() int + // deriveKeypair deterministically expands a master seed into a suite public + // key and private key. + deriveKeypair(seed []byte) (pub, priv []byte, err error) + // wrap encapsulates fileKey to pub, returning one or more header stanzas. + wrap(pub, fileKey []byte) ([]*Stanza, error) + // unwrap recovers fileKey from a stanza using priv. It must return an error + // wrapping ErrIncorrectIdentity if the stanza does not match. + unwrap(priv []byte, s *Stanza) ([]byte, error) + // publicKeySize is the expected length of a suite public key. + publicKeySize() int +} + +var ( + pqSuitesByTag = map[string]pqSuite{} + pqSuitesByID = map[uint16]pqSuite{} +) + +// registerSuite adds a suite to the registry. It is called from suite files' +// init functions. +func registerSuite(s pqSuite) { + if _, ok := pqSuitesByTag[s.tag()]; ok { + panic("fury: duplicate suite tag " + s.tag()) + } + if _, ok := pqSuitesByID[s.id()]; ok { + panic(fmt.Sprintf("fury: duplicate suite id %#04x", s.id())) + } + pqSuitesByTag[s.tag()] = s + pqSuitesByID[s.id()] = s +} + +// defaultSuite returns the highest-ranked registered suite, used for key +// generation and encryption. +func defaultSuite() pqSuite { + var best pqSuite + for _, s := range pqSuitesByTag { + if best == nil || s.rank() > best.rank() { + best = s + } + } + if best == nil { + panic("fury: no PQ suites registered") + } + return best +} + +// PQRecipient is a post-quantum public key. Messages encrypted to it can be +// decrypted with the corresponding [PQIdentity]. +// +// It is anonymous: an observer can't tell from a message alone whether it is +// encrypted to a given recipient. +type PQRecipient struct { + suite pqSuite + pub []byte +} + +var _ Recipient = &PQRecipient{} +var _ RecipientWithLabels = &PQRecipient{} + +func (r *PQRecipient) Wrap(fileKey []byte) ([]*Stanza, error) { + return r.suite.wrap(r.pub, fileKey) +} + +// WrapWithLabels returns a "postquantum" label so a PQRecipient can only be +// combined with other post-quantum recipients, never with a recipient type that +// would defeat its security. +func (r *PQRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) { + s, err := r.Wrap(fileKey) + if err != nil { + return nil, nil, err + } + return s, []string{"postquantum"}, nil +} + +// SuiteID returns the numeric identifier of the recipient's suite. +func (r *PQRecipient) SuiteID() uint16 { return r.suite.id() } + +// Bytes returns the raw suite public key. +func (r *PQRecipient) Bytes() []byte { return append([]byte(nil), r.pub...) } + +// PQIdentity is a post-quantum private key, derived from a compact master seed, +// which can decrypt messages encrypted to the corresponding [PQRecipient]. +type PQIdentity struct { + suite pqSuite + seed []byte + pub []byte + priv []byte +} + +var _ Identity = &PQIdentity{} + +// GeneratePQIdentity randomly generates a new identity using the default suite. +func GeneratePQIdentity() (*PQIdentity, error) { + s := defaultSuite() + seed := make([]byte, s.seedSize()) + if _, err := rand.Read(seed); err != nil { + return nil, fmt.Errorf("failed to generate seed: %w", err) + } + id, err := newPQIdentityFromSeed(s, seed) + wipe(seed) + return id, err +} + +// newPQIdentityFromSeed derives an identity from a suite and master seed. +func newPQIdentityFromSeed(s pqSuite, seed []byte) (*PQIdentity, error) { + if len(seed) != s.seedSize() { + return nil, fmt.Errorf("invalid seed size %d for suite %s", len(seed), s.tag()) + } + seed = append([]byte(nil), seed...) + pub, priv, err := s.deriveKeypair(seed) + if err != nil { + return nil, fmt.Errorf("failed to derive keypair: %w", err) + } + return &PQIdentity{suite: s, seed: seed, pub: pub, priv: priv}, nil +} + +// usable reports whether the identity still holds its secret material. A +// closed identity, and one that was never fully initialised, both report +// false rather than panicking on a nil slice. +func (i *PQIdentity) usable() bool { + return i != nil && i.suite != nil && len(i.seed) == i.suite.seedSize() && len(i.priv) != 0 +} + +// ErrIdentityClosed is returned by operations on an identity whose secret +// material has been cleared by Close. +var ErrIdentityClosed = errors.New("fury identity is closed or uninitialised") + +// Close overwrites the secret material this identity owns: its master seed +// and its expanded private key. Repeated calls are safe, and a partially +// initialised identity closes without panicking. The public key is retained, +// so [PQIdentity.Recipient] keeps working on a closed identity. +// +// Explicit overwriting shortens the lifetime of buffers Fury knows about. It +// cannot guarantee removal from physical memory: the Go compiler and runtime +// may have copied a value during allocation, garbage collection, stack growth +// or escape analysis, and Fury has no way to find or clear those copies. Treat +// this as defence in depth, not as erasure. +func (i *PQIdentity) Close() { + if i == nil { + return + } + wipe(i.seed) + wipe(i.priv) + i.seed, i.priv = nil, nil +} + +func (i *PQIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) { + if !i.usable() { + return nil, ErrIdentityClosed + } + return multiUnwrap(i.unwrap, stanzas) +} + +func (i *PQIdentity) unwrap(s *Stanza) ([]byte, error) { + su, ok := pqSuitesByTag[s.Type] + if !ok || su.tag() != i.suite.tag() { + return nil, ErrIncorrectIdentity + } + fileKey, err := su.unwrap(i.priv, s) + if err != nil { + return nil, err + } + // Enforce the file key length centrally, so no future suite can feed a + // wrong-sized key into the payload STREAM by forgetting its own check. + if len(fileKey) != fileKeySize { + return nil, fmt.Errorf("suite %s returned a file key of %d bytes, want %d", su.tag(), len(fileKey), fileKeySize) + } + return fileKey, nil +} + +// Recipient returns the public [PQRecipient] corresponding to i. It remains +// valid after [PQIdentity.Close], because a public key is not secret. +func (i *PQIdentity) Recipient() *PQRecipient { + return &PQRecipient{suite: i.suite, pub: append([]byte(nil), i.pub...)} +} + +// Seed returns a copy of the compact master seed, or nil if the identity is +// closed. +// +// The returned slice is secret-equivalent: it regenerates both the private and +// the public key, so anything that can read it can decrypt everything the +// identity can. It must never be shared as a recipient, written unencrypted, +// logged, or passed to a subprocess. +// +// The copy is deliberate: it is never an alias into the identity's own buffer, +// so closing the identity does not clear it. From the moment it is returned it +// is the caller's responsibility to wipe it when finished and to keep its +// lifetime short. +func (i *PQIdentity) Seed() []byte { + if !i.usable() { + return nil + } + return append([]byte(nil), i.seed...) +} + +// SuiteID returns the numeric identifier of the identity's suite. +func (i *PQIdentity) SuiteID() uint16 { return i.suite.id() } + +// NewPQIdentityFromSeed derives an identity of the given suite from its master +// seed, as returned by [PQIdentity.Seed]. +func NewPQIdentityFromSeed(suiteID uint16, seed []byte) (*PQIdentity, error) { + s, ok := pqSuitesByID[suiteID] + if !ok { + return nil, fmt.Errorf("unknown suite id %#04x", suiteID) + } + return newPQIdentityFromSeed(s, seed) +} diff --git a/internal/pqcrypt/suite_frodo_mlkem.go b/internal/pqcrypt/suite_frodo_mlkem.go new file mode 100644 index 0000000..4bc4035 --- /dev/null +++ b/internal/pqcrypt/suite_frodo_mlkem.go @@ -0,0 +1,215 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package pqcrypt + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "slices" + + "github.com/andydixon/vault/internal/format" + "github.com/cloudflare/circl/kem" + "github.com/cloudflare/circl/kem/frodo/frodo640shake" + "github.com/cloudflare/circl/kem/mlkem/mlkem768" + "golang.org/x/crypto/hkdf" +) + +// frodoMLKEM768 is the default post-quantum suite: a hybrid of FrodoKEM-640 +// (unstructured-lattice) and ML-KEM-768 (module-lattice). The file key stays +// secret unless both KEMs are broken. It is pure Go, so fury keeps building as a +// static binary with no cgo. (A code-based suite such as HQC or Classic McEliece +// via liboqs can be registered later with a higher rank; see suite.go.) +type frodoMLKEM768 struct{} + +func init() { registerSuite(frodoMLKEM768{}) } + +func (frodoMLKEM768) id() uint16 { return 0x0001 } +func (frodoMLKEM768) tag() string { return "frodo640shake+mlkem768" } +func (frodoMLKEM768) rank() int { return 10 } +func (frodoMLKEM768) seedSize() int { return 32 } + +func (frodoMLKEM768) schemes() (frodo, ml kem.Scheme) { + return frodo640shake.Scheme(), mlkem768.Scheme() +} + +func (s frodoMLKEM768) publicKeySize() int { + frodo, ml := s.schemes() + return frodo.PublicKeySize() + ml.PublicKeySize() +} + +// deriveKeypair expands a 32-byte master seed into per-KEM seeds via HKDF, then +// derives each keypair deterministically. The public key is frodoPub||mlkemPub +// and the private key is frodoPriv||mlkemPriv. +func (s frodoMLKEM768) deriveKeypair(seed []byte) (pub, priv []byte, err error) { + frodo, ml := s.schemes() + r := hkdf.New(sha256.New, seed, nil, []byte("fury/v1 seed-expand "+s.tag())) + frodoSeed := make([]byte, frodo.SeedSize()) + mlSeed := make([]byte, ml.SeedSize()) + // The per-KEM seeds regenerate the private keys, so they are as sensitive + // as the master seed and are cleared as soon as the keys are derived. + defer wipe(frodoSeed) + defer wipe(mlSeed) + if _, err := io.ReadFull(r, frodoSeed); err != nil { + return nil, nil, err + } + if _, err := io.ReadFull(r, mlSeed); err != nil { + return nil, nil, err + } + + fpk, fsk := frodo.DeriveKeyPair(frodoSeed) + mpk, msk := ml.DeriveKeyPair(mlSeed) + + fpkb, err := fpk.MarshalBinary() + if err != nil { + return nil, nil, err + } + mpkb, err := mpk.MarshalBinary() + if err != nil { + return nil, nil, err + } + fskb, err := fsk.MarshalBinary() + if err != nil { + return nil, nil, err + } + defer wipe(fskb) + mskb, err := msk.MarshalBinary() + if err != nil { + return nil, nil, err + } + defer wipe(mskb) + // fpkb and mpkb are public key bytes; wiping them would be theatre. + return slices.Concat(fpkb, mpkb), slices.Concat(fskb, mskb), nil +} + +// wrap encapsulates to both KEMs and seals the file key under the combined +// KEK. +// +// INVARIANT (fresh encapsulation). Both Encapsulate calls draw fresh +// randomness on every invocation. Wrapping the same file key to the same +// recipient twice therefore produces two independent KEKs, never a repeat. +func (s frodoMLKEM768) wrap(pub, fileKey []byte) ([]*Stanza, error) { + frodo, ml := s.schemes() + if len(pub) != s.publicKeySize() { + return nil, fmt.Errorf("invalid %s public key size", s.tag()) + } + fpk, err := frodo.UnmarshalBinaryPublicKey(pub[:frodo.PublicKeySize()]) + if err != nil { + return nil, fmt.Errorf("invalid FrodoKEM public key: %v", err) + } + mpk, err := ml.UnmarshalBinaryPublicKey(pub[frodo.PublicKeySize():]) + if err != nil { + return nil, fmt.Errorf("invalid ML-KEM public key: %v", err) + } + + ct1, ss1, err := frodo.Encapsulate(fpk) + if err != nil { + return nil, fmt.Errorf("FrodoKEM encapsulation failed: %v", err) + } + ct2, ss2, err := ml.Encapsulate(mpk) + if err != nil { + return nil, fmt.Errorf("ML-KEM encapsulation failed: %v", err) + } + + kek := s.deriveKEK(ss1, ss2, ct1, ct2) + defer wipe(ss1) + defer wipe(ss2) + defer wipe(kek) + body, err := aeadEncrypt(kek, fileKey) + if err != nil { + return nil, fmt.Errorf("failed to wrap file key: %v", err) + } + + return []*Stanza{{ + Type: s.tag(), + Args: []string{format.EncodeToString(ct1), format.EncodeToString(ct2)}, + Body: body, + }}, nil +} + +func (s frodoMLKEM768) unwrap(priv []byte, st *Stanza) ([]byte, error) { + frodo, ml := s.schemes() + if st.Type != s.tag() { + return nil, ErrIncorrectIdentity + } + if len(st.Args) != 2 { + return nil, fmt.Errorf("invalid %s stanza: expected 2 arguments", s.tag()) + } + ct1, err := format.DecodeString(st.Args[0]) + if err != nil { + return nil, fmt.Errorf("invalid FrodoKEM ciphertext: %v", err) + } + ct2, err := format.DecodeString(st.Args[1]) + if err != nil { + return nil, fmt.Errorf("invalid ML-KEM ciphertext: %v", err) + } + if len(ct1) != frodo.CiphertextSize() || len(ct2) != ml.CiphertextSize() { + return nil, errIncorrectCiphertextSize + } + if len(priv) != frodo.PrivateKeySize()+ml.PrivateKeySize() { + return nil, errors.New("invalid private key size") + } + + fsk, err := frodo.UnmarshalBinaryPrivateKey(priv[:frodo.PrivateKeySize()]) + if err != nil { + return nil, fmt.Errorf("invalid FrodoKEM private key: %v", err) + } + // priv itself belongs to the caller's identity and is cleared by + // PQIdentity.Close; the parsed key objects are owned by CIRCL and expose + // no buffer Fury can clear. + msk, err := ml.UnmarshalBinaryPrivateKey(priv[frodo.PrivateKeySize():]) + if err != nil { + return nil, fmt.Errorf("invalid ML-KEM private key: %v", err) + } + + ss1, err := frodo.Decapsulate(fsk, ct1) + if err != nil { + return nil, fmt.Errorf("FrodoKEM decapsulation failed: %v", err) + } + ss2, err := ml.Decapsulate(msk, ct2) + if err != nil { + return nil, fmt.Errorf("ML-KEM decapsulation failed: %v", err) + } + + // Both KEMs use implicit rejection, so a mismatched key yields a wrong + // shared secret rather than an error; detection happens here at aeadDecrypt. + kek := s.deriveKEK(ss1, ss2, ct1, ct2) + defer wipe(ss1) + defer wipe(ss2) + defer wipe(kek) + fileKey, err := aeadDecrypt(kek, fileKeySize, st.Body) + if err != nil { + return nil, ErrIncorrectIdentity + } + return fileKey, nil +} + +// deriveKEK combines the two KEM shared secrets and both ciphertexts into a +// 256-bit key-encryption key. +// +// INVARIANT (both secrets contribute). ss1 and ss2 are concatenated as the +// HKDF input keying material, so the KEK stays unpredictable unless both KEMs +// are broken for the same target. Dropping either would silently reduce the +// construction to a single KEM. +// +// INVARIANT (transcript binding). Both ciphertexts are bound into the HKDF +// info string, preventing a mix-and-match of encapsulations across the two +// KEMs: an attacker cannot pair ct1 from one stanza with ct2 from another and +// obtain a KEK either party would compute. +// +// INVARIANT (single use). Because both encapsulations are performed freshly +// for every wrap, the resulting KEK is unique to that wrap. That is what +// makes the fixed AEAD nonce in aeadEncrypt safe; see primitives.go. +func (s frodoMLKEM768) deriveKEK(ss1, ss2, ct1, ct2 []byte) []byte { + secret := slices.Concat(ss1, ss2) + defer wipe(secret) + info := slices.Concat([]byte("fury/v1 kek "+s.tag()), ct1, ct2) + r := hkdf.New(sha256.New, secret, nil, info) + kek := make([]byte, 32) + if _, err := io.ReadFull(r, kek); err != nil { + panic("fury: internal error: failed to read from HKDF: " + err.Error()) + } + return kek +} diff --git a/internal/pqcrypt/suite_test.go b/internal/pqcrypt/suite_test.go new file mode 100644 index 0000000..dcb2d69 --- /dev/null +++ b/internal/pqcrypt/suite_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package pqcrypt + +import ( + "bytes" + "io" + "testing" +) + +// TestPQRoundTrip exercises the full Encrypt -> Decrypt path through the default +// post-quantum suite with a 256-bit file key. +func TestPQRoundTrip(t *testing.T) { + id, err := GeneratePQIdentity() + if err != nil { + t.Fatal(err) + } + r := id.Recipient() + + plaintext := []byte("fury: post-quantum only, hard g like GIF") + + buf := &bytes.Buffer{} + w, err := Encrypt(buf, r) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + out, err := Decrypt(bytes.NewReader(buf.Bytes()), id) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(out) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("round-trip mismatch: got %q want %q", got, plaintext) + } +} + +func TestPQFileKeyIs256Bit(t *testing.T) { + if fileKeySize != 32 { + t.Fatalf("fileKeySize = %d, want 32 (256-bit)", fileKeySize) + } +} + +// TestPQWrongIdentity confirms a different identity cannot decrypt. +func TestPQWrongIdentity(t *testing.T) { + id, _ := GeneratePQIdentity() + other, _ := GeneratePQIdentity() + + buf := &bytes.Buffer{} + w, err := Encrypt(buf, id.Recipient()) + if err != nil { + t.Fatal(err) + } + w.Write([]byte("secret")) + w.Close() + + if _, err := Decrypt(bytes.NewReader(buf.Bytes()), other); err == nil { + t.Fatal("decryption with wrong identity unexpectedly succeeded") + } +} + +// TestSuiteRegistry checks the default suite is registered and self-describing. +func TestSuiteRegistry(t *testing.T) { + s := defaultSuite() + if s.tag() != "frodo640shake+mlkem768" { + t.Fatalf("unexpected default suite %q", s.tag()) + } + if pqSuitesByID[s.id()] != s { + t.Fatal("suite not registered by id") + } + if pqSuitesByTag[s.tag()] != s { + t.Fatal("suite not registered by tag") + } +} + +// TestSeedDeterministic confirms an identity round-trips through its seed, which +// is what makes compact passphrase-wrapped identities possible. +func TestSeedDeterministic(t *testing.T) { + id, err := GeneratePQIdentity() + if err != nil { + t.Fatal(err) + } + id2, err := newPQIdentityFromSeed(defaultSuite(), id.Seed()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(id.pub, id2.pub) || !bytes.Equal(id.priv, id2.priv) { + t.Fatal("identity not deterministic from seed") + } +} + +func TestIdentityAndRecipientAccessorsReturnCopies(t *testing.T) { + id, err := GeneratePQIdentity() + if err != nil { + t.Fatal(err) + } + + seed := id.Seed() + seed[0] ^= 0xff + if id.seed[0] == seed[0] { + t.Fatal("Seed returned mutable identity internals") + } + + inputSeed := id.Seed() + id2, err := newPQIdentityFromSeed(defaultSuite(), inputSeed) + if err != nil { + t.Fatal(err) + } + inputSeed[0] ^= 0xff + if id2.seed[0] == inputSeed[0] { + t.Fatal("newPQIdentityFromSeed retained caller-owned seed slice") + } + + recipient := id.Recipient() + pub := recipient.Bytes() + pub[0] ^= 0xff + if recipient.pub[0] == pub[0] { + t.Fatal("Bytes returned mutable recipient internals") + } + + recipient.pub[0] ^= 0xff + if id.pub[0] == recipient.pub[0] { + t.Fatal("Recipient returned mutable identity public-key internals") + } +} diff --git a/internal/stream/parallel_decrypt.go b/internal/stream/parallel_decrypt.go new file mode 100644 index 0000000..448e37b --- /dev/null +++ b/internal/stream/parallel_decrypt.go @@ -0,0 +1,271 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package stream + +import ( + "crypto/cipher" + "errors" + "fmt" + "io" + "sync" + + "golang.org/x/crypto/chacha20poly1305" +) + +// NewParallelDecryptReader is equivalent to [NewDecryptReader], except that +// chunks are opened by a pool of worker goroutines instead of inline on the +// calling goroutine. +// +// The sequential DecryptReader disambiguates a full-length final chunk by +// trying to open it with the non-last nonce first and, on authentication +// failure, retrying with the last-chunk nonce. That trick is inherently +// sequential (try, then fall back), so this reader instead keeps a single +// raw chunk of read-ahead: the one goroutine that reads from src (reads must +// stay sequential, there is only one underlying stream) always reads one +// chunk further than it has dispatched, so by the time chunk i is handed to +// the worker pool, whether it is the last chunk is already known for +// certain from the shape of the next read. Each worker then does exactly +// one Open call, with a predetermined nonce, no retry. +// +// For well-formed files this produces byte-for-byte identical plaintext and +// the same errors as NewDecryptReader. For adversarially malformed files, +// the accept/reject verdict is always the same, but the exact error message +// in some corner cases (a corrupted chunk that happens to be full-length) +// may differ, because the classification strategy differs; see the package +// tests for the cases this was checked against. +// +// workers <= 1 returns a plain [NewDecryptReader] with no pipeline overhead. +// +// The returned Reader starts background goroutines that run until the +// stream is fully consumed or a fatal error occurs; abandoning it before +// reading to EOF leaks those goroutines (bounded to a small multiple of +// workers) until they unblock or the process exits. +func NewParallelDecryptReader(key []byte, src io.Reader, workers int) (io.Reader, error) { + if workers <= 1 { + return NewDecryptReader(key, src) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + + depth := workers * pipelineDepthPerWorker + r := ¶llelDecryptReader{ + a: aead, + src: src, + jobs: make(chan decJob, depth), + order: make(chan chan decResult, depth), + } + r.wg.Add(workers) + for i := 0; i < workers; i++ { + go r.work() + } + go r.pump() + return r, nil +} + +type decJob struct { + nonce [chacha20poly1305.NonceSize]byte + ciphertext []byte + result chan decResult +} + +// decResult is either a successfully decrypted chunk (err == nil) or a +// terminal event: a decryption failure, a raw read error, or a trailing-data +// violation. There is no explicit "clean EOF" value: a clean end of stream +// is represented by the order channel closing with nothing left to receive. +type decResult struct { + plaintext []byte + err error +} + +type parallelDecryptReader struct { + a cipher.AEAD + src io.Reader + + jobs chan decJob + order chan chan decResult + wg sync.WaitGroup + + unread []byte + err error +} + +func (r *parallelDecryptReader) Read(p []byte) (int, error) { + if len(r.unread) > 0 { + n := copy(p, r.unread) + r.unread = r.unread[n:] + return n, nil + } + if r.err != nil { + return 0, r.err + } + if len(p) == 0 { + return 0, nil + } + + ch, ok := <-r.order + if !ok { + r.err = io.EOF + return 0, r.err + } + res := <-ch + if res.err != nil { + r.err = res.err + return 0, r.err + } + + r.unread = res.plaintext + n := copy(p, r.unread) + r.unread = r.unread[n:] + return n, nil +} + +// dispatch hands a raw chunk, whose classification is already known for +// certain, to the worker pool. +func (r *parallelDecryptReader) dispatch(index int64, ciphertext []byte, last bool) { + nonce := nonceForChunk(index) + if last { + setLastChunkFlag(nonce) + } + ch := make(chan decResult, 1) + r.order <- ch + r.jobs <- decJob{nonce: *nonce, ciphertext: ciphertext, result: ch} +} + +// emitDirect enqueues a terminal result produced by the pump goroutine +// itself (a read error, a malformed-stream error, or a trailing-data +// violation), without involving the worker pool. +func (r *parallelDecryptReader) emitDirect(res decResult) { + ch := make(chan decResult, 1) + r.order <- ch + ch <- res +} + +// work opens dispatched chunks. Unlike the sequential reader, there is no +// retry here: the pump goroutine has already determined, from the shape of +// the raw stream, whether each chunk is the last one. +func (r *parallelDecryptReader) work() { + defer r.wg.Done() + for j := range r.jobs { + out := make([]byte, 0, ChunkSize) + plaintext, err := r.a.Open(out, j.nonce[:], j.ciphertext, nil) + if err != nil { + j.result <- decResult{err: errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")} + continue + } + j.result <- decResult{plaintext: plaintext} + } +} + +type chunkKind int + +const ( + chunkFull chunkKind = iota + chunkShort + chunkEOF + chunkErr +) + +type rawChunk struct { + kind chunkKind + data []byte + err error +} + +// readRawChunk reads exactly one raw encChunkSize block from src into a +// freshly allocated buffer and classifies the read, mirroring the switch at +// the top of the sequential DecryptReader.readChunk. +func readRawChunk(src io.Reader) rawChunk { + buf := make([]byte, encChunkSize) + n, err := io.ReadFull(src, buf) + switch { + case err == nil: + return rawChunk{kind: chunkFull, data: buf} + case err == io.EOF: + return rawChunk{kind: chunkEOF} + case err == io.ErrUnexpectedEOF: + return rawChunk{kind: chunkShort, data: buf[:n]} + default: + return rawChunk{kind: chunkErr, err: err} + } +} + +// pump is the single goroutine that reads raw chunks from src, in order, +// always staying one chunk ahead so that every dispatched chunk's +// last-chunk status is already certain. It closes r.jobs and r.order when +// the stream ends or a fatal error is found, which is what lets Read +// observe a clean end of stream (order closed with nothing left) versus an +// explicit terminal error (an emitDirect result). +func (r *parallelDecryptReader) pump() { + defer close(r.jobs) + defer close(r.order) + + first := readRawChunk(r.src) + switch first.kind { + case chunkErr: + r.emitDirect(decResult{err: first.err}) + return + case chunkEOF: + // A message can't end without a marked chunk; this message is + // truncated, exactly like the sequential reader's first read. + r.emitDirect(decResult{err: io.ErrUnexpectedEOF}) + return + case chunkShort: + // This is chunk index 0: an empty chunk here is the valid + // empty-plaintext-file case, not the "empty last chunk" error, + // which can only apply to a later chunk. + r.dispatch(0, first.data, true) + r.probeTrailing() + return + } + + // first.kind == chunkFull: ambiguous until we see what follows it. + pendingIndex := int64(0) + pending := first.data + for { + next := readRawChunk(r.src) + switch next.kind { + case chunkErr: + r.dispatch(pendingIndex, pending, false) + r.emitDirect(decResult{err: next.err}) + return + case chunkEOF: + // Nothing follows: pending was a full-length final chunk. The + // clean EOF we just observed is itself the trailing-data proof, + // no separate probe needed. + r.dispatch(pendingIndex, pending, true) + return + case chunkShort: + r.dispatch(pendingIndex, pending, false) + if len(next.data) == chacha20poly1305.Overhead { + // A short chunk here is never index 0, so an empty one is + // always the disallowed case. + r.emitDirect(decResult{err: errors.New("last chunk is empty; encrypted payload is malformed")}) + return + } + r.dispatch(pendingIndex+1, next.data, true) + r.probeTrailing() + return + case chunkFull: + r.dispatch(pendingIndex, pending, false) + pendingIndex++ + pending = next.data + continue + } + } +} + +// probeTrailing confirms there is no data after a short final chunk, exactly +// as the sequential DecryptReader.Read does after processing a last chunk. +func (r *parallelDecryptReader) probeTrailing() { + var b [1]byte + _, err := r.src.Read(b[:]) + switch { + case err == nil: + r.emitDirect(decResult{err: errors.New("trailing data after end of encrypted file")}) + case err != io.EOF: + r.emitDirect(decResult{err: fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err)}) + } +} diff --git a/internal/stream/parallel_encrypt.go b/internal/stream/parallel_encrypt.go new file mode 100644 index 0000000..71e7118 --- /dev/null +++ b/internal/stream/parallel_encrypt.go @@ -0,0 +1,192 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package stream + +import ( + "bytes" + "crypto/cipher" + "errors" + "io" + "sync" + + "golang.org/x/crypto/chacha20poly1305" +) + +// pipelineDepthPerWorker bounds how many chunks may be queued for, or +// awaiting collection from, a single worker at once. It caps the pipeline's +// memory use to a small multiple of ChunkSize regardless of input size. +const pipelineDepthPerWorker = 2 + +// NewParallelEncryptWriter is equivalent to [NewEncryptWriter], except that +// chunks are sealed by a pool of worker goroutines instead of inline on the +// calling goroutine. Its output is byte-for-byte identical to +// NewEncryptWriter's: STREAM chunk nonces are a pure counter, so sealing +// chunk i never depends on chunk i-1. Only the write order to dst must stay +// sequential, which a dedicated collector goroutine enforces. +// +// workers <= 1 returns a plain [NewEncryptWriter] with no pipeline overhead. +func NewParallelEncryptWriter(key []byte, dst io.Writer, workers int) (io.WriteCloser, error) { + if workers <= 1 { + return NewEncryptWriter(key, dst) + } + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + + depth := workers * pipelineDepthPerWorker + w := ¶llelEncryptWriter{ + a: aead, + dst: dst, + jobs: make(chan encJob, depth), + order: make(chan chan []byte, depth), + done: make(chan struct{}), + } + w.wg.Add(workers) + for i := 0; i < workers; i++ { + go w.work() + } + go w.collect() + return w, nil +} + +type encJob struct { + nonce [chacha20poly1305.NonceSize]byte + plaintext []byte + result chan []byte +} + +type parallelEncryptWriter struct { + a cipher.AEAD + dst io.Writer + + buf bytes.Buffer + index int64 + + jobs chan encJob + order chan chan []byte + wg sync.WaitGroup + done chan struct{} // closed once the collector goroutine has returned + + mu sync.Mutex + err error +} + +func (w *parallelEncryptWriter) getErr() error { + w.mu.Lock() + defer w.mu.Unlock() + return w.err +} + +func (w *parallelEncryptWriter) setErr(err error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.err == nil { + w.err = err + } +} + +func (w *parallelEncryptWriter) Write(p []byte) (n int, err error) { + if err := w.getErr(); err != nil { + return 0, err + } + if len(p) == 0 { + return 0, nil + } + + total := len(p) + for len(p) > 0 { + room := ChunkSize - w.buf.Len() + nn := min(len(p), room) + w.buf.Write(p[:nn]) + p = p[nn:] + + // Only submit a chunk once we know more data is coming, so the very + // last chunk (submitted from Close) is always the one that gets the + // last-chunk-flag nonce, exactly like the sequential EncryptWriter. + if w.buf.Len() == ChunkSize && len(p) > 0 { + if err := w.submitChunk(false); err != nil { + w.setErr(err) + return 0, err + } + } + } + return total, nil +} + +// Close flushes the last chunk and waits for every queued chunk to be +// sealed and written to dst, in order. It does not close the underlying +// Writer. +func (w *parallelEncryptWriter) Close() error { + if err := w.getErr(); err != nil { + return err + } + + // submitChunk cannot fail here: we just confirmed there is no prior + // error, and channel sends to w.jobs/w.order only block, they never + // return an error. + _ = w.submitChunk(true) + + close(w.jobs) + close(w.order) + w.wg.Wait() + <-w.done + + if err := w.getErr(); err != nil { + return err + } + w.setErr(errors.New("stream.Writer is already closed")) + return nil +} + +// submitChunk copies out the current buffer as a chunk of plaintext, assigns +// it the next sequential nonce, and hands it to the worker pool. last must be +// true only for the final call, made from Close. +func (w *parallelEncryptWriter) submitChunk(last bool) error { + if err := w.getErr(); err != nil { + return err + } + + plaintext := append([]byte(nil), w.buf.Bytes()...) + w.buf.Reset() + + nonce := nonceForChunk(w.index) + if last { + setLastChunkFlag(nonce) + } + w.index++ + + result := make(chan []byte, 1) + w.order <- result + w.jobs <- encJob{nonce: *nonce, plaintext: plaintext, result: result} + return nil +} + +// work seals queued chunks. Seal cannot fail: the nonce is always the +// correct size, so the only possible failure in this pipeline is dst.Write, +// handled in collect. +func (w *parallelEncryptWriter) work() { + defer w.wg.Done() + for j := range w.jobs { + ciphertext := w.a.Seal(make([]byte, 0, len(j.plaintext)+w.a.Overhead()), j.nonce[:], j.plaintext, nil) + j.result <- ciphertext + } +} + +// collect writes sealed chunks to dst strictly in submission order, even +// though workers may finish sealing them in a different order. +func (w *parallelEncryptWriter) collect() { + defer close(w.done) + var failed bool + for result := range w.order { + ciphertext := <-result + if failed { + continue + } + if _, err := w.dst.Write(ciphertext); err != nil { + w.setErr(err) + failed = true + } + } +} diff --git a/internal/stream/parallel_test.go b/internal/stream/parallel_test.go new file mode 100644 index 0000000..9703345 --- /dev/null +++ b/internal/stream/parallel_test.go @@ -0,0 +1,332 @@ +// Copyright 2026 The fury Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package stream_test + +import ( + "bytes" + "crypto/cipher" + "crypto/rand" + "encoding/binary" + "fmt" + "io" + "runtime" + "testing" + + "github.com/andydixon/vault/internal/stream" + "golang.org/x/crypto/chacha20poly1305" +) + +func workerCounts() []int { + return []int{1, 2, 4, runtime.NumCPU() + 4} +} + +func randBytes(t *testing.T, n int) []byte { + t.Helper() + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + t.Fatal(err) + } + return b +} + +// TestParallelEncryptMatchesSequential is the main compatibility check: for a +// range of lengths and worker counts, the parallel encrypter must produce +// byte-identical ciphertext to the sequential one, and ciphertext from +// either encrypter must decrypt correctly with either reader. +func TestParallelEncryptMatchesSequential(t *testing.T) { + lengths := []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} + + for _, length := range lengths { + for _, workers := range workerCounts() { + t.Run(fmt.Sprintf("len=%d,workers=%d", length, workers), func(t *testing.T) { + src := randBytes(t, length) + key := randBytes(t, chacha20poly1305.KeySize) + + seqBuf := &bytes.Buffer{} + sw, err := stream.NewEncryptWriter(key, seqBuf) + if err != nil { + t.Fatal(err) + } + if _, err := sw.Write(src); err != nil { + t.Fatal(err) + } + if err := sw.Close(); err != nil { + t.Fatal(err) + } + + parBuf := &bytes.Buffer{} + pw, err := stream.NewParallelEncryptWriter(key, parBuf, workers) + if err != nil { + t.Fatal(err) + } + if _, err := pw.Write(src); err != nil { + t.Fatal(err) + } + if err := pw.Close(); err != nil { + t.Fatal(err) + } + + if !bytes.Equal(seqBuf.Bytes(), parBuf.Bytes()) { + t.Fatalf("parallel ciphertext (%d bytes) differs from sequential (%d bytes)", + parBuf.Len(), seqBuf.Len()) + } + + t.Run("decrypt-with-sequential", func(t *testing.T) { + dr, err := stream.NewDecryptReader(key, bytes.NewReader(parBuf.Bytes())) + if err != nil { + t.Fatal(err) + } + checkRoundTrip(t, dr, src) + }) + t.Run("decrypt-with-parallel", func(t *testing.T) { + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(parBuf.Bytes()), workers) + if err != nil { + t.Fatal(err) + } + checkRoundTrip(t, dr, src) + }) + t.Run("sequential-ciphertext-decrypts-with-parallel", func(t *testing.T) { + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(seqBuf.Bytes()), workers) + if err != nil { + t.Fatal(err) + } + checkRoundTrip(t, dr, src) + }) + }) + } + } +} + +func checkRoundTrip(t *testing.T, r io.Reader, want []byte) { + t.Helper() + got, err := io.ReadAll(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(got, want) { + t.Fatalf("decrypted mismatch: got %d bytes, want %d bytes", len(got), len(want)) + } +} + +// TestParallelEncryptWriteInSteps confirms the parallel writer's output is +// unaffected by how the caller chunks its Write calls, mirroring the +// sequential TestRoundTrip's step-size coverage but scoped to a couple of +// representative lengths to keep runtime reasonable. +func TestParallelEncryptWriteInSteps(t *testing.T) { + lengths := []int{cs + 100, 2*cs + 500} + steps := []int{1, 537, cs} + + for _, length := range lengths { + for _, step := range steps { + t.Run(fmt.Sprintf("len=%d,step=%d", length, step), func(t *testing.T) { + src := randBytes(t, length) + key := randBytes(t, chacha20poly1305.KeySize) + + seqBuf := &bytes.Buffer{} + sw, err := stream.NewEncryptWriter(key, seqBuf) + if err != nil { + t.Fatal(err) + } + writeInSteps(t, sw, src, step) + if err := sw.Close(); err != nil { + t.Fatal(err) + } + + parBuf := &bytes.Buffer{} + pw, err := stream.NewParallelEncryptWriter(key, parBuf, 4) + if err != nil { + t.Fatal(err) + } + writeInSteps(t, pw, src, step) + if err := pw.Close(); err != nil { + t.Fatal(err) + } + + if !bytes.Equal(seqBuf.Bytes(), parBuf.Bytes()) { + t.Fatalf("parallel ciphertext differs from sequential with step=%d", step) + } + }) + } + } +} + +func writeInSteps(t *testing.T, w io.Writer, src []byte, step int) { + t.Helper() + for len(src) > 0 { + n := min(step, len(src)) + if _, err := w.Write(src[:n]); err != nil { + t.Fatal(err) + } + src = src[n:] + } +} + +// TestParallelDecryptMalformed exercises the same categories of malformed +// input the sequential reader rejects. The parallel reader resolves the +// last-chunk ambiguity from the shape of the raw stream (read-ahead) rather +// than by a trial decryption, so in adversarial cases the exact error +// message can differ from the sequential reader's -- what must not differ is +// the accept/reject verdict. Where the message is deterministic under the +// new strategy too, it's asserted explicitly. +func TestParallelDecryptMalformed(t *testing.T) { + newCiphertext := func(t *testing.T, key []byte, plaintext []byte) []byte { + t.Helper() + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() + } + + t.Run("wrong key", func(t *testing.T) { + key := randBytes(t, chacha20poly1305.KeySize) + ciphertext := newCiphertext(t, key, randBytes(t, 1000)) + wrongKey := randBytes(t, chacha20poly1305.KeySize) + + dr, err := stream.NewParallelDecryptReader(wrongKey, bytes.NewReader(ciphertext), 4) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(dr); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("corrupted byte in non-final chunk", func(t *testing.T) { + key := randBytes(t, chacha20poly1305.KeySize) + ciphertext := newCiphertext(t, key, randBytes(t, 2*cs+500)) + ciphertext[10] ^= 0xff // inside the first (non-final) chunk + + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(ciphertext), 4) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(dr); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("truncated", func(t *testing.T) { + key := randBytes(t, chacha20poly1305.KeySize) + ciphertext := newCiphertext(t, key, randBytes(t, 2*cs+500)) + truncated := ciphertext[:len(ciphertext)-100] + + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(truncated), 4) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(dr); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("trailing data appended after short last chunk", func(t *testing.T) { + // With a bytes.Reader, io.ReadFull for the (undersized) chunk buffer + // greedily consumes the real short chunk AND the trailing garbage in + // one read, since everything is available at once: the result is a + // misaligned AEAD tag, not a distinguishable "trailing data" case. + // This is also true of the sequential reader (same io.ReadFull over + // the same fixed-size buffer) -- the verdict, an error, is what must + // hold, not a specific message. + key := randBytes(t, chacha20poly1305.KeySize) + ciphertext := newCiphertext(t, key, randBytes(t, 1000)) // ends in a short chunk + withTrailer := append(append([]byte(nil), ciphertext...), 1, 2, 3, 4, 5) + + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(withTrailer), 4) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadAll(dr); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("trailing data after full-length last chunk", func(t *testing.T) { + key := randBytes(t, chacha20poly1305.KeySize) + ciphertext := newCiphertext(t, key, randBytes(t, cs)) // exact multiple: full-length last chunk + withTrailer := append(append([]byte(nil), ciphertext...), 1, 2, 3, 4, 5) + + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(withTrailer), 4) + if err != nil { + t.Fatal(err) + } + // The trailing bytes are re-interpreted as a short "final" chunk here + // (a different, but still safe, rejection path than the sequential + // reader's trailing-byte probe); the verdict must still be an error. + if _, err := io.ReadAll(dr); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("empty chunk after a non-empty chunk is rejected", func(t *testing.T) { + key := randBytes(t, chacha20poly1305.KeySize) + aead, err := chacha20poly1305.New(key) + if err != nil { + t.Fatal(err) + } + + var malformed []byte + malformed = append(malformed, sealChunkForTest(t, aead, 0, false, randBytes(t, cs))...) + malformed = append(malformed, sealChunkForTest(t, aead, 1, true, nil)...) + + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(malformed), 4) + if err != nil { + t.Fatal(err) + } + _, err = io.ReadAll(dr) + if err == nil { + t.Fatal("expected error, got nil") + } + if got, want := err.Error(), "last chunk is empty; encrypted payload is malformed"; got != want { + t.Fatalf("error = %q, want %q", got, want) + } + }) +} + +// sealChunkForTest manually seals a chunk using the same nonce scheme as the +// stream package (a big-endian chunk counter in bytes [3:11], a last-chunk +// flag in the final byte), to construct deliberately malformed streams that +// aren't reachable through the public encrypt API. +func sealChunkForTest(t *testing.T, aead cipher.AEAD, index int64, last bool, plaintext []byte) []byte { + t.Helper() + var nonce [chacha20poly1305.NonceSize]byte + binary.BigEndian.PutUint64(nonce[3:11], uint64(index)) + if last { + nonce[len(nonce)-1] = 1 + } + return aead.Seal(nil, nonce[:], plaintext, nil) +} + +// TestParallelRace exercises the pipeline with oversubscribed workers on a +// multi-chunk payload; run with -race to catch data races in the ordered +// fan-in. +func TestParallelRace(t *testing.T) { + key := randBytes(t, chacha20poly1305.KeySize) + src := randBytes(t, 6*cs+12345) + + buf := &bytes.Buffer{} + w, err := stream.NewParallelEncryptWriter(key, buf, runtime.NumCPU()*2) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(src); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + dr, err := stream.NewParallelDecryptReader(key, bytes.NewReader(buf.Bytes()), runtime.NumCPU()*2) + if err != nil { + t.Fatal(err) + } + checkRoundTrip(t, dr, src) +} diff --git a/internal/stream/stream.go b/internal/stream/stream.go new file mode 100644 index 0000000..ac3b1b0 --- /dev/null +++ b/internal/stream/stream.go @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package stream implements a variant of the STREAM chunked encryption scheme. +// +// The invariants this package exists to hold: +// +// - Counter uniqueness. A chunk's nonce is a pure big-endian counter over +// bytes 3..10, incremented once per chunk and never reset within a +// stream. incNonce panics rather than wrapping, so a (key, nonce) pair +// can never repeat: the counter is 88 bits and the panic is unreachable +// for any real file. +// - Content independence. The nonce depends only on the chunk index and the +// last-chunk flag, never on chunk contents. That is what lets the parallel +// encoder seal chunks out of order and still produce byte-identical +// output. +// - Truncation resistance. The final chunk is sealed under a nonce whose +// last byte carries lastChunkFlag. A truncated stream therefore fails to +// authenticate: the attacker would have to forge a last-chunk tag for a +// chunk the writer sealed as non-final. +// - No trailing data. After a last chunk is accepted, the reader confirms +// the underlying source is at EOF and fails otherwise, so appended bytes +// are rejected rather than silently ignored. +package stream + +import ( + "bytes" + "crypto/cipher" + "encoding/binary" + "errors" + "fmt" + "io" + "sync/atomic" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ChunkSize = 64 * 1024 + +func EncryptedChunkCount(encryptedSize int64) (int64, error) { + chunks := (encryptedSize + encChunkSize - 1) / encChunkSize + + plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead + expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize + // Empty plaintext, the only case that allows (and requires) an empty chunk. + if plaintextSize == 0 { + expChunks = 1 + } + if expChunks != chunks { + return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize) + } + + return chunks, nil +} + +func PlaintextSize(encryptedSize int64) (int64, error) { + chunks, err := EncryptedChunkCount(encryptedSize) + if err != nil { + return 0, err + } + plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead + return plaintextSize, nil +} + +type DecryptReader struct { + a cipher.AEAD + src io.Reader + + unread []byte // decrypted but unread data, backed by buf + buf [encChunkSize]byte + + err error + nonce [chacha20poly1305.NonceSize]byte +} + +const ( + encChunkSize = ChunkSize + chacha20poly1305.Overhead + lastChunkFlag = 0x01 +) + +func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + return &DecryptReader{a: aead, src: src}, nil +} + +func (r *DecryptReader) Read(p []byte) (int, error) { + if len(r.unread) > 0 { + n := copy(p, r.unread) + r.unread = r.unread[n:] + return n, nil + } + if r.err != nil { + return 0, r.err + } + if len(p) == 0 { + return 0, nil + } + + last, err := r.readChunk() + if err != nil { + r.err = err + return 0, err + } + + n := copy(p, r.unread) + r.unread = r.unread[n:] + + if last { + // Ensure there is an EOF after the last chunk as expected. In other + // words, check for trailing data after a full-length final chunk. + // Hopefully, the underlying reader supports returning EOF even if it + // had previously returned an EOF to ReadFull. + if _, err := r.src.Read(make([]byte, 1)); err == nil { + r.err = errors.New("trailing data after end of encrypted file") + } else if err != io.EOF { + r.err = fmt.Errorf("non-EOF error reading after end of encrypted file: %w", err) + } else { + r.err = io.EOF + } + } + + return n, nil +} + +// readChunk reads the next chunk of ciphertext from r.src and makes it available +// in r.unread. last is true if the chunk was marked as the end of the message. +// readChunk must not be called again after returning a last chunk or an error. +func (r *DecryptReader) readChunk() (last bool, err error) { + if len(r.unread) != 0 { + panic("stream: internal error: readChunk called with dirty buffer") + } + + in := r.buf[:] + n, err := io.ReadFull(r.src, in) + switch { + case err == io.EOF: + // A message can't end without a marked chunk. This message is truncated. + return false, io.ErrUnexpectedEOF + case err == io.ErrUnexpectedEOF: + // The last chunk can be short, but not empty unless it's the first and + // only chunk. + if !nonceIsZero(&r.nonce) && n == r.a.Overhead() { + return false, errors.New("last chunk is empty; encrypted payload is malformed") + } + in = in[:n] + last = true + setLastChunkFlag(&r.nonce) + case err != nil: + return false, err + } + + outBuf := make([]byte, 0, ChunkSize) + out, err := r.a.Open(outBuf, r.nonce[:], in, nil) + if err != nil && !last { + // Check if this was a full-length final chunk. + last = true + setLastChunkFlag(&r.nonce) + out, err = r.a.Open(outBuf, r.nonce[:], in, nil) + } + if err != nil { + return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with") + } + + incNonce(&r.nonce) + r.unread = r.buf[:copy(r.buf[:], out)] + return last, nil +} + +func incNonce(nonce *[chacha20poly1305.NonceSize]byte) { + for i := len(nonce) - 2; i >= 0; i-- { + nonce[i]++ + if nonce[i] != 0 { + return + } + } + // The counter is 88 bits, this is unreachable. + panic("stream: chunk counter wrapped around") +} + +func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte { + var nonce [chacha20poly1305.NonceSize]byte + binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex)) + return &nonce +} + +func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) { + nonce[len(nonce)-1] = lastChunkFlag +} + +func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool { + return *nonce == [chacha20poly1305.NonceSize]byte{} +} + +type EncryptWriter struct { + a cipher.AEAD + dst io.Writer + buf bytes.Buffer + nonce [chacha20poly1305.NonceSize]byte + err error +} + +func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + return &EncryptWriter{a: aead, dst: dst}, nil +} + +func (w *EncryptWriter) Write(p []byte) (n int, err error) { + if w.err != nil { + return 0, w.err + } + if len(p) == 0 { + return 0, nil + } + + total := len(p) + for len(p) > 0 { + n := min(len(p), ChunkSize-w.buf.Len()) + w.buf.Write(p[:n]) + p = p[n:] + + // Only flush if there's a full chunk with bytes still to write, or we + // can't know if this is the last chunk yet. + if w.buf.Len() == ChunkSize && len(p) > 0 { + if err := w.flushChunk(notLastChunk); err != nil { + w.err = err + return 0, err + } + } + } + return total, nil +} + +// Close flushes the last chunk. It does not close the underlying Writer. +func (w *EncryptWriter) Close() error { + if w.err != nil { + return w.err + } + + w.err = w.flushChunk(lastChunk) + if w.err != nil { + return w.err + } + + w.err = errors.New("stream.Writer is already closed") + return nil +} + +const ( + lastChunk = true + notLastChunk = false +) + +func (w *EncryptWriter) flushChunk(last bool) error { + if !last && w.buf.Len() != ChunkSize { + panic("stream: internal error: flush called with partial chunk") + } + + if last { + setLastChunkFlag(&w.nonce) + } + w.buf.Grow(chacha20poly1305.Overhead) + ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil) + _, err := w.dst.Write(ciphertext) + incNonce(&w.nonce) + w.buf.Reset() + return err +} + +type EncryptReader struct { + a cipher.AEAD + src io.Reader + + // The first ready bytes of buf are already encrypted. This may be less than + // buf.Len(), because we need to over-read to know if a chunk is the last. + ready int + buf bytes.Buffer + + nonce [chacha20poly1305.NonceSize]byte + err error +} + +func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + return &EncryptReader{a: aead, src: src}, nil +} + +func (r *EncryptReader) Read(p []byte) (int, error) { + if r.ready > 0 { + n, err := r.buf.Read(p[:min(len(p), r.ready)]) + r.ready -= n + return n, err + } + if r.err != nil { + return 0, r.err + } + if len(p) == 0 { + return 0, nil + } + + if err := r.feedBuffer(); err != nil { + r.err = err + return 0, err + } + + n, err := r.buf.Read(p[:min(len(p), r.ready)]) + r.ready -= n + return n, err +} + +// feedBuffer reads and encrypts the next chunk from r.src and appends it to +// r.buf. It sets r.ready to the number of newly available bytes in r.buf. +func (r *EncryptReader) feedBuffer() error { + if r.ready > 0 { + panic("stream: internal error: feedBuffer called with dirty buffer") + } + + // CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly. + // We need ChunkSize + 1 bytes to determine if this is the last chunk. + _, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1)) + if err != nil && err != io.EOF { + return err + } + + if last := r.buf.Len() <= ChunkSize; last { + setLastChunkFlag(&r.nonce) + + // After Grow, we know r.buf.Bytes() has enough capacity for the + // overhead. We encrypt in place and then do a Write to include the + // overhead in the buffer. + r.buf.Grow(chacha20poly1305.Overhead) + plaintext := r.buf.Bytes() + r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil) + incNonce(&r.nonce) + r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead]) + r.ready = r.buf.Len() + + r.err = io.EOF + return nil + } + + // Same, but accounting for the tail byte which will remain unencrypted and + // needs to be shifted past the overhead. + if r.buf.Len() != ChunkSize+1 { + panic("stream: internal error: unexpected buffer length") + } + tailByte := r.buf.Bytes()[ChunkSize] + r.buf.Grow(chacha20poly1305.Overhead) + plaintext := r.buf.Bytes()[:ChunkSize] + r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil) + incNonce(&r.nonce) + r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead]) + r.buf.WriteByte(tailByte) + r.ready = ChunkSize + chacha20poly1305.Overhead + + return nil +} + +type DecryptReaderAt struct { + a cipher.AEAD + src io.ReaderAt + size int64 + chunks int64 + cache atomic.Pointer[cachedChunk] +} + +type cachedChunk struct { + off int64 + data []byte +} + +func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) { + aead, err := chacha20poly1305.New(key) + if err != nil { + return nil, err + } + + // Check that size is valid by decrypting the final chunk. + chunks, err := EncryptedChunkCount(size) + if err != nil { + return nil, err + } + finalChunkIndex := chunks - 1 + finalChunkOff := finalChunkIndex * encChunkSize + finalChunkSize := size - finalChunkOff + finalChunk := make([]byte, finalChunkSize) + if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil { + return nil, fmt.Errorf("failed to read final chunk: %w", err) + } + nonce := nonceForChunk(finalChunkIndex) + setLastChunkFlag(nonce) + plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err) + } + cache := &cachedChunk{off: finalChunkOff, data: plaintext} + + plaintextSize := size - chunks*chacha20poly1305.Overhead + r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks} + r.cache.Store(cache) + return r, nil +} + +func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) { + if off < 0 || off > r.size { + return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off) + } + if len(p) == 0 { + return 0, nil + } + var cacheUpdate *cachedChunk + chunk := make([]byte, encChunkSize) + for len(p) > 0 && off < r.size { + chunkIndex := off / ChunkSize + chunkOff := chunkIndex * encChunkSize + encSize := r.size + r.chunks*chacha20poly1305.Overhead + chunkSize := min(encSize-chunkOff, encChunkSize) + + cached := r.cache.Load() + var plaintext []byte + if cached != nil && cached.off == chunkOff { + plaintext = cached.data + cacheUpdate = nil + } else { + nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff) + if err == io.EOF { + if int64(nn) != chunkSize { + err = io.ErrUnexpectedEOF + } else { + err = nil + } + } + if err != nil { + return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err) + } + nonce := nonceForChunk(chunkIndex) + if chunkIndex == r.chunks-1 { + setLastChunkFlag(nonce) + } + plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil) + if err != nil { + return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err) + } + cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext} + } + + plainChunkOff := int(off - chunkIndex*ChunkSize) + copySize := min(len(plaintext)-plainChunkOff, len(p)) + copy(p, plaintext[plainChunkOff:plainChunkOff+copySize]) + p = p[copySize:] + off += int64(copySize) + n += copySize + } + if cacheUpdate != nil { + r.cache.Store(cacheUpdate) + } + if off == r.size { + return n, io.EOF + } + return n, nil +} diff --git a/internal/stream/stream_test.go b/internal/stream/stream_test.go new file mode 100644 index 0000000..5a162e8 --- /dev/null +++ b/internal/stream/stream_test.go @@ -0,0 +1,950 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package stream_test + +import ( + "bytes" + "crypto/rand" + "fmt" + "io" + "testing" + "testing/iotest" + + "github.com/andydixon/vault/internal/stream" + "golang.org/x/crypto/chacha20poly1305" +) + +const cs = stream.ChunkSize + +func TestRoundTrip(t *testing.T) { + for _, length := range []int{0, 1000, cs - 1, cs, cs + 1, cs + 100, 2 * cs, 2*cs + 500} { + for _, stepSize := range []int{512, 600, 1000, cs - 1, cs, cs + 1} { + t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) { + testRoundTrip(t, stepSize, length) + }) + } + } + + length, stepSize := 2*cs+500, 1 + t.Run(fmt.Sprintf("len=%d,step=%d", length, stepSize), func(t *testing.T) { + testRoundTrip(t, stepSize, length) + }) +} + +func testRoundTrip(t *testing.T, stepSize, length int) { + src := make([]byte, length) + if _, err := rand.Read(src); err != nil { + t.Fatal(err) + } + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + var ciphertext []byte + + t.Run("EncryptWriter", func(t *testing.T) { + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + + var n int + for n < length { + b := min(length-n, stepSize) + nn, err := w.Write(src[n : n+b]) + if err != nil { + t.Fatal(err) + } + if nn != b { + t.Errorf("Write returned %d, expected %d", nn, b) + } + n += nn + + nn, err = w.Write(src[n:n]) + if err != nil { + t.Fatal(err) + } + if nn != 0 { + t.Errorf("Write returned %d, expected 0", nn) + } + } + if err := w.Close(); err != nil { + t.Error("Close returned an error:", err) + } + + ciphertext = buf.Bytes() + }) + + t.Run("DecryptReader", func(t *testing.T) { + r, err := stream.NewDecryptReader(key, bytes.NewReader(ciphertext)) + if err != nil { + t.Fatal(err) + } + + var n int + readBuf := make([]byte, stepSize) + for n < length { + nn, err := r.Read(readBuf) + if err != nil { + t.Fatalf("Read error at index %d: %v", n, err) + } + + if !bytes.Equal(readBuf[:nn], src[n:n+nn]) { + t.Errorf("wrong data at indexes %d - %d", n, n+nn) + } + + n += nn + } + + t.Run("TestReader", func(t *testing.T) { + if length > 1000 && testing.Short() { + t.Skip("skipping slow iotest.TestReader on long input") + } + r, _ := stream.NewDecryptReader(key, bytes.NewReader(ciphertext)) + if err := iotest.TestReader(r, src); err != nil { + t.Error("iotest.TestReader error on DecryptReader:", err) + } + }) + }) + + t.Run("DecryptReaderAt", func(t *testing.T) { + rAt, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext))) + if err != nil { + t.Fatal(err) + } + rr := io.NewSectionReader(rAt, 0, int64(len(ciphertext))) + + var n int + readBuf := make([]byte, stepSize) + for n < length { + nn, err := rr.Read(readBuf) + if n+nn == length && err == io.EOF { + err = nil + } + if err != nil { + t.Fatalf("ReadAt error at index %d: %v", n, err) + } + + if !bytes.Equal(readBuf[:nn], src[n:n+nn]) { + t.Errorf("wrong data at indexes %d - %d", n, n+nn) + } + + n += nn + } + + t.Run("TestReader", func(t *testing.T) { + if length > 1000 && testing.Short() { + t.Skip("skipping slow iotest.TestReader on long input") + } + rr := io.NewSectionReader(rAt, 0, int64(len(src))) + if err := iotest.TestReader(rr, src); err != nil { + t.Error("iotest.TestReader error on DecryptReaderAt:", err) + } + }) + }) + + t.Run("EncryptReader", func(t *testing.T) { + er, err := stream.NewEncryptReader(key, bytes.NewReader(src)) + if err != nil { + t.Fatal(err) + } + + var n int + readBuf := make([]byte, stepSize) + for { + nn, err := er.Read(readBuf) + if nn == 0 && err == io.EOF { + break + } else if err != nil { + t.Fatalf("EncryptReader Read error at index %d: %v", n, err) + } + + if !bytes.Equal(readBuf[:nn], ciphertext[n:n+nn]) { + t.Errorf("EncryptReader wrong data at indexes %d - %d", n, n+nn) + } + + n += nn + } + if n != len(ciphertext) { + t.Errorf("EncryptReader read %d bytes, expected %d", n, len(ciphertext)) + } + + t.Run("TestReader", func(t *testing.T) { + if length > 1000 && testing.Short() { + t.Skip("skipping slow iotest.TestReader on long input") + } + er, _ := stream.NewEncryptReader(key, bytes.NewReader(src)) + if err := iotest.TestReader(er, ciphertext); err != nil { + t.Error("iotest.TestReader error on EncryptReader:", err) + } + }) + }) +} + +// trackingReaderAt wraps an io.ReaderAt and tracks whether ReadAt was called. +type trackingReaderAt struct { + r io.ReaderAt + called bool +} + +func (t *trackingReaderAt) ReadAt(p []byte, off int64) (int, error) { + t.called = true + return t.r.ReadAt(p, off) +} + +func (t *trackingReaderAt) reset() { + t.called = false +} + +func TestDecryptReaderAt(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + // Create plaintext spanning exactly 3 chunks: 2 full chunks + partial third + // Chunk 0: [0, cs) + // Chunk 1: [cs, 2*cs) + // Chunk 2: [2*cs, 2*cs+500) + plaintextSize := 2*cs + 500 + plaintext := make([]byte, plaintextSize) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + // Encrypt + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + // Create tracking ReaderAt + tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)} + + // Create DecryptReaderAt (this reads and caches the final chunk) + ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext))) + if err != nil { + t.Fatal(err) + } + tracker.reset() + + // Helper to check reads + checkRead := func(name string, off int64, size int, wantN int, wantEOF bool, wantSrcRead bool) { + t.Helper() + tracker.reset() + p := make([]byte, size) + n, err := ra.ReadAt(p, off) + + if wantEOF { + if err != io.EOF { + t.Errorf("%s: got err=%v, want EOF", name, err) + } + } else { + if err != nil { + t.Errorf("%s: got err=%v, want nil", name, err) + } + } + + if n != wantN { + t.Errorf("%s: got n=%d, want %d", name, n, wantN) + } + + if tracker.called != wantSrcRead { + t.Errorf("%s: src.ReadAt called=%v, want %v", name, tracker.called, wantSrcRead) + } + + // Verify data correctness + if n > 0 && off >= 0 && off < int64(plaintextSize) { + end := int(off) + n + if end > plaintextSize { + end = plaintextSize + } + if !bytes.Equal(p[:n], plaintext[off:end]) { + t.Errorf("%s: data mismatch", name) + } + } + } + + // Test 1: Read from final chunk (cached by constructor) + checkRead("final chunk (cached)", int64(2*cs+100), 100, 100, false, false) + + // Test 2: Read spanning second and third chunk + checkRead("span chunks 1-2", int64(cs+cs-50), 100, 100, false, true) + + // Test 3: Read from final chunk again (cached from test 2) + // When reading across chunks 1-2 in test 2, the loop processes chunk 1 then chunk 2, + // so chunk 2 ends up in the cache. + checkRead("final chunk after span", int64(2*cs+200), 100, 100, false, false) + + // Test 4: Read from final chunk again (now cached) + checkRead("final chunk (cached again)", int64(2*cs+50), 50, 50, false, false) + + // Test 5: Read from first chunk (not cached) + checkRead("first chunk", 0, 100, 100, false, true) + + // Test 6: Read from first chunk again (now cached) + checkRead("first chunk (cached)", 50, 100, 100, false, false) + + // Test 7: Read spanning all chunks + tracker.reset() + p := make([]byte, plaintextSize) + n, err := ra.ReadAt(p, 0) + if err != io.EOF { + t.Errorf("span all: got err=%v, want EOF", err) + } + if n != plaintextSize { + t.Errorf("span all: got n=%d, want %d", n, plaintextSize) + } + if !bytes.Equal(p, plaintext) { + t.Errorf("span all: data mismatch") + } + + // Test 8: Read beyond the end (offset > size) + tracker.reset() + p = make([]byte, 100) + n, err = ra.ReadAt(p, int64(plaintextSize+100)) + if err == nil { + t.Error("beyond end: expected error, got nil") + } + if n != 0 { + t.Errorf("beyond end: got n=%d, want 0", n) + } + + // Test 9: Read with off = size (should return 0, EOF) + tracker.reset() + p = make([]byte, 100) + n, err = ra.ReadAt(p, int64(plaintextSize)) + if err != io.EOF { + t.Errorf("off=size: got err=%v, want EOF", err) + } + if n != 0 { + t.Errorf("off=size: got n=%d, want 0", n) + } + + // Test 10: Read spanning last chunk and beyond + tracker.reset() + p = make([]byte, 1000) // request more than available + n, err = ra.ReadAt(p, int64(2*cs+400)) + if err != io.EOF { + t.Errorf("span last+beyond: got err=%v, want EOF", err) + } + wantN := 500 - 400 // only 100 bytes available from offset 2*cs+400 + if n != wantN { + t.Errorf("span last+beyond: got n=%d, want %d", n, wantN) + } + if !bytes.Equal(p[:n], plaintext[2*cs+400:]) { + t.Error("span last+beyond: data mismatch") + } + + // Test 11: Read spanning second+last chunk and beyond + tracker.reset() + p = make([]byte, cs+1000) // request more than available + n, err = ra.ReadAt(p, int64(cs+100)) + if err != io.EOF { + t.Errorf("span 1-2+beyond: got err=%v, want EOF", err) + } + wantN = plaintextSize - (cs + 100) + if n != wantN { + t.Errorf("span 1-2+beyond: got n=%d, want %d", n, wantN) + } + if !bytes.Equal(p[:n], plaintext[cs+100:]) { + t.Error("span 1-2+beyond: data mismatch") + } + + // Test 12: Negative offset + tracker.reset() + p = make([]byte, 100) + n, err = ra.ReadAt(p, -1) + if err == nil { + t.Error("negative offset: expected error, got nil") + } + if n != 0 { + t.Errorf("negative offset: got n=%d, want 0", n) + } + + // Test 13: Zero-length read in the middle + tracker.reset() + p = make([]byte, 0) + n, err = ra.ReadAt(p, 100) + if err != nil { + t.Errorf("zero-length middle: got err=%v, want nil", err) + } + if n != 0 { + t.Errorf("zero-length middle: got n=%d, want 0", n) + } + + // Test 14: Zero-length read at end + tracker.reset() + p = make([]byte, 0) + n, err = ra.ReadAt(p, int64(plaintextSize)) + if err != nil { + t.Errorf("zero-length end: got err=%v, want nil", err) + } + if n != 0 { + t.Errorf("zero-length end: got n=%d, want 0", n) + } + + // Test 15: Read exactly one chunk at chunk boundary + checkRead("exact chunk at boundary", int64(cs), cs, cs, false, true) + + // Test 16: Read one byte at each chunk boundary + checkRead("one byte at start", 0, 1, 1, false, true) + checkRead("one byte at cs-1", int64(cs-1), 1, 1, false, false) // cached from test 15 + checkRead("one byte at cs", int64(cs), 1, 1, false, true) + checkRead("one byte at 2*cs-1", int64(2*cs-1), 1, 1, false, false) // same chunk + checkRead("one byte at 2*cs", int64(2*cs), 1, 1, false, true) + checkRead("last byte", int64(plaintextSize-1), 1, 1, true, false) // same chunk, EOF because we reach end + + // Test 17: Read crossing exactly one chunk boundary + checkRead("cross boundary 0-1", int64(cs-50), 100, 100, false, true) + checkRead("cross boundary 1-2", int64(2*cs-50), 100, 100, false, true) +} + +func TestDecryptReaderAtEmpty(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + // Create empty encrypted file + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)} + ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext))) + if err != nil { + t.Fatal(err) + } + tracker.reset() + + // Test 1: Read from empty file at offset 0 + p := make([]byte, 100) + n, err := ra.ReadAt(p, 0) + if err != io.EOF { + t.Errorf("empty read: got err=%v, want EOF", err) + } + if n != 0 { + t.Errorf("empty read: got n=%d, want 0", n) + } + + // Test 2: Zero-length read from empty file + p = make([]byte, 0) + n, err = ra.ReadAt(p, 0) + if err != nil { + t.Errorf("empty zero-length: got err=%v, want nil", err) + } + if n != 0 { + t.Errorf("empty zero-length: got n=%d, want 0", n) + } + + // Test 3: Read beyond empty file + p = make([]byte, 100) + n, err = ra.ReadAt(p, 1) + if err == nil { + t.Error("empty beyond: expected error, got nil") + } + if n != 0 { + t.Errorf("empty beyond: got n=%d, want 0", n) + } +} + +func TestDecryptReaderAtSingleChunk(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + // Single chunk, not full + plaintext := make([]byte, 1000) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)} + ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext))) + if err != nil { + t.Fatal(err) + } + tracker.reset() + + // All reads should use cache (final chunk = only chunk) + p := make([]byte, 100) + n, err := ra.ReadAt(p, 0) + if err != nil { + t.Errorf("single chunk start: got err=%v, want nil", err) + } + if n != 100 { + t.Errorf("single chunk start: got n=%d, want 100", n) + } + if tracker.called { + t.Error("single chunk start: unexpected src.ReadAt call") + } + if !bytes.Equal(p[:n], plaintext[:100]) { + t.Error("single chunk start: data mismatch") + } + + // Read at end + n, err = ra.ReadAt(p, 900) + if err != io.EOF { + t.Errorf("single chunk end: got err=%v, want EOF", err) + } + if n != 100 { + t.Errorf("single chunk end: got n=%d, want 100", n) + } + if tracker.called { + t.Error("single chunk end: unexpected src.ReadAt call") + } + if !bytes.Equal(p[:n], plaintext[900:]) { + t.Error("single chunk end: data mismatch") + } +} + +func TestDecryptReaderAtFullChunks(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + // Exactly 2 full chunks + plaintext := make([]byte, 2*cs) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + tracker := &trackingReaderAt{r: bytes.NewReader(ciphertext)} + ra, err := stream.NewDecryptReaderAt(key, tracker, int64(len(ciphertext))) + if err != nil { + t.Fatal(err) + } + tracker.reset() + + // Read last byte of second chunk (cached) + p := make([]byte, 1) + n, err := ra.ReadAt(p, int64(2*cs-1)) + if err != io.EOF { + t.Errorf("last byte: got err=%v, want EOF", err) + } + if n != 1 { + t.Errorf("last byte: got n=%d, want 1", n) + } + if tracker.called { + t.Error("last byte: unexpected src.ReadAt call (should be cached)") + } + if p[0] != plaintext[2*cs-1] { + t.Error("last byte: data mismatch") + } + + // Read at exactly the boundary between chunks + p = make([]byte, 100) + n, err = ra.ReadAt(p, int64(cs-50)) + if err != nil { + t.Errorf("boundary: got err=%v, want nil", err) + } + if n != 100 { + t.Errorf("boundary: got n=%d, want 100", n) + } + if !bytes.Equal(p, plaintext[cs-50:cs+50]) { + t.Error("boundary: data mismatch") + } +} + +func TestDecryptReaderAtWrongKey(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + plaintext := make([]byte, 1000) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + // Try to decrypt with wrong key + wrongKey := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(wrongKey); err != nil { + t.Fatal(err) + } + + _, err = stream.NewDecryptReaderAt(wrongKey, bytes.NewReader(ciphertext), int64(len(ciphertext))) + if err == nil { + t.Error("wrong key: expected error, got nil") + } +} + +func TestDecryptReaderAtInvalidSize(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + plaintext := make([]byte, 1000) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + // Wrong size (too small) + _, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)-1)) + if err == nil { + t.Error("wrong size (small): expected error, got nil") + } + + // Wrong size (too large) + _, err = stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext)+1)) + if err == nil { + t.Error("wrong size (large): expected error, got nil") + } + + // Size that would imply empty final chunk (invalid) + // This would be: one full encrypted chunk + just overhead + invalidSize := int64(cs + chacha20poly1305.Overhead + chacha20poly1305.Overhead) + _, err = stream.NewDecryptReaderAt(key, bytes.NewReader(make([]byte, invalidSize)), invalidSize) + if err == nil { + t.Error("invalid size (empty final chunk): expected error, got nil") + } +} + +func TestDecryptReaderAtTruncated(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + plaintext := make([]byte, 2*cs+500) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + // Truncate ciphertext but lie about size + truncated := ciphertext[:len(ciphertext)-100] + _, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), int64(len(ciphertext))) + if err == nil { + t.Error("truncated: expected error, got nil") + } +} + +func TestDecryptReaderAtTruncatedChunk(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + // Create 4 chunks: 3 full + 1 partial + plaintext := make([]byte, 3*cs+500) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + // Truncate to 3 chunks (remove the actual final chunk) + // The third chunk was NOT encrypted with the last chunk flag, + // so decryption should fail when we try to use it as the final chunk. + encChunkSize := cs + 16 // ChunkSize + Overhead + truncatedSize := int64(3 * encChunkSize) + truncated := ciphertext[:truncatedSize] + + _, err = stream.NewDecryptReaderAt(key, bytes.NewReader(truncated), truncatedSize) + if err == nil { + t.Error("truncated at chunk boundary: expected error, got nil") + } +} + +func TestDecryptReaderAtConcurrent(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + // Create plaintext spanning 3 chunks: 2 full + partial + plaintextSize := 2*cs + 500 + plaintext := make([]byte, plaintextSize) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + // Encrypt + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := buf.Bytes() + + ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(ciphertext), int64(len(ciphertext))) + if err != nil { + t.Fatal(err) + } + + t.Run("same chunk", func(t *testing.T) { + t.Parallel() + const goroutines = 10 + const iterations = 100 + errc := make(chan error, goroutines) + + for g := range goroutines { + go func(id int) { + for i := range iterations { + off := int64((id*iterations + i) % 500) + p := make([]byte, 100) + n, err := ra.ReadAt(p, off) + if err != nil { + errc <- fmt.Errorf("goroutine %d iter %d: %v", id, i, err) + return + } + if n != 100 { + errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want 100", id, i, n) + return + } + if !bytes.Equal(p, plaintext[off:off+100]) { + errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i) + return + } + } + errc <- nil + }(g) + } + + for range goroutines { + if err := <-errc; err != nil { + t.Error(err) + } + } + }) + + t.Run("different chunks", func(t *testing.T) { + t.Parallel() + const goroutines = 10 + const iterations = 100 + errc := make(chan error, goroutines) + + for g := range goroutines { + go func(id int) { + for i := range iterations { + // Each goroutine reads from a different chunk based on id + chunkIdx := id % 3 + off := int64(chunkIdx*cs + (i % 400)) + size := 100 + if off+int64(size) > int64(plaintextSize) { + size = plaintextSize - int(off) + } + p := make([]byte, size) + n, err := ra.ReadAt(p, off) + if n == size && err == io.EOF { + err = nil // EOF at end is acceptable + } + if err != nil { + errc <- fmt.Errorf("goroutine %d iter %d: off=%d: %v", id, i, off, err) + return + } + if n != size { + errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size) + return + } + if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) { + errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i) + return + } + } + errc <- nil + }(g) + } + + for range goroutines { + if err := <-errc; err != nil { + t.Error(err) + } + } + }) + + t.Run("across chunks", func(t *testing.T) { + t.Parallel() + const goroutines = 10 + const iterations = 100 + errc := make(chan error, goroutines) + + for g := range goroutines { + go func(id int) { + for i := range iterations { + // Read across chunk boundaries + boundary := (id%2 + 1) * cs // either cs or 2*cs + off := int64(boundary - 50 + (i % 30)) + size := 100 + if off+int64(size) > int64(plaintextSize) { + size = plaintextSize - int(off) + } + if size <= 0 { + continue + } + p := make([]byte, size) + n, err := ra.ReadAt(p, off) + if n == size && err == io.EOF { + err = nil + } + if err != nil { + errc <- fmt.Errorf("goroutine %d iter %d: off=%d size=%d: %v", id, i, off, size, err) + return + } + if n != size { + errc <- fmt.Errorf("goroutine %d iter %d: n=%d, want %d", id, i, n, size) + return + } + if !bytes.Equal(p[:n], plaintext[off:off+int64(n)]) { + errc <- fmt.Errorf("goroutine %d iter %d: data mismatch", id, i) + return + } + } + errc <- nil + }(g) + } + + for range goroutines { + if err := <-errc; err != nil { + t.Error(err) + } + } + }) +} + +func TestDecryptReaderAtCorrupted(t *testing.T) { + key := make([]byte, chacha20poly1305.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + + plaintext := make([]byte, 2*cs+500) + if _, err := rand.Read(plaintext); err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := stream.NewEncryptWriter(key, buf) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + ciphertext := bytes.Clone(buf.Bytes()) + + // Corrupt final chunk - should fail in constructor + corruptedFinal := bytes.Clone(ciphertext) + corruptedFinal[len(corruptedFinal)-10] ^= 0xFF + _, err = stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFinal), int64(len(corruptedFinal))) + if err == nil { + t.Error("corrupted final: expected error, got nil") + } + + // Corrupt first chunk - should fail on read + corruptedFirst := bytes.Clone(ciphertext) + corruptedFirst[10] ^= 0xFF + ra, err := stream.NewDecryptReaderAt(key, bytes.NewReader(corruptedFirst), int64(len(corruptedFirst))) + if err != nil { + t.Fatalf("corrupted first constructor: unexpected error: %v", err) + } + p := make([]byte, 100) + _, err = ra.ReadAt(p, 0) + if err == nil { + t.Error("corrupted first read: expected error, got nil") + } +} diff --git a/internal/term/term.go b/internal/term/term.go new file mode 100644 index 0000000..e45779b --- /dev/null +++ b/internal/term/term.go @@ -0,0 +1,89 @@ +package term + +import ( + "fmt" + "os" + "runtime" + + "golang.org/x/term" +) + +// enableVirtualTerminalProcessing tries to enable virtual terminal processing +// on Windows. If it fails, avoid using escape sequences to prevent weird +// characters being printed to the console. +var enableVirtualTerminalProcessing func(out *os.File) error + +// clearLine clears the current line on the terminal, or opens a new line if +// terminal escape codes don't work. +func clearLine(out *os.File) { + const ( + CUI = "\033[" // Control Sequence Introducer + CPL = CUI + "F" // Cursor Previous Line + EL = CUI + "K" // Erase in Line + ) + + // First, open a new line, which is guaranteed to work everywhere. Then, try + // to erase the line above with escape codes, if possible. + // + // (We use CRLF instead of LF to work around an apparent bug in WSL2's + // handling of CONOUT$. Only when running a Windows binary from WSL2, the + // cursor would not go back to the start of the line with a simple LF. + // Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.) + fmt.Fprintf(out, "\r\n") + if enableVirtualTerminalProcessing == nil || enableVirtualTerminalProcessing(out) == nil { + fmt.Fprintf(out, CPL+EL) + } +} + +// WithTerminal runs f with the terminal input and output files, if available. +// WithTerminal does not open a non-terminal stdin, so the caller does not need +// to check if stdin is in use. +func WithTerminal(f func(in, out *os.File) error) error { + if runtime.GOOS == "windows" { + in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) + if err != nil { + return err + } + defer out.Close() + return f(in, out) + } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { + defer tty.Close() + return f(tty, tty) + } else if term.IsTerminal(int(os.Stdin.Fd())) { + return f(os.Stdin, os.Stdin) + } else { + return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) + } +} + +// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral. +func ReadSecret(prompt string) (s []byte, err error) { + err = WithTerminal(func(in, out *os.File) error { + fmt.Fprintf(out, "%s ", prompt) + defer clearLine(out) + s, err = term.ReadPassword(int(in.Fd())) + return err + }) + return +} + +// ReadPassphrase returns the passphrase from the environment variable env if +// it is set, otherwise prompts on the terminal. +func ReadPassphrase(prompt, env string) (string, error) { + if p, ok := os.LookupEnv(env); ok { + if p == "" { + return "", fmt.Errorf("%s is set but empty", env) + } + return p, nil + } + p, err := ReadSecret(prompt) + if err != nil { + return "", fmt.Errorf("could not read passphrase (set %s for non-interactive use): %v", env, err) + } + return string(p), nil +} diff --git a/internal/term/term_windows.go b/internal/term/term_windows.go new file mode 100644 index 0000000..993878f --- /dev/null +++ b/internal/term/term_windows.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package term + +import ( + "errors" + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func init() { + enableVirtualTerminalProcessing = func(out *os.File) error { + // Some instances of the Windows Console (e.g., cmd.exe and Windows PowerShell) + // do not have the virtual terminal processing enabled, which is necessary to + // make terminal escape sequences work. For this reason the clearLine function + // may not properly work. Here we enable the virtual terminal processing, if + // possible. + // + // See https://learn.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences. + + const ( + ENABLE_PROCESSED_OUTPUT uint32 = 0x1 + ENABLE_VIRTUAL_TERMINAL_PROCESSING uint32 = 0x4 + ) + + kernel32DLL := windows.NewLazySystemDLL("Kernel32.dll") + setConsoleMode := kernel32DLL.NewProc("SetConsoleMode") + + var mode uint32 + if err := syscall.GetConsoleMode(syscall.Handle(out.Fd()), &mode); err != nil { + return err + } + + mode |= ENABLE_PROCESSED_OUTPUT + mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING + + // If the SetConsoleMode function fails, the return value is zero. + // See https://learn.microsoft.com/en-us/windows/console/setconsolemode#return-value. + if ret, _, _ := setConsoleMode.Call(out.Fd(), uintptr(mode)); ret == 0 { + return errors.New("SetConsoleMode failed") + } + return nil + } +} diff --git a/keyfile.go b/keyfile.go new file mode 100644 index 0000000..f2eabed --- /dev/null +++ b/keyfile.go @@ -0,0 +1,126 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "encoding/pem" + "errors" + "os" + "path/filepath" + + "github.com/andydixon/vault/internal/pqcrypt" + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/chacha20poly1305" +) + +// A key file seals a (suite ID, master seed) pair under an Argon2id-derived +// key. The private seed never touches disk unencrypted. + +const ( + keyPEMType = "VAULT KEY" + argonTime = 3 + argonMemory = 64 * 1024 // KiB + argonThreads = 4 + slotSaltSize = 16 + seedSize = 32 + slotPlainSize = 2 + seedSize + slotSize = slotSaltSize + chacha20poly1305.NonceSizeX + slotPlainSize + chacha20poly1305.Overhead +) + +var keyMagic = []byte("VAULTKEY1") + +// keyHeader is the authenticated, non-secret prefix of every key file. +func keyHeader() []byte { + h := append([]byte(nil), keyMagic...) + h = binary.BigEndian.AppendUint32(h, argonTime) + h = binary.BigEndian.AppendUint32(h, argonMemory) + return append(h, argonThreads) +} + +// sealSlot encrypts the identity's seed under passphrase. +func sealSlot(id *pqcrypt.PQIdentity, passphrase string) ([]byte, error) { + slot := make([]byte, slotSaltSize+chacha20poly1305.NonceSizeX, slotSize) + if _, err := rand.Read(slot); err != nil { + return nil, err + } + aead, err := slotAEAD(passphrase, slot[:slotSaltSize]) + if err != nil { + return nil, err + } + plain := binary.BigEndian.AppendUint16(nil, id.SuiteID()) + seed := id.Seed() + plain = append(plain, seed...) + clear(seed) + defer clear(plain) + return aead.Seal(slot, slot[slotSaltSize:], plain, keyHeader()), nil +} + +func slotAEAD(passphrase string, salt []byte) (interface { + Seal(dst, nonce, plaintext, ad []byte) []byte + Open(dst, nonce, ciphertext, ad []byte) ([]byte, error) +}, error) { + key := argon2.IDKey([]byte(passphrase), salt, argonTime, argonMemory, argonThreads, chacha20poly1305.KeySize) + defer clear(key) + return chacha20poly1305.NewX(key) +} + +// writeKeyFile creates a new key file holding a fresh identity. +func writeKeyFile(path, passphrase string) error { + id, err := pqcrypt.GeneratePQIdentity() + if err != nil { + return err + } + slot, err := sealSlot(id, passphrase) + id.Close() + if err != nil { + return err + } + body := append(keyHeader(), slot...) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if err := pem.Encode(f, &pem.Block{Type: keyPEMType, Bytes: body}); err != nil { + f.Close() + os.Remove(path) + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() +} + +// unlockKeyFile returns the identity the passphrase unlocks. +func unlockKeyFile(path, passphrase string) (*pqcrypt.PQIdentity, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, rest := pem.Decode(data) + header := keyHeader() + if block == nil || block.Type != keyPEMType || len(bytes.TrimSpace(rest)) != 0 || + len(block.Bytes) != len(header)+slotSize || !bytes.Equal(block.Bytes[:len(header)], header) { + return nil, errors.New("not a vault key file") + } + slot := block.Bytes[len(header):] + aead, err := slotAEAD(passphrase, slot[:slotSaltSize]) + if err != nil { + return nil, err + } + plain, err := aead.Open(nil, slot[slotSaltSize:][:chacha20poly1305.NonceSizeX], + slot[slotSaltSize+chacha20poly1305.NonceSizeX:], header) + if err != nil { + return nil, errors.New("incorrect passphrase") + } + defer clear(plain) + return pqcrypt.NewPQIdentityFromSeed(binary.BigEndian.Uint16(plain), plain[2:]) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c3e2be4 --- /dev/null +++ b/main.go @@ -0,0 +1,241 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +// vault is a post-quantum encrypted file vault. +package main + +import ( + "flag" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/andydixon/vault/internal/term" +) + +const usage = `Usage: + vault genkey create a new passphrase-protected key + vault init create a new, empty vault + vault add FILE|DIR [FOLDER] encrypt a file or directory into the vault, + optionally under a folder inside the vault + vault list [PREFIX] list stored files + vault get NAME [-o OUTPUT] decrypt a stored file (default: its base name) + vault mv OLD NEW rename a stored file + vault rm NAME delete a stored file + +Options (any command): + --key PATH key file (default $VAULT_KEY, else ~/.vault/key) + --vault DIR vault directory (default $VAULT_DIR, else ~/.vault/store) + +The passphrase is read from the terminal, or from $VAULT_PASSPHRASE.` + +const passphraseEnv = "VAULT_PASSPHRASE" + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, usage) + os.Exit(2) + } + cmd, args := os.Args[1], os.Args[2:] + fset := flag.NewFlagSet("vault "+cmd, flag.ContinueOnError) + fset.SetOutput(io.Discard) + keyPath := fset.String("key", defaultPath("VAULT_KEY", "key"), "") + vaultDir := fset.String("vault", defaultPath("VAULT_DIR", "store"), "") + output := fset.String("o", "", "") + pos, err := parseInterleaved(fset, args) + if err != nil { + fail("%v\n\n%s", err, usage) + } + want := func(min, max int) { + if len(pos) < min || len(pos) > max { + fail("wrong number of arguments for %s\n\n%s", cmd, usage) + } + } + + switch cmd { + case "genkey": + want(0, 0) + if _, err := os.Stat(*keyPath); err == nil { + fail("%s already exists", *keyPath) + } + pass := newPassphrase("passphrase") + check(writeKeyFile(*keyPath, pass)) + fmt.Fprintf(os.Stderr, "key written to %s\n", *keyPath) + case "init": + want(0, 0) + check(initStore(*vaultDir)) + fmt.Fprintf(os.Stderr, "vault created at %s\n", *vaultDir) + case "add": + want(1, 2) + dest := "" + if len(pos) == 2 { + dest = strings.Trim(pos[1], "/") + } + s := open(*keyPath, *vaultDir) + cat, err := s.catalogue() + check(err) + check(addPath(s, cat, pos[0], dest)) + case "list", "ls": + want(0, 1) + prefix := "" + if len(pos) == 1 { + prefix = strings.Trim(pos[0], "/") + } + cat, err := open(*keyPath, *vaultDir).catalogue() + check(err) + list(cat, prefix) + case "get": + want(1, 1) + out := *output + if out == "" { + out = path.Base(pos[0]) + } + check(open(*keyPath, *vaultDir).get(pos[0], out)) + case "mv", "rename": + want(2, 2) + check(open(*keyPath, *vaultDir).rename(pos[0], pos[1])) + case "rm", "delete": + want(1, 1) + check(open(*keyPath, *vaultDir).delete(pos[0])) + case "help", "-h", "--help": + fmt.Println(usage) + default: + fail("unknown command %q\n\n%s", cmd, usage) + } +} + +// parseInterleaved lets flags appear before, between or after positional +// arguments. +func parseInterleaved(fset *flag.FlagSet, args []string) ([]string, error) { + var pos []string + for { + if err := fset.Parse(args); err != nil { + return nil, err + } + if fset.NArg() == 0 { + return pos, nil + } + pos = append(pos, fset.Arg(0)) + args = fset.Args()[1:] + } +} + +func defaultPath(env, name string) string { + if v := os.Getenv(env); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + fail("cannot find home directory; set %s", env) + } + return filepath.Join(home, ".vault", name) +} + +func open(keyPath, vaultDir string) *store { + pass, err := term.ReadPassphrase("Enter passphrase:", passphraseEnv) + check(err) + id, err := unlockKeyFile(keyPath, pass) + check(err) + s, err := openStore(vaultDir, id) + check(err) + return s +} + +func newPassphrase(what string) string { + if p, ok := os.LookupEnv(passphraseEnv); ok && p != "" { + return p + } + p, err := term.ReadPassphrase("Enter "+what+":", passphraseEnv) + check(err) + if p == "" { + fail("%s must not be empty", what) + } + again, err := term.ReadPassphrase("Confirm "+what+":", passphraseEnv) + check(err) + if p != again { + fail("%ss do not match", what) + } + return p +} + +// addPath adds a file, or every regular file under a directory, beneath dest. +func addPath(s *store, cat map[string][]*record, src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + base := filepath.Base(filepath.Clean(src)) + if !info.IsDir() { + return addOne(s, cat, src, path.Join(dest, base)) + } + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if !d.Type().IsRegular() { + fmt.Fprintf(os.Stderr, "skipping %s: not a regular file\n", p) + return nil + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + return addOne(s, cat, p, path.Join(dest, base, filepath.ToSlash(rel))) + }) +} + +func addOne(s *store, cat map[string][]*record, src, name string) error { + replaced, err := s.add(cat, src, name) + if err != nil { + return err + } + verb := "added" + if replaced { + verb = "replaced" + } + fmt.Fprintf(os.Stderr, "%s %s\n", verb, name) + return nil +} + +func list(cat map[string][]*record, prefix string) { + var names []string + for name := range cat { + if prefix == "" || name == prefix || strings.HasPrefix(name, prefix+"/") { + names = append(names, name) + } + } + sort.Strings(names) + for _, name := range names { + r := cat[name][0] + fmt.Printf("%10s %s %s\n", humanSize(r.Size), r.Modified.Local().Format("2006-01-02 15:04"), name) + } +} + +func humanSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +func check(err error) { + if err != nil { + fail("%v", err) + } +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, "vault: "+format+"\n", args...) + os.Exit(1) +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..cd4ec67 --- /dev/null +++ b/store.go @@ -0,0 +1,426 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/andydixon/vault/internal/pqcrypt" +) + +// On disk a vault is: +// +// DIR/.vault marker +// DIR/records/ one encrypted catalogue record per file +// DIR/objects/ one encrypted file body per record +// +// IDs are random. Records are padded to one fixed size and objects to +// power-of-two buckets, so the storage reveals neither names, exact sizes nor +// timestamps. Every ciphertext's mtime is pinned to the epoch. + +const ( + markerName = ".vault" + markerContent = "VAULT 1\n" + recordsDir = "records" + objectsDir = "objects" + recordSize = 4096 + nameLimit = 1024 + fileLimit = int64(1) << 40 + minPadding = int64(64 << 10) + idSize = 16 +) + +type record struct { + Name string `json:"name"` + Object string `json:"object"` + Size int64 `json:"size"` + Mode uint32 `json:"mode"` + Modified time.Time `json:"modified"` + Added time.Time `json:"added"` + + id string // record file ID; not stored +} + +type store struct { + root string + id *pqcrypt.PQIdentity +} + +func initStore(root string) error { + if _, err := os.Stat(filepath.Join(root, markerName)); err == nil { + return fmt.Errorf("%s is already a vault", root) + } + for _, dir := range []string{root, filepath.Join(root, recordsDir), filepath.Join(root, objectsDir)} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } + return os.WriteFile(filepath.Join(root, markerName), []byte(markerContent), 0o600) +} + +func openStore(root string, id *pqcrypt.PQIdentity) (*store, error) { + marker, err := os.ReadFile(filepath.Join(root, markerName)) + if err != nil || string(marker) != markerContent { + return nil, fmt.Errorf("%s is not a vault; run vault init", root) + } + return &store{root: root, id: id}, nil +} + +func randomID() (string, error) { + b := make([]byte, idSize) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func validID(s string) bool { + b, err := hex.DecodeString(s) + return err == nil && len(b) == idSize && hex.EncodeToString(b) == s +} + +func validName(name string) bool { + if name == "" || len(name) > nameLimit || !utf8.ValidString(name) || path.Clean(name) != name || + path.IsAbs(name) || name == "." || name == ".." || strings.HasPrefix(name, "../") || strings.Contains(name, "\\") { + return false + } + for _, r := range name { + if unicode.IsControl(r) { + return false + } + } + return true +} + +// paddedSize rounds size up to the next power-of-two bucket. +func paddedSize(size int64) int64 { + p := minPadding + for p < size { + p *= 2 + } + return p +} + +// writeCiphertext encrypts to dir/id via a temporary file, so a partial +// ciphertext never appears under a real ID. +func (s *store) writeCiphertext(dir, id string, write func(io.Writer) error) error { + dir = filepath.Join(s.root, dir) + tmp, err := os.CreateTemp(dir, ".pending-") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + w, err := pqcrypt.Encrypt(tmp, s.id.Recipient()) + if err != nil { + return err + } + if err := write(w); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + epoch := time.Unix(0, 0) + if err := os.Chtimes(tmp.Name(), epoch, epoch); err != nil { + return err + } + if err := os.Rename(tmp.Name(), filepath.Join(dir, id)); err != nil { + return err + } + return syncDir(dir) +} + +func syncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + return d.Sync() +} + +func (s *store) writeRecord(r *record) error { + data, err := json.Marshal(r) + if err != nil { + return err + } + if len(data) > recordSize-4 { + return errors.New("catalogue record too large") + } + plain := make([]byte, recordSize) + binary.BigEndian.PutUint32(plain, uint32(len(data))) + copy(plain[4:], data) + if _, err := rand.Read(plain[4+len(data):]); err != nil { + return err + } + if r.id, err = randomID(); err != nil { + return err + } + return s.writeCiphertext(recordsDir, r.id, func(w io.Writer) error { + _, err := w.Write(plain) + return err + }) +} + +// catalogue decrypts every record this identity can open. Records sealed to +// a different key are skipped silently: they are simply not ours. +func (s *store) catalogue() (map[string][]*record, error) { + entries, err := os.ReadDir(filepath.Join(s.root, recordsDir)) + if err != nil { + return nil, err + } + byName := map[string][]*record{} + for _, e := range entries { + if !validID(e.Name()) { + continue + } + r, err := s.readRecord(e.Name()) + var noMatch *pqcrypt.NoIdentityMatchError + if errors.As(err, &noMatch) { + continue + } + if err != nil { + fmt.Fprintf(os.Stderr, "vault: skipping unreadable record %s: %v\n", e.Name(), err) + continue + } + byName[r.Name] = append(byName[r.Name], r) + } + // Newest first, so [0] is current. Duplicates only exist after an + // interrupted replace; the next write of that name cleans them up. + for _, rs := range byName { + sort.Slice(rs, func(i, j int) bool { return rs[i].Added.After(rs[j].Added) }) + } + return byName, nil +} + +func (s *store) readRecord(id string) (*record, error) { + f, err := os.Open(filepath.Join(s.root, recordsDir, id)) + if err != nil { + return nil, err + } + defer f.Close() + pr, err := pqcrypt.Decrypt(f, s.id) + if err != nil { + return nil, err + } + plain, err := io.ReadAll(io.LimitReader(pr, recordSize+1)) + if err != nil { + return nil, err + } + if len(plain) != recordSize { + return nil, errors.New("wrong record size") + } + n := binary.BigEndian.Uint32(plain) + if n > recordSize-4 { + return nil, errors.New("malformed record") + } + r := &record{id: id} + dec := json.NewDecoder(bytes.NewReader(plain[4 : 4+n])) + dec.DisallowUnknownFields() + if err := dec.Decode(r); err != nil { + return nil, err + } + if !validName(r.Name) || !validID(r.Object) || r.Size < 0 || r.Size > fileLimit { + return nil, errors.New("malformed record") + } + return r, nil +} + +// add stores the file at src as name, replacing any existing file of that +// name, and updates cat to match. It reports whether something was replaced. +func (s *store) add(cat map[string][]*record, src, name string) (bool, error) { + if !validName(name) { + return false, fmt.Errorf("invalid vault path %q", name) + } + f, err := os.Open(src) + if err != nil { + return false, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return false, err + } + if !info.Mode().IsRegular() || info.Size() > fileLimit { + return false, fmt.Errorf("%s: not a regular file of at most 1 TiB", src) + } + objID, err := randomID() + if err != nil { + return false, err + } + err = s.writeCiphertext(objectsDir, objID, func(w io.Writer) error { + // The object ID leads the plaintext so a record can only ever + // open the object it names, not one swapped in under its ID. + raw, _ := hex.DecodeString(objID) + if _, err := w.Write(raw); err != nil { + return err + } + n, err := io.Copy(w, io.LimitReader(f, info.Size())) + if err != nil { + return err + } + if n != info.Size() { + return fmt.Errorf("%s changed while being added", src) + } + _, err = io.CopyN(w, rand.Reader, paddedSize(idSize+n)-idSize-n) + return err + }) + if err != nil { + return false, err + } + r := &record{ + Name: name, Object: objID, Size: info.Size(), Mode: uint32(info.Mode().Perm()), + Modified: info.ModTime().UTC(), Added: time.Now().UTC(), + } + if err := s.writeRecord(r); err != nil { + os.Remove(filepath.Join(s.root, objectsDir, objID)) + return false, err + } + replaced := len(cat[name]) > 0 + if err := s.remove(cat, name, true); err != nil { + return replaced, err + } + cat[name] = []*record{r} + return replaced, nil +} + +// remove deletes every record for name and, when dropObjects is set, any of +// their objects no other record still uses. Records go first, so an +// interruption only ever leaves an orphaned object. +func (s *store) remove(cat map[string][]*record, name string, dropObjects bool) error { + inUse := map[string]bool{} + for n, rs := range cat { + for _, r := range rs { + if n != name { + inUse[r.Object] = true + } + } + } + for _, r := range cat[name] { + if err := os.Remove(filepath.Join(s.root, recordsDir, r.id)); err != nil { + return err + } + if dropObjects && !inUse[r.Object] { + if err := os.Remove(filepath.Join(s.root, objectsDir, r.Object)); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} + +func (s *store) lookup(name string) (map[string][]*record, *record, error) { + cat, err := s.catalogue() + if err != nil { + return nil, nil, err + } + rs := cat[name] + if len(rs) == 0 { + return nil, nil, fmt.Errorf("%s: not in the vault", name) + } + return cat, rs[0], nil +} + +func (s *store) delete(name string) error { + cat, _, err := s.lookup(name) + if err != nil { + return err + } + return s.remove(cat, name, true) +} + +func (s *store) rename(oldName, newName string) error { + if !validName(newName) { + return fmt.Errorf("invalid vault path %q", newName) + } + cat, r, err := s.lookup(oldName) + if err != nil { + return err + } + if len(cat[newName]) > 0 { + return fmt.Errorf("%s already exists", newName) + } + moved := *r + moved.Name = newName + if err := s.writeRecord(&moved); err != nil { + return err + } + return s.remove(cat, oldName, false) +} + +// get decrypts name to out. Nothing appears at out until the entire object, +// padding included, has authenticated. +func (s *store) get(name, out string) error { + _, r, err := s.lookup(name) + if err != nil { + return err + } + if _, err := os.Lstat(out); err == nil { + return fmt.Errorf("%s already exists", out) + } + f, err := os.Open(filepath.Join(s.root, objectsDir, r.Object)) + if err != nil { + return err + } + defer f.Close() + pr, err := pqcrypt.Decrypt(f, s.id) + if err != nil { + return err + } + head := make([]byte, idSize) + if _, err := io.ReadFull(pr, head); err != nil || hex.EncodeToString(head) != r.Object { + return errors.New("object does not match its catalogue record") + } + tmp, err := os.CreateTemp(filepath.Dir(out), ".vault-get-") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + defer tmp.Close() + if _, err := io.CopyN(tmp, pr, r.Size); err != nil { + return err + } + pad, err := io.Copy(io.Discard, pr) + if err != nil { + return err + } + if idSize+r.Size+pad != paddedSize(idSize+r.Size) { + return errors.New("object size does not match its catalogue record") + } + if err := tmp.Chmod(os.FileMode(r.Mode) & 0o777); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chtimes(tmp.Name(), r.Modified, r.Modified); err != nil { + return err + } + if _, err := os.Lstat(out); err == nil { + return fmt.Errorf("%s already exists", out) + } + return os.Rename(tmp.Name(), out) +} diff --git a/vault_test.go b/vault_test.go new file mode 100644 index 0000000..6ed5794 --- /dev/null +++ b/vault_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 The vault Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestKeyFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "key") + if err := writeKeyFile(path, "pass"); err != nil { + t.Fatal(err) + } + if _, err := unlockKeyFile(path, "wrong"); err == nil { + t.Fatal("wrong passphrase unlocked the key") + } + if _, err := unlockKeyFile(path, "pass"); err != nil { + t.Fatal(err) + } +} + +func newTestStore(t *testing.T, keyPath, pass string) *store { + t.Helper() + id, err := unlockKeyFile(keyPath, pass) + if err != nil { + t.Fatal(err) + } + s, err := openStore(filepath.Join(filepath.Dir(keyPath), "store"), id) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestStoreLifecycle(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "key") + if err := writeKeyFile(keyPath, "pass"); err != nil { + t.Fatal(err) + } + if err := initStore(filepath.Join(dir, "store")); err != nil { + t.Fatal(err) + } + s := newTestStore(t, keyPath, "pass") + src := filepath.Join(dir, "in") + want := bytes.Repeat([]byte("x"), 70000) + os.WriteFile(src, want, 0o640) + + cat, _ := s.catalogue() + if _, err := s.add(cat, src, "a/b"); err != nil { + t.Fatal(err) + } + if replaced, err := s.add(cat, src, "a/b"); err != nil || !replaced { + t.Fatalf("replace: %v %v", replaced, err) + } + if _, err := s.add(cat, src, "c"); err != nil { + t.Fatal(err) + } + if cat, _ = s.catalogue(); len(cat) != 2 || len(cat["a/b"]) != 1 { + t.Fatalf("catalogue = %v", cat) + } + if err := s.rename("a/b", "d"); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, "out") + if err := s.get("d", out); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(out); !bytes.Equal(got, want) { + t.Fatal("round trip mismatch") + } + if err := s.get("d", out); err == nil { + t.Fatal("get overwrote an existing file") + } + + // Swapping one object for another must be detected. + cat, _ = s.catalogue() + d, c := cat["d"][0], cat["c"][0] + objects := filepath.Join(s.root, objectsDir) + data, _ := os.ReadFile(filepath.Join(objects, c.Object)) + os.WriteFile(filepath.Join(objects, d.Object), data, 0o600) + if err := s.get("d", filepath.Join(dir, "swapped")); err == nil { + t.Fatal("swapped object was accepted") + } + + if err := s.delete("c"); err != nil { + t.Fatal(err) + } + if cat, _ = s.catalogue(); len(cat) != 1 { + t.Fatalf("after delete: %v", cat) + } +}