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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/vault
|
||||||
|
/vault.exe
|
||||||
|
.claude/
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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/<id> one encrypted catalogue record per file
|
||||||
|
// DIR/objects/<id> 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user