Key transparency server

This commit is contained in:
Katherine Yen 2025-07-11 12:12:32 -04:00
commit a3732f0c03
122 changed files with 22049 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.git
.github
filter-key-updates/target
cmd/kt-server/kt-server
cmd/kt-client/kt-client
cmd/kt-stress/kt-stress
cmd/generate-keys/generate-keys
example/db

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.go]
indent_style = tab
indent_size = 4
ij_go_add_parentheses_for_single_import = true
ij_go_GROUP_CURRENT_PROJECT_IMPORTS = true
ij_go_group_stdlib_imports = true
ij_go_import_sorting = goimports
ij_go_local_group_mode = project
ij_go_local_package_prefixes = "github.com/signalapp/keytransparency"
ij_go_move_all_imports_in_one_declaration = true
ij_go_move_all_stdlib_imports_in_one_group = true

56
.github/workflows/push.yml vendored Normal file
View File

@ -0,0 +1,56 @@
name: Build and push Docker image
on:
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: write
steps:
- name: Checkout main project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
with:
role-to-assume: ${{ vars.AWS_IAM_ROLE }}
aws-region: ${{ vars.AWS_REGION }}
- name: Login to ECR
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ vars.ECR_REGISTRY }}
- name: Set up Go
id: setup-go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version-file: go.mod
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'temurin'
java-version: 21
cache: 'maven'
- name: Test Go
run: go test ./...
- name: Build and push the Docker image of the KT server
run: |
docker build . --file docker/Dockerfile --build-arg GO_VERSION=${{ steps.setup-go.outputs.go-version }} \
--tag "${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPO }}:${GITHUB_REF_NAME}"
docker push "${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPO }}:${GITHUB_REF_NAME}"
- name: Build and push filter key updates lambda artifact to S3
working-directory: ./filter-key-updates
run: |
./mvnw -e -B deploy -Djgitver.use-version=${{ github.ref_name }} \
-DbucketName=${{ vars.S3_BUCKET }} \
-DbucketKey=${{ vars.S3_BUCKET_KEY }}

36
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
permissions:
packages: read
contents: read
steps:
- name: Checkout main project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
submodules: true
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version-file: go.mod
- name: Build Go
run: go build ./...
- name: Test Go
run: go test ./...
- name: Set up JDK 21
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'temurin'
java-version: 21
cache: 'maven'
- name: Build and test Java
run: ./mvnw verify
working-directory: filter-key-updates

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
example/db/
.idea
cmd/kt-server/kt-server
cmd/kt-client/kt-client
cmd/kt-stress/kt-stress
cmd/generate-keys/generate-keys

619
LICENSE Normal file
View File

@ -0,0 +1,619 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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

148
README.md Normal file
View File

@ -0,0 +1,148 @@
key-transparency-server
========================
In an end-to-end encrypted messaging ecosystem, users must have some way to obtain the public keys of the users they wish to message.
This typically happens by users uploading a mapping of their public identifier (for example, their phone number)
to their public key to a central directory or service, and other users querying this service.
However, this requires users to trust that the service operator will behave honestly and not tamper with the keys.
_Key transparency_ is a mechanism to ensure that a service operator cannot do this without being detected.
It does so by sequentially recording updates to the mappings, where an update is either an insert of a new mapping
from a search key to a value, or an update of an existing mapping.
In this context, "search key" refers to something like "n:+18005550101", and the value it maps to could be the public key associated with that phone number.
Every update—also referred to as a log entry—is appended to the _key transparency log_,
a globally consistent, cryptographically-protected, append-only log.
Although the key transparency log is an append-only construct, callers will need to update existing search keys
from time to time (e.g. if they lose their phone and wind up generating a new public key).
The log allows for updates by appending new log entries that supersede previous entries for the same search key.
This repo defines a key transparency server that maintains such a log for Signal, as well as associated testing and development tools.
It uses this [protocol][ietf-protocol] in the IETF's datatracker, although it
accommodates Signal's specific application requirements and therefore contains some modifications.
[ietf-protocol]: https://datatracker.ietf.org/doc/draft-ietf-keytrans-protocol/
Repo Overview
-------------
Key transparency code:
- `tree`: Defines the data structures used in the key transparency log, along with the logic to interact with them and generate proofs for various operations.
Operating the log:
- `cmd/kt-server`: Defines the server logic for interacting with the key transparency log.
- `db`: Defines various database and cache implementations.
- `filter-key-updates`: Defines the lambda that reads account changes from a DynamoDB stream and writes relevant updates to a Kinesis stream.
Tools/testing:
- `cmd/kt-client`: A tool for running various operations against a local or remote key transparency server.
- `cmd/kt-stress`: A tool for stress testing a key transparency server.
- `cmd/generate-auditing-test-vectors`: Generates test vectors for an auditor implementation.
- `cmd/generate-keys`: Generates a new set of private keys needed to run the key transparency server.
Key Transparency Services
-------------------------
The responsibilities of the key transparency server are split into three services:
- `KeyTransparencyQueryService` is a read-only server. It contains the `Search` and `Monitor` endpoints that clients use to query the log.
- `KeyTransparencyService` is a read-and-write server. It contains the audit endpoints and internally also writes updates to the log.
- `KeyTransparencyTestService` is a write-only server. It contains the `Update` endpoint and only exists for local testing and development use by `kt-client` and `kt-stress`.
All three services communicate via [gRPC](https://grpc.io/). Their definitions can be found in the `cmd/kt-server/pb` directory.
Data Structures
---------------
The key transparency log consists of two main types of data structures: a _log tree_ and _prefix trees_.
Log entries are stored as leaves in the log tree, and prefix trees are used to facilitate
efficient lookups of a specific mapping in the large and ever-growing log tree.
The transparency log uses various cryptographic techniques to append to the log tree and update prefix trees in a verifiable manner.
More details about each data structure and how they're used can be found in the [protocol][ietf-protocol].
Tests
-----
To run all tests:
```go
go test ./...
```
Quickstart
----------
You can run a key transparency server locally using [LevelDB](https://github.com/google/leveldb) as a backing database.
To do so, first run the `generate-keys` command to generate a new set of private keys:
```shell
go run github.com/signalapp/keytransparency/cmd/generate-keys
```
Copy-paste the keys into `example/config.yaml`. Then run the read-only, audit, and test servers locally (all three are required for
`kt-client` to have full functionality):
```shell
go run github.com/signalapp/keytransparency/cmd/kt-server -config ./example/config.yaml
```
You can now access metrics and the transparency
servers at the configured addresses. You can interact with the transparency log
via `kt-client`:
```shell
# Add an ACI to the log
go run github.com/signalapp/keytransparency/cmd/kt-client update aci \
<UUID> <base64_encoded_aci_identity_key>
# Add an E164 to the log. Be sure to use the same ACI as a previous update.
go run github.com/signalapp/keytransparency/cmd/kt-client update aci \
<e164_formatted_number> <UUID>
# Search for the ACI and provide the value it's mapped to
go run github.com/signalapp/keytransparency/cmd/kt-client search \
<UUID> <base64_encoded_aci_identity_key>
# Look up the distinguished key
go run github.com/signalapp/keytransparency/cmd/kt-client distinguished
```
To look up a username hash, you must also provide the ACI and ACI identity key:
```shell
# Search for an E164
go run github.com/signalapp/keytransparency/cmd/kt-client \
-username_hash <base64url_encoded_username_hash> \
search <UUID> <base64_encoded_aci_identity_key>
```
To look up an E164, you must provide the ACI, the ACI identity key, and an unidentified access key.
If using the `mock` AccountDB configuration, the default `-uak` value matches the one used by the mock implementation and can be left out.
```shell
# Search for an E164
go run github.com/signalapp/keytransparency/cmd/kt-client \
-e164 <e164_formatted_number> -uak <base64_encoded_uak> \
search <UUID> <base64_encoded_aci_identity_key>
```
To time the latency of a search or monitor request:
```shell
go run github.com/signalapp/keytransparency/cmd/kt-client -sample-size 50 -num-samples 10 \
search-timing <UUID> <base64_encoded_aci_identity_key>
# Note that this will make a search request first so that it can compute the commitment index
# necessary for a monitor request
go run github.com/signalapp/keytransparency/cmd/kt-client -sample-size 50 -num-samples 10 \
monitor-timing <UUID> <base64_encoded_aci_identity_key>
```
References
----------
- The `docs/` folder contains details on the format of the data stored in the database.
- [Key Transparency Architecture](https://datatracker.ietf.org/doc/draft-ietf-keytrans-architecture/) describes
the various deployment modes of key transparency and how to apply it to messaging and other applications.
You can also read these academic papers for background:
- [Merkle^2: A Low-Latency Transparency Log System](https://eprint.iacr.org/2021/453)
- [CONIKS: Bringing Key Transparency to End Users](https://eprint.iacr.org/2014/1004)

View File

@ -0,0 +1,243 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
//go:generate protoc -I ./pb -I ../../tree/transparency/pb --go_out=pb --go_opt=paths=source_relative --go-grpc_out=pb --go-grpc_opt=paths=source_relative vectors.proto
// Command generate-auditing-test-vectors outputs a file in the current working
// directory that contains test vectors for an auditor implementation.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"fmt"
"log"
"os"
"google.golang.org/protobuf/proto"
"github.com/signalapp/keytransparency/cmd/generate-auditing-test-vectors/pb"
"github.com/signalapp/keytransparency/tree/transparency"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
transparency_test "github.com/signalapp/keytransparency/tree/transparency/test"
)
func random() []byte {
out := make([]byte, 16)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
var output []*pb.TestVectors_ShouldFailTestVector
// first proof type must be newTree
{
tree, _, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
updates, _, _ := tree.Audit(0, 1000)
updates = updates[1:]
output = append(output, &pb.TestVectors_ShouldFailTestVector{
Description: "first proof type must be newTree",
Updates: updates,
})
}
// first proof type must be a real update
{
tree, _, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
updates, _, _ := tree.Audit(0, 1000)
updates[0].Real = false
output = append(output, &pb.TestVectors_ShouldFailTestVector{
Description: "first proof type must be real update",
Updates: updates,
})
}
// newTree proof cannot be given for a non-empty tree
{
tree, _, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
updates, _, _ := tree.Audit(0, 1000)
updates[1] = updates[0]
output = append(output, &pb.TestVectors_ShouldFailTestVector{
Description: "newTree proof cannot be given for a non-empty tree",
Updates: updates,
})
}
// differentKey must match old root
{
tree, _, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
updates, _, _ := tree.Audit(0, 1000)
updates[2].Proof.Proof.(*tpb.AuditorProof_DifferentKey_).DifferentKey.OldSeed[0] ^= 1
output = append(output, &pb.TestVectors_ShouldFailTestVector{
Description: "differentKey must match old root",
Updates: updates,
})
}
// sameKey must match old root
{
tree, _, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
newKey := random()
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: newKey, Value: random()})
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: newKey, Value: random()})
updates, _, _ := tree.Audit(0, 1000)
updates[2].Proof.Proof.(*tpb.AuditorProof_SameKey_).SameKey.Position += 1
output = append(output, &pb.TestVectors_ShouldFailTestVector{
Description: "sameKey must match old root",
Updates: updates,
})
}
// proof may not be sameKey if update type is fake
{
tree, _, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: random(), Value: random()})
newKey := random()
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: newKey, Value: random()})
tree.UpdateSimple(&tpb.UpdateRequest{SearchKey: newKey, Value: random()})
updates, _, _ := tree.Audit(0, 1000)
updates[2].Real = false
output = append(output, &pb.TestVectors_ShouldFailTestVector{
Description: "proof may not be sameKey if update type is fake",
Updates: updates,
})
}
// happy path
var successVector *pb.TestVectors_ShouldSucceedTestVector
{
tree, store, _, _ := transparency_test.NewTree(nil, transparency.ContactMonitoring)
transparency_test.RandomTree(tree, store, 10, []int{3}, []int{4, 9})
updates, _, _ := tree.Audit(0, 1000)
vector := &pb.TestVectors_ShouldSucceedTestVector{}
for i, update := range updates {
root, err := tree.GetLogTree().GetRoot(uint64(i + 1))
if err != nil {
log.Fatal(err)
}
vector.Updates = append(vector.Updates, &pb.TestVectors_ShouldSucceedTestVector_UpdateAndHash{
Update: update,
LogRoot: root,
})
}
successVector = vector
}
// signature
var signatureVector *pb.TestVectors_SignatureTestVector
{
auditorName := "example-auditor"
_, _, config, _ := transparency_test.NewTree(nil, transparency.ThirdPartyAuditing)
auditorPub, auditorPriv, err := ed25519.GenerateKey(nil)
if err != nil {
log.Fatal(err)
}
config.AuditorKeys = map[string]ed25519.PublicKey{
auditorName: auditorPub,
}
head, signatureInput, _ := transparency.SignNewAuditorHead(auditorPriv, config.Public(), 1337, make([]byte, 32), auditorName)
// Encode auditor public and private key for Java.
auditorPub, err = x509.MarshalPKIXPublicKey(auditorPub)
if err != nil {
log.Fatal(err)
}
auditorPriv, err = x509.MarshalPKCS8PrivateKey(auditorPriv)
if err != nil {
log.Fatal(err)
}
sigKey, err := x509.MarshalPKIXPublicKey(config.SigKey.Public())
if err != nil {
log.Fatal(err)
}
vrfKey, err := x509.MarshalPKIXPublicKey(ed25519.PublicKey(config.VrfKey.Public().([]byte)))
if err != nil {
log.Fatal(err)
}
signatureVector = &pb.TestVectors_SignatureTestVector{
AuditorPrivKey: auditorPriv,
DeploymentMode: uint32(config.Mode),
SigPubKey: sigKey,
AuditorPubKey: auditorPub,
VrfPubKey: vrfKey,
TreeSize: 1337,
Timestamp: head.Timestamp,
Root: make([]byte, 32),
Signature: head.Signature,
SignatureInput: signatureInput,
}
}
fmtProof := func(proof *tpb.AuditorProof) string {
switch p := proof.Proof.(type) {
case *tpb.AuditorProof_NewTree_:
return "newTree{}"
case *tpb.AuditorProof_DifferentKey_:
return fmt.Sprintf("differentKey{copath: %v, old_seed: %x}", len(p.DifferentKey.Copath), p.DifferentKey.OldSeed)
case *tpb.AuditorProof_SameKey_:
return fmt.Sprintf("sameKey{copath: %v, ctr: %v, pos: %v}", len(p.SameKey.Copath), p.SameKey.Counter, p.SameKey.Position)
default:
panic("unreachable")
}
}
for _, updates := range output {
for _, update := range updates.Updates {
fmt.Printf("real=%v index=%x seed=%x commitment = %x proof = %v\n", update.Real, update.Index, update.Seed, update.Commitment, fmtProof(update.Proof))
}
fmt.Println("----")
}
raw, err := proto.Marshal(&pb.TestVectors{
ShouldFail: output,
ShouldSucceed: successVector,
Signature: signatureVector,
})
if err != nil {
log.Fatal(err)
} else if err := os.WriteFile("./kt_test_vectors.pb", raw, 0777); err != nil {
log.Fatal(err)
}
}

View File

@ -0,0 +1,485 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.2
// protoc v5.28.3
// source: vectors.proto
package pb
import (
pb "github.com/signalapp/keytransparency/tree/transparency/pb"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type TestVectors struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
ShouldFail []*TestVectors_ShouldFailTestVector `protobuf:"bytes,1,rep,name=should_fail,json=shouldFail,proto3" json:"should_fail,omitempty"`
ShouldSucceed *TestVectors_ShouldSucceedTestVector `protobuf:"bytes,2,opt,name=should_succeed,json=shouldSucceed,proto3" json:"should_succeed,omitempty"`
Signature *TestVectors_SignatureTestVector `protobuf:"bytes,3,opt,name=signature,proto3" json:"signature,omitempty"`
}
func (x *TestVectors) Reset() {
*x = TestVectors{}
mi := &file_vectors_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestVectors) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestVectors) ProtoMessage() {}
func (x *TestVectors) ProtoReflect() protoreflect.Message {
mi := &file_vectors_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestVectors.ProtoReflect.Descriptor instead.
func (*TestVectors) Descriptor() ([]byte, []int) {
return file_vectors_proto_rawDescGZIP(), []int{0}
}
func (x *TestVectors) GetShouldFail() []*TestVectors_ShouldFailTestVector {
if x != nil {
return x.ShouldFail
}
return nil
}
func (x *TestVectors) GetShouldSucceed() *TestVectors_ShouldSucceedTestVector {
if x != nil {
return x.ShouldSucceed
}
return nil
}
func (x *TestVectors) GetSignature() *TestVectors_SignatureTestVector {
if x != nil {
return x.Signature
}
return nil
}
type TestVectors_ShouldFailTestVector struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"`
Updates []*pb.AuditorUpdate `protobuf:"bytes,2,rep,name=updates,proto3" json:"updates,omitempty"`
}
func (x *TestVectors_ShouldFailTestVector) Reset() {
*x = TestVectors_ShouldFailTestVector{}
mi := &file_vectors_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestVectors_ShouldFailTestVector) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestVectors_ShouldFailTestVector) ProtoMessage() {}
func (x *TestVectors_ShouldFailTestVector) ProtoReflect() protoreflect.Message {
mi := &file_vectors_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestVectors_ShouldFailTestVector.ProtoReflect.Descriptor instead.
func (*TestVectors_ShouldFailTestVector) Descriptor() ([]byte, []int) {
return file_vectors_proto_rawDescGZIP(), []int{0, 0}
}
func (x *TestVectors_ShouldFailTestVector) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *TestVectors_ShouldFailTestVector) GetUpdates() []*pb.AuditorUpdate {
if x != nil {
return x.Updates
}
return nil
}
type TestVectors_ShouldSucceedTestVector struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Updates []*TestVectors_ShouldSucceedTestVector_UpdateAndHash `protobuf:"bytes,1,rep,name=updates,proto3" json:"updates,omitempty"`
}
func (x *TestVectors_ShouldSucceedTestVector) Reset() {
*x = TestVectors_ShouldSucceedTestVector{}
mi := &file_vectors_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestVectors_ShouldSucceedTestVector) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestVectors_ShouldSucceedTestVector) ProtoMessage() {}
func (x *TestVectors_ShouldSucceedTestVector) ProtoReflect() protoreflect.Message {
mi := &file_vectors_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestVectors_ShouldSucceedTestVector.ProtoReflect.Descriptor instead.
func (*TestVectors_ShouldSucceedTestVector) Descriptor() ([]byte, []int) {
return file_vectors_proto_rawDescGZIP(), []int{0, 1}
}
func (x *TestVectors_ShouldSucceedTestVector) GetUpdates() []*TestVectors_ShouldSucceedTestVector_UpdateAndHash {
if x != nil {
return x.Updates
}
return nil
}
type TestVectors_SignatureTestVector struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
AuditorPrivKey []byte `protobuf:"bytes,8,opt,name=auditor_priv_key,json=auditorPrivKey,proto3" json:"auditor_priv_key,omitempty"`
DeploymentMode uint32 `protobuf:"varint,1,opt,name=deployment_mode,json=deploymentMode,proto3" json:"deployment_mode,omitempty"`
SigPubKey []byte `protobuf:"bytes,2,opt,name=sig_pub_key,json=sigPubKey,proto3" json:"sig_pub_key,omitempty"`
AuditorPubKey []byte `protobuf:"bytes,9,opt,name=auditor_pub_key,json=auditorPubKey,proto3" json:"auditor_pub_key,omitempty"`
VrfPubKey []byte `protobuf:"bytes,3,opt,name=vrf_pub_key,json=vrfPubKey,proto3" json:"vrf_pub_key,omitempty"`
TreeSize uint64 `protobuf:"varint,4,opt,name=tree_size,json=treeSize,proto3" json:"tree_size,omitempty"`
Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
Root []byte `protobuf:"bytes,6,opt,name=root,proto3" json:"root,omitempty"`
Signature []byte `protobuf:"bytes,7,opt,name=signature,proto3" json:"signature,omitempty"`
SignatureInput []byte `protobuf:"bytes,10,opt,name=signature_input,json=signatureInput,proto3" json:"signature_input,omitempty"`
}
func (x *TestVectors_SignatureTestVector) Reset() {
*x = TestVectors_SignatureTestVector{}
mi := &file_vectors_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestVectors_SignatureTestVector) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestVectors_SignatureTestVector) ProtoMessage() {}
func (x *TestVectors_SignatureTestVector) ProtoReflect() protoreflect.Message {
mi := &file_vectors_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestVectors_SignatureTestVector.ProtoReflect.Descriptor instead.
func (*TestVectors_SignatureTestVector) Descriptor() ([]byte, []int) {
return file_vectors_proto_rawDescGZIP(), []int{0, 2}
}
func (x *TestVectors_SignatureTestVector) GetAuditorPrivKey() []byte {
if x != nil {
return x.AuditorPrivKey
}
return nil
}
func (x *TestVectors_SignatureTestVector) GetDeploymentMode() uint32 {
if x != nil {
return x.DeploymentMode
}
return 0
}
func (x *TestVectors_SignatureTestVector) GetSigPubKey() []byte {
if x != nil {
return x.SigPubKey
}
return nil
}
func (x *TestVectors_SignatureTestVector) GetAuditorPubKey() []byte {
if x != nil {
return x.AuditorPubKey
}
return nil
}
func (x *TestVectors_SignatureTestVector) GetVrfPubKey() []byte {
if x != nil {
return x.VrfPubKey
}
return nil
}
func (x *TestVectors_SignatureTestVector) GetTreeSize() uint64 {
if x != nil {
return x.TreeSize
}
return 0
}
func (x *TestVectors_SignatureTestVector) GetTimestamp() int64 {
if x != nil {
return x.Timestamp
}
return 0
}
func (x *TestVectors_SignatureTestVector) GetRoot() []byte {
if x != nil {
return x.Root
}
return nil
}
func (x *TestVectors_SignatureTestVector) GetSignature() []byte {
if x != nil {
return x.Signature
}
return nil
}
func (x *TestVectors_SignatureTestVector) GetSignatureInput() []byte {
if x != nil {
return x.SignatureInput
}
return nil
}
type TestVectors_ShouldSucceedTestVector_UpdateAndHash struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Update *pb.AuditorUpdate `protobuf:"bytes,1,opt,name=update,proto3" json:"update,omitempty"`
LogRoot []byte `protobuf:"bytes,2,opt,name=log_root,json=logRoot,proto3" json:"log_root,omitempty"`
}
func (x *TestVectors_ShouldSucceedTestVector_UpdateAndHash) Reset() {
*x = TestVectors_ShouldSucceedTestVector_UpdateAndHash{}
mi := &file_vectors_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestVectors_ShouldSucceedTestVector_UpdateAndHash) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestVectors_ShouldSucceedTestVector_UpdateAndHash) ProtoMessage() {}
func (x *TestVectors_ShouldSucceedTestVector_UpdateAndHash) ProtoReflect() protoreflect.Message {
mi := &file_vectors_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestVectors_ShouldSucceedTestVector_UpdateAndHash.ProtoReflect.Descriptor instead.
func (*TestVectors_ShouldSucceedTestVector_UpdateAndHash) Descriptor() ([]byte, []int) {
return file_vectors_proto_rawDescGZIP(), []int{0, 1, 0}
}
func (x *TestVectors_ShouldSucceedTestVector_UpdateAndHash) GetUpdate() *pb.AuditorUpdate {
if x != nil {
return x.Update
}
return nil
}
func (x *TestVectors_ShouldSucceedTestVector_UpdateAndHash) GetLogRoot() []byte {
if x != nil {
return x.LogRoot
}
return nil
}
var File_vectors_proto protoreflect.FileDescriptor
var file_vectors_proto_rawDesc = []byte{
0x0a, 0x0d, 0x76, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
0x12, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x22, 0x83, 0x07, 0x0a, 0x0b, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74,
0x6f, 0x72, 0x73, 0x12, 0x42, 0x0a, 0x0b, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x66, 0x61,
0x69, 0x6c, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56,
0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2e, 0x53, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x46, 0x61, 0x69,
0x6c, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x0a, 0x73, 0x68, 0x6f,
0x75, 0x6c, 0x64, 0x46, 0x61, 0x69, 0x6c, 0x12, 0x4b, 0x0a, 0x0e, 0x73, 0x68, 0x6f, 0x75, 0x6c,
0x64, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x24, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73, 0x2e, 0x53, 0x68,
0x6f, 0x75, 0x6c, 0x64, 0x53, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x54, 0x65, 0x73, 0x74, 0x56,
0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x0d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x75, 0x63,
0x63, 0x65, 0x65, 0x64, 0x12, 0x3e, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72,
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65,
0x63, 0x74, 0x6f, 0x72, 0x73, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x54,
0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61,
0x74, 0x75, 0x72, 0x65, 0x1a, 0x6f, 0x0a, 0x14, 0x53, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x46, 0x61,
0x69, 0x6c, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x20, 0x0a, 0x0b,
0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x35,
0x0a, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32,
0x1b, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x41,
0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x07, 0x75, 0x70,
0x64, 0x61, 0x74, 0x65, 0x73, 0x1a, 0xc8, 0x01, 0x0a, 0x17, 0x53, 0x68, 0x6f, 0x75, 0x6c, 0x64,
0x53, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f,
0x72, 0x12, 0x4c, 0x0a, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03,
0x28, 0x0b, 0x32, 0x32, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x73,
0x2e, 0x53, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x75, 0x63, 0x63, 0x65, 0x65, 0x64, 0x54, 0x65,
0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41,
0x6e, 0x64, 0x48, 0x61, 0x73, 0x68, 0x52, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x1a,
0x5f, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x6e, 0x64, 0x48, 0x61, 0x73, 0x68,
0x12, 0x33, 0x0a, 0x06, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x1b, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e,
0x41, 0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x06, 0x75,
0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x5f, 0x72, 0x6f, 0x6f,
0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x52, 0x6f, 0x6f, 0x74,
0x1a, 0xe6, 0x02, 0x0a, 0x13, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x54, 0x65,
0x73, 0x74, 0x56, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x75, 0x64, 0x69,
0x74, 0x6f, 0x72, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x08, 0x20, 0x01,
0x28, 0x0c, 0x52, 0x0e, 0x61, 0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x50, 0x72, 0x69, 0x76, 0x4b,
0x65, 0x79, 0x12, 0x27, 0x0a, 0x0f, 0x64, 0x65, 0x70, 0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74,
0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x64, 0x65, 0x70,
0x6c, 0x6f, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1e, 0x0a, 0x0b, 0x73,
0x69, 0x67, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c,
0x52, 0x09, 0x73, 0x69, 0x67, 0x50, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x26, 0x0a, 0x0f, 0x61,
0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x09,
0x20, 0x01, 0x28, 0x0c, 0x52, 0x0d, 0x61, 0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x50, 0x75, 0x62,
0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0b, 0x76, 0x72, 0x66, 0x5f, 0x70, 0x75, 0x62, 0x5f, 0x6b,
0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x76, 0x72, 0x66, 0x50, 0x75, 0x62,
0x4b, 0x65, 0x79, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65,
0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x08, 0x74, 0x72, 0x65, 0x65, 0x53, 0x69, 0x7a, 0x65,
0x12, 0x1c, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x05, 0x20,
0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x12,
0x0a, 0x04, 0x72, 0x6f, 0x6f, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x72, 0x6f,
0x6f, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18,
0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65,
0x12, 0x27, 0x0a, 0x0f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x69, 0x6e,
0x70, 0x75, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x73, 0x69, 0x67, 0x6e, 0x61,
0x74, 0x75, 0x72, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, 0x4c, 0x5a, 0x4a, 0x67, 0x69, 0x74,
0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x61, 0x70,
0x70, 0x2f, 0x6b, 0x65, 0x79, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63,
0x79, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x2d, 0x61,
0x75, 0x64, 0x69, 0x74, 0x69, 0x6e, 0x67, 0x2d, 0x74, 0x65, 0x73, 0x74, 0x2d, 0x76, 0x65, 0x63,
0x74, 0x6f, 0x72, 0x73, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_vectors_proto_rawDescOnce sync.Once
file_vectors_proto_rawDescData = file_vectors_proto_rawDesc
)
func file_vectors_proto_rawDescGZIP() []byte {
file_vectors_proto_rawDescOnce.Do(func() {
file_vectors_proto_rawDescData = protoimpl.X.CompressGZIP(file_vectors_proto_rawDescData)
})
return file_vectors_proto_rawDescData
}
var file_vectors_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_vectors_proto_goTypes = []any{
(*TestVectors)(nil), // 0: TestVectors
(*TestVectors_ShouldFailTestVector)(nil), // 1: TestVectors.ShouldFailTestVector
(*TestVectors_ShouldSucceedTestVector)(nil), // 2: TestVectors.ShouldSucceedTestVector
(*TestVectors_SignatureTestVector)(nil), // 3: TestVectors.SignatureTestVector
(*TestVectors_ShouldSucceedTestVector_UpdateAndHash)(nil), // 4: TestVectors.ShouldSucceedTestVector.UpdateAndHash
(*pb.AuditorUpdate)(nil), // 5: transparency.AuditorUpdate
}
var file_vectors_proto_depIdxs = []int32{
1, // 0: TestVectors.should_fail:type_name -> TestVectors.ShouldFailTestVector
2, // 1: TestVectors.should_succeed:type_name -> TestVectors.ShouldSucceedTestVector
3, // 2: TestVectors.signature:type_name -> TestVectors.SignatureTestVector
5, // 3: TestVectors.ShouldFailTestVector.updates:type_name -> transparency.AuditorUpdate
4, // 4: TestVectors.ShouldSucceedTestVector.updates:type_name -> TestVectors.ShouldSucceedTestVector.UpdateAndHash
5, // 5: TestVectors.ShouldSucceedTestVector.UpdateAndHash.update:type_name -> transparency.AuditorUpdate
6, // [6:6] is the sub-list for method output_type
6, // [6:6] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_vectors_proto_init() }
func file_vectors_proto_init() {
if File_vectors_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_vectors_proto_rawDesc,
NumEnums: 0,
NumMessages: 5,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_vectors_proto_goTypes,
DependencyIndexes: file_vectors_proto_depIdxs,
MessageInfos: file_vectors_proto_msgTypes,
}.Build()
File_vectors_proto = out.File
file_vectors_proto_rawDesc = nil
file_vectors_proto_goTypes = nil
file_vectors_proto_depIdxs = nil
}

View File

@ -0,0 +1,46 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
syntax = "proto3";
option go_package = "github.com/signalapp/keytransparency/cmd/generate-auditing-test-vectors/pb";
import "transparency.proto";
message TestVectors {
message ShouldFailTestVector {
string description = 1;
repeated transparency.AuditorUpdate updates = 2;
}
message ShouldSucceedTestVector {
message UpdateAndHash {
transparency.AuditorUpdate update = 1;
bytes log_root = 2;
}
repeated UpdateAndHash updates = 1;
}
message SignatureTestVector {
bytes auditor_priv_key = 8;
uint32 deployment_mode = 1;
bytes sig_pub_key = 2;
bytes auditor_pub_key = 9;
bytes vrf_pub_key = 3;
uint64 tree_size = 4;
int64 timestamp = 5;
bytes root = 6;
bytes signature = 7;
bytes signature_input = 10;
// next = 11
}
repeated ShouldFailTestVector should_fail = 1;
ShouldSucceedTestVector should_succeed = 2;
SignatureTestVector signature = 3;
}

42
cmd/generate-keys/main.go Normal file
View File

@ -0,0 +1,42 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Command generate-keys outputs fresh cryptographic keys for a Key Transparency Server.
package main
import (
"crypto/ed25519"
"crypto/rand"
"fmt"
"log"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
signingKey := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(signingKey); err != nil {
log.Fatal(err)
}
fmt.Printf("Signing Key: %x\n", signingKey)
vrfPriv := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(vrfPriv); err != nil {
log.Fatal(err)
}
fmt.Printf("VRF Private Key: %x\n", vrfPriv)
prefixAesKey := make([]byte, 32)
if _, err := rand.Read(prefixAesKey); err != nil {
log.Fatal(err)
}
fmt.Printf("Prefix Aes Key: %x\n", prefixAesKey)
openingKey := make([]byte, 32)
if _, err := rand.Read(openingKey); err != nil {
log.Fatal(err)
}
fmt.Printf("Opening Key: %x\n", openingKey)
}

View File

@ -0,0 +1,360 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package config
import (
"crypto/ed25519"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"time"
"github.com/signalapp/keytransparency/crypto/vrf"
edvrf "github.com/signalapp/keytransparency/crypto/vrf/ed25519"
"github.com/signalapp/keytransparency/db"
"github.com/signalapp/keytransparency/tree/transparency"
"gopkg.in/yaml.v2"
)
// envstr is a string in the YAML config file that expands environment variables
// when parsed.
type envstr string
func (es *envstr) UnmarshalYAML(unmarshal func(interface{}) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
*es = envstr(os.ExpandEnv(s))
return nil
}
func (es envstr) String() string { return string(es) }
// Config specifies the file format of config files.
type Config struct {
KtServiceConfig *ServiceConfig `yaml:"kt"`
KtQueryServiceConfig *ServiceConfig `yaml:"kt-query"`
KtTestServiceConfig *ServiceConfig `yaml:"kt-test"`
LogOutputFile envstr `yaml:"log-output"`
MetricsAddr envstr `yaml:"metrics-addr"`
DatadogAddr envstr `yaml:"datadog-addr"`
HealthAddr envstr `yaml:"health-addr"`
APIConfig *APIConfig `yaml:"api"`
StreamConfig *StreamConfig `yaml:"stream"`
DatabaseConfig *DatabaseConfig `yaml:"db"`
AccountDB string `yaml:"account-db"`
CacheConfig *CacheConfig `yaml:"cache"`
}
type CacheConfig struct {
PrefixSize int `yaml:"prefix-size"`
LogSize int `yaml:"log-size"`
TopSize int `yaml:"top-size"`
}
type ServiceConfig struct {
ServerAddr envstr `yaml:"server-addr"`
// a map of headers to a list of authorized values. at least one header to value mapping must be present on client requests
AuthorizedHeaders map[string][]string `yaml:"authorized-headers"`
// a map of header values to auditor name. each key in this map should match a value in the AuthorizedHeaders map.
HeaderValueToAuditorName map[string]string `yaml:"header-value-to-auditor-name"`
}
type APIConfig struct {
SigningKey envstr `yaml:"signing-key"` // 32 byte hex-encoded seed for the signing private key.
signingKey ed25519.PrivateKey
VRFKey envstr `yaml:"vrf-key"` // PEM encoded VRF private key.
vrfKey vrf.PrivateKey
PrefixAesKey envstr `yaml:"prefix-key"` // 32 random hex-encoded bytes.
prefixAesKey []byte
OpeningKey envstr `yaml:"opening-key"` // 32 random hex-encoded bytes.
openingKey []byte
FakeUpdates *FakeUpdates `yaml:"fake"`
// A map of auditor name to its hex-encoded public signature key.
AuditorConfigs map[string]envstr `yaml:"auditors"`
auditorConfigs map[string]ed25519.PublicKey
Distinguished time.Duration `yaml:"distinguished"`
// Minimum latency for a search request
MinimumSearchDelay time.Duration `yaml:"min-search-delay"`
// Minimum latency for a monitor request
MinimumMonitorDelay time.Duration `yaml:"min-monitor-delay"`
// What percent of the minimum delay to use for determining the jitter range
JitterPercent int `yaml:"jitter-percent"`
}
func (config *APIConfig) TreeConfig() *transparency.PrivateConfig {
mode := transparency.ContactMonitoring
if config.auditorConfigs != nil {
mode = transparency.ThirdPartyAuditing
}
return &transparency.PrivateConfig{
Mode: mode,
SigKey: config.signingKey,
AuditorKeys: config.auditorConfigs,
VrfKey: config.vrfKey,
PrefixAesKey: config.prefixAesKey,
OpeningKey: config.openingKey,
}
}
func (config *APIConfig) NewTree(tx db.TransparencyStore) (*transparency.Tree, error) {
return transparency.NewTree(config.TreeConfig(), tx)
}
// FakeUpdates specifies how often to make fake updates. Updates are made such
// that there are `count` updates total every `interval` of time.
type FakeUpdates struct {
Count int `yaml:"count"`
Interval time.Duration `yaml:"interval"`
}
type StreamConfig struct {
Name envstr `yaml:"name"`
// If Name is provided but TableName is not, backfill will not be attempted.
TableName envstr `yaml:"table"`
InitialHorizon time.Duration `yaml:"initial-horizon"`
}
type DatabaseConfig struct {
// LevelDB
File string `yaml:"file"`
// DynamoDB
Table envstr `yaml:"table"`
Parallel int `yaml:"parallel"`
}
func (config *DatabaseConfig) Validate() error {
if config == nil {
return fmt.Errorf("field not provided: db")
}
level := config.File != ""
dynamo := config.Table != "" && config.Parallel != 0
if !level && !dynamo {
return fmt.Errorf("no database connection information provided")
} else if level && dynamo {
return fmt.Errorf("can not provide both leveldb and dynamodb connections")
}
return nil
}
func (config *DatabaseConfig) Connect() (db.TransparencyStore, error) {
if config.File != "" {
return db.NewLDBTransparencyStore(config.File)
}
return db.NewDynamoDBTransparencyStore(config.Table.String(), config.Parallel)
}
func (c *Config) ConnectAccountDB() (db.AccountDB, error) {
if c.AccountDB == "mock" {
return &db.MockAccountDB{}, nil
}
return db.NewAccountDB(c.AccountDB)
}
func Read(filename string) (*Config, error) {
// Read from file and parse.
raw, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var parsed Config
if err := yaml.Unmarshal(raw, &parsed); err != nil {
return nil, err
}
// Check that all required fields are populated.
if parsed.KtQueryServiceConfig != nil {
if parsed.KtQueryServiceConfig.ServerAddr == "" {
return nil, fmt.Errorf("field not provided for service kt-query: server-addr")
}
if parsed.APIConfig.MinimumSearchDelay == 0 {
return nil, fmt.Errorf("field not provided for service kt-query: min-search-delay")
}
if parsed.APIConfig.MinimumMonitorDelay == 0 {
return nil, fmt.Errorf("field not provided for service kt-query: min-monitor-delay")
}
}
if parsed.KtServiceConfig != nil {
if parsed.KtServiceConfig.ServerAddr == "" {
return nil, fmt.Errorf("field not provided for service kt: server-addr")
}
if parsed.KtServiceConfig.AuthorizedHeaders == nil || len(parsed.KtServiceConfig.AuthorizedHeaders) == 0 {
return nil, fmt.Errorf("field not provided for service kt: authorized-headers")
}
if parsed.KtServiceConfig.HeaderValueToAuditorName == nil || len(parsed.KtServiceConfig.HeaderValueToAuditorName) == 0 {
return nil, fmt.Errorf("field not provided for service kt: header-value-to-auditor-name")
}
if parsed.APIConfig.AuditorConfigs == nil || len(parsed.APIConfig.AuditorConfigs) == 0 {
return nil, fmt.Errorf("field not provided for service kt: auditors")
}
// Ensure every header value maps to an auditor name
for _, values := range parsed.KtServiceConfig.AuthorizedHeaders {
for _, value := range values {
if len(parsed.KtServiceConfig.HeaderValueToAuditorName[value]) == 0 {
return nil, fmt.Errorf("header value %s has no associated auditor name", value)
}
}
}
// Ensure every auditor name maps to a public key
for _, auditorName := range parsed.KtServiceConfig.HeaderValueToAuditorName {
if len(parsed.APIConfig.AuditorConfigs[auditorName]) == 0 {
return nil, fmt.Errorf("auditor %s has no associated public key", auditorName)
}
}
if parsed.APIConfig.Distinguished == 0 {
return nil, fmt.Errorf("field not provided for service kt: distinguished")
}
}
if parsed.KtTestServiceConfig != nil {
if parsed.KtTestServiceConfig.ServerAddr == "" {
return nil, fmt.Errorf("field not provided for service kt-test: server-addr")
}
}
if parsed.KtServiceConfig == nil && parsed.KtQueryServiceConfig == nil && parsed.KtTestServiceConfig == nil {
return nil, fmt.Errorf("at least one server-addr field must be provided")
} else if parsed.MetricsAddr == "" {
return nil, fmt.Errorf("field not provided: metrics-addr")
} else if parsed.HealthAddr == "" {
return nil, fmt.Errorf("field not provided: health-addr")
} else if parsed.APIConfig == nil {
return nil, fmt.Errorf("field not provided: api")
} else if parsed.APIConfig.SigningKey == "" {
return nil, fmt.Errorf("field not provided: api.signing-key")
} else if parsed.APIConfig.VRFKey == "" {
return nil, fmt.Errorf("field not provided: api.vrf-key")
} else if parsed.APIConfig.PrefixAesKey == "" {
return nil, fmt.Errorf("field not provided: api.prefix-key")
} else if parsed.APIConfig.OpeningKey == "" {
return nil, fmt.Errorf("field not provided: api.opening-key")
} else if err := parsed.DatabaseConfig.Validate(); err != nil {
return nil, err
}
if parsed.APIConfig.FakeUpdates != nil {
if parsed.APIConfig.FakeUpdates.Count == 0 {
return nil, fmt.Errorf("field not provided: api.fake.count")
} else if parsed.APIConfig.FakeUpdates.Interval == 0 {
return nil, fmt.Errorf("field not provided: api.fake.interval")
}
}
if parsed.StreamConfig != nil {
if parsed.StreamConfig.Name == "" {
return nil, fmt.Errorf("field not provided: stream.name")
} else if parsed.StreamConfig.InitialHorizon == 0 {
return nil, fmt.Errorf("field not provided: stream.initial-horizon")
}
}
if parsed.APIConfig.JitterPercent < 0 || parsed.APIConfig.JitterPercent > 100 {
return nil, fmt.Errorf("jitter percent must be between 0 and 100")
}
// Parse cryptographic keys.
seed, err := hex.DecodeString(parsed.APIConfig.SigningKey.String())
if err != nil {
return nil, fmt.Errorf("failed to parse signing key: %v", err)
} else if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("signing key is wrong size: wanted=%v, got=%v", ed25519.SeedSize, len(seed))
}
parsed.APIConfig.signingKey = ed25519.NewKeyFromSeed(seed)
vrfKey, err := hex.DecodeString(parsed.APIConfig.VRFKey.String())
if err != nil {
return nil, fmt.Errorf("failed to parse vrf key: %v", err)
}
parsed.APIConfig.vrfKey, err = edvrf.NewVRFSigner(vrfKey)
if err != nil {
return nil, fmt.Errorf("failed to parse vrf key: %v", err)
}
prefixAesKey, err := hex.DecodeString(parsed.APIConfig.PrefixAesKey.String())
if err != nil {
return nil, fmt.Errorf("failed to parsed prefix seed: %v", err)
} else if len(prefixAesKey) != 32 {
return nil, fmt.Errorf("prefix AES key is wrong size: wanted=%v, got=%v", 32, len(prefixAesKey))
}
parsed.APIConfig.prefixAesKey = prefixAesKey
openingKey, err := hex.DecodeString(parsed.APIConfig.OpeningKey.String())
if err != nil {
return nil, fmt.Errorf("failed to parsed opening key: %v", err)
} else if len(openingKey) != 32 {
return nil, fmt.Errorf("opening key is wrong size: wanted=%v, got=%v", 32, len(openingKey))
}
parsed.APIConfig.openingKey = openingKey
parsed.APIConfig.auditorConfigs = map[string]ed25519.PublicKey{}
for auditorName, publicKey := range parsed.APIConfig.AuditorConfigs {
pubKey, err := hex.DecodeString(publicKey.String())
if err != nil {
return nil, fmt.Errorf("failed to parse auditor public key: %v for auditor %s", err, auditorName)
} else if len(pubKey) != ed25519.PublicKeySize {
return nil, fmt.Errorf("auditor public key is wrong size: wanted=%v, got=%v for auditor %s", ed25519.PublicKeySize, len(pubKey), auditorName)
}
parsed.APIConfig.auditorConfigs[auditorName] = pubKey
}
// If unspecified, use default cache sizes
if parsed.CacheConfig == nil {
parsed.CacheConfig = &CacheConfig{
PrefixSize: 20000,
LogSize: 2000,
TopSize: 2000,
}
}
if parsed.CacheConfig.PrefixSize == 0 {
parsed.CacheConfig.PrefixSize = 20000
}
if parsed.CacheConfig.LogSize == 0 {
parsed.CacheConfig.LogSize = 2000
}
if parsed.CacheConfig.TopSize == 0 {
parsed.CacheConfig.TopSize = 2000
}
return &parsed, nil
}
func (c *Config) SetLogOutput() error {
if c.LogOutputFile != "" {
f, err := os.OpenFile(c.LogOutputFile.String(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("Failed to open log file: %v", err)
}
log.Default().SetOutput(io.MultiWriter(os.Stderr, f))
go func() {
for range time.Tick(time.Second) {
f.Sync()
}
}()
return nil
}
return nil
}

View File

@ -0,0 +1,12 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package util
const (
AciPrefix = 'a'
NumberPrefix = 'n'
UsernameHashPrefix = 'u'
)

View File

@ -0,0 +1,53 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package util
import (
"os"
"time"
"github.com/rs/zerolog"
)
type Logger struct {
logger *zerolog.Logger
}
var instance *Logger
func SetLoggerInstance(l *zerolog.Logger) {
instance = &Logger{l}
}
func Log() *Logger {
if instance == nil {
instance = _defaultLogger()
instance.Warnf("default logger in use. SetLoggerInstance() should be called first")
}
return instance
}
func _defaultLogger() *Logger {
zeroLogLogger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Timestamp().Logger()
return &Logger{&zeroLogLogger}
}
func (l *Logger) Infof(format string, v ...interface{}) {
l.logger.Info().Msgf(format, v...)
}
func (l *Logger) Warnf(format string, v ...interface{}) {
l.logger.Warn().Msgf(format, v...)
}
func (l *Logger) Errorf(format string, v ...interface{}) {
l.logger.Error().Msgf(format, v...)
}
func (l *Logger) Fatalf(format string, v ...interface{}) {
l.logger.Fatal().Msgf(format, v...)
}

45
cmd/kt-client/audit.go Normal file
View File

@ -0,0 +1,45 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"flag"
"log"
"strconv"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
)
func handleAudit(client pb.KeyTransparencyServiceClient) {
if flag.Arg(1) == "" {
log.Fatal("No starting position given. Usage: kt-client audit <start> <limit>")
} else if flag.Arg(2) == "" {
log.Fatal("No entry limit given. Usage: kt-client audit <start> <limit>")
}
start, err := strconv.ParseUint(flag.Arg(1), 10, 64)
checkErr("parsing starting position for audit request", err)
limit, err := strconv.ParseUint(flag.Arg(2), 10, 64)
checkErr("parsing entry limit for audit request", err)
res, err := client.Audit(context.Background(), &pb.AuditRequest{Start: start, Limit: limit})
checkErr("audit request", err)
p.Println("Updates:")
for _, update := range res.Updates {
p.Printf(" - real=%-5v index=%-64x seed=%x commitment=%x\n", update.Real, update.Index, update.Seed, update.Commitment)
}
if len(res.Updates) == 0 {
p.Println(" (None)")
}
p.Println()
if res.More {
p.Printf("More available, starting at: %v\n", start+limit)
} else {
p.Println("Reached end of log.")
}
}

View File

@ -0,0 +1,40 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/tree/transparency"
)
func handleDistinguished(client pb.KeyTransparencyQueryServiceClient) {
req := new(pb.DistinguishedRequest)
if *last >= 0 {
x := uint64(*last)
req.Last = &x
}
res, err := client.Distinguished(context.Background(), req)
checkErr("distinguished request", err)
printFullTreeHead(res.TreeHead)
p.Printf("Distinguished search response: \n")
p.Printf("VRF: %x\n\n", res.Distinguished.VrfProof)
printSearchProof(res.Distinguished.Search)
p.Printf("Opening: %x\n", res.Distinguished.Opening)
p.Printf("Value: %s\n\n", res.Distinguished.Value.Value)
if *configFile == "" {
p.Printf("Verification skipped\n")
} else {
if err := transparency.VerifySearch(newStore(), createTreeSearchRequest([]byte("distinguished")), createTreeSearchResponse(res.Distinguished, res.TreeHead)); err != nil {
p.Printf("Verification failed: %v\n", err)
} else {
p.Printf("Verification successful\n")
}
}
}

166
cmd/kt-client/main.go Normal file
View File

@ -0,0 +1,166 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Command kt-client is a test/example client used for interacting with a
// key transparency server.
package main
import (
"context"
"flag"
"log"
"time"
"golang.org/x/text/message"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
var (
p = message.NewPrinter(message.MatchLanguage("en"))
ktQueryServerAddr = flag.String("query-addr", "localhost:8080", "Address of read-only server.")
ktServerAddr = flag.String("kt-addr", "localhost:8082", "Address of read-write server.")
testServerAddr = flag.String("test-addr", "localhost:8081", "Address of test server.")
configFile = flag.String("config", "", "(Optional) Location of server config file.")
usernameHash = flag.String("username-hash", "", "Base64url encoded username hash")
e164 = flag.String("e164", "", "E164-formatted phone number. Must be preceded with a '+'. E.g. +14155550101")
uak = flag.String("uak", "", "Standard base64 encoded unidentified access key")
timingNumSamples = flag.Int("num-samples", 5, "Number of samples to use for measuring timing of a query request")
timingSampleSize = flag.Int("sample-size", 100, "Number of requests per sample to use for measuring timing of a query request")
last = flag.Int("last", -1, "(Optional) Size of tree when last observed, or -1 for none.")
)
func consistency(x *int) *tpb.Consistency {
if *x == -1 {
return &tpb.Consistency{}
} else if *x < -1 {
log.Fatal("Flag value may not be less than -1.")
}
y := uint64(*x)
return &tpb.Consistency{Last: &y}
}
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile | log.LUTC)
flag.Parse()
configs, err := readConfig(*configFile)
checkErr("parsing config", err)
ktQueryConn, err := grpc.NewClient(*ktQueryServerAddr, getClientOptions(configs.KtQueryServiceConfig)...)
checkErr("instantiating kt query client", err)
defer ktQueryConn.Close()
ktQueryClient := pb.NewKeyTransparencyQueryServiceClient(ktQueryConn)
testConn, err := grpc.NewClient(*testServerAddr, getClientOptions(configs.KtTestServiceConfig)...)
checkErr("instantiating kt test client", err)
defer testConn.Close()
testClient := pb.NewKeyTransparencyTestServiceClient(testConn)
ktConn, err := grpc.NewClient(*ktServerAddr, getClientOptions(configs.KtServiceConfig)...)
checkErr("instantiating kt client", err)
defer ktConn.Close()
ktClient := pb.NewKeyTransparencyServiceClient(ktConn)
switch flag.Arg(0) {
case "distinguished":
handleDistinguished(ktQueryClient)
case "search":
handleSearch(ktQueryClient)
case "search-timing":
handleSearchTiming(ktQueryClient)
case "update":
handleUpdate(testClient)
case "monitor":
handleMonitor(ktQueryClient)
case "monitor-timing":
handleMonitorTiming(ktQueryClient)
case "audit":
handleAudit(ktClient)
default:
log.Fatal("Unexpected operation requested. Allowed arguments: search, update, audit, config")
}
}
func readConfig(configFile string) (*config.Config, error) {
if configFile == "" {
return &config.Config{}, nil
}
cfg, err := config.Read(configFile)
if err != nil {
return nil, err
}
return cfg, nil
}
func getClientOptions(config *config.ServiceConfig) []grpc.DialOption {
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
if config == nil || len(config.AuthorizedHeaders) == 0 {
return opts
}
opts = append(opts,
grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
headers := []string{}
for header, values := range config.AuthorizedHeaders {
for _, value := range values {
headers = append(append(headers, header), value)
}
}
ctx = metadata.AppendToOutgoingContext(ctx, headers...)
return invoker(ctx, method, req, reply, cc, opts...)
}))
return opts
}
func round(d time.Duration) time.Duration {
if d > time.Second {
return d.Round(time.Second)
}
return d.Round(time.Millisecond)
}
func printFullTreeHead(fth *tpb.FullTreeHead) {
p.Printf("Full Tree Head:\n")
p.Printf(" Tree Head:\n")
p.Printf(" Tree Size: %v\n", fth.TreeHead.TreeSize)
ts := time.UnixMilli(fth.TreeHead.Timestamp)
p.Printf(" Timestamp: %v (%v ago)\n", ts, round(time.Now().Sub(ts)))
p.Printf(" Signature: %x\n", fth.TreeHead.Signatures)
if n := len(fth.Last); n > 0 {
p.Printf(" Consistency Proof: Given (%v entries)\n", n)
} else {
p.Printf(" Consistency Proof: None\n")
}
p.Println()
}
func printSearchProof(proof *tpb.SearchProof) {
p.Printf("Search Proof:\n")
p.Printf(" Pos: %v\n", proof.Pos)
p.Printf(" Inclusion Proof: %v entries\n", len(proof.Inclusion))
for _, step := range proof.Steps {
p.Printf(" - counter=%v commitment=%x\n", step.Prefix.Counter, step.Commitment)
}
p.Println()
}

94
cmd/kt-client/monitor.go Normal file
View File

@ -0,0 +1,94 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
_ "embed"
"fmt"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/crypto/vrf"
)
func constructMonitorRequest(args QueryArgs, vrfVerifier vrf.PublicKey, searchResponse *pb.SearchResponse) *pb.MonitorRequest {
monitorRequest := &pb.MonitorRequest{
Consistency: consistency(last),
}
aciCommitmentIndex, err := vrfVerifier.ECVRFVerify(append([]byte{'a'}, args.Aci...), searchResponse.Aci.VrfProof)
checkErr("create aci commitment index", err)
monitorRequest.Aci = &pb.AciMonitorRequest{
Aci: args.Aci,
EntryPosition: searchResponse.GetAci().GetSearch().GetPos(),
CommitmentIndex: aciCommitmentIndex[:],
}
if searchResponse.E164 != nil {
e164CommitmentIndex, err := vrfVerifier.ECVRFVerify(append([]byte{'n'}, []byte(args.E164)...), searchResponse.E164.VrfProof)
checkErr("create e164 commitment index", err)
monitorRequest.E164 = &pb.E164MonitorRequest{
E164: &args.E164,
EntryPosition: searchResponse.GetE164().GetSearch().GetPos(),
CommitmentIndex: e164CommitmentIndex[:],
}
}
if searchResponse.UsernameHash != nil {
usernameHashCommitmentIndex, err := vrfVerifier.ECVRFVerify(append([]byte{'u'}, args.UsernameHash...), searchResponse.UsernameHash.VrfProof)
checkErr("create username hash commitment index", err)
monitorRequest.UsernameHash = &pb.UsernameHashMonitorRequest{
UsernameHash: args.UsernameHash,
EntryPosition: searchResponse.GetUsernameHash().GetSearch().GetPos(),
CommitmentIndex: usernameHashCommitmentIndex[:],
}
}
return monitorRequest
}
// handleMonitor makes 2 requests in succession:
// 1. Search request for the specified identifiers to get the commitment index necessary for the monitor request
// 2. Monitor request
func handleMonitor(client pb.KeyTransparencyQueryServiceClient) {
args := extractQueryArgs("monitor")
// First search the identifiers to get back the data necessary to make a monitor request
searchResponse, err := client.Search(context.Background(), constructSearchRequest(args))
checkErr("search identifiers before making a monitor request", err)
fmt.Println("Search request: OK")
vrfVerifier := newStore().PublicConfig().VrfKey
monitorResponse, err := client.Monitor(context.Background(), constructMonitorRequest(args, vrfVerifier, searchResponse))
checkErr("monitor request", err)
printFullTreeHead(monitorResponse.TreeHead)
p.Println("ACI monitor response:")
p.Println("Steps: ")
for _, step := range monitorResponse.Aci.Steps {
p.Printf(" - counter=%v commitment=%x\n", step.Prefix.Counter, step.Commitment)
}
if monitorResponse.E164 != nil {
p.Println("\nE164 monitor response:")
p.Println("Steps: ")
for _, step := range monitorResponse.E164.Steps {
p.Printf(" - counter=%v commitment=%x\n", step.Prefix.Counter, step.Commitment)
}
}
if monitorResponse.UsernameHash != nil {
p.Println("\nUsername hash monitor response:")
p.Println("Steps: ")
for _, step := range monitorResponse.UsernameHash.Steps {
p.Printf(" - counter=%v commitment=%x\n", step.Prefix.Counter, step.Commitment)
}
}
// Verifying the monitor response would require persistent state, which kt-client doesn't have,
// so we skip it.
p.Println("\nVerification skipped for the monitor response.")
}

View File

@ -0,0 +1,33 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"fmt"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
)
func handleMonitorTiming(client pb.KeyTransparencyQueryServiceClient) {
args := extractQueryArgs("[-sample-size int] [-num-samples int] monitor-timing")
samplingArgs := extractSamplingArgs()
// First search the identifiers to get back the data necessary to make a monitor request
searchResponse, err := client.Search(context.Background(), constructSearchRequest(args))
checkErr("Search identifiers before making a monitor request", err)
fmt.Println("Search request: OK")
fmt.Printf("Measuring latency for monitoring ACI %x and E164 %s (%d rounds, %d requests per round)\n", args.Aci, *e164, samplingArgs.NumSamples, samplingArgs.SampleSize)
vrfVerifier := newStore().PublicConfig().VrfKey
req := constructMonitorRequest(args, vrfVerifier, searchResponse)
timeRequest(func() error {
_, err := client.Monitor(context.Background(), req)
return err
}, samplingArgs)
}

120
cmd/kt-client/search.go Normal file
View File

@ -0,0 +1,120 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/tree/transparency"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
func constructSearchRequest(args QueryArgs) *pb.SearchRequest {
req := &pb.SearchRequest{
Aci: args.Aci,
AciIdentityKey: args.AciIdentityKey,
Consistency: consistency(last),
}
if args.E164 != "" {
req.E164SearchRequest = &pb.E164SearchRequest{
E164: &args.E164,
UnidentifiedAccessKey: args.UnidentifiedAccessKey,
}
}
if args.UsernameHash != nil {
req.UsernameHash = args.UsernameHash
}
return req
}
func handleSearch(client pb.KeyTransparencyQueryServiceClient) {
args := extractQueryArgs("search")
res, err := client.Search(context.Background(), constructSearchRequest(args))
checkErr("search request", err)
printFullTreeHead(res.TreeHead)
p.Printf("ACI search response: \n")
p.Printf("VRF: %x\n\n", res.Aci.VrfProof)
printSearchProof(res.Aci.Search)
p.Printf("Opening: %x\n", res.Aci.Opening)
p.Printf("Value: %x\n\n", res.Aci.Value.Value)
if res.E164 != nil {
p.Printf("E164 search response: \n")
p.Printf("VRF: %x\n\n", res.E164.VrfProof)
printSearchProof(res.E164.Search)
p.Printf("Opening: %x\n", res.E164.Opening)
p.Printf("Value: %x\n\n", res.E164.Value.Value)
}
if res.UsernameHash != nil {
p.Printf("Username hash search response: \n")
p.Printf("VRF: %x\n\n", res.UsernameHash.VrfProof)
printSearchProof(res.UsernameHash.Search)
p.Printf("Opening: %x\n", res.UsernameHash.Opening)
p.Printf("Value: %x\n\n", res.UsernameHash.Value.Value)
}
if *configFile == "" {
p.Printf("Verification skipped\n")
return
}
if *last != -1 {
// Verifying the consistency proof would require persistent state, which kt-client doesn't have,
// so we nullify these fields.
res.TreeHead.Last = nil
res.TreeHead.Distinguished = nil
}
allVerificationsSuccessful := true
if err := transparency.VerifySearch(newStore(), createIdentifierSearchRequest(util.AciPrefix, args.Aci), createTreeSearchResponse(res.Aci, res.TreeHead)); err != nil {
p.Printf("ACI verification failed: %v\n", err)
allVerificationsSuccessful = false
}
if res.E164 != nil {
if err := transparency.VerifySearch(newStore(), createIdentifierSearchRequest(util.NumberPrefix, []byte(*e164)), createTreeSearchResponse(res.E164, res.TreeHead)); err != nil {
p.Printf("E164 verification failed: %v\n", err)
allVerificationsSuccessful = false
}
}
if res.UsernameHash != nil {
if err := transparency.VerifySearch(newStore(), createIdentifierSearchRequest(util.UsernameHashPrefix, args.UsernameHash), createTreeSearchResponse(res.UsernameHash, res.TreeHead)); err != nil {
p.Printf("Username hash verification failed: %v\n", err)
allVerificationsSuccessful = false
}
}
if allVerificationsSuccessful {
p.Printf("All verifications successful\n")
}
}
func createIdentifierSearchRequest(prefix byte, identifier []byte) *tpb.TreeSearchRequest {
return createTreeSearchRequest(append([]byte{prefix}, identifier...))
}
func createTreeSearchRequest(key []byte) *tpb.TreeSearchRequest {
return &tpb.TreeSearchRequest{
SearchKey: key,
Consistency: consistency(last),
}
}
func createTreeSearchResponse(response *pb.CondensedTreeSearchResponse, treeHead *tpb.FullTreeHead) *tpb.TreeSearchResponse {
return &tpb.TreeSearchResponse{
TreeHead: treeHead,
VrfProof: response.VrfProof,
Search: response.Search,
Opening: response.Opening,
Value: response.Value,
}
}

View File

@ -0,0 +1,26 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"fmt"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
)
func handleSearchTiming(client pb.KeyTransparencyQueryServiceClient) {
args := extractQueryArgs("[-sample-size int] [-num-samples int] search-timing")
samplingArgs := extractSamplingArgs()
fmt.Printf("Measuring latency for searching ACI %x and E164 %s (%d rounds, %d requests per round)\n", args.Aci, *e164, samplingArgs.NumSamples, samplingArgs.SampleSize)
req := constructSearchRequest(args)
timeRequest(func() error {
_, err := client.Search(context.Background(), req)
return err
}, samplingArgs)
}

45
cmd/kt-client/store.go Normal file
View File

@ -0,0 +1,45 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/db"
"github.com/signalapp/keytransparency/tree/transparency"
)
// clientStorage is a no-op implementation of the ClientStorage interface.
type clientStorage struct {
config *transparency.PublicConfig
head *db.TransparencyTreeHead
root []byte
}
func newStore() *clientStorage {
config, err := config.Read(*configFile)
checkErr("loading config", err)
return &clientStorage{config: config.APIConfig.TreeConfig().Public()}
}
func (cs *clientStorage) PublicConfig() *transparency.PublicConfig { return cs.config }
func (cs *clientStorage) GetLastTreeHead() (*db.TransparencyTreeHead, []byte, error) {
return cs.head, cs.root, nil
}
func (cs *clientStorage) SetLastTreeHead(head *db.TransparencyTreeHead, root []byte) error {
cs.head, cs.root = head, root
return nil
}
func (cs *clientStorage) GetData(key []byte) (*transparency.MonitoringData, error) {
return nil, nil
}
func (cs *clientStorage) SetData(key []byte, data *transparency.MonitoringData) error {
return nil
}

108
cmd/kt-client/update.go Normal file
View File

@ -0,0 +1,108 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"encoding/base64"
"flag"
"log"
"github.com/google/uuid"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/tree/transparency"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
func handleUpdate(client pb.KeyTransparencyTestServiceClient) {
var updateKey []byte
var updateValue []byte
switch flag.Arg(1) {
case "aci":
if flag.Arg(2) == "" {
log.Fatal("No update key given. Usage: kt-client update aci <UUID> <base64_encoded_aci_identity_key>")
} else if flag.Arg(3) == "" {
log.Fatal("No update value given. Usage: kt-client update aci <UUID> <base64_encoded_aci_identity_key>")
}
aci, err := uuid.Parse(flag.Arg(2))
checkErr("invalid UUID string for ACI", err)
aciBytes, err := aci.MarshalBinary()
checkErr("getting UUID bytes", err)
updateKey = append([]byte{util.AciPrefix}, aciBytes...)
aciIdentityKeyBytes, err := base64.StdEncoding.DecodeString(flag.Arg(3))
checkErr("decoding base64 encoding for ACI identity key", err)
updateValue = append([]byte{0}, aciIdentityKeyBytes...)
case "e164":
if flag.Arg(2) == "" {
log.Fatal("No update key given. Usage: kt-client update e164 <e164_string> <UUID>")
} else if flag.Arg(3) == "" {
log.Fatal("No update value given. Usage: kt-client update e164 <e164_string> <UUID>")
}
updateKey = append([]byte{util.NumberPrefix}, []byte(flag.Arg(2))...)
aci, err := uuid.Parse(flag.Arg(3))
checkErr("invalid UUID string for ACI", err)
aciBytes, err := aci.MarshalBinary()
checkErr("getting UUID bytes", err)
updateValue = append([]byte{0}, aciBytes...)
case "username_hash":
if flag.Arg(2) == "" {
log.Fatal("No update key given. Usage: kt-client update username_hash <base64url_encoded_username_hash> <UUID>")
} else if flag.Arg(3) == "" {
log.Fatal("No update value given. Usage: kt-client update username_hash <base64url_encoded_username_hash> <UUID>")
}
usernameHashBytes, err := base64.URLEncoding.DecodeString(flag.Arg(2))
checkErr("decoding base64url encoding for username hash", err)
updateKey = append([]byte{util.UsernameHashPrefix}, usernameHashBytes...)
aci, err := uuid.Parse(flag.Arg(3))
checkErr("invalid UUID string for ACI", err)
aciBytes, err := aci.MarshalBinary()
checkErr("getting UUID bytes", err)
updateValue = append([]byte{0}, aciBytes...)
}
req := &tpb.UpdateRequest{
SearchKey: updateKey,
Value: updateValue,
Consistency: consistency(last),
ReturnUpdateResponse: true,
}
res, err := client.Update(context.Background(), req)
checkErr("update request", err)
printFullTreeHead(res.TreeHead)
p.Printf("VRF: %x\n\n", res.VrfProof)
printSearchProof(res.Search)
p.Printf("Opening: %x\n\n", res.Opening)
if *configFile == "" {
p.Printf("Verification skipped\n")
} else {
// Verifying the consistency proof would require persistent state, which kt-client doesn't have,
// so we nullify these fields.
if *last != -1 {
req.Consistency = nil
res.TreeHead.Last = nil
}
if err := transparency.VerifyUpdate(newStore(), req, res); err != nil {
p.Printf("Verification failed: %v\n", err)
} else {
p.Printf("Verification successful\n")
}
}
}

145
cmd/kt-client/util.go Normal file
View File

@ -0,0 +1,145 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"encoding/base64"
"flag"
"fmt"
"log"
"os"
"time"
"github.com/google/uuid"
"github.com/signalapp/keytransparency/db"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type QueryArgs struct {
Aci []byte
AciIdentityKey []byte
E164 string
UnidentifiedAccessKey []byte
UsernameHash []byte
}
func extractQueryArgs(command string) QueryArgs {
if flag.Arg(1) == "" {
log.Fatalf("No ACI given. Usage: kt-client [-e164 e164] [-uak base64_encoded_uak] [-username-hash base64url_encoded_username_hash] %s <UUID> <base64_encoded_aci_identity_key>", command)
} else if flag.Arg(2) == "" {
log.Fatalf("No ACI identity key given. Usage: kt-client [-e164 e164] [-uak base64_encoded_uak] [-username-hash base64url_encoded_username_hash] %s <UUID> <base64_encoded_aci_identity_key>", command)
}
aci, err := uuid.Parse(flag.Arg(1))
checkErr("invalid UUID string for ACI", err)
aciBytes, err := aci.MarshalBinary()
checkErr("getting UUID bytes", err)
aciIdentityKeyBytes, err := base64.StdEncoding.DecodeString(flag.Arg(2))
checkErr("decoding base64 encoding for ACI identity key", err)
var unidentifiedAccessKeyBytes []byte
if *uak == "" {
unidentifiedAccessKeyBytes = db.UnidentifiedAccessKey
} else {
unidentifiedAccessKeyBytes, err = base64.StdEncoding.DecodeString(*uak)
checkErr("decoding base64 encoding for unidentified access key", err)
}
var usernameHashBytes []byte
if *usernameHash != "" {
usernameHashBytes, err = base64.URLEncoding.DecodeString(*usernameHash)
checkErr("decoding base64url encoding for username hash", err)
}
return QueryArgs{
aciBytes,
aciIdentityKeyBytes,
*e164,
unidentifiedAccessKeyBytes,
usernameHashBytes,
}
}
type SamplingArgs struct {
SampleSize int
NumSamples int
}
func extractSamplingArgs() SamplingArgs {
var sampleSize int
if *timingSampleSize == 0 {
log.Fatal("sample size cannot be 0")
}
var numSamples int
if *timingNumSamples == 0 {
log.Fatal("number of samples cannot be 0")
}
return SamplingArgs{
SampleSize: sampleSize,
NumSamples: numSamples,
}
}
func timeRequest(grpcCall func() error, samplingArgs SamplingArgs) {
for j := 0; j < samplingArgs.NumSamples; j++ {
fmt.Printf("\n\nRound %d of %d:\n", j, samplingArgs.NumSamples)
var totalDuration time.Duration
var minDuration time.Duration
var maxDuration time.Duration
minDuration = time.Hour
for i := 0; i < samplingArgs.SampleSize; i++ {
start := time.Now()
err := grpcCall()
duration := time.Since(start)
gprcError, ok := status.FromError(err)
if !ok {
fmt.Printf("Could not parse err: %v", err)
}
if gprcError.Code() == codes.NotFound {
if i%10 == 0 {
fmt.Printf("Request %d returned not found: %.5f seconds\n", i, duration.Seconds())
}
} else if gprcError.Code() == codes.OK {
if i%10 == 0 {
fmt.Printf("Request %d succeeded: %.5f seconds\n", i, duration.Seconds())
}
} else {
log.Fatalf("Unexpected error: %v", err)
}
totalDuration += duration
if duration < minDuration {
minDuration = duration
}
if duration > maxDuration {
maxDuration = duration
}
}
avgDuration := totalDuration / time.Duration(samplingArgs.SampleSize)
fmt.Println("\nResults:")
fmt.Printf(" Min latency: %.5f seconds\n", minDuration.Seconds())
fmt.Printf(" Max latency: %.5f seconds\n", maxDuration.Seconds())
fmt.Printf(" Avg latency: %.5f seconds\n", avgDuration.Seconds())
}
}
func checkErr(context string, err error) {
if err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("%s: %v\n", context, err))
os.Exit(1)
}
}

View File

@ -0,0 +1,92 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/hashicorp/go-metrics"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/signalapp/keytransparency/cmd/internal/util"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
const (
distinguishedSearchKey = "distinguished"
)
// distinguishedLookup looks up the value of the distinguished key, parses it as
// a Unix timestamp, and returns the timestamp.
func distinguishedLookup(updateHandler *KtUpdateHandler) time.Time {
tree, err := updateHandler.config.NewTree(updateHandler.tx)
if err != nil {
util.Log().Fatalf("failed to initialize tree for distinguished lookup: %v", err)
}
for {
res, err := tree.Search(&tpb.TreeSearchRequest{
SearchKey: []byte(distinguishedSearchKey),
Consistency: &tpb.Consistency{},
})
metrics.IncrCounterWithLabels([]string{"distinguished_lookup"}, 1, []metrics.Label{successLabel(err)})
if err != nil {
errStr := err.Error()
if gprcError, ok := status.FromError(err); ok && gprcError.Code() == codes.NotFound {
return time.Time{}
} else if strings.HasSuffix(errStr, "tree is empty") {
return time.Time{}
}
util.Log().Warnf("Failed to lookup distinguished key: %v", err)
time.Sleep(5 * time.Second)
continue
}
timestamp, err := strconv.ParseInt(string(res.Value.Value), 10, 64)
if err != nil {
util.Log().Fatalf("Failed to parse distinguished key value: %v", err)
}
return time.Unix(timestamp, 0)
}
}
// distinguishedUpdate updates the value of the distinguished key to be the
// current time.
func distinguishedUpdate(updateHandler *KtUpdateHandler) {
for i := 0; i < 5; i++ {
_, err := updateHandler.update(context.Background(), &tpb.UpdateRequest{
SearchKey: []byte(distinguishedSearchKey),
Value: []byte(fmt.Sprint(time.Now().Unix())),
Consistency: &tpb.Consistency{},
}, 5*time.Second)
metrics.IncrCounterWithLabels([]string{"distinguished_update"}, 1, []metrics.Label{successLabel(err)})
if err == nil {
return
}
util.Log().Warnf("Failed to update distinguished key: %v", err)
time.Sleep(5 * time.Second)
}
util.Log().Warnf("Failed to update distinguished key")
}
// distinguished maintains a distinguished key in the log.
func distinguished(updateHandler *KtUpdateHandler, interval time.Duration) {
var durUntilNext time.Duration
durSinceLast := time.Now().Sub(distinguishedLookup(updateHandler))
if durSinceLast < interval {
durUntilNext = interval - durSinceLast
}
for {
time.Sleep(durUntilNext)
distinguishedUpdate(updateHandler)
durUntilNext = interval
}
}

122
cmd/kt-server/kt_handler.go Normal file
View File

@ -0,0 +1,122 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/hashicorp/go-metrics"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/tree/transparency"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/db"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
type KtHandler struct {
config *config.APIConfig
tx db.TransparencyStore
ch chan<- updateRequest
auditorTreeHeadsCh chan<- updateAuditorTreeHeadRequest // Channel used to set auditor tree heads
pb.UnimplementedKeyTransparencyServiceServer
}
func (h *KtHandler) Audit(ctx context.Context, req *pb.AuditRequest) (*pb.AuditResponse, error) {
auditor, err := extractAuditorName(ctx)
if err != nil {
return nil, err
}
start := time.Now()
res, err := h.audit(ctx, req)
lbls := []metrics.Label{successLabel(err), auditorLabel(auditor)}
metrics.IncrCounterWithLabels([]string{"audit_requests"}, 1, lbls)
metrics.MeasureSinceWithLabels([]string{"audit_duration"}, start, lbls)
return res, err
}
func (h *KtHandler) audit(ctx context.Context, req *pb.AuditRequest) (*pb.AuditResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "invalid request")
}
tree, err := h.config.NewTree(h.tx)
if err != nil {
return nil, err
}
updates, more, err := tree.Audit(req.Start, req.Limit)
if err != nil {
if errors.Is(err, transparency.ErrInvalidArgument) {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return nil, err
}
return &pb.AuditResponse{Updates: updates, More: more}, nil
}
func (h *KtHandler) SetAuditorHead(ctx context.Context, head *tpb.AuditorTreeHead) (*emptypb.Empty, error) {
start := time.Now()
auditor, err := extractAuditorName(ctx)
if err != nil {
return nil, err
}
res, err := h.setAuditorHead(ctx, head, auditor)
lbls := []metrics.Label{successLabel(err), auditorLabel(auditor)}
metrics.IncrCounterWithLabels([]string{"auditor_head_requests"}, 1, lbls)
metrics.MeasureSinceWithLabels([]string{"auditor_head_duration"}, start, lbls)
return res, err
}
func (h *KtHandler) setAuditorHead(ctx context.Context, head *tpb.AuditorTreeHead, auditorName string) (*emptypb.Empty, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
errorCh := make(chan error, 1)
select {
case h.auditorTreeHeadsCh <- updateAuditorTreeHeadRequest{auditorTreeHead: head, auditorName: auditorName, err: errorCh}:
case <-ctx.Done():
return nil, fmt.Errorf("submitting auditor head timed out: %w", ctx.Err())
}
select {
case err := <-errorCh:
if errors.Is(err, transparency.ErrInvalidArgument) {
return nil, status.Error(codes.InvalidArgument, err.Error())
} else if _, isType := err.(*transparency.ErrAuditorSignatureVerificationFailed); isType {
util.Log().Errorf("failed to verify auditor tree head signature: %s", err.Error())
return nil, status.Error(codes.FailedPrecondition, err.Error())
} else if errors.Is(err, transparency.ErrFailedPrecondition) {
return nil, status.Error(codes.FailedPrecondition, err.Error())
} else if err != nil {
return nil, status.Error(codes.Unavailable, err.Error())
}
case <-ctx.Done():
return nil, fmt.Errorf("waiting for auditor head insertion result timed out: %w", ctx.Err())
}
return &emptypb.Empty{}, nil
}
// Checks that the auditor name exists on the incoming context and if so, parses and returns it
func extractAuditorName(ctx context.Context) (string, error) {
auditorName, ok := ctx.Value(AuditorNameContextKey).(string)
if !ok {
return "", status.Error(codes.InvalidArgument, fmt.Sprintf("invalid type for auditor name. expected string, got %T", auditorName))
}
if len(auditorName) == 0 {
return "", status.Error(codes.InvalidArgument, "no auditor name in context")
}
return auditorName, nil
}

View File

@ -0,0 +1,52 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var testAuditEndpointsParameters = []struct {
key string
value string
expectError bool
expectedName string
}{
// No metadata
{"", "", true, ""},
// Wrong metadata key
{"wrong-metadata-key", "", true, ""},
{"wrong-metadata-key", "some-value", true, ""},
// Right metadata key, no auditor name
{AuditorNameContextKey, "", true, ""},
{AuditorNameContextKey, "some-value", false, "some-value"},
}
func TestExtractAuditorName(t *testing.T) {
for _, p := range testAuditEndpointsParameters {
ctx := context.Background()
if len(p.key) > 0 {
ctx = context.WithValue(ctx, p.key, p.value)
}
name, err := extractAuditorName(ctx)
if p.expectError {
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.InvalidArgument || !ok {
t.Fatalf("Expected %v, got %v", codes.InvalidArgument, err)
}
} else {
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
}
assert.Equal(t, p.expectedName, name)
}
}

View File

@ -0,0 +1,399 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"crypto/subtle"
"math/rand"
"strings"
"time"
"github.com/hashicorp/go-metrics"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/db"
"github.com/signalapp/keytransparency/tree/transparency"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
type KtQueryHandler struct {
config *config.APIConfig
tx db.TransparencyStore
accountDB db.AccountDB
pb.UnimplementedKeyTransparencyQueryServiceServer
}
func (h *KtQueryHandler) Distinguished(ctx context.Context, req *pb.DistinguishedRequest) (*pb.DistinguishedResponse, error) {
start := time.Now()
res, err := h.distinguished(req)
lbls := []metrics.Label{successLabel(err), grpcStatusLabel(err)}
metrics.IncrCounterWithLabels([]string{"distinguished_requests"}, 1, lbls)
metrics.MeasureSinceWithLabels([]string{"distinguished_duration"}, start, lbls)
if err, _ := status.FromError(err); err.Code() == codes.Unknown {
util.Log().Errorf("Unexpected search error for distinguished key in key transparency service: %v", err.Err())
}
return res, err
}
func (h *KtQueryHandler) distinguished(req *pb.DistinguishedRequest) (*pb.DistinguishedResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "invalid request")
}
tree, err := h.config.NewTree(h.tx)
if err != nil {
return nil, err
}
searchReq := &tpb.TreeSearchRequest{
SearchKey: []byte(distinguishedSearchKey),
Consistency: &tpb.Consistency{
Distinguished: req.Last,
},
}
resp, err := tree.Search(searchReq)
if err != nil {
return nil, err
}
return &pb.DistinguishedResponse{
TreeHead: resp.TreeHead,
Distinguished: convertToCondensedSearchResponse(resp),
}, nil
}
func (h *KtQueryHandler) Search(ctx context.Context, req *pb.SearchRequest) (*pb.SearchResponse, error) {
start := time.Now()
tree, err := h.config.NewTree(h.tx)
if err != nil {
return nil, err
}
res, err := h.search(req, tree)
lbls := []metrics.Label{successLabel(err), grpcStatusLabel(err)}
metrics.IncrCounterWithLabels([]string{"search_requests"}, 1, lbls)
metrics.MeasureSinceWithLabels([]string{"search_duration"}, start, lbls)
if err, _ := status.FromError(err); err.Code() == codes.Unknown {
util.Log().Errorf("Unexpected search error in key transparency service: %v", err.Err())
}
// Achieve some minimum delay with jitter on the request to avoid a timing side-channel.
addRandomDelay(start, time.Now(), h.config.MinimumSearchDelay, h.config.JitterPercent, "search")
metrics.MeasureSinceWithLabels([]string{"total_search_duration"}, start, lbls)
return res, err
}
// Only ACI searches result in a `NotFound` or `PermissionDenied` response.
// Phone numbers and username hashes that are not found or fail verification will return an empty `TreeSearchResponse`.
func (h *KtQueryHandler) search(req *pb.SearchRequest, tree *transparency.Tree) (*pb.SearchResponse, error) {
err := validateRequestParameters(req)
if err != nil {
return nil, err
}
fullTreeHead, aciResponse, err := aciSearch(req, tree)
if err != nil {
return nil, err
}
usernameHashResponse, err := usernameHashSearch(req, tree)
if err != nil {
return nil, err
}
phoneNumberResponse, err := h.phoneNumberSearch(req, tree)
if err != nil {
return nil, err
}
return &pb.SearchResponse{
TreeHead: fullTreeHead,
Aci: aciResponse,
UsernameHash: usernameHashResponse,
E164: phoneNumberResponse,
}, nil
}
func validateRequestParameters(req *pb.SearchRequest) error {
if req == nil {
return status.Error(codes.InvalidArgument, "invalid request")
} else if len(req.Aci) != 16 {
return status.Error(codes.InvalidArgument, "invalid ACI")
} else if len(req.AciIdentityKey) == 0 {
return status.Error(codes.InvalidArgument, "must provide ACI identity key")
} else if req.E164SearchRequest != nil {
if len(req.E164SearchRequest.UnidentifiedAccessKey) == 0 {
return status.Error(codes.InvalidArgument, "must provide unidentified access key for a phone number search")
} else if !isPossiblePhoneNumber(req.E164SearchRequest.GetE164()) {
return status.Error(codes.InvalidArgument, "invalid phone number")
}
} else if len(req.UsernameHash) != 0 && len(req.UsernameHash) != 32 {
return status.Error(codes.InvalidArgument, "invalid username hash")
} else if req.Consistency == nil {
return status.Error(codes.InvalidArgument, "consistency cannot be nil")
}
return nil
}
// Looks up the ACI identifier and verifies that the mapped value matches.
// Other queries will not be allowed to continue unless this one verifies successfully.
func aciSearch(req *pb.SearchRequest, tree *transparency.Tree) (*tpb.FullTreeHead, *pb.CondensedTreeSearchResponse, error) {
consistency := &tpb.Consistency{
Last: req.Consistency.Last,
Distinguished: req.Consistency.Distinguished,
}
aciResponse, err := tree.Search(&tpb.TreeSearchRequest{
SearchKey: append([]byte{util.AciPrefix}, req.Aci...),
Consistency: consistency,
})
if err != nil {
return nil, nil, err
} else if len(aciResponse.Value.Value) < 2 || aciResponse.Value.Value[0] != 0 {
return nil, nil, status.Error(codes.Internal, "unexpected response value")
}
err = verifyMappedValueConstantTime(req.AciIdentityKey, aciResponse.Value.Value[1:])
if err != nil {
return nil, nil, err
}
fullTreeHead := aciResponse.GetTreeHead()
return fullTreeHead, convertToCondensedSearchResponse(aciResponse), nil
}
func usernameHashSearch(req *pb.SearchRequest, tree *transparency.Tree) (*pb.CondensedTreeSearchResponse, error) {
if len(req.UsernameHash) == 0 {
return nil, nil
}
usernameHashResponse, responseErr := tree.Search(&tpb.TreeSearchRequest{
SearchKey: append([]byte{util.UsernameHashPrefix}, req.UsernameHash...),
Consistency: &tpb.Consistency{},
})
if responseErr != nil {
// A non-nil err should be returned except in the case where it's "not found".
// In that case, we don't respond to the search but still allow a phone number search to continue.
if grpcError, _ := status.FromError(responseErr); grpcError.Code() == codes.NotFound {
return nil, nil
}
return nil, responseErr
} else if len(usernameHashResponse.Value.Value) < 2 || usernameHashResponse.Value.Value[0] != 0 {
return nil, status.Error(codes.Internal, "unexpected response value")
} else {
err := verifyMappedValueConstantTime(req.Aci, usernameHashResponse.Value.Value[1:])
if err != nil {
// If the ACI doesn't match, don't respond to the search
// but still allow a phone number search to continue.
return nil, nil
}
}
return convertToCondensedSearchResponse(usernameHashResponse), nil
}
func (h *KtQueryHandler) phoneNumberSearch(req *pb.SearchRequest, tree *transparency.Tree) (*pb.CondensedTreeSearchResponse, error) {
if req.E164SearchRequest == nil {
return nil, nil
}
accountData, err := h.accountDB.GetAccountByAci(req.Aci)
if err != nil {
return nil, err
}
// A non-nil responseErr should be returned except in the case where it's "not found" for a phone number lookup.
// This is to prevent short-circuiting and creating a timing difference between an account that doesn't exist
// with the given phone number, and one that does but is undiscoverable.
phoneNumberResponse, responseErr := tree.Search(&tpb.TreeSearchRequest{
SearchKey: append([]byte{util.NumberPrefix}, []byte(req.E164SearchRequest.GetE164())...),
Consistency: &tpb.Consistency{},
})
var valueForComparison []byte
if responseErr != nil {
if grpcError, _ := status.FromError(responseErr); grpcError.Code() == codes.NotFound {
// Set this value to something that will always fail comparison
valueForComparison = createDistinctValue(req.Aci)
} else {
return nil, responseErr
}
} else if len(phoneNumberResponse.Value.Value) < 2 || phoneNumberResponse.Value.Value[0] != 0 {
return nil, status.Error(codes.Internal, "unexpected response value")
} else {
valueForComparison = phoneNumberResponse.Value.Value[1:]
}
err = verifyPhoneNumberSearchConstantTime(req.Aci, valueForComparison, req.E164SearchRequest.UnidentifiedAccessKey, accountData)
if err != nil {
return nil, nil
}
return convertToCondensedSearchResponse(phoneNumberResponse), nil
}
func (h *KtQueryHandler) Monitor(ctx context.Context, req *pb.MonitorRequest) (*pb.MonitorResponse, error) {
start := time.Now()
res, err := h.monitor(req)
lbls := []metrics.Label{successLabel(err), grpcStatusLabel(err)}
metrics.IncrCounterWithLabels([]string{"monitor_requests"}, 1, lbls)
metrics.MeasureSinceWithLabels([]string{"monitor_duration"}, start, lbls)
if err, _ := status.FromError(err); err.Code() == codes.Unknown {
util.Log().Errorf("Unexpected monitor error in key transparency service: %v", err.Err())
}
// Achieve some minimum delay with jitter on the request to avoid a timing side-channel.
addRandomDelay(start, time.Now(), h.config.MinimumMonitorDelay, h.config.JitterPercent, "monitor")
metrics.MeasureSinceWithLabels([]string{"total_monitor_duration"}, start, lbls)
return res, err
}
func (h *KtQueryHandler) monitor(req *pb.MonitorRequest) (*pb.MonitorResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "invalid request")
}
tree, err := h.config.NewTree(h.tx)
if err != nil {
return nil, err
}
monitorKeys := []*tpb.MonitorKey{
{
SearchKey: append([]byte{util.AciPrefix}, req.Aci.GetAci()...),
EntryPosition: req.Aci.GetEntryPosition(),
CommitmentIndex: req.Aci.GetCommitmentIndex(),
},
}
if req.GetUsernameHash() != nil {
monitorKeys = append(monitorKeys, &tpb.MonitorKey{
SearchKey: append([]byte{util.UsernameHashPrefix}, req.UsernameHash.GetUsernameHash()...),
EntryPosition: req.UsernameHash.GetEntryPosition(),
CommitmentIndex: req.UsernameHash.GetCommitmentIndex(),
})
}
if req.GetE164() != nil {
monitorKeys = append(monitorKeys, &tpb.MonitorKey{
SearchKey: append([]byte{util.NumberPrefix}, req.E164.GetE164()...),
EntryPosition: req.E164.GetEntryPosition(),
CommitmentIndex: req.E164.GetCommitmentIndex(),
})
}
internalMonitorRequest := &tpb.MonitorRequest{
Keys: monitorKeys,
Consistency: req.Consistency,
}
internalMonitorResponse, err := tree.Monitor(internalMonitorRequest)
if err != nil {
return nil, err
}
var aciProof *tpb.MonitorProof
aciProof, internalMonitorResponse.Proofs = internalMonitorResponse.Proofs[0], internalMonitorResponse.Proofs[1:]
var usernameHashProof *tpb.MonitorProof
if req.GetUsernameHash() != nil {
usernameHashProof, internalMonitorResponse.Proofs = internalMonitorResponse.Proofs[0], internalMonitorResponse.Proofs[1:]
}
var e164Proof *tpb.MonitorProof
if req.GetE164() != nil {
e164Proof = internalMonitorResponse.Proofs[0]
}
return &pb.MonitorResponse{
TreeHead: internalMonitorResponse.TreeHead,
Aci: aciProof,
UsernameHash: usernameHashProof,
E164: e164Proof,
Inclusion: internalMonitorResponse.Inclusion,
}, nil
}
func isPossiblePhoneNumber(number string) bool {
if !strings.HasPrefix(number, "+") {
return false
} else if len(number[1:]) > 15 {
// E.164 specifies a maximum of 15 digits
return false
}
return true
}
// Phone number searches must pass additional checks:
// - the account must be discoverable
// - the unidentified access key provided in the request must match the one on the account
func verifyPhoneNumberSearchConstantTime(mappedValue, expectedValue, reqUnidentifiedAccessKey []byte, accountData *db.Account) error {
var accountUnidentifiedAccessKeyForComparison []byte
discoverable := 0
if accountData == nil {
// If no account exists, set this value to something that will always fail comparison
accountUnidentifiedAccessKeyForComparison = createDistinctValue(reqUnidentifiedAccessKey)
} else {
accountUnidentifiedAccessKeyForComparison = accountData.UnidentifiedAccessKey
if accountData.DiscoverableByPhoneNumber {
discoverable = 1
}
}
unidentifiedAccessKeysEqual := 0
if subtle.ConstantTimeCompare(accountUnidentifiedAccessKeyForComparison, reqUnidentifiedAccessKey) == 1 {
unidentifiedAccessKeysEqual = 1
}
if (discoverable & unidentifiedAccessKeysEqual) == 0 {
// We want to avoid leaking data about the existence of an account with a given phone number
return status.Error(codes.NotFound, "user not found")
}
return verifyMappedValueConstantTime(mappedValue, expectedValue)
}
func convertToCondensedSearchResponse(response *tpb.TreeSearchResponse) *pb.CondensedTreeSearchResponse {
return &pb.CondensedTreeSearchResponse{
VrfProof: response.VrfProof,
Search: response.Search,
Opening: response.Opening,
Value: response.Value,
}
}
// addJitter adds random jitter to the specified duration and returns the final duration.
// For example, if the minDelay is 1000ms and the jitterPercent is 10, addJitter will
// return a random duration in the interval [1000ms, 1100ms].
func addJitter(minDelay time.Duration, jitterPercent int) time.Duration {
upperBound := float64(minDelay.Nanoseconds()) * float64(jitterPercent) / 100.0
// Generate a random value between [0, upperBound]
jitter := rand.Int63n(int64(upperBound) + 1)
return minDelay + time.Duration(jitter)
}
// addRandomDelay injects a delay to achieve some minimum jittered delay for the request.
func addRandomDelay(start, now time.Time, minDelay time.Duration, jitterPercent int, endpoint string) {
elapsed := now.Sub(start)
jitteredMinDelay := addJitter(minDelay, jitterPercent)
timeToSleep := jitteredMinDelay - elapsed
metrics.AddSampleWithLabels([]string{"random_delay_duration"}, float32(timeToSleep.Nanoseconds())/float32(time.Millisecond), []metrics.Label{endpointLabel(endpoint)})
if timeToSleep < 0 {
metrics.IncrCounterWithLabels([]string{"elapsed_greater_than_min_delay"}, 1, []metrics.Label{endpointLabel(endpoint)})
metrics.AddSampleWithLabels([]string{"negative_random_delay_duration"}, float32(timeToSleep.Nanoseconds())/float32(time.Millisecond), []metrics.Label{endpointLabel(endpoint)})
}
time.Sleep(timeToSleep)
}

View File

@ -0,0 +1,674 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/db"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
var (
mockConfigFile = "test_config.yaml"
validUsernameHash1 = random(32)
validPhoneNumber1 = "+14155550101"
unidentifiedAccessKey = random(16)
mismatchedUnidentifiedAccessKey = createDistinctValue(unidentifiedAccessKey)
invalidAci = make([]byte, 15)
invalidUsernameHash = random(31)
invalidPhoneNumber1 = "14155550101"
invalidPhoneNumber2 = "+1415555010101456"
)
func TestDistinguished_NilRequest(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
_, err := h.distinguished(nil)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.InvalidArgument || !ok {
t.Fatalf("Expected %v, got %v",
codes.InvalidArgument, err)
}
}
var testInvalidSearchRequestParameters = []struct {
searchRequest *pb.SearchRequest
}{
// Nil search request
{nil},
// No aci
{&pb.SearchRequest{AciIdentityKey: validAciIdentityKey1, Consistency: &tpb.Consistency{}}},
// ACI wrong length
{&pb.SearchRequest{Aci: invalidAci, AciIdentityKey: validAciIdentityKey1, Consistency: &tpb.Consistency{}}},
// No ACI identity key
{&pb.SearchRequest{Aci: validAci1, Consistency: &tpb.Consistency{}}},
// Phone number search with no phone number or unidentified access key
{&pb.SearchRequest{Aci: invalidAci, AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{}, Consistency: &tpb.Consistency{}}},
// Phone number search key with no unidentified access key
{&pb.SearchRequest{Aci: invalidAci, AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{E164: &validPhoneNumber1},
Consistency: &tpb.Consistency{}}},
// Phone number search key missing leading '+'
{&pb.SearchRequest{Aci: invalidAci, AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{E164: &invalidPhoneNumber1, UnidentifiedAccessKey: unidentifiedAccessKey},
Consistency: &tpb.Consistency{}}},
// Phone number search key with invalid length
{&pb.SearchRequest{Aci: invalidAci, AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{E164: &invalidPhoneNumber2, UnidentifiedAccessKey: unidentifiedAccessKey},
Consistency: &tpb.Consistency{}}},
// Username hash search key with invalid length
{&pb.SearchRequest{Aci: validAci1, AciIdentityKey: validAciIdentityKey1,
UsernameHash: invalidUsernameHash, Consistency: &tpb.Consistency{}}},
// Consistency cannot be nil
{&pb.SearchRequest{Aci: validAci1, AciIdentityKey: validAciIdentityKey1}},
}
func TestSearch_InvalidArgument(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
for _, p := range testInvalidSearchRequestParameters {
_, err := h.search(p.searchRequest, tree)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.InvalidArgument || !ok {
t.Fatalf("Expected %v, got %v",
codes.InvalidArgument, err)
}
}
}
func TestSearch_AciNotFound(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
// Add ACI so that we're not searching an empty tree
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.AciPrefix}, validAci1...),
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree, %v", err)
}
// Search for a different ACI
resp, err := h.search(&pb.SearchRequest{
Aci: random(16),
AciIdentityKey: validAciIdentityKey1,
Consistency: &tpb.Consistency{},
}, tree)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.NotFound || !ok {
t.Fatalf("Expected %v, got %v",
codes.NotFound, err)
} else if resp != nil {
t.Fatalf("Expected no search response")
}
}
func TestSearch_AciPermissionDenied(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
// Add ACI
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.AciPrefix}, validAci1...),
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Search for the same ACI, but provide the wrong ACI identity key
searchReq := &pb.SearchRequest{
Aci: validAci1,
AciIdentityKey: mismatchedUnidentifiedAccessKey,
Consistency: &tpb.Consistency{},
}
resp, err := h.search(searchReq, tree)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.PermissionDenied || !ok {
t.Fatalf("Expected %v, got %v",
codes.PermissionDenied, err)
} else if resp != nil {
t.Fatalf("Expected no search response")
}
}
func TestSearch_UsernameHashNotFound(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
// Add ACI so that we're not searching an empty tree
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.AciPrefix}, validAci1...),
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Search for ACI and non-existent username hash
searchReq := &pb.SearchRequest{
Aci: validAci1,
AciIdentityKey: validAciIdentityKey1,
UsernameHash: validUsernameHash1,
Consistency: &tpb.Consistency{},
}
resp, err := h.search(searchReq, tree)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
} else if resp == nil || resp.Aci == nil {
t.Fatalf("Expected ACI search response")
} else if resp.TreeHead == nil {
t.Fatalf("Expected top-level tree head")
} else if resp.UsernameHash != nil {
t.Fatalf("Expected no username hash search response")
}
}
func TestSearch_E164NotFound(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
// Add ACI so that we're not searching an empty tree
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.AciPrefix}, validAci1...),
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Search for ACI and non-existent E164
resp, err := h.search(&pb.SearchRequest{
Aci: validAci1,
AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{
E164: &validPhoneNumber1,
UnidentifiedAccessKey: unidentifiedAccessKey,
},
Consistency: &tpb.Consistency{},
}, tree)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
} else if resp == nil || resp.Aci == nil {
t.Fatalf("Expected an ACI search response")
} else if resp.TreeHead == nil {
t.Fatalf("Expected a top-level tree head")
} else if resp.E164 != nil {
t.Fatalf("Expected no E164 search response")
}
}
func TestSearch_E164DoesNotMatch(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
// Add ACI
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.AciPrefix}, validAci1...),
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Add username hash
usernameHashUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...),
Value: append([]byte{0}, validAci1...),
Consistency: &tpb.Consistency{},
}
_, err = tree.UpdateSimple(usernameHashUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Add E164 that maps to a different ACI
e164UpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.NumberPrefix}, []byte(validPhoneNumber1)...),
Value: append([]byte{0}, mismatchedAci...),
Consistency: &tpb.Consistency{},
}
_, err = tree.UpdateSimple(e164UpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Search for ACI and E164. Provide the wrong ACI for the E164.
searchReq := &pb.SearchRequest{
Aci: validAci1,
AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{
E164: &validPhoneNumber1,
UnidentifiedAccessKey: unidentifiedAccessKey,
},
UsernameHash: validUsernameHash1,
Consistency: &tpb.Consistency{},
}
resp, err := h.search(searchReq, tree)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
} else if resp == nil || resp.Aci == nil || resp.UsernameHash == nil {
t.Fatalf("Expected ACI and username hash search responses")
} else if resp.TreeHead == nil {
t.Fatalf("Expected ACI search response to have tree head")
} else if resp.E164 != nil {
t.Fatalf("Expected no E164 search response")
}
}
func TestSearch_UsernameHashDoesNotMatch(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
// Add ACI
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.AciPrefix}, validAci1...),
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Add E164
e164UpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.NumberPrefix}, []byte(validPhoneNumber1)...),
Value: append([]byte{0}, validAci1...),
Consistency: &tpb.Consistency{},
}
_, err = tree.UpdateSimple(e164UpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Add username hash that maps to a different ACI
usernameHashUpdateReq := &tpb.UpdateRequest{
SearchKey: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...),
Value: append([]byte{0}, mismatchedAci...),
Consistency: &tpb.Consistency{},
}
_, err = tree.UpdateSimple(usernameHashUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree")
}
// Search for all three identifiers
searchReq := &pb.SearchRequest{
Aci: validAci1,
AciIdentityKey: validAciIdentityKey1,
E164SearchRequest: &pb.E164SearchRequest{
E164: &validPhoneNumber1,
UnidentifiedAccessKey: db.UnidentifiedAccessKey,
},
UsernameHash: validUsernameHash1,
Consistency: &tpb.Consistency{},
}
resp, err := h.search(searchReq, tree)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
} else if resp == nil || resp.Aci == nil || resp.E164 == nil {
t.Fatalf("Expected ACI and E164 search responses")
} else if resp.TreeHead == nil {
t.Fatalf("Expected top-level tree head")
} else if resp.UsernameHash != nil {
t.Fatalf("Expected no username hash search response")
}
}
var testInvalidMonitorParameters = []struct {
monitorRequest *pb.MonitorRequest
expectedError codes.Code
}{
// nil monitor request
{nil, codes.InvalidArgument},
// aci monitor request must not be nil
{&pb.MonitorRequest{}, codes.InvalidArgument},
// aci.Aci must not be nil
{&pb.MonitorRequest{
Aci: &pb.AciMonitorRequest{},
}, codes.InvalidArgument},
// aci.Entries must not be nil
{&pb.MonitorRequest{
Aci: &pb.AciMonitorRequest{Aci: validAci1},
}, codes.InvalidArgument},
// aci.CommitmentIndex must not be nil
{&pb.MonitorRequest{
Aci: &pb.AciMonitorRequest{Aci: validAci1, EntryPosition: 0},
}, codes.InvalidArgument},
// aci.CommitmentIndex must be of length 32
{&pb.MonitorRequest{
Aci: &pb.AciMonitorRequest{Aci: validAci1, EntryPosition: 0, CommitmentIndex: []byte{0}},
}, codes.InvalidArgument},
// aci.CommitmentIndex does not match
{&pb.MonitorRequest{
Aci: &pb.AciMonitorRequest{Aci: validAci1, EntryPosition: 0, CommitmentIndex: make([]byte, 32)}},
codes.PermissionDenied},
}
func TestMonitor_InvalidRequests(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
for _, p := range testInvalidMonitorParameters {
_, err := h.monitor(p.monitorRequest)
if p.expectedError != codes.OK {
if grpcError, ok := status.FromError(err); grpcError.Code() != p.expectedError || !ok {
t.Fatalf("Expected error of type %v, got %v", p.expectedError, grpcError)
}
}
}
}
func TestMonitor(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
accountDb := db.MockAccountDB{}
h := KtQueryHandler{config: mockConfig.APIConfig, tx: mockTransparencyStore, accountDB: &accountDb}
tree, _ := mockConfig.APIConfig.NewTree(mockTransparencyStore)
// Setup part 1: add data so that we're not using an empty tree
aciSearchKey := append([]byte{util.AciPrefix}, validAci1...)
aciUpdateReq := &tpb.UpdateRequest{
SearchKey: aciSearchKey,
Value: append([]byte{0}, validAciIdentityKey1...),
Consistency: &tpb.Consistency{},
}
_, err := tree.UpdateSimple(aciUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree, %v", err)
}
usernameHashSearchKey := append([]byte{util.UsernameHashPrefix}, validUsernameHash1...)
usernameHashUpdateReq := &tpb.UpdateRequest{
SearchKey: usernameHashSearchKey,
Value: append([]byte{0}, validAci1...),
Consistency: &tpb.Consistency{},
}
_, err = tree.UpdateSimple(usernameHashUpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree, %v", err)
}
e164SearchKey := append([]byte{util.NumberPrefix}, validPhoneNumber1...)
e164UpdateReq := &tpb.UpdateRequest{
SearchKey: e164SearchKey,
Value: append([]byte{0}, validAci1...),
Consistency: &tpb.Consistency{},
}
_, err = tree.UpdateSimple(e164UpdateReq)
if err != nil {
t.Fatalf("Unexpected error updating the tree, %v", err)
}
// Setup part 2: Search, to get a valid commitment index
searchResponse, err := h.search(&pb.SearchRequest{
Aci: validAci1,
AciIdentityKey: validAciIdentityKey1,
UsernameHash: validUsernameHash1,
E164SearchRequest: &pb.E164SearchRequest{
E164: &validPhoneNumber1,
UnidentifiedAccessKey: db.UnidentifiedAccessKey,
},
Consistency: &tpb.Consistency{},
}, tree)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
aciCommitmentIndex, err := mockConfig.APIConfig.TreeConfig().Public().VrfKey.ECVRFVerify(aciSearchKey, searchResponse.Aci.VrfProof)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
usernameHashCommitmentIndex, err := mockConfig.APIConfig.TreeConfig().Public().VrfKey.ECVRFVerify(usernameHashSearchKey, searchResponse.UsernameHash.VrfProof)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
e164CommitmentIndex, err := mockConfig.APIConfig.TreeConfig().Public().VrfKey.ECVRFVerify(e164SearchKey, searchResponse.E164.VrfProof)
if err != nil {
t.Fatalf("Unexpected error %v", err)
}
// test 1: just ACI
req := &pb.MonitorRequest{
Aci: &pb.AciMonitorRequest{
Aci: validAci1,
EntryPosition: searchResponse.Aci.Search.Pos,
CommitmentIndex: aciCommitmentIndex[:],
},
Consistency: &tpb.Consistency{},
}
res, err := h.monitor(req)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.OK || !ok {
t.Fatalf("Unexpected error %v", grpcError)
}
if res.Aci == nil {
t.Fatalf("ACI proof should not be nil")
}
if res.UsernameHash != nil {
t.Fatalf("Username hash proof should be nil")
}
if res.E164 != nil {
t.Fatalf("E164 proof should be nil")
}
if len(res.Inclusion) == 0 {
t.Fatalf("Inclusion proof should not be empty")
}
// test 2: ACI + Username Hash
req.UsernameHash = &pb.UsernameHashMonitorRequest{
UsernameHash: validUsernameHash1,
EntryPosition: searchResponse.UsernameHash.Search.Pos,
CommitmentIndex: usernameHashCommitmentIndex[:],
}
res, err = h.monitor(req)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.OK || !ok {
t.Fatalf("Unexpected error %v", grpcError)
}
if res.Aci == nil {
t.Fatalf("ACI proof should not be nil")
}
if res.UsernameHash == nil {
t.Fatalf("Username hash proof should not be nil")
}
if res.E164 != nil {
t.Fatalf("E164 proof should be nil")
}
if len(res.Inclusion) == 0 {
t.Fatalf("Inclusion proof should not be empty")
}
// test 3: ACI + Username Hash + E164
req.E164 = &pb.E164MonitorRequest{
E164: &validPhoneNumber1,
EntryPosition: searchResponse.E164.Search.Pos,
CommitmentIndex: e164CommitmentIndex[:],
}
res, err = h.monitor(req)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.OK || !ok {
t.Fatalf("Unexpected error %v", grpcError)
}
if res.Aci == nil {
t.Fatalf("ACI proof should not be nil")
}
if res.UsernameHash == nil {
t.Fatalf("Username hash proof should not be nil")
}
if res.E164 == nil {
t.Fatalf("E164 proof should not be nil")
}
if len(res.Inclusion) == 0 {
t.Fatalf("Inclusion proof should not be empty")
}
// test 4: ACI + E164
req.UsernameHash = nil
res, err = h.monitor(req)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.OK || !ok {
t.Fatalf("Unexpected error %v", grpcError)
}
if res.Aci == nil {
t.Fatalf("ACI proof should not be nil")
}
if res.UsernameHash != nil {
t.Fatalf("Username hash proof should be nil")
}
if res.E164 == nil {
t.Fatalf("E164 proof should not be nil")
}
if len(res.Inclusion) == 0 {
t.Fatalf("Inclusion proof should not be empty")
}
}
var testVerifyPhoneNumberSearchParameters = []struct {
providedValue []byte
expectedValue []byte
unidentifiedAccessKey []byte
account *db.Account
expectedErrorType codes.Code
}{
// Discoverable; unidentified access key matches; provided value matches; no error
{validAci1, validAci1, unidentifiedAccessKey, &db.Account{
UnidentifiedAccessKey: unidentifiedAccessKey,
DiscoverableByPhoneNumber: true,
}, codes.OK},
// Account does not exist; expect error
{validAci1, validAci1, unidentifiedAccessKey, nil, codes.NotFound},
// User not discoverable by phone number; expect error
{validAci1, validAci1, unidentifiedAccessKey, &db.Account{
UnidentifiedAccessKey: unidentifiedAccessKey,
DiscoverableByPhoneNumber: false,
}, codes.NotFound},
// Unidentified access key does not match; expect error
{validAci1, validAci1, unidentifiedAccessKey, &db.Account{
UnidentifiedAccessKey: mismatchedUnidentifiedAccessKey,
DiscoverableByPhoneNumber: true,
}, codes.NotFound},
// Provided and expected mapped values do not match; expect error
{validAci1, mismatchedAci, unidentifiedAccessKey, &db.Account{
UnidentifiedAccessKey: unidentifiedAccessKey,
DiscoverableByPhoneNumber: true,
}, codes.PermissionDenied},
}
func TestVerifyPhoneNumberSearchConstantTime(t *testing.T) {
for _, p := range testVerifyPhoneNumberSearchParameters {
err := verifyPhoneNumberSearchConstantTime(p.providedValue, p.expectedValue, p.unidentifiedAccessKey, p.account)
if (p.expectedErrorType != codes.OK) != (err != nil) {
t.Fatalf("Expected %v, got %v",
p.expectedErrorType, err)
}
if p.expectedErrorType != codes.OK {
if grpcError, ok := status.FromError(err); grpcError.Code() != p.expectedErrorType || !ok {
t.Fatalf("Expected error of type %v, got %v", p.expectedErrorType, grpcError)
}
}
}
}
func TestAddJitter(t *testing.T) {
for i := 0; i < 100; i++ {
jitteredVal := addJitter(1000, 10)
if jitteredVal < 1000 || jitteredVal > 1100 {
t.Errorf("Jittered value outside expected range [1000, 1100]")
}
}
}
func TestAddRandomDelay(t *testing.T) {
start := time.Now()
// The request took 50 milliseconds, but require a minimum delay of 100 milliseconds.
requestTime := 50 * time.Millisecond
minDelay := 100 * time.Millisecond
jitterPercent := 10
maxJitter := time.Duration((float64(jitterPercent) / 100.0) * float64(minDelay))
buffer := 10 * time.Millisecond
for i := 0; i < 50; i++ {
testStart := time.Now()
addRandomDelay(start, start.Add(requestTime), minDelay, jitterPercent, "test")
testDuration := time.Since(testStart)
if testDuration < minDelay-requestTime {
t.Errorf("Expected at least %v delay, got %v instead", minDelay-requestTime, testDuration)
}
if testDuration > minDelay-requestTime+maxJitter+buffer {
t.Errorf("Expected at most %v delay, got %v instead", minDelay-requestTime+maxJitter+buffer, testDuration)
}
}
}

View File

@ -0,0 +1,79 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"fmt"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
metrics "github.com/hashicorp/go-metrics"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/db"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
type KtUpdateHandler struct {
config *config.APIConfig
tx db.TransparencyStore
ch chan<- updateRequest
pb.UnimplementedKeyTransparencyTestServiceServer
}
func (h *KtUpdateHandler) Update(ctx context.Context, req *tpb.UpdateRequest) (*tpb.UpdateResponse, error) {
start := time.Now()
res, err := h.update(ctx, req, 5*time.Second)
lbls := []metrics.Label{successLabel(err), grpcStatusLabel(err)}
metrics.IncrCounterWithLabels([]string{"update_requests"}, 1, lbls)
metrics.MeasureSinceWithLabels([]string{"update_duration"}, start, lbls)
if err, _ := status.FromError(err); err.Code() == codes.Unknown {
util.Log().Errorf("Unexpected update error in key transparency service: %v", err.Err())
}
return res, err
}
func (h *KtUpdateHandler) update(ctx context.Context, req *tpb.UpdateRequest, timeout time.Duration) (*tpb.UpdateResponse, error) {
tree, err := h.config.NewTree(h.tx)
if err != nil {
return nil, err
}
pre, err := tree.PreUpdate(req)
if err != nil {
return nil, err
}
ch := make(chan updateResponse, 1)
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
select {
case h.ch <- updateRequest{req: pre, res: ch}:
case <-ctx.Done():
return nil, fmt.Errorf("submitting insertion request timed out: %w", ctx.Err())
}
select {
case res := <-ch:
if res.err != nil {
return nil, res.err
} else if res.res == nil {
// In the case of tombstone updates, it is an expected case to get back
// no update response and no error.
return nil, nil
}
if req.ReturnUpdateResponse {
return tree.PostUpdate(res.res)
}
return nil, nil
case <-ctx.Done():
return nil, fmt.Errorf("waiting for insertion result timed out: %w", ctx.Err())
}
}

250
cmd/kt-server/main.go Normal file
View File

@ -0,0 +1,250 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
//go:generate protoc -I ./pb -I ../../tree/transparency/pb --go_out=pb --go_opt=paths=source_relative --go-grpc_out=pb --go-grpc_opt=paths=source_relative key_transparency.proto key_transparency_query.proto key_transparency_test.proto
// Command kt-server is the main server process that answers all client
// requests and sequences new changes to the log.
package main
import (
"context"
"flag"
"io"
"net"
"os"
"runtime"
"time"
"github.com/rs/zerolog"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"gopkg.in/natefinch/lumberjack.v2"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
"github.com/signalapp/keytransparency/db"
)
var (
Version = "dev"
GoVersion = runtime.Version()
configFile = flag.String("config", "", "Location of config file.")
liveness = "liveness"
readiness = "readiness"
)
func main() {
flag.Parse()
ctx := context.Background()
consoleWriter := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}
// Load config from disk.
if *configFile == "" {
logger := zerolog.New(consoleWriter).With().Timestamp().Logger()
logger.Fatal().Msg("no config file specified")
}
config, err := config.Read(*configFile)
if err != nil {
logger := zerolog.New(consoleWriter).With().Timestamp().Logger()
logger.Fatal().Msgf("failed to parse config file: %v", err)
}
var zeroLogLogger zerolog.Logger
var logWriter io.Writer
if len(config.LogOutputFile.String()) > 0 {
logWriter = zerolog.MultiLevelWriter(
consoleWriter,
&lumberjack.Logger{
Filename: config.LogOutputFile.String(),
MaxBackups: 10,
Compress: true,
},
)
zeroLogLogger = zerolog.New(logWriter).With().Timestamp().Logger().Level(zerolog.InfoLevel)
} else {
logWriter = consoleWriter
zeroLogLogger = zerolog.New(logWriter).With().Caller().Timestamp().Logger()
}
util.SetLoggerInstance(&zeroLogLogger)
// Register healthCheck service
healthServer := grpc.NewServer()
healthCheck := health.NewServer()
healthgrpc.RegisterHealthServer(healthServer, healthCheck)
// Initialize liveness and readiness states
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_SERVING)
healthCheck.SetServingStatus(readiness, healthpb.HealthCheckResponse_NOT_SERVING)
// Configure healthCheck service to listen on its dedicated port
lis, err := net.Listen("tcp", config.HealthAddr.String())
if err != nil {
util.Log().Fatalf("failed to listen on health check port %v: %v", config.HealthAddr.String(), err)
}
util.Log().Infof("Starting health check server at: %v", config.HealthAddr)
// Start the health server.
go healthServer.Serve(lis)
// Start the metrics server.
exportMetrics(config.DatadogAddr.String())
go metricsServer(config.MetricsAddr.String())
// Start the inserter thread.
tx, err := config.DatabaseConfig.Connect()
if err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("Failed to connect to database: %v", err)
}
// Is this a new, empty tree?
first := false
if tth, _, err := tx.GetHead(); err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("unable to get transparency tree head: %v", err)
} else {
util.Log().Infof("Tree size: %d", tth.TreeSize)
first = tth.TreeSize == 0
}
// This is the read-only service.
// For now, it always queries the database directly and does not use a cache.
if config.KtQueryServiceConfig != nil {
// For read-only servers, cache immutable data.
// The log tree data is only cached when the chunk is full (and therefore immutable).
queryCachedTransparencyStore := db.NewCachedTransparencyStore(tx, db.TransparencyCache|db.PrefixCache|db.LogCache, config.CacheConfig.TopSize, config.CacheConfig.LogSize, config.CacheConfig.PrefixSize)
// Connect to the account DB
accountDB, err := config.ConnectAccountDB()
ktQueryHandler := &KtQueryHandler{config: config.APIConfig, tx: queryCachedTransparencyStore.Clone(), accountDB: accountDB}
// Create listener for specified port
ktQueryListener, err := createListener(config.KtQueryServiceConfig)
if err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("Failed to create listener for kt query server: %v", err)
}
// Register kt query server
ktQueryServer := grpc.NewServer(getServerOptions(config.KtQueryServiceConfig, nil)...)
pb.RegisterKeyTransparencyQueryServiceServer(ktQueryServer, ktQueryHandler)
util.Log().Infof("Starting kt-query server at: %v", config.KtQueryServiceConfig.ServerAddr)
if config.KtServiceConfig == nil && config.KtTestServiceConfig == nil {
healthCheck.SetServingStatus(readiness, healthpb.HealthCheckResponse_SERVING)
ktQueryServer.Serve(ktQueryListener)
} else {
go ktQueryServer.Serve(ktQueryListener)
}
}
ch := make(chan updateRequest)
auditorTreeHeadsCh := make(chan updateAuditorTreeHeadRequest)
// Cache all data for the service that handles writes
cachedTransparencyStore := db.NewCachedTransparencyStore(tx, db.TransparencyCache|db.PrefixCache|db.LogCache|db.HeadCache, config.CacheConfig.TopSize, config.CacheConfig.LogSize, config.CacheConfig.PrefixSize)
// Define a handler that provides a common interface for:
// - stream-based updates
// - distinguished updates
// - manual updates from local testing
// Its update functionality will only ever be exposed externally in a local development/testing context.
updateHandler := &KtUpdateHandler{config: config.APIConfig, tx: cachedTransparencyStore.Clone(), ch: ch}
if config.KtServiceConfig != nil || config.KtTestServiceConfig != nil {
updaterTree, err := config.APIConfig.NewTree(cachedTransparencyStore)
if err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("failed to initialize tree: %v", err)
}
// Start updater goroutine
go updater(updaterTree, ch, auditorTreeHeadsCh, config.APIConfig.FakeUpdates)
// Start a goroutine that regularly updates a distinguished key
if config.APIConfig.Distinguished != 0 {
util.Log().Infof("Distinguished key will be maintained: %v", config.APIConfig.Distinguished)
go distinguished(updateHandler, config.APIConfig.Distinguished)
}
}
// This is the read and write service
if config.KtServiceConfig != nil {
// Start scanning a Kinesis stream, if one is provided.
if config.StreamConfig != nil {
s := &Streamer{config: config.APIConfig, tx: cachedTransparencyStore.Clone()}
go func() {
var streamStartTimestamp time.Time
if first && config.StreamConfig.TableName != "" {
// start the stream from when the backfill started, minus some padding for clock drift
streamStartTimestamp = time.Now().Add(-time.Minute * 15)
util.Log().Infof("Backfilling from DynamoDB table %q", config.StreamConfig.TableName)
if err := backfill(ctx, config.StreamConfig.TableName.String(), updateHandler); err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("stream backfill failed: %v", err)
}
} else {
streamStartTimestamp = time.Now().Add(-config.StreamConfig.InitialHorizon)
}
util.Log().Infof("Starting stream processing from Kinesis stream %q", config.StreamConfig.Name.String())
s.run(ctx, config.StreamConfig.Name.String(), streamStartTimestamp, updateHandler)
}()
}
ktHandler := &KtHandler{config: config.APIConfig, tx: cachedTransparencyStore.Clone(), ch: ch, auditorTreeHeadsCh: auditorTreeHeadsCh}
ktServiceConfig := config.KtServiceConfig
// Create listener on the specified port
ktListener, err := createListener(ktServiceConfig)
if err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("Failed to create listener for kt server: %v", err)
}
ktServer := grpc.NewServer(getServerOptions(config.KtServiceConfig, []grpc.UnaryServerInterceptor{storeAuditorNameInterceptor(config.KtServiceConfig)})...)
pb.RegisterKeyTransparencyServiceServer(ktServer, ktHandler)
util.Log().Infof("Starting kt server at: %v", ktServiceConfig.ServerAddr)
if config.KtTestServiceConfig == nil {
healthCheck.SetServingStatus(readiness, healthpb.HealthCheckResponse_SERVING)
ktServer.Serve(ktListener)
} else {
go ktServer.Serve(ktListener)
}
}
// This is a service for local development.
// For testing purposes, it exposes an endpoint for manual updates.
if config.KtTestServiceConfig != nil {
util.Log().Warnf("Test service config found. This should only be configured in a development environment.")
ktTestServiceConfig := config.KtTestServiceConfig
// Create listener on the specified port
ktTestListener, err := createListener(ktTestServiceConfig)
if err != nil {
healthCheck.SetServingStatus(liveness, healthpb.HealthCheckResponse_NOT_SERVING)
util.Log().Fatalf("Failed to create listener for kt test server: %v", err)
}
ktTestServer := grpc.NewServer(getServerOptions(config.KtTestServiceConfig, nil)...)
pb.RegisterKeyTransparencyTestServiceServer(ktTestServer, updateHandler)
util.Log().Infof("Starting kt test server at: %v", ktTestServiceConfig.ServerAddr)
healthCheck.SetServingStatus(readiness, healthpb.HealthCheckResponse_SERVING)
ktTestServer.Serve(ktTestListener)
}
}
func createListener(serviceConfig *config.ServiceConfig) (net.Listener, error) {
return net.Listen("tcp", serviceConfig.ServerAddr.String())
}

102
cmd/kt-server/metrics.go Normal file
View File

@ -0,0 +1,102 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"fmt"
"net/http"
"net/http/pprof"
metrics "github.com/hashicorp/go-metrics"
"github.com/hashicorp/go-metrics/datadog"
"github.com/hashicorp/go-metrics/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"google.golang.org/grpc/status"
"github.com/signalapp/keytransparency/cmd/internal/util"
)
func successLabel(err error) metrics.Label {
return metrics.Label{Name: "success", Value: fmt.Sprint(err == nil)}
}
func auditorLabel(auditorName string) metrics.Label {
return metrics.Label{Name: "auditor", Value: auditorName}
}
func grpcStatusLabel(err error) metrics.Label {
grpcError, _ := status.FromError(err)
return metrics.Label{Name: "grpcStatus", Value: grpcError.Code().String()}
}
func realLabel(real bool) metrics.Label {
return metrics.Label{Name: "real", Value: fmt.Sprint(real)}
}
func endpointLabel(endpoint string) metrics.Label {
return metrics.Label{Name: "endpoint", Value: endpoint}
}
func exportMetrics(addr string) {
prom, err := prometheus.NewPrometheusSink()
if err != nil {
util.Log().Fatalf("building prometheus sink: %v", err)
}
sink := metrics.FanoutSink{prom}
if addr != "" {
util.Log().Infof("Initiating datadog metrics at %q", addr)
ddog, err := datadog.NewDogStatsdSink(addr, "")
if err != nil {
util.Log().Fatalf("error initializing statsd client: %v", err)
}
sink = append(sink, ddog)
}
// Disable hostname tagging, this can be provided by the downstream sink
cfg := metrics.DefaultConfig("kt")
cfg.EnableHostname = false
cfg.EnableHostnameLabel = false
if _, err = metrics.NewGlobal(cfg, sink); err != nil {
util.Log().Fatalf("error initializing metrics : %v", err)
}
metrics.IncrCounterWithLabels([]string{"build_info"}, 1, []metrics.Label{
{Name: "version", Value: Version},
{Name: "goversion", Value: GoVersion},
})
}
func metricsServer(addr string) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/" {
fmt.Fprintln(rw, "Hi, I'm a key transparency metrics and debugging server!")
} else {
rw.WriteHeader(404)
fmt.Fprintln(rw, "404 not found")
}
})
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
mux.HandleFunc("/debug/version", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "Version: %s, GoVersion: %s", Version, GoVersion)
})
srv := &http.Server{
Addr: addr,
Handler: mux,
}
util.Log().Infof("Starting metrics server at: %v", addr)
// go 1.24 requires a constant format string to Printf-like functions
util.Log().Fatalf("%s", srv.ListenAndServe().Error())
}

View File

@ -0,0 +1,228 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.2
// protoc v5.28.3
// source: key_transparency.proto
package pb
import (
pb "github.com/signalapp/keytransparency/tree/transparency/pb"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// AuditRequest comes from a third-party auditor that wishes to sync with the
// latest state of the log.
type AuditRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Start uint64 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
Limit uint64 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"`
}
func (x *AuditRequest) Reset() {
*x = AuditRequest{}
mi := &file_key_transparency_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuditRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuditRequest) ProtoMessage() {}
func (x *AuditRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuditRequest.ProtoReflect.Descriptor instead.
func (*AuditRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_proto_rawDescGZIP(), []int{0}
}
func (x *AuditRequest) GetStart() uint64 {
if x != nil {
return x.Start
}
return 0
}
func (x *AuditRequest) GetLimit() uint64 {
if x != nil {
return x.Limit
}
return 0
}
// AuditResponse contains the list of new changes to the log.
type AuditResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Updates []*pb.AuditorUpdate `protobuf:"bytes,1,rep,name=updates,proto3" json:"updates,omitempty"`
More bool `protobuf:"varint,2,opt,name=more,proto3" json:"more,omitempty"`
}
func (x *AuditResponse) Reset() {
*x = AuditResponse{}
mi := &file_key_transparency_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AuditResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AuditResponse) ProtoMessage() {}
func (x *AuditResponse) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AuditResponse.ProtoReflect.Descriptor instead.
func (*AuditResponse) Descriptor() ([]byte, []int) {
return file_key_transparency_proto_rawDescGZIP(), []int{1}
}
func (x *AuditResponse) GetUpdates() []*pb.AuditorUpdate {
if x != nil {
return x.Updates
}
return nil
}
func (x *AuditResponse) GetMore() bool {
if x != nil {
return x.More
}
return false
}
var File_key_transparency_proto protoreflect.FileDescriptor
var file_key_transparency_proto_rawDesc = []byte{
0x0a, 0x16, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e,
0x63, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x02, 0x6b, 0x74, 0x1a, 0x1b, 0x67, 0x6f,
0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d,
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x12, 0x74, 0x72, 0x61, 0x6e, 0x73,
0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x3a, 0x0a,
0x0c, 0x41, 0x75, 0x64, 0x69, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a,
0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x73, 0x74,
0x61, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x18, 0x02, 0x20, 0x01,
0x28, 0x04, 0x52, 0x05, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x22, 0x5a, 0x0a, 0x0d, 0x41, 0x75, 0x64,
0x69, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x07, 0x75, 0x70,
0x64, 0x61, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x74, 0x72,
0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x41, 0x75, 0x64, 0x69, 0x74,
0x6f, 0x72, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x07, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65,
0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
0x04, 0x6d, 0x6f, 0x72, 0x65, 0x32, 0x93, 0x01, 0x0a, 0x16, 0x4b, 0x65, 0x79, 0x54, 0x72, 0x61,
0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
0x12, 0x2e, 0x0a, 0x05, 0x41, 0x75, 0x64, 0x69, 0x74, 0x12, 0x10, 0x2e, 0x6b, 0x74, 0x2e, 0x41,
0x75, 0x64, 0x69, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x11, 0x2e, 0x6b, 0x74,
0x2e, 0x41, 0x75, 0x64, 0x69, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
0x12, 0x49, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x41, 0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x48, 0x65,
0x61, 0x64, 0x12, 0x1d, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63,
0x79, 0x2e, 0x41, 0x75, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x54, 0x72, 0x65, 0x65, 0x48, 0x65, 0x61,
0x64, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x30, 0x5a, 0x2e, 0x67,
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c,
0x61, 0x70, 0x70, 0x2f, 0x6b, 0x65, 0x79, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65,
0x6e, 0x63, 0x79, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x6b, 0x74, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_key_transparency_proto_rawDescOnce sync.Once
file_key_transparency_proto_rawDescData = file_key_transparency_proto_rawDesc
)
func file_key_transparency_proto_rawDescGZIP() []byte {
file_key_transparency_proto_rawDescOnce.Do(func() {
file_key_transparency_proto_rawDescData = protoimpl.X.CompressGZIP(file_key_transparency_proto_rawDescData)
})
return file_key_transparency_proto_rawDescData
}
var file_key_transparency_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_key_transparency_proto_goTypes = []any{
(*AuditRequest)(nil), // 0: kt.AuditRequest
(*AuditResponse)(nil), // 1: kt.AuditResponse
(*pb.AuditorUpdate)(nil), // 2: transparency.AuditorUpdate
(*pb.AuditorTreeHead)(nil), // 3: transparency.AuditorTreeHead
(*emptypb.Empty)(nil), // 4: google.protobuf.Empty
}
var file_key_transparency_proto_depIdxs = []int32{
2, // 0: kt.AuditResponse.updates:type_name -> transparency.AuditorUpdate
0, // 1: kt.KeyTransparencyService.Audit:input_type -> kt.AuditRequest
3, // 2: kt.KeyTransparencyService.SetAuditorHead:input_type -> transparency.AuditorTreeHead
1, // 3: kt.KeyTransparencyService.Audit:output_type -> kt.AuditResponse
4, // 4: kt.KeyTransparencyService.SetAuditorHead:output_type -> google.protobuf.Empty
3, // [3:5] is the sub-list for method output_type
1, // [1:3] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_key_transparency_proto_init() }
func file_key_transparency_proto_init() {
if File_key_transparency_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_key_transparency_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_key_transparency_proto_goTypes,
DependencyIndexes: file_key_transparency_proto_depIdxs,
MessageInfos: file_key_transparency_proto_msgTypes,
}.Build()
File_key_transparency_proto = out.File
file_key_transparency_proto_rawDesc = nil
file_key_transparency_proto_goTypes = nil
file_key_transparency_proto_depIdxs = nil
}

View File

@ -0,0 +1,34 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
syntax = "proto3";
package kt;
option go_package = "github.com/signalapp/keytransparency/cmd/kt/pb";
import "google/protobuf/empty.proto";
import "transparency.proto";
// AuditRequest comes from a third-party auditor that wishes to sync with the
// latest state of the log.
message AuditRequest {
uint64 start = 1;
uint64 limit = 2;
}
// AuditResponse contains the list of new changes to the log.
message AuditResponse {
repeated transparency.AuditorUpdate updates = 1;
bool more = 2;
}
// A key transparency service used to update the transparency log and to accept auditor-signed tree heads.
// With the exception of the third-party auditor, this service's endpoints are *not* intended to be used by external clients.
// It is exposed to the public internet by necessity but will reject calls from unauthenticated callers.
service KeyTransparencyService {
// Auditors use this endpoint to request a batch of key transparency service updates to audit.
rpc Audit(AuditRequest) returns (AuditResponse) {}
// Auditors use this endpoint to return a signature on the log tree root hash corresponding to the last audited update.
rpc SetAuditorHead(transparency.AuditorTreeHead) returns (google.protobuf.Empty) {}
}

View File

@ -0,0 +1,179 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.28.3
// source: key_transparency.proto
package pb
import (
context "context"
pb "github.com/signalapp/keytransparency/tree/transparency/pb"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
KeyTransparencyService_Audit_FullMethodName = "/kt.KeyTransparencyService/Audit"
KeyTransparencyService_SetAuditorHead_FullMethodName = "/kt.KeyTransparencyService/SetAuditorHead"
)
// KeyTransparencyServiceClient is the client API for KeyTransparencyService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// A key transparency service used to update the transparency log and to accept auditor-signed tree heads.
// With the exception of the third-party auditor, this service's endpoints are *not* intended to be used by external clients.
// It is exposed to the public internet by necessity but will reject calls from unauthenticated callers.
type KeyTransparencyServiceClient interface {
// Auditors use this endpoint to request a batch of key transparency service updates to audit.
Audit(ctx context.Context, in *AuditRequest, opts ...grpc.CallOption) (*AuditResponse, error)
// Auditors use this endpoint to return a signature on the log tree root hash corresponding to the last audited update.
SetAuditorHead(ctx context.Context, in *pb.AuditorTreeHead, opts ...grpc.CallOption) (*emptypb.Empty, error)
}
type keyTransparencyServiceClient struct {
cc grpc.ClientConnInterface
}
func NewKeyTransparencyServiceClient(cc grpc.ClientConnInterface) KeyTransparencyServiceClient {
return &keyTransparencyServiceClient{cc}
}
func (c *keyTransparencyServiceClient) Audit(ctx context.Context, in *AuditRequest, opts ...grpc.CallOption) (*AuditResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AuditResponse)
err := c.cc.Invoke(ctx, KeyTransparencyService_Audit_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *keyTransparencyServiceClient) SetAuditorHead(ctx context.Context, in *pb.AuditorTreeHead, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, KeyTransparencyService_SetAuditorHead_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// KeyTransparencyServiceServer is the server API for KeyTransparencyService service.
// All implementations must embed UnimplementedKeyTransparencyServiceServer
// for forward compatibility.
//
// A key transparency service used to update the transparency log and to accept auditor-signed tree heads.
// With the exception of the third-party auditor, this service's endpoints are *not* intended to be used by external clients.
// It is exposed to the public internet by necessity but will reject calls from unauthenticated callers.
type KeyTransparencyServiceServer interface {
// Auditors use this endpoint to request a batch of key transparency service updates to audit.
Audit(context.Context, *AuditRequest) (*AuditResponse, error)
// Auditors use this endpoint to return a signature on the log tree root hash corresponding to the last audited update.
SetAuditorHead(context.Context, *pb.AuditorTreeHead) (*emptypb.Empty, error)
mustEmbedUnimplementedKeyTransparencyServiceServer()
}
// UnimplementedKeyTransparencyServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedKeyTransparencyServiceServer struct{}
func (UnimplementedKeyTransparencyServiceServer) Audit(context.Context, *AuditRequest) (*AuditResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Audit not implemented")
}
func (UnimplementedKeyTransparencyServiceServer) SetAuditorHead(context.Context, *pb.AuditorTreeHead) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetAuditorHead not implemented")
}
func (UnimplementedKeyTransparencyServiceServer) mustEmbedUnimplementedKeyTransparencyServiceServer() {
}
func (UnimplementedKeyTransparencyServiceServer) testEmbeddedByValue() {}
// UnsafeKeyTransparencyServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to KeyTransparencyServiceServer will
// result in compilation errors.
type UnsafeKeyTransparencyServiceServer interface {
mustEmbedUnimplementedKeyTransparencyServiceServer()
}
func RegisterKeyTransparencyServiceServer(s grpc.ServiceRegistrar, srv KeyTransparencyServiceServer) {
// If the following call pancis, it indicates UnimplementedKeyTransparencyServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&KeyTransparencyService_ServiceDesc, srv)
}
func _KeyTransparencyService_Audit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AuditRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyTransparencyServiceServer).Audit(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KeyTransparencyService_Audit_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyTransparencyServiceServer).Audit(ctx, req.(*AuditRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyTransparencyService_SetAuditorHead_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(pb.AuditorTreeHead)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyTransparencyServiceServer).SetAuditorHead(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KeyTransparencyService_SetAuditorHead_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyTransparencyServiceServer).SetAuditorHead(ctx, req.(*pb.AuditorTreeHead))
}
return interceptor(ctx, in, info, handler)
}
// KeyTransparencyService_ServiceDesc is the grpc.ServiceDesc for KeyTransparencyService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var KeyTransparencyService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "kt.KeyTransparencyService",
HandlerType: (*KeyTransparencyServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Audit",
Handler: _KeyTransparencyService_Audit_Handler,
},
{
MethodName: "SetAuditorHead",
Handler: _KeyTransparencyService_SetAuditorHead_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "key_transparency.proto",
}

View File

@ -0,0 +1,988 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.2
// protoc v5.28.3
// source: key_transparency_query.proto
package pb
import (
pb "github.com/signalapp/keytransparency/tree/transparency/pb"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// DistinguishedRequest looks up the most recent `distinguished` key in the transparency log.
type DistinguishedRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// The tree size of the last verified distinguished request. With the exception of a client's
// very first request, this field should always be populated.
Last *uint64 `protobuf:"varint,1,opt,name=last,proto3,oneof" json:"last,omitempty"`
}
func (x *DistinguishedRequest) Reset() {
*x = DistinguishedRequest{}
mi := &file_key_transparency_query_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DistinguishedRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DistinguishedRequest) ProtoMessage() {}
func (x *DistinguishedRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DistinguishedRequest.ProtoReflect.Descriptor instead.
func (*DistinguishedRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{0}
}
func (x *DistinguishedRequest) GetLast() uint64 {
if x != nil && x.Last != nil {
return *x.Last
}
return 0
}
// DistinguishedResponse contains the tree head and search proof for the most recent `distinguished` key in the log.
type DistinguishedResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
TreeHead *pb.FullTreeHead `protobuf:"bytes,1,opt,name=tree_head,json=treeHead,proto3" json:"tree_head,omitempty"`
Distinguished *CondensedTreeSearchResponse `protobuf:"bytes,2,opt,name=distinguished,proto3" json:"distinguished,omitempty"`
}
func (x *DistinguishedResponse) Reset() {
*x = DistinguishedResponse{}
mi := &file_key_transparency_query_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DistinguishedResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DistinguishedResponse) ProtoMessage() {}
func (x *DistinguishedResponse) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DistinguishedResponse.ProtoReflect.Descriptor instead.
func (*DistinguishedResponse) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{1}
}
func (x *DistinguishedResponse) GetTreeHead() *pb.FullTreeHead {
if x != nil {
return x.TreeHead
}
return nil
}
func (x *DistinguishedResponse) GetDistinguished() *CondensedTreeSearchResponse {
if x != nil {
return x.Distinguished
}
return nil
}
// SearchRequest comes from a user that wishes to look up one or more identifiers in the transparency log.
type SearchRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Aci []byte `protobuf:"bytes,1,opt,name=aci,proto3" json:"aci,omitempty"`
AciIdentityKey []byte `protobuf:"bytes,2,opt,name=aci_identity_key,json=aciIdentityKey,proto3" json:"aci_identity_key,omitempty"`
UsernameHash []byte `protobuf:"bytes,3,opt,name=username_hash,json=usernameHash,proto3,oneof" json:"username_hash,omitempty"`
E164SearchRequest *E164SearchRequest `protobuf:"bytes,4,opt,name=e164_search_request,json=e164SearchRequest,proto3,oneof" json:"e164_search_request,omitempty"`
Consistency *pb.Consistency `protobuf:"bytes,5,opt,name=consistency,proto3" json:"consistency,omitempty"`
}
func (x *SearchRequest) Reset() {
*x = SearchRequest{}
mi := &file_key_transparency_query_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SearchRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchRequest) ProtoMessage() {}
func (x *SearchRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SearchRequest.ProtoReflect.Descriptor instead.
func (*SearchRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{2}
}
func (x *SearchRequest) GetAci() []byte {
if x != nil {
return x.Aci
}
return nil
}
func (x *SearchRequest) GetAciIdentityKey() []byte {
if x != nil {
return x.AciIdentityKey
}
return nil
}
func (x *SearchRequest) GetUsernameHash() []byte {
if x != nil {
return x.UsernameHash
}
return nil
}
func (x *SearchRequest) GetE164SearchRequest() *E164SearchRequest {
if x != nil {
return x.E164SearchRequest
}
return nil
}
func (x *SearchRequest) GetConsistency() *pb.Consistency {
if x != nil {
return x.Consistency
}
return nil
}
// E164SearchRequest contains the data that the user must provide when looking up an E164
type E164SearchRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
E164 *string `protobuf:"bytes,1,opt,name=e164,proto3,oneof" json:"e164,omitempty"`
UnidentifiedAccessKey []byte `protobuf:"bytes,2,opt,name=unidentified_access_key,json=unidentifiedAccessKey,proto3" json:"unidentified_access_key,omitempty"`
}
func (x *E164SearchRequest) Reset() {
*x = E164SearchRequest{}
mi := &file_key_transparency_query_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *E164SearchRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*E164SearchRequest) ProtoMessage() {}
func (x *E164SearchRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use E164SearchRequest.ProtoReflect.Descriptor instead.
func (*E164SearchRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{3}
}
func (x *E164SearchRequest) GetE164() string {
if x != nil && x.E164 != nil {
return *x.E164
}
return ""
}
func (x *E164SearchRequest) GetUnidentifiedAccessKey() []byte {
if x != nil {
return x.UnidentifiedAccessKey
}
return nil
}
// CondensedTreeSearchResponse contains the search proof and other data for a given identifier.
// It is used in the SearchResponse protobuf which is returned to external clients.
type CondensedTreeSearchResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
VrfProof []byte `protobuf:"bytes,1,opt,name=vrf_proof,json=vrfProof,proto3" json:"vrf_proof,omitempty"`
Search *pb.SearchProof `protobuf:"bytes,2,opt,name=search,proto3" json:"search,omitempty"`
Opening []byte `protobuf:"bytes,3,opt,name=opening,proto3" json:"opening,omitempty"`
Value *pb.UpdateValue `protobuf:"bytes,4,opt,name=value,proto3" json:"value,omitempty"`
}
func (x *CondensedTreeSearchResponse) Reset() {
*x = CondensedTreeSearchResponse{}
mi := &file_key_transparency_query_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CondensedTreeSearchResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CondensedTreeSearchResponse) ProtoMessage() {}
func (x *CondensedTreeSearchResponse) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CondensedTreeSearchResponse.ProtoReflect.Descriptor instead.
func (*CondensedTreeSearchResponse) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{4}
}
func (x *CondensedTreeSearchResponse) GetVrfProof() []byte {
if x != nil {
return x.VrfProof
}
return nil
}
func (x *CondensedTreeSearchResponse) GetSearch() *pb.SearchProof {
if x != nil {
return x.Search
}
return nil
}
func (x *CondensedTreeSearchResponse) GetOpening() []byte {
if x != nil {
return x.Opening
}
return nil
}
func (x *CondensedTreeSearchResponse) GetValue() *pb.UpdateValue {
if x != nil {
return x.Value
}
return nil
}
// SearchResponse contains search proofs for each of the requested identifiers.
type SearchResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
TreeHead *pb.FullTreeHead `protobuf:"bytes,1,opt,name=tree_head,json=treeHead,proto3" json:"tree_head,omitempty"`
Aci *CondensedTreeSearchResponse `protobuf:"bytes,2,opt,name=aci,proto3" json:"aci,omitempty"`
E164 *CondensedTreeSearchResponse `protobuf:"bytes,3,opt,name=e164,proto3,oneof" json:"e164,omitempty"`
UsernameHash *CondensedTreeSearchResponse `protobuf:"bytes,4,opt,name=username_hash,json=usernameHash,proto3,oneof" json:"username_hash,omitempty"`
}
func (x *SearchResponse) Reset() {
*x = SearchResponse{}
mi := &file_key_transparency_query_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SearchResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SearchResponse) ProtoMessage() {}
func (x *SearchResponse) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SearchResponse.ProtoReflect.Descriptor instead.
func (*SearchResponse) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{5}
}
func (x *SearchResponse) GetTreeHead() *pb.FullTreeHead {
if x != nil {
return x.TreeHead
}
return nil
}
func (x *SearchResponse) GetAci() *CondensedTreeSearchResponse {
if x != nil {
return x.Aci
}
return nil
}
func (x *SearchResponse) GetE164() *CondensedTreeSearchResponse {
if x != nil {
return x.E164
}
return nil
}
func (x *SearchResponse) GetUsernameHash() *CondensedTreeSearchResponse {
if x != nil {
return x.UsernameHash
}
return nil
}
type MonitorRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Aci *AciMonitorRequest `protobuf:"bytes,1,opt,name=aci,proto3" json:"aci,omitempty"`
UsernameHash *UsernameHashMonitorRequest `protobuf:"bytes,2,opt,name=username_hash,json=usernameHash,proto3,oneof" json:"username_hash,omitempty"`
E164 *E164MonitorRequest `protobuf:"bytes,3,opt,name=e164,proto3,oneof" json:"e164,omitempty"`
Consistency *pb.Consistency `protobuf:"bytes,4,opt,name=consistency,proto3" json:"consistency,omitempty"`
}
func (x *MonitorRequest) Reset() {
*x = MonitorRequest{}
mi := &file_key_transparency_query_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MonitorRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MonitorRequest) ProtoMessage() {}
func (x *MonitorRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MonitorRequest.ProtoReflect.Descriptor instead.
func (*MonitorRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{6}
}
func (x *MonitorRequest) GetAci() *AciMonitorRequest {
if x != nil {
return x.Aci
}
return nil
}
func (x *MonitorRequest) GetUsernameHash() *UsernameHashMonitorRequest {
if x != nil {
return x.UsernameHash
}
return nil
}
func (x *MonitorRequest) GetE164() *E164MonitorRequest {
if x != nil {
return x.E164
}
return nil
}
func (x *MonitorRequest) GetConsistency() *pb.Consistency {
if x != nil {
return x.Consistency
}
return nil
}
type AciMonitorRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Aci []byte `protobuf:"bytes,1,opt,name=aci,proto3" json:"aci,omitempty"`
EntryPosition uint64 `protobuf:"varint,2,opt,name=entry_position,json=entryPosition,proto3" json:"entry_position,omitempty"`
CommitmentIndex []byte `protobuf:"bytes,3,opt,name=commitment_index,json=commitmentIndex,proto3" json:"commitment_index,omitempty"`
}
func (x *AciMonitorRequest) Reset() {
*x = AciMonitorRequest{}
mi := &file_key_transparency_query_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AciMonitorRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AciMonitorRequest) ProtoMessage() {}
func (x *AciMonitorRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AciMonitorRequest.ProtoReflect.Descriptor instead.
func (*AciMonitorRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{7}
}
func (x *AciMonitorRequest) GetAci() []byte {
if x != nil {
return x.Aci
}
return nil
}
func (x *AciMonitorRequest) GetEntryPosition() uint64 {
if x != nil {
return x.EntryPosition
}
return 0
}
func (x *AciMonitorRequest) GetCommitmentIndex() []byte {
if x != nil {
return x.CommitmentIndex
}
return nil
}
type UsernameHashMonitorRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
UsernameHash []byte `protobuf:"bytes,1,opt,name=username_hash,json=usernameHash,proto3" json:"username_hash,omitempty"`
EntryPosition uint64 `protobuf:"varint,2,opt,name=entry_position,json=entryPosition,proto3" json:"entry_position,omitempty"`
CommitmentIndex []byte `protobuf:"bytes,3,opt,name=commitment_index,json=commitmentIndex,proto3" json:"commitment_index,omitempty"`
}
func (x *UsernameHashMonitorRequest) Reset() {
*x = UsernameHashMonitorRequest{}
mi := &file_key_transparency_query_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UsernameHashMonitorRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UsernameHashMonitorRequest) ProtoMessage() {}
func (x *UsernameHashMonitorRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UsernameHashMonitorRequest.ProtoReflect.Descriptor instead.
func (*UsernameHashMonitorRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{8}
}
func (x *UsernameHashMonitorRequest) GetUsernameHash() []byte {
if x != nil {
return x.UsernameHash
}
return nil
}
func (x *UsernameHashMonitorRequest) GetEntryPosition() uint64 {
if x != nil {
return x.EntryPosition
}
return 0
}
func (x *UsernameHashMonitorRequest) GetCommitmentIndex() []byte {
if x != nil {
return x.CommitmentIndex
}
return nil
}
type E164MonitorRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
E164 *string `protobuf:"bytes,1,opt,name=e164,proto3,oneof" json:"e164,omitempty"`
EntryPosition uint64 `protobuf:"varint,2,opt,name=entry_position,json=entryPosition,proto3" json:"entry_position,omitempty"`
CommitmentIndex []byte `protobuf:"bytes,3,opt,name=commitment_index,json=commitmentIndex,proto3" json:"commitment_index,omitempty"`
}
func (x *E164MonitorRequest) Reset() {
*x = E164MonitorRequest{}
mi := &file_key_transparency_query_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *E164MonitorRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*E164MonitorRequest) ProtoMessage() {}
func (x *E164MonitorRequest) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use E164MonitorRequest.ProtoReflect.Descriptor instead.
func (*E164MonitorRequest) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{9}
}
func (x *E164MonitorRequest) GetE164() string {
if x != nil && x.E164 != nil {
return *x.E164
}
return ""
}
func (x *E164MonitorRequest) GetEntryPosition() uint64 {
if x != nil {
return x.EntryPosition
}
return 0
}
func (x *E164MonitorRequest) GetCommitmentIndex() []byte {
if x != nil {
return x.CommitmentIndex
}
return nil
}
type MonitorResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
TreeHead *pb.FullTreeHead `protobuf:"bytes,1,opt,name=tree_head,json=treeHead,proto3" json:"tree_head,omitempty"`
Aci *pb.MonitorProof `protobuf:"bytes,2,opt,name=aci,proto3" json:"aci,omitempty"`
UsernameHash *pb.MonitorProof `protobuf:"bytes,3,opt,name=username_hash,json=usernameHash,proto3,oneof" json:"username_hash,omitempty"`
E164 *pb.MonitorProof `protobuf:"bytes,4,opt,name=e164,proto3,oneof" json:"e164,omitempty"`
Inclusion [][]byte `protobuf:"bytes,5,rep,name=inclusion,proto3" json:"inclusion,omitempty"`
}
func (x *MonitorResponse) Reset() {
*x = MonitorResponse{}
mi := &file_key_transparency_query_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MonitorResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MonitorResponse) ProtoMessage() {}
func (x *MonitorResponse) ProtoReflect() protoreflect.Message {
mi := &file_key_transparency_query_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MonitorResponse.ProtoReflect.Descriptor instead.
func (*MonitorResponse) Descriptor() ([]byte, []int) {
return file_key_transparency_query_proto_rawDescGZIP(), []int{10}
}
func (x *MonitorResponse) GetTreeHead() *pb.FullTreeHead {
if x != nil {
return x.TreeHead
}
return nil
}
func (x *MonitorResponse) GetAci() *pb.MonitorProof {
if x != nil {
return x.Aci
}
return nil
}
func (x *MonitorResponse) GetUsernameHash() *pb.MonitorProof {
if x != nil {
return x.UsernameHash
}
return nil
}
func (x *MonitorResponse) GetE164() *pb.MonitorProof {
if x != nil {
return x.E164
}
return nil
}
func (x *MonitorResponse) GetInclusion() [][]byte {
if x != nil {
return x.Inclusion
}
return nil
}
var File_key_transparency_query_proto protoreflect.FileDescriptor
var file_key_transparency_query_proto_rawDesc = []byte{
0x0a, 0x1c, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e,
0x63, 0x79, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08,
0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x1a, 0x12, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70,
0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x38, 0x0a, 0x14,
0x44, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x04, 0x6c, 0x61, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01,
0x28, 0x04, 0x48, 0x00, 0x52, 0x04, 0x6c, 0x61, 0x73, 0x74, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a,
0x05, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x22, 0x9d, 0x01, 0x0a, 0x15, 0x44, 0x69, 0x73, 0x74, 0x69,
0x6e, 0x67, 0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
0x12, 0x37, 0x0a, 0x09, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e,
0x63, 0x79, 0x2e, 0x46, 0x75, 0x6c, 0x6c, 0x54, 0x72, 0x65, 0x65, 0x48, 0x65, 0x61, 0x64, 0x52,
0x08, 0x74, 0x72, 0x65, 0x65, 0x48, 0x65, 0x61, 0x64, 0x12, 0x4b, 0x0a, 0x0d, 0x64, 0x69, 0x73,
0x74, 0x69, 0x6e, 0x67, 0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x25, 0x2e, 0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x43, 0x6f, 0x6e, 0x64,
0x65, 0x6e, 0x73, 0x65, 0x64, 0x54, 0x72, 0x65, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67,
0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x22, 0xae, 0x02, 0x0a, 0x0d, 0x53, 0x65, 0x61, 0x72, 0x63,
0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x63, 0x69, 0x18,
0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x61, 0x63, 0x69, 0x12, 0x28, 0x0a, 0x10, 0x61, 0x63,
0x69, 0x5f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x02,
0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x61, 0x63, 0x69, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x74,
0x79, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65,
0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x0c, 0x75,
0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x48, 0x61, 0x73, 0x68, 0x88, 0x01, 0x01, 0x12, 0x50,
0x0a, 0x13, 0x65, 0x31, 0x36, 0x34, 0x5f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x72, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6b, 0x74,
0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x45, 0x31, 0x36, 0x34, 0x53, 0x65, 0x61, 0x72, 0x63,
0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x01, 0x52, 0x11, 0x65, 0x31, 0x36, 0x34,
0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x88, 0x01, 0x01,
0x12, 0x3b, 0x0a, 0x0b, 0x63, 0x6f, 0x6e, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18,
0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72,
0x65, 0x6e, 0x63, 0x79, 0x2e, 0x43, 0x6f, 0x6e, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x79,
0x52, 0x0b, 0x63, 0x6f, 0x6e, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x10, 0x0a,
0x0e, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x42,
0x16, 0x0a, 0x14, 0x5f, 0x65, 0x31, 0x36, 0x34, 0x5f, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f,
0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x6d, 0x0a, 0x11, 0x45, 0x31, 0x36, 0x34, 0x53,
0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x04,
0x65, 0x31, 0x36, 0x34, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x65, 0x31,
0x36, 0x34, 0x88, 0x01, 0x01, 0x12, 0x36, 0x0a, 0x17, 0x75, 0x6e, 0x69, 0x64, 0x65, 0x6e, 0x74,
0x69, 0x66, 0x69, 0x65, 0x64, 0x5f, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x6b, 0x65, 0x79,
0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x15, 0x75, 0x6e, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69,
0x66, 0x69, 0x65, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4b, 0x65, 0x79, 0x42, 0x07, 0x0a,
0x05, 0x5f, 0x65, 0x31, 0x36, 0x34, 0x22, 0xb8, 0x01, 0x0a, 0x1b, 0x43, 0x6f, 0x6e, 0x64, 0x65,
0x6e, 0x73, 0x65, 0x64, 0x54, 0x72, 0x65, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x76, 0x72, 0x66, 0x5f, 0x70, 0x72,
0x6f, 0x6f, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x76, 0x72, 0x66, 0x50, 0x72,
0x6f, 0x6f, 0x66, 0x12, 0x31, 0x0a, 0x06, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x18, 0x02, 0x20,
0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e,
0x63, 0x79, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x52, 0x06,
0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x65, 0x6e, 0x69, 0x6e,
0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x6f, 0x70, 0x65, 0x6e, 0x69, 0x6e, 0x67,
0x12, 0x2f, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x19, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75,
0x65, 0x22, 0xae, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x37, 0x0a, 0x09, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x68, 0x65, 0x61,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70,
0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x46, 0x75, 0x6c, 0x6c, 0x54, 0x72, 0x65, 0x65, 0x48,
0x65, 0x61, 0x64, 0x52, 0x08, 0x74, 0x72, 0x65, 0x65, 0x48, 0x65, 0x61, 0x64, 0x12, 0x37, 0x0a,
0x03, 0x61, 0x63, 0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x74, 0x5f,
0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x65, 0x6e, 0x73, 0x65, 0x64, 0x54,
0x72, 0x65, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x52, 0x03, 0x61, 0x63, 0x69, 0x12, 0x3e, 0x0a, 0x04, 0x65, 0x31, 0x36, 0x34, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e,
0x43, 0x6f, 0x6e, 0x64, 0x65, 0x6e, 0x73, 0x65, 0x64, 0x54, 0x72, 0x65, 0x65, 0x53, 0x65, 0x61,
0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x48, 0x00, 0x52, 0x04, 0x65,
0x31, 0x36, 0x34, 0x88, 0x01, 0x01, 0x12, 0x4f, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61,
0x6d, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e,
0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x43, 0x6f, 0x6e, 0x64, 0x65, 0x6e, 0x73,
0x65, 0x64, 0x54, 0x72, 0x65, 0x65, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x48, 0x01, 0x52, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65,
0x48, 0x61, 0x73, 0x68, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x65, 0x31, 0x36, 0x34,
0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x68, 0x61,
0x73, 0x68, 0x22, 0x9e, 0x02, 0x0a, 0x0e, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2d, 0x0a, 0x03, 0x61, 0x63, 0x69, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x41, 0x63,
0x69, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52,
0x03, 0x61, 0x63, 0x69, 0x12, 0x4e, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65,
0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6b, 0x74,
0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x48,
0x61, 0x73, 0x68, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x48, 0x00, 0x52, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x48, 0x61, 0x73,
0x68, 0x88, 0x01, 0x01, 0x12, 0x35, 0x0a, 0x04, 0x65, 0x31, 0x36, 0x34, 0x18, 0x03, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x45, 0x31,
0x36, 0x34, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x48, 0x01, 0x52, 0x04, 0x65, 0x31, 0x36, 0x34, 0x88, 0x01, 0x01, 0x12, 0x3b, 0x0a, 0x0b, 0x63,
0x6f, 0x6e, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b,
0x32, 0x19, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e,
0x43, 0x6f, 0x6e, 0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x52, 0x0b, 0x63, 0x6f, 0x6e,
0x73, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x65,
0x31, 0x36, 0x34, 0x22, 0x77, 0x0a, 0x11, 0x41, 0x63, 0x69, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f,
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x63, 0x69, 0x18,
0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x03, 0x61, 0x63, 0x69, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6e,
0x74, 0x72, 0x79, 0x5f, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
0x28, 0x04, 0x52, 0x0d, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f,
0x6e, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f,
0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x63, 0x6f, 0x6d,
0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x22, 0x93, 0x01, 0x0a,
0x1a, 0x55, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x48, 0x61, 0x73, 0x68, 0x4d, 0x6f, 0x6e,
0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x75,
0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0c, 0x52, 0x0c, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x48, 0x61, 0x73, 0x68,
0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x50,
0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6f, 0x6d, 0x6d, 0x69,
0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20, 0x01, 0x28,
0x0c, 0x52, 0x0f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x64,
0x65, 0x78, 0x22, 0x88, 0x01, 0x0a, 0x12, 0x45, 0x31, 0x36, 0x34, 0x4d, 0x6f, 0x6e, 0x69, 0x74,
0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x04, 0x65, 0x31, 0x36,
0x34, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x65, 0x31, 0x36, 0x34, 0x88,
0x01, 0x01, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x70, 0x6f, 0x73, 0x69,
0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x65, 0x6e, 0x74, 0x72,
0x79, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x29, 0x0a, 0x10, 0x63, 0x6f, 0x6d,
0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x03, 0x20,
0x01, 0x28, 0x0c, 0x52, 0x0f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x49,
0x6e, 0x64, 0x65, 0x78, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x65, 0x31, 0x36, 0x34, 0x22, 0xac, 0x02,
0x0a, 0x0f, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x37, 0x0a, 0x09, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65,
0x6e, 0x63, 0x79, 0x2e, 0x46, 0x75, 0x6c, 0x6c, 0x54, 0x72, 0x65, 0x65, 0x48, 0x65, 0x61, 0x64,
0x52, 0x08, 0x74, 0x72, 0x65, 0x65, 0x48, 0x65, 0x61, 0x64, 0x12, 0x2c, 0x0a, 0x03, 0x61, 0x63,
0x69, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70,
0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x50, 0x72,
0x6f, 0x6f, 0x66, 0x52, 0x03, 0x61, 0x63, 0x69, 0x12, 0x44, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x72,
0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x1a, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x4d,
0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x48, 0x00, 0x52, 0x0c, 0x75,
0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x48, 0x61, 0x73, 0x68, 0x88, 0x01, 0x01, 0x12, 0x33,
0x0a, 0x04, 0x65, 0x31, 0x36, 0x34, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x74,
0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x4d, 0x6f, 0x6e, 0x69,
0x74, 0x6f, 0x72, 0x50, 0x72, 0x6f, 0x6f, 0x66, 0x48, 0x01, 0x52, 0x04, 0x65, 0x31, 0x36, 0x34,
0x88, 0x01, 0x01, 0x12, 0x1c, 0x0a, 0x09, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x73, 0x69, 0x6f, 0x6e,
0x18, 0x05, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x09, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x73, 0x69, 0x6f,
0x6e, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x68,
0x61, 0x73, 0x68, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x65, 0x31, 0x36, 0x34, 0x32, 0xf2, 0x01, 0x0a,
0x1b, 0x4b, 0x65, 0x79, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79,
0x51, 0x75, 0x65, 0x72, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x0d,
0x44, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x12, 0x1e, 0x2e,
0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x44, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67,
0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e,
0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x44, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67,
0x75, 0x69, 0x73, 0x68, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
0x12, 0x3d, 0x0a, 0x06, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x12, 0x17, 0x2e, 0x6b, 0x74, 0x5f,
0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x53,
0x65, 0x61, 0x72, 0x63, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
0x40, 0x0a, 0x07, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x12, 0x18, 0x2e, 0x6b, 0x74, 0x5f,
0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x6b, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e,
0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
0x00, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x61, 0x70, 0x70, 0x2f, 0x6b, 0x65, 0x79, 0x74, 0x72, 0x61,
0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x6b, 0x74,
0x2d, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x33,
}
var (
file_key_transparency_query_proto_rawDescOnce sync.Once
file_key_transparency_query_proto_rawDescData = file_key_transparency_query_proto_rawDesc
)
func file_key_transparency_query_proto_rawDescGZIP() []byte {
file_key_transparency_query_proto_rawDescOnce.Do(func() {
file_key_transparency_query_proto_rawDescData = protoimpl.X.CompressGZIP(file_key_transparency_query_proto_rawDescData)
})
return file_key_transparency_query_proto_rawDescData
}
var file_key_transparency_query_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_key_transparency_query_proto_goTypes = []any{
(*DistinguishedRequest)(nil), // 0: kt_query.DistinguishedRequest
(*DistinguishedResponse)(nil), // 1: kt_query.DistinguishedResponse
(*SearchRequest)(nil), // 2: kt_query.SearchRequest
(*E164SearchRequest)(nil), // 3: kt_query.E164SearchRequest
(*CondensedTreeSearchResponse)(nil), // 4: kt_query.CondensedTreeSearchResponse
(*SearchResponse)(nil), // 5: kt_query.SearchResponse
(*MonitorRequest)(nil), // 6: kt_query.MonitorRequest
(*AciMonitorRequest)(nil), // 7: kt_query.AciMonitorRequest
(*UsernameHashMonitorRequest)(nil), // 8: kt_query.UsernameHashMonitorRequest
(*E164MonitorRequest)(nil), // 9: kt_query.E164MonitorRequest
(*MonitorResponse)(nil), // 10: kt_query.MonitorResponse
(*pb.FullTreeHead)(nil), // 11: transparency.FullTreeHead
(*pb.Consistency)(nil), // 12: transparency.Consistency
(*pb.SearchProof)(nil), // 13: transparency.SearchProof
(*pb.UpdateValue)(nil), // 14: transparency.UpdateValue
(*pb.MonitorProof)(nil), // 15: transparency.MonitorProof
}
var file_key_transparency_query_proto_depIdxs = []int32{
11, // 0: kt_query.DistinguishedResponse.tree_head:type_name -> transparency.FullTreeHead
4, // 1: kt_query.DistinguishedResponse.distinguished:type_name -> kt_query.CondensedTreeSearchResponse
3, // 2: kt_query.SearchRequest.e164_search_request:type_name -> kt_query.E164SearchRequest
12, // 3: kt_query.SearchRequest.consistency:type_name -> transparency.Consistency
13, // 4: kt_query.CondensedTreeSearchResponse.search:type_name -> transparency.SearchProof
14, // 5: kt_query.CondensedTreeSearchResponse.value:type_name -> transparency.UpdateValue
11, // 6: kt_query.SearchResponse.tree_head:type_name -> transparency.FullTreeHead
4, // 7: kt_query.SearchResponse.aci:type_name -> kt_query.CondensedTreeSearchResponse
4, // 8: kt_query.SearchResponse.e164:type_name -> kt_query.CondensedTreeSearchResponse
4, // 9: kt_query.SearchResponse.username_hash:type_name -> kt_query.CondensedTreeSearchResponse
7, // 10: kt_query.MonitorRequest.aci:type_name -> kt_query.AciMonitorRequest
8, // 11: kt_query.MonitorRequest.username_hash:type_name -> kt_query.UsernameHashMonitorRequest
9, // 12: kt_query.MonitorRequest.e164:type_name -> kt_query.E164MonitorRequest
12, // 13: kt_query.MonitorRequest.consistency:type_name -> transparency.Consistency
11, // 14: kt_query.MonitorResponse.tree_head:type_name -> transparency.FullTreeHead
15, // 15: kt_query.MonitorResponse.aci:type_name -> transparency.MonitorProof
15, // 16: kt_query.MonitorResponse.username_hash:type_name -> transparency.MonitorProof
15, // 17: kt_query.MonitorResponse.e164:type_name -> transparency.MonitorProof
0, // 18: kt_query.KeyTransparencyQueryService.Distinguished:input_type -> kt_query.DistinguishedRequest
2, // 19: kt_query.KeyTransparencyQueryService.Search:input_type -> kt_query.SearchRequest
6, // 20: kt_query.KeyTransparencyQueryService.Monitor:input_type -> kt_query.MonitorRequest
1, // 21: kt_query.KeyTransparencyQueryService.Distinguished:output_type -> kt_query.DistinguishedResponse
5, // 22: kt_query.KeyTransparencyQueryService.Search:output_type -> kt_query.SearchResponse
10, // 23: kt_query.KeyTransparencyQueryService.Monitor:output_type -> kt_query.MonitorResponse
21, // [21:24] is the sub-list for method output_type
18, // [18:21] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
}
func init() { file_key_transparency_query_proto_init() }
func file_key_transparency_query_proto_init() {
if File_key_transparency_query_proto != nil {
return
}
file_key_transparency_query_proto_msgTypes[0].OneofWrappers = []any{}
file_key_transparency_query_proto_msgTypes[2].OneofWrappers = []any{}
file_key_transparency_query_proto_msgTypes[3].OneofWrappers = []any{}
file_key_transparency_query_proto_msgTypes[5].OneofWrappers = []any{}
file_key_transparency_query_proto_msgTypes[6].OneofWrappers = []any{}
file_key_transparency_query_proto_msgTypes[9].OneofWrappers = []any{}
file_key_transparency_query_proto_msgTypes[10].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_key_transparency_query_proto_rawDesc,
NumEnums: 0,
NumMessages: 11,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_key_transparency_query_proto_goTypes,
DependencyIndexes: file_key_transparency_query_proto_depIdxs,
MessageInfos: file_key_transparency_query_proto_msgTypes,
}.Build()
File_key_transparency_query_proto = out.File
file_key_transparency_query_proto_rawDesc = nil
file_key_transparency_query_proto_goTypes = nil
file_key_transparency_query_proto_depIdxs = nil
}

View File

@ -0,0 +1,108 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
syntax = "proto3";
package kt_query;
option go_package = "github.com/signalapp/keytransparency/cmd/kt-query/pb";
import "transparency.proto";
// An external-facing, read-only key transparency service used by Signal's chat server
// to look up and monitor identifiers.
// There are three types of identifier mappings stored by the key transparency log:
// - An ACI which maps to an ACI identity key
// - An E164-formatted phone number which maps to an ACI
// - A username hash which also maps to an ACI
// Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.
// Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.
service KeyTransparencyQueryService {
// An endpoint used by clients to look up the most recent `distinguished` key.
rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}
// An endpoint used by clients to search for the given identifiers in the transparency log.
// The server returns proof that the requested identifiers exist in the log.
rpc Search(SearchRequest) returns (SearchResponse) {}
// An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
// constructed correctly in later entries for those identifiers.
rpc Monitor(MonitorRequest) returns (MonitorResponse) {}
}
// DistinguishedRequest looks up the most recent `distinguished` key in the transparency log.
message DistinguishedRequest {
// The tree size of the last verified distinguished request. With the exception of a client's
// very first request, this field should always be populated.
optional uint64 last = 1;
}
// DistinguishedResponse contains the tree head and search proof for the most recent `distinguished` key in the log.
message DistinguishedResponse {
transparency.FullTreeHead tree_head = 1;
CondensedTreeSearchResponse distinguished = 2;
}
// SearchRequest comes from a user that wishes to look up one or more identifiers in the transparency log.
message SearchRequest {
bytes aci = 1;
bytes aci_identity_key = 2;
optional bytes username_hash = 3;
optional E164SearchRequest e164_search_request = 4;
transparency.Consistency consistency = 5;
}
// E164SearchRequest contains the data that the user must provide when looking up an E164
message E164SearchRequest {
optional string e164 = 1;
bytes unidentified_access_key = 2;
}
// CondensedTreeSearchResponse contains the search proof and other data for a given identifier.
// It is used in the SearchResponse protobuf which is returned to external clients.
message CondensedTreeSearchResponse {
bytes vrf_proof = 1;
transparency.SearchProof search = 2;
bytes opening = 3;
transparency.UpdateValue value = 4;
}
// SearchResponse contains search proofs for each of the requested identifiers.
message SearchResponse {
transparency.FullTreeHead tree_head = 1;
CondensedTreeSearchResponse aci = 2;
optional CondensedTreeSearchResponse e164 = 3;
optional CondensedTreeSearchResponse username_hash = 4;
}
message MonitorRequest {
AciMonitorRequest aci = 1;
optional UsernameHashMonitorRequest username_hash = 2;
optional E164MonitorRequest e164 = 3;
transparency.Consistency consistency = 4;
}
message AciMonitorRequest {
bytes aci = 1;
uint64 entry_position = 2;
bytes commitment_index = 3;
}
message UsernameHashMonitorRequest {
bytes username_hash = 1;
uint64 entry_position = 2;
bytes commitment_index = 3;
}
message E164MonitorRequest {
optional string e164 = 1;
uint64 entry_position = 2;
bytes commitment_index = 3;
}
message MonitorResponse {
transparency.FullTreeHead tree_head = 1;
transparency.MonitorProof aci = 2;
optional transparency.MonitorProof username_hash = 3;
optional transparency.MonitorProof e164 = 4;
repeated bytes inclusion = 5;
}

View File

@ -0,0 +1,231 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.28.3
// source: key_transparency_query.proto
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
KeyTransparencyQueryService_Distinguished_FullMethodName = "/kt_query.KeyTransparencyQueryService/Distinguished"
KeyTransparencyQueryService_Search_FullMethodName = "/kt_query.KeyTransparencyQueryService/Search"
KeyTransparencyQueryService_Monitor_FullMethodName = "/kt_query.KeyTransparencyQueryService/Monitor"
)
// KeyTransparencyQueryServiceClient is the client API for KeyTransparencyQueryService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// An external-facing, read-only key transparency service used by Signal's chat server
// to look up and monitor identifiers.
// There are three types of identifier mappings stored by the key transparency log:
// - An ACI which maps to an ACI identity key
// - An E164-formatted phone number which maps to an ACI
// - A username hash which also maps to an ACI
// Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.
// Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.
type KeyTransparencyQueryServiceClient interface {
// An endpoint used by clients to look up the most recent `distinguished` key.
Distinguished(ctx context.Context, in *DistinguishedRequest, opts ...grpc.CallOption) (*DistinguishedResponse, error)
// An endpoint used by clients to search for the given identifiers in the transparency log.
// The server returns proof that the requested identifiers exist in the log.
Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error)
// An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
// constructed correctly in later entries for those identifiers.
Monitor(ctx context.Context, in *MonitorRequest, opts ...grpc.CallOption) (*MonitorResponse, error)
}
type keyTransparencyQueryServiceClient struct {
cc grpc.ClientConnInterface
}
func NewKeyTransparencyQueryServiceClient(cc grpc.ClientConnInterface) KeyTransparencyQueryServiceClient {
return &keyTransparencyQueryServiceClient{cc}
}
func (c *keyTransparencyQueryServiceClient) Distinguished(ctx context.Context, in *DistinguishedRequest, opts ...grpc.CallOption) (*DistinguishedResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DistinguishedResponse)
err := c.cc.Invoke(ctx, KeyTransparencyQueryService_Distinguished_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *keyTransparencyQueryServiceClient) Search(ctx context.Context, in *SearchRequest, opts ...grpc.CallOption) (*SearchResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SearchResponse)
err := c.cc.Invoke(ctx, KeyTransparencyQueryService_Search_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *keyTransparencyQueryServiceClient) Monitor(ctx context.Context, in *MonitorRequest, opts ...grpc.CallOption) (*MonitorResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MonitorResponse)
err := c.cc.Invoke(ctx, KeyTransparencyQueryService_Monitor_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// KeyTransparencyQueryServiceServer is the server API for KeyTransparencyQueryService service.
// All implementations must embed UnimplementedKeyTransparencyQueryServiceServer
// for forward compatibility.
//
// An external-facing, read-only key transparency service used by Signal's chat server
// to look up and monitor identifiers.
// There are three types of identifier mappings stored by the key transparency log:
// - An ACI which maps to an ACI identity key
// - An E164-formatted phone number which maps to an ACI
// - A username hash which also maps to an ACI
// Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.
// Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.
type KeyTransparencyQueryServiceServer interface {
// An endpoint used by clients to look up the most recent `distinguished` key.
Distinguished(context.Context, *DistinguishedRequest) (*DistinguishedResponse, error)
// An endpoint used by clients to search for the given identifiers in the transparency log.
// The server returns proof that the requested identifiers exist in the log.
Search(context.Context, *SearchRequest) (*SearchResponse, error)
// An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be
// constructed correctly in later entries for those identifiers.
Monitor(context.Context, *MonitorRequest) (*MonitorResponse, error)
mustEmbedUnimplementedKeyTransparencyQueryServiceServer()
}
// UnimplementedKeyTransparencyQueryServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedKeyTransparencyQueryServiceServer struct{}
func (UnimplementedKeyTransparencyQueryServiceServer) Distinguished(context.Context, *DistinguishedRequest) (*DistinguishedResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Distinguished not implemented")
}
func (UnimplementedKeyTransparencyQueryServiceServer) Search(context.Context, *SearchRequest) (*SearchResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Search not implemented")
}
func (UnimplementedKeyTransparencyQueryServiceServer) Monitor(context.Context, *MonitorRequest) (*MonitorResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Monitor not implemented")
}
func (UnimplementedKeyTransparencyQueryServiceServer) mustEmbedUnimplementedKeyTransparencyQueryServiceServer() {
}
func (UnimplementedKeyTransparencyQueryServiceServer) testEmbeddedByValue() {}
// UnsafeKeyTransparencyQueryServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to KeyTransparencyQueryServiceServer will
// result in compilation errors.
type UnsafeKeyTransparencyQueryServiceServer interface {
mustEmbedUnimplementedKeyTransparencyQueryServiceServer()
}
func RegisterKeyTransparencyQueryServiceServer(s grpc.ServiceRegistrar, srv KeyTransparencyQueryServiceServer) {
// If the following call pancis, it indicates UnimplementedKeyTransparencyQueryServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&KeyTransparencyQueryService_ServiceDesc, srv)
}
func _KeyTransparencyQueryService_Distinguished_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DistinguishedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyTransparencyQueryServiceServer).Distinguished(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KeyTransparencyQueryService_Distinguished_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyTransparencyQueryServiceServer).Distinguished(ctx, req.(*DistinguishedRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyTransparencyQueryService_Search_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SearchRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyTransparencyQueryServiceServer).Search(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KeyTransparencyQueryService_Search_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyTransparencyQueryServiceServer).Search(ctx, req.(*SearchRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyTransparencyQueryService_Monitor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MonitorRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyTransparencyQueryServiceServer).Monitor(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KeyTransparencyQueryService_Monitor_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyTransparencyQueryServiceServer).Monitor(ctx, req.(*MonitorRequest))
}
return interceptor(ctx, in, info, handler)
}
// KeyTransparencyQueryService_ServiceDesc is the grpc.ServiceDesc for KeyTransparencyQueryService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var KeyTransparencyQueryService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "kt_query.KeyTransparencyQueryService",
HandlerType: (*KeyTransparencyQueryServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Distinguished",
Handler: _KeyTransparencyQueryService_Distinguished_Handler,
},
{
MethodName: "Search",
Handler: _KeyTransparencyQueryService_Search_Handler,
},
{
MethodName: "Monitor",
Handler: _KeyTransparencyQueryService_Monitor_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "key_transparency_query.proto",
}

View File

@ -0,0 +1,83 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.2
// protoc v5.28.3
// source: key_transparency_test.proto
package pb
import (
pb "github.com/signalapp/keytransparency/tree/transparency/pb"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
var File_key_transparency_test_proto protoreflect.FileDescriptor
var file_key_transparency_test_proto_rawDesc = []byte{
0x0a, 0x1b, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e,
0x63, 0x79, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x07, 0x6b,
0x74, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72,
0x65, 0x6e, 0x63, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x32, 0x63, 0x0a, 0x1a, 0x4b, 0x65,
0x79, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x54, 0x65, 0x73,
0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x45, 0x0a, 0x06, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x12, 0x1b, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63,
0x79, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x1c, 0x2e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2e, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42,
0x35, 0x5a, 0x33, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x69,
0x67, 0x6e, 0x61, 0x6c, 0x61, 0x70, 0x70, 0x2f, 0x6b, 0x65, 0x79, 0x74, 0x72, 0x61, 0x6e, 0x73,
0x70, 0x61, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x6b, 0x74, 0x2d, 0x74,
0x65, 0x73, 0x74, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var file_key_transparency_test_proto_goTypes = []any{
(*pb.UpdateRequest)(nil), // 0: transparency.UpdateRequest
(*pb.UpdateResponse)(nil), // 1: transparency.UpdateResponse
}
var file_key_transparency_test_proto_depIdxs = []int32{
0, // 0: kt_test.KeyTransparencyTestService.Update:input_type -> transparency.UpdateRequest
1, // 1: kt_test.KeyTransparencyTestService.Update:output_type -> transparency.UpdateResponse
1, // [1:2] is the sub-list for method output_type
0, // [0:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_key_transparency_test_proto_init() }
func file_key_transparency_test_proto_init() {
if File_key_transparency_test_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_key_transparency_test_proto_rawDesc,
NumEnums: 0,
NumMessages: 0,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_key_transparency_test_proto_goTypes,
DependencyIndexes: file_key_transparency_test_proto_depIdxs,
}.Build()
File_key_transparency_test_proto = out.File
file_key_transparency_test_proto_rawDesc = nil
file_key_transparency_test_proto_goTypes = nil
file_key_transparency_test_proto_depIdxs = nil
}

View File

@ -0,0 +1,16 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
syntax = "proto3";
package kt_test;
option go_package = "github.com/signalapp/keytransparency/cmd/kt-test/pb";
import "transparency.proto";
// A key transparency service intended for local testing and development purposes only.
service KeyTransparencyTestService {
// An endpoint used by local clients to update a search key.
rpc Update(transparency.UpdateRequest) returns (transparency.UpdateResponse) {}
}

View File

@ -0,0 +1,134 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v5.28.3
// source: key_transparency_test.proto
package pb
import (
context "context"
pb "github.com/signalapp/keytransparency/tree/transparency/pb"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
KeyTransparencyTestService_Update_FullMethodName = "/kt_test.KeyTransparencyTestService/Update"
)
// KeyTransparencyTestServiceClient is the client API for KeyTransparencyTestService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// A key transparency service intended for local testing and development purposes only.
type KeyTransparencyTestServiceClient interface {
// An endpoint used by local clients to update a search key.
Update(ctx context.Context, in *pb.UpdateRequest, opts ...grpc.CallOption) (*pb.UpdateResponse, error)
}
type keyTransparencyTestServiceClient struct {
cc grpc.ClientConnInterface
}
func NewKeyTransparencyTestServiceClient(cc grpc.ClientConnInterface) KeyTransparencyTestServiceClient {
return &keyTransparencyTestServiceClient{cc}
}
func (c *keyTransparencyTestServiceClient) Update(ctx context.Context, in *pb.UpdateRequest, opts ...grpc.CallOption) (*pb.UpdateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(pb.UpdateResponse)
err := c.cc.Invoke(ctx, KeyTransparencyTestService_Update_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// KeyTransparencyTestServiceServer is the server API for KeyTransparencyTestService service.
// All implementations must embed UnimplementedKeyTransparencyTestServiceServer
// for forward compatibility.
//
// A key transparency service intended for local testing and development purposes only.
type KeyTransparencyTestServiceServer interface {
// An endpoint used by local clients to update a search key.
Update(context.Context, *pb.UpdateRequest) (*pb.UpdateResponse, error)
mustEmbedUnimplementedKeyTransparencyTestServiceServer()
}
// UnimplementedKeyTransparencyTestServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedKeyTransparencyTestServiceServer struct{}
func (UnimplementedKeyTransparencyTestServiceServer) Update(context.Context, *pb.UpdateRequest) (*pb.UpdateResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Update not implemented")
}
func (UnimplementedKeyTransparencyTestServiceServer) mustEmbedUnimplementedKeyTransparencyTestServiceServer() {
}
func (UnimplementedKeyTransparencyTestServiceServer) testEmbeddedByValue() {}
// UnsafeKeyTransparencyTestServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to KeyTransparencyTestServiceServer will
// result in compilation errors.
type UnsafeKeyTransparencyTestServiceServer interface {
mustEmbedUnimplementedKeyTransparencyTestServiceServer()
}
func RegisterKeyTransparencyTestServiceServer(s grpc.ServiceRegistrar, srv KeyTransparencyTestServiceServer) {
// If the following call pancis, it indicates UnimplementedKeyTransparencyTestServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&KeyTransparencyTestService_ServiceDesc, srv)
}
func _KeyTransparencyTestService_Update_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(pb.UpdateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyTransparencyTestServiceServer).Update(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: KeyTransparencyTestService_Update_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyTransparencyTestServiceServer).Update(ctx, req.(*pb.UpdateRequest))
}
return interceptor(ctx, in, info, handler)
}
// KeyTransparencyTestService_ServiceDesc is the grpc.ServiceDesc for KeyTransparencyTestService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var KeyTransparencyTestService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "kt_test.KeyTransparencyTestService",
HandlerType: (*KeyTransparencyTestServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Update",
Handler: _KeyTransparencyTestService_Update_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "key_transparency_test.proto",
}

440
cmd/kt-server/stream.go Normal file
View File

@ -0,0 +1,440 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"math"
"strconv"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/retry"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
kinesistypes "github.com/aws/aws-sdk-go-v2/service/kinesis/types"
consumer "github.com/harlow/kinesis-consumer"
metrics "github.com/hashicorp/go-metrics"
"golang.org/x/sync/errgroup"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/db"
"github.com/signalapp/keytransparency/tree/transparency/pb"
)
const (
backfillScanShards = 1000
backfillWorkers = 96
withinBackfill = "backfill"
withinStream = "stream"
tombstoneString = "tombstone"
)
var tombstoneBytes = marshalValue([]byte(tombstoneString))
var logUpdater = &LogUpdater{}
// metricsCounter implements the consumer.Counter interface for exporting
// Kinesis metrics.
type metricsCounter struct{}
func (pc metricsCounter) Add(name string, val int64) {
metrics.IncrCounterWithLabels([]string{withinStream, "kinesis"}, float32(val), []metrics.Label{{Name: "type", Value: name}})
}
// kinesisLogger implements the consumer.Logger interface for printing Kinesis
// logs to stdout.
type kinesisLogger struct{}
func (kl kinesisLogger) Log(v ...any) { util.Log().Infof("%s", fmt.Sprintln(v...)) }
// shardState is used to keep track of the scan state of each shard.
type shardState struct {
// sinceLast is the number of entries from this shard that have been
// sequenced since its last checkpoint.
sinceLast int
// wg is a WaitGroup that all goroutines sequencing entries from this shard
// are given.
wg *sync.WaitGroup
}
type Streamer struct {
config *config.APIConfig
tx db.TransparencyStore
}
// run runs the streamer, blocking forever.
func (s *Streamer) run(ctx context.Context, name string, startAtTimestamp time.Time, updateHandler *KtUpdateHandler) {
i := 0
for {
var updatesWg sync.WaitGroup
var shardsMu sync.Mutex
// Reset the shards map on consumer restart.
shards := make(map[string]*shardState)
// Create a new context for each run.
runCtx, cancel := context.WithCancel(ctx)
// Note on thread safety: The Kinesis consumer library will use one
// goroutine per shard to scan. As such, a mutex is required to lookup shard
// state from the `shards` map because many shards may be read/written to
// the map in parallel. But the returned shardState struct can then used
// without a mutex because there is only one goroutine working with it.
c, err := consumer.New(
name,
consumer.WithLogger(kinesisLogger{}),
consumer.WithCounter(metricsCounter{}),
consumer.WithStore(s.tx.StreamStore()),
consumer.WithShardIteratorType(string(kinesistypes.ShardIteratorTypeAtTimestamp)),
consumer.WithTimestamp(startAtTimestamp),
)
if err != nil {
util.Log().Errorf("stream consumer initialization error: %v", err)
time.Sleep(5 * time.Second)
continue
}
err = c.Scan(runCtx, func(r *consumer.Record) error {
// Get the existing shardState struct or initialize a new one if needed.
shardsMu.Lock()
if _, ok := shards[r.ShardID]; !ok {
shards[r.ShardID] = &shardState{0, &sync.WaitGroup{}}
}
state := shards[r.ShardID]
shardsMu.Unlock()
// Start a dedicated goroutine to process this update. We don't call
// wg.Done until the update is successfully executed, retrying
// infinitely if necessary. This is required to prevent us from
// checkpointing past an update that we might've failed to sequence.
state.sinceLast += 1
state.wg.Add(1)
go func(ctx context.Context, data []byte, wg *sync.WaitGroup) {
updatesWg.Add(1)
defer updatesWg.Done()
for {
select {
case <-ctx.Done():
return
default:
err := updateFromStream(ctx, data, updateHandler, logUpdater)
if err != nil {
util.Log().Infof("failed to update entry from stream: %v", err)
metrics.IncrCounter([]string{withinStream, "errors"}, 1)
time.Sleep(3 * time.Second)
} else {
wg.Done()
return
}
}
}
}(runCtx, dup(r.Data), state.wg)
// If only a few entries have been sequenced from this shard, move on.
if state.sinceLast < 100 {
return consumer.ErrSkipCheckpoint
}
// If many entries have been sequenced, we need to checkpoint. First
// wait for all processing updates to complete.
state.wg.Wait()
state.sinceLast = 0
return nil
})
util.Log().Errorf("stream consumer error: %v", err)
// We only reach this point if c.Scan returns an error.
// In this case, clean up the current context, sleep with an exponential backoff,
// and wait for all spawned goroutines to exit.
cancel()
// Cap the backoff at 60 seconds
delay := time.Duration(math.Min(60, math.Pow(2, float64(i)))) * time.Second
util.Log().Infof("iteration %d of stream consumer, sleeping %s", i, delay)
time.Sleep(delay)
// Ensure that all update goroutines have exited before restarting the consumer
updatesWg.Wait()
i++
}
}
func backfill(ctx context.Context, table string, updateHandler *KtUpdateHandler) error {
eg, ctx := errgroup.WithContext(ctx)
cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRetryer(func() aws.Retryer {
// Max attempts set to 0 indicates that the attempt should be retried until it succeeds
// https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws/retry#AdaptiveMode.MaxAttempts
return retry.AddWithMaxAttempts(retry.NewAdaptiveMode(), 0)
}))
if err != nil {
return fmt.Errorf("loading aws sdk config: %w", err)
}
ddb := dynamodb.NewFromConfig(cfg)
totalShards := int32(backfillScanShards)
eg.SetLimit(backfillWorkers)
for shard := int32(0); shard < backfillScanShards; shard++ {
shard := shard
eg.Go(func() (returnedErr error) {
util.Log().Infof("Starting processing of backfill shard %d", shard)
defer func() {
metrics.IncrCounter([]string{withinBackfill, "shards_processed"}, 1)
util.Log().Infof("Finished processing of backfill shard %d: err=%v", shard, returnedErr)
}()
var exclusiveStartKey map[string]types.AttributeValue
for i := 0; ; i++ {
if err := ctx.Err(); err != nil {
return err
}
out, err := ddb.Scan(ctx, &dynamodb.ScanInput{
TableName: &table,
Segment: &shard,
TotalSegments: &totalShards,
ExclusiveStartKey: exclusiveStartKey,
})
if err != nil {
return fmt.Errorf("scan %d of shard %d failed: %w", i, shard, err)
} else if err := backfillScanOutput(ctx, out, updateHandler, logUpdater); err != nil {
return fmt.Errorf("scan %d of shard %d backfill processing failed: %w", i, shard, err)
} else if exclusiveStartKey = out.LastEvaluatedKey; len(exclusiveStartKey) == 0 {
return nil
}
}
})
}
return eg.Wait()
}
func backfillScanOutput(ctx context.Context, scan *dynamodb.ScanOutput, updateHandler *KtUpdateHandler, updater Updater) error {
for i, item := range scan.Items {
if err := updateFromBackfill(ctx, item, updateHandler, updater); err != nil {
return fmt.Errorf("processing item %d: %w", i, err)
}
}
return nil
}
type accountPair struct {
Prev *account `json:"prev"`
Next *account `json:"next"`
}
type account struct {
ACI []byte `json:"aci"`
ACIIdentityKey []byte `json:"aciIdentityKey"`
Number string `json:"number"`
UsernameHash []byte `json:"usernameHash"`
}
func updateFromBackfill(ctx context.Context, item map[string]types.AttributeValue, updateHandler *KtUpdateHandler, updater Updater) error {
type backfillAccount struct {
Number string `json:"number"`
ACIIdentityKey []byte `json:"identityKey"`
UsernameHash string `json:"usernameHash"` // URL-encoded base64
}
u := item["U"]
if u == nil {
return fmt.Errorf("no account ID")
}
ub, ok := u.(*types.AttributeValueMemberB)
if !ok {
return fmt.Errorf("account ID not bytes")
} else if len(ub.Value) != 16 {
return fmt.Errorf("account ID not valid")
}
accountID := ub.Value
d := item["D"]
if d == nil {
return fmt.Errorf("account %x no data", accountID)
}
db, ok := d.(*types.AttributeValueMemberB)
if !ok {
return fmt.Errorf("account %x data not bytes", accountID)
}
var account backfillAccount
if err := json.Unmarshal(db.Value, &account); err != nil {
return fmt.Errorf("parsing account %x data: %w", accountID, err)
} else if len(account.Number) == 0 {
return fmt.Errorf("account %x data has empty number", accountID)
}
if len(account.ACIIdentityKey) > 0 {
if err := updater.update(ctx, withinBackfill,
append([]byte{util.AciPrefix}, accountID...),
marshalValue(account.ACIIdentityKey), updateHandler, nil); err != nil {
return fmt.Errorf("updating %x ACI: %w", accountID, err)
}
}
if len(account.Number) > 0 {
if err := updater.update(ctx, withinBackfill,
append([]byte{util.NumberPrefix}, []byte(account.Number)...),
marshalValue(accountID), updateHandler, nil); err != nil {
return fmt.Errorf("updating %x Number: %w", accountID, err)
}
}
if len(account.UsernameHash) > 0 {
usernameHash, err := base64.RawURLEncoding.DecodeString(account.UsernameHash)
if err != nil {
return fmt.Errorf("updating %x username hash: failed to base64 decode hash: %w", accountID, err)
}
if err := updater.update(ctx, withinBackfill,
append([]byte{util.UsernameHashPrefix}, usernameHash...),
marshalValue(accountID), updateHandler, nil); err != nil {
return fmt.Errorf("updating %x username hash: %w", accountID, err)
}
}
return nil
}
func updateFromStream(ctx context.Context, data []byte, updateHandler *KtUpdateHandler, updater Updater) error {
pair := &accountPair{}
if err := json.Unmarshal(data, pair); err != nil {
// Note: This is not a temporary error and will intentionally cause the
// scanner to get stuck until new code is deployed that can handle
// whatever is in the stream.
return fmt.Errorf("unmarshaling from stream: %w", err)
} else if pair.Prev == nil && pair.Next == nil {
// This should never happen, but we want to know about it if it does
metrics.IncrCounter([]string{"stream_empty_pair"}, 1)
return nil
} else if pair.Prev == nil {
// New registration. ACI and number should always be present on these updates.
if err := updater.update(ctx, withinStream,
append([]byte{util.AciPrefix}, pair.Next.ACI...),
marshalValue(pair.Next.ACIIdentityKey), updateHandler, nil); err != nil {
return fmt.Errorf("updating ACI: %w", err)
}
if err := updater.update(ctx, withinStream,
append([]byte{util.NumberPrefix}, []byte(pair.Next.Number)...),
marshalValue(pair.Next.ACI), updateHandler, nil); err != nil {
return fmt.Errorf("updating number: %w", err)
}
if len(pair.Next.UsernameHash) > 0 {
if err := updater.update(ctx, withinStream,
append([]byte{util.UsernameHashPrefix}, pair.Next.UsernameHash...),
marshalValue(pair.Next.ACI), updateHandler, nil); err != nil {
return fmt.Errorf("updating username hash: %w", err)
}
}
} else if pair.Next == nil {
// Account deletion. Overwrite all associated mappings to point to a tombstone value.
if err := updater.update(ctx, withinStream,
append([]byte{util.AciPrefix}, pair.Prev.ACI...),
tombstoneBytes, updateHandler, marshalValue(pair.Prev.ACIIdentityKey)); err != nil {
return fmt.Errorf("updating ACI: %w", err)
}
if err := updater.update(ctx, withinStream,
append([]byte{util.NumberPrefix}, []byte(pair.Prev.Number)...),
tombstoneBytes, updateHandler, marshalValue(pair.Prev.ACI)); err != nil {
return fmt.Errorf("updating number: %w", err)
}
if len(pair.Prev.UsernameHash) > 0 {
if err := updater.update(ctx, withinStream,
append([]byte{util.UsernameHashPrefix}, pair.Prev.UsernameHash...),
tombstoneBytes, updateHandler, marshalValue(pair.Prev.ACI)); err != nil {
return fmt.Errorf("updating username hash: %w", err)
}
}
} else {
if !bytes.Equal(pair.Prev.ACIIdentityKey, pair.Next.ACIIdentityKey) {
if err := updater.update(ctx, withinStream,
append([]byte{util.AciPrefix}, pair.Next.ACI...),
marshalValue(pair.Next.ACIIdentityKey), updateHandler, nil); err != nil {
return fmt.Errorf("updating ACI: %w", err)
}
}
if !bytes.Equal(pair.Prev.UsernameHash, pair.Next.UsernameHash) {
if len(pair.Prev.UsernameHash) > 0 {
// Tombstone the old username hash
if err := updater.update(ctx, withinStream,
append([]byte{util.UsernameHashPrefix}, pair.Prev.UsernameHash...),
tombstoneBytes, updateHandler, marshalValue(pair.Prev.ACI)); err != nil {
return fmt.Errorf("updating username hash: %w", err)
}
}
if len(pair.Next.UsernameHash) > 0 {
if err := updater.update(ctx, withinStream,
append([]byte{util.UsernameHashPrefix}, pair.Next.UsernameHash...),
marshalValue(pair.Next.ACI), updateHandler, nil); err != nil {
return fmt.Errorf("updating username hash: %w", err)
}
}
}
if pair.Prev.Number != pair.Next.Number {
if len(pair.Prev.Number) > 0 {
// Tombstone the old phone number
if err := updater.update(ctx, withinStream,
append([]byte{util.NumberPrefix}, pair.Prev.Number...),
tombstoneBytes, updateHandler, marshalValue(pair.Prev.ACI)); err != nil {
return fmt.Errorf("updating number: %w", err)
}
}
if len(pair.Next.Number) > 0 {
if err := updater.update(ctx, withinStream,
append([]byte{util.NumberPrefix}, []byte(pair.Next.Number)...),
marshalValue(pair.Next.ACI), updateHandler, nil); err != nil {
return fmt.Errorf("updating number: %w", err)
}
}
}
}
return nil
}
// Updater interface supports mocking in tests
type Updater interface {
update(ctx context.Context, within string, key, value []byte, handler *KtUpdateHandler, expectedPreUpdateValue []byte) error
}
type LogUpdater struct{}
func (s *LogUpdater) update(ctx context.Context, within string, key, value []byte, updateHandler *KtUpdateHandler, expectedPreUpdateValue []byte) (returnedErr error) {
defer func() {
success := returnedErr == nil
metrics.IncrCounterWithLabels([]string{within, "items_processed"}, 1, []metrics.Label{{Name: "success", Value: strconv.FormatBool(success)}})
}()
updateReq := &pb.UpdateRequest{
SearchKey: key,
Value: value,
Consistency: &pb.Consistency{}}
if expectedPreUpdateValue != nil {
updateReq.ExpectedPreUpdateValue = expectedPreUpdateValue
}
_, err := updateHandler.update(ctx, updateReq, 30*time.Minute)
return err
}
func marshalValue(bytes []byte) []byte {
// It's not clear to me if we'll want to store more information in the log
// later. For now, prefix with a 0 as a format version identifier.
return append([]byte{0}, bytes...)
}
func dup(in []byte) []byte {
if in == nil {
return nil
}
out := make([]byte, len(in))
copy(out, in)
return out
}

View File

@ -0,0 +1,158 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/db"
)
var (
validAciIdentityKey2 = createDistinctValue(validAciIdentityKey1)
validUsernameHash2 = createDistinctValue(validUsernameHash1)
validPhoneNumber2 = "+14155550102"
)
type mockLogUpdater struct {
mock.Mock
}
func (m *mockLogUpdater) update(ctx context.Context, within string, key, value []byte, updateHandler *KtUpdateHandler, expectedPreUpdateValue []byte) (returnedErr error) {
m.Called(ctx, within, key, value, updateHandler, expectedPreUpdateValue)
return nil
}
type expectedUpdateInputs struct {
key []byte
value []byte
preUpdateValue []byte
}
var testUpdateAccountPairs = []struct {
prev *account
next *account
expectedNumUpdates int
expectedUpdateInputs []expectedUpdateInputs
}{
// No change
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
0,
[]expectedUpdateInputs{},
},
// New registration
{
nil,
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
3,
[]expectedUpdateInputs{
{key: append([]byte{util.AciPrefix}, validAci1...), value: marshalValue(validAciIdentityKey1), preUpdateValue: nil},
{key: append([]byte{util.NumberPrefix}, []byte(validPhoneNumber1)...), value: marshalValue(validAci1), preUpdateValue: nil},
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...), value: marshalValue(validAci1), preUpdateValue: nil}},
},
// Re-registration - the server sets the old username to null but keeps it reserved for the client to reclaim
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey2, Number: validPhoneNumber1},
2,
[]expectedUpdateInputs{
{key: append([]byte{util.AciPrefix}, validAci1...), value: marshalValue(validAciIdentityKey2), preUpdateValue: nil},
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)}},
},
// Re-registration - client reclaims username
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey2, Number: validPhoneNumber1},
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey2, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
1,
[]expectedUpdateInputs{
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...), value: marshalValue(validAci1), preUpdateValue: nil}},
},
// Some re-registrations do not change the identity key
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, Number: validPhoneNumber1},
1,
[]expectedUpdateInputs{
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)}},
},
// Account deletion with username
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
nil,
3,
[]expectedUpdateInputs{
{key: append([]byte{util.AciPrefix}, validAci1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAciIdentityKey1)},
{key: append([]byte{util.NumberPrefix}, []byte(validPhoneNumber1)...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)},
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)}},
},
// Account deletion with no username
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, Number: validPhoneNumber1},
nil,
2,
[]expectedUpdateInputs{
{key: append([]byte{util.AciPrefix}, validAci1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAciIdentityKey1)},
{key: append([]byte{util.NumberPrefix}, []byte(validPhoneNumber1)...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)}},
},
// Username change
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash2, Number: validPhoneNumber1},
2,
[]expectedUpdateInputs{
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash2...), value: marshalValue(validAci1), preUpdateValue: nil},
{key: append([]byte{util.UsernameHashPrefix}, validUsernameHash1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)}},
},
// Phone number change
{
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber1},
&account{ACI: validAci1, ACIIdentityKey: validAciIdentityKey1, UsernameHash: validUsernameHash1, Number: validPhoneNumber2},
2,
[]expectedUpdateInputs{
{key: append([]byte{util.NumberPrefix}, validPhoneNumber2...), value: marshalValue(validAci1), preUpdateValue: nil},
{key: append([]byte{util.NumberPrefix}, validPhoneNumber1...), value: tombstoneBytes, preUpdateValue: marshalValue(validAci1)}},
},
}
func TestUpdateFromStream(t *testing.T) {
mockConfig, _ := config.Read(mockConfigFile)
mockTransparencyStore := db.NewMemoryTransparencyStore()
updateRequestChannel := make(chan updateRequest)
mockUpdateHandler := &KtUpdateHandler{
config: mockConfig.APIConfig,
tx: mockTransparencyStore,
ch: updateRequestChannel,
}
for _, p := range testUpdateAccountPairs {
mockUpdater := new(mockLogUpdater)
accounts := &accountPair{Prev: p.prev, Next: p.next}
marshaledData, err := json.Marshal(accounts)
if err != nil {
t.Fatalf("Unexpected error marshaling acocunt pair")
}
for _, pair := range p.expectedUpdateInputs {
mockUpdater.On("update", mock.Anything, mock.Anything, pair.key, pair.value, mock.Anything, pair.preUpdateValue).Return(nil)
}
err = updateFromStream(context.Background(), marshaledData, mockUpdateHandler, mockUpdater)
assert.NoError(t, err)
mockUpdater.AssertNumberOfCalls(t, "update", p.expectedNumUpdates)
mockUpdater.AssertExpectations(t)
}
}

View File

@ -0,0 +1,40 @@
kt:
server-addr: localhost:8082
authorized-headers:
ExampleHeader1:
- example value one
- example value two
header-value-to-auditor-name:
example value one: example-auditor-1
example value two: example-auditor-2
kt-query:
server-addr: localhost:8080
kt-test:
server-addr: localhost:8081
metrics-addr: localhost:8083
health-addr: localhost:8084
# Paste in the keys generated via `go run github.com/signalapp/keytransparency/cmd/generate-keys`
api:
signing-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
vrf-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
prefix-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
opening-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
# fake:
# count: 1
# interval: 10s
distinguished: 1m
auditors:
example-auditor-1: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
example-auditor-2: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
min-search-delay: 1s
min-monitor-delay: 1s
jitter-percent: 10
db:
file: example/db
account-db: mock

145
cmd/kt-server/updater.go Normal file
View File

@ -0,0 +1,145 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"time"
metrics "github.com/hashicorp/go-metrics"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/cmd/internal/util"
"github.com/signalapp/keytransparency/tree/transparency"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
type updateRequest struct {
req *transparency.PreUpdateState
res chan<- updateResponse
}
type updateResponse struct {
res *transparency.PostUpdateState
err error
}
type updateAuditorTreeHeadRequest struct {
auditorTreeHead *tpb.AuditorTreeHead
auditorName string
err chan<- error
}
// updater is a goroutine that:
// - Updates the log from the Kinesis account update stream by receiving update requests over `ch`
// - Updates the "distinguished" key by receiving update requests over `ch`
// - Inserts fake updates
// - Sets the auditor tree head
func updater(tree *transparency.Tree, ch chan updateRequest, auditorTreeHeadsCh chan updateAuditorTreeHeadRequest, fake *config.FakeUpdates) {
var ticker <-chan time.Time
if fake != nil {
ticker = time.NewTicker(fake.Interval).C
}
sinceLastTick := 0
for {
select {
case <-ticker: // Apply some fake updates to keep update rate consistent.
if tree.CanFakeUpdate() {
if sinceLastTick < fake.Count {
numFakeUpdatesNeeded := fake.Count - sinceLastTick
start := time.Now()
err := tree.BatchUpdateFake(numFakeUpdatesNeeded)
incrementInsertMetrics(err, start, float32(numFakeUpdatesNeeded), false)
if err != nil {
util.Log().Warnf("Error applying fake updates: %v", err)
continue
}
}
}
sinceLastTick = 0
case first := <-ch: // Handle a real request to update the tree.
if isTombstoneUpdate(first.req.GetUpdateRequest()) {
handleTombstoneUpdate(tree, first)
sinceLastTick++
continue // Do not start a batch of updates
}
reqs := []updateRequest{first}
var tombstoneUpdate *updateRequest
loop:
for {
select {
case req := <-ch:
// If the channel receives a tombstone update, process the batch of updates so far
// and then handle the tombstone update separately to preserve the ordering of the updates
// as they're received from the channel.
if isTombstoneUpdate(first.req.GetUpdateRequest()) {
tombstoneUpdate = &req
break loop
} else {
reqs = append(reqs, req)
}
default:
break loop
}
}
start := time.Now()
states := make([]*transparency.PreUpdateState, len(reqs))
for i, req := range reqs {
states[i] = req.req
}
res, err := tree.BatchUpdate(states)
incrementInsertMetrics(err, start, float32(len(states)), true)
sinceLastTick += len(reqs)
for i, req := range reqs {
// These channel writes are guaranteed to not block, since this is the
// only time we write to them, and they're buffered channels of size 1.
if err == nil {
req.res <- updateResponse{res: res[i], err: nil}
} else {
req.res <- updateResponse{res: nil, err: err}
}
}
if tombstoneUpdate != nil {
handleTombstoneUpdate(tree, *tombstoneUpdate)
sinceLastTick++
}
case req := <-auditorTreeHeadsCh: // We received a new tree head from our auditor.
if err := tree.SetAuditorHead(req.auditorTreeHead, req.auditorName); err != nil {
util.Log().Warnf("Error updating auditor head: %v", err)
req.err <- err
} else {
req.err <- nil
}
}
}
}
func incrementInsertMetrics(err error, start time.Time, batchSize float32, real bool) {
metrics.IncrCounterWithLabels([]string{"inserts"}, batchSize, []metrics.Label{realLabel(real), successLabel(err)})
metrics.IncrCounterWithLabels([]string{"insert_operations"}, 1, []metrics.Label{realLabel(real), successLabel(err), grpcStatusLabel(err)})
metrics.AddSampleWithLabels([]string{"insert_batch_size"}, batchSize, []metrics.Label{realLabel(real), successLabel(err)})
metrics.MeasureSinceWithLabels([]string{"insert_duration"}, start, []metrics.Label{realLabel(real), successLabel(err)})
}
func handleTombstoneUpdate(tree *transparency.Tree, internalUpdateRequest updateRequest) {
start := time.Now()
res, err := tree.UpdateExistingIndexWithTombstoneValue(internalUpdateRequest.req)
metrics.IncrCounterWithLabels([]string{"tombstone_update"}, 1, []metrics.Label{successLabel(err), grpcStatusLabel(err)})
incrementInsertMetrics(err, start, 1, true)
if err == nil {
internalUpdateRequest.res <- updateResponse{res: res, err: nil}
} else {
internalUpdateRequest.res <- updateResponse{res: nil, err: err}
}
}

133
cmd/kt-server/util.go Normal file
View File

@ -0,0 +1,133 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"bytes"
"context"
"crypto/subtle"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/signalapp/keytransparency/tree/transparency/pb"
)
const (
AuditorNameContextKey = "auditor-name"
HeaderValueContextKey = "header-value"
)
func verifyMappedValueConstantTime(mappedValue, expectedValue []byte) error {
if 1 != subtle.ConstantTimeCompare(expectedValue, mappedValue) {
return status.Error(codes.PermissionDenied, "provided value does not match expected value")
}
return nil
}
// createDistinctValue returns a value that is different from the given []byte
func createDistinctValue(value []byte) []byte {
if len(value) < 1 {
// This should only ever happen in the case of a programmer error.
return []byte{0}
}
distinctValue := make([]byte, len(value))
copy(distinctValue, value)
distinctValue[0] = distinctValue[0] + 1
return distinctValue
}
func getServerOptions(config *config.ServiceConfig, additionalInterceptors []grpc.UnaryServerInterceptor) []grpc.ServerOption {
if config.AuthorizedHeaders == nil || len(config.AuthorizedHeaders) == 0 {
return nil
}
interceptors := []grpc.UnaryServerInterceptor{func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unavailable, "metadata read error")
}
matchedHeaderValue, err := validateAuthorizedHeaders(config.AuthorizedHeaders, md)
if err != nil {
return nil, err
}
// Store the matched header value in the context for downstream interceptors
ctx = context.WithValue(ctx, HeaderValueContextKey, matchedHeaderValue)
return handler(ctx, req)
}}
if len(additionalInterceptors) > 0 {
interceptors = append(interceptors, additionalInterceptors...)
}
return []grpc.ServerOption{
grpc.ChainUnaryInterceptor(interceptors...),
}
}
func storeAuditorNameInterceptor(config *config.ServiceConfig) func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
headerValue, ok := ctx.Value(HeaderValueContextKey).(string)
if !ok {
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("invalid type for header value. expected string, got %T", headerValue))
}
if len(headerValue) == 0 {
return nil, status.Error(codes.InvalidArgument, "no matched header value in context")
}
auditorName := config.HeaderValueToAuditorName[headerValue]
if len(auditorName) == 0 {
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("auditor name not specified for header value: %s", headerValue))
}
// Store the auditor name in the context
ctx = context.WithValue(ctx, AuditorNameContextKey, auditorName)
return handler(ctx, req)
}
}
// validateAuthorizedHeaders ensures that at least one of the specified header to value mappings is present on the request
// Returns the last header value that matched.
func validateAuthorizedHeaders(authorizedHeaders map[string][]string, md metadata.MD) (string, error) {
if authorizedHeaders == nil || len(authorizedHeaders) == 0 {
return "", nil
}
passedValidation := false
matchedValue := ""
for header, authorizedHeaderValues := range authorizedHeaders {
requestHeaderValues := md.Get(header)
if len(requestHeaderValues) == 0 {
continue
}
for _, requestHeaderValue := range requestHeaderValues {
for _, authorizedValue := range authorizedHeaderValues {
if subtle.ConstantTimeCompare([]byte(authorizedValue), []byte(requestHeaderValue)) == 1 {
matchedValue = requestHeaderValue
passedValidation = true
}
}
}
}
if !passedValidation {
return "", status.Error(codes.PermissionDenied, fmt.Sprintf("invalid header values"))
}
return matchedValue, nil
}
func isTombstoneUpdate(updateRequest *pb.UpdateRequest) bool {
return bytes.Equal(updateRequest.GetValue(), tombstoneBytes)
}

163
cmd/kt-server/util_test.go Normal file
View File

@ -0,0 +1,163 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package main
import (
"context"
"crypto/rand"
"testing"
"github.com/signalapp/keytransparency/cmd/internal/config"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
var (
validAci1 = random(16)
mismatchedAci = createDistinctValue(validAci1)
validAciIdentityKey1 = random(16)
)
func random(length int) []byte {
out := make([]byte, length)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
var testVerifyMappedValueParameters = []struct {
providedValue []byte
expectedValue []byte
expectedErrorType codes.Code
}{
{validAci1, validAci1, codes.OK},
{validAciIdentityKey1, validAciIdentityKey1, codes.OK},
{validAci1, mismatchedAci, codes.PermissionDenied},
}
func TestVerifyMappedValueConstantTime(t *testing.T) {
for _, p := range testVerifyMappedValueParameters {
err := verifyMappedValueConstantTime(p.providedValue, p.expectedValue)
if (p.expectedErrorType != codes.OK) != (err != nil) {
t.Fatalf("Expected %v, got %v",
p.expectedErrorType, err)
}
if p.expectedErrorType != codes.OK {
if grpcError, ok := status.FromError(err); grpcError.Code() != p.expectedErrorType || !ok {
t.Fatalf("Expected error of type %v, got %v", p.expectedErrorType, grpcError)
}
}
}
}
var testValidateAuthorizedHeadersParameters = []struct {
authorizedHeaders map[string][]string
metadataHeaders map[string]string
expectedErrorType codes.Code
expectedMatchedValue string
}{
// empty is ok
{nil, nil, codes.OK, ""},
{map[string][]string{}, map[string]string{}, codes.OK, ""},
// extra headers ok
{nil, map[string]string{"H": "V"}, codes.OK, ""},
// missing header is not ok
{map[string][]string{"H": {"V"}}, nil, codes.PermissionDenied, ""},
{map[string][]string{"H": {"V"}}, map[string]string{}, codes.PermissionDenied, ""},
// authorized value with incorrect header is not ok
{map[string][]string{"H": {"V"}}, map[string]string{"H1": "V"}, codes.PermissionDenied, ""},
// correct header, incorrect value is not ok
{map[string][]string{"H": {"V"}}, map[string]string{"H": "V1"}, codes.PermissionDenied, ""},
// single header matches
{map[string][]string{"H": {"V"}}, map[string]string{"H": "V"}, codes.OK, "V"},
// one match, one missing is ok
{map[string][]string{"H1": {"V1", "V2"}}, map[string]string{"H1": "V1"}, codes.OK, "V1"},
// one match, one not match is ok
{map[string][]string{"H1": {"V1"}, "H2": {"V3"}}, map[string]string{"H1": "V1", "H2": "V2"}, codes.OK, "V1"},
}
func TestValidateAuthorizedHeaders(t *testing.T) {
for _, p := range testValidateAuthorizedHeadersParameters {
md := metadata.New(p.metadataHeaders)
matchedValue, err := validateAuthorizedHeaders(p.authorizedHeaders, md)
if (p.expectedErrorType != codes.OK) != (err != nil) {
t.Fatalf("Expected %v, got %v",
p.expectedErrorType, err)
}
if p.expectedErrorType != codes.OK {
if grpcError, ok := status.FromError(err); grpcError.Code() != p.expectedErrorType || !ok {
t.Fatalf("Expected error of type %v, got %v", p.expectedErrorType, grpcError)
}
} else {
if matchedValue != p.expectedMatchedValue {
t.Fatalf("Expected matched value %s, got %s", p.expectedMatchedValue, matchedValue)
}
}
}
}
var testStoreAuditorNameInterceptorErrorParameters = []string{"example2.auditor", ""}
func TestStoreAuditorNameInterceptor_Error(t *testing.T) {
cfg := &config.ServiceConfig{
HeaderValueToAuditorName: map[string]string{
"example1.auditor": "example-auditor-1",
},
}
mockHandler := func(ctx context.Context, req any) (any, error) {
auditorName, ok := ctx.Value(AuditorNameContextKey).(string)
if !ok {
return nil, status.Error(codes.Internal, "auditor name not found in context")
}
return auditorName, nil
}
for _, invalidHeaderValue := range testStoreAuditorNameInterceptorErrorParameters {
ctx := context.WithValue(context.Background(), HeaderValueContextKey, invalidHeaderValue)
interceptor := storeAuditorNameInterceptor(cfg)
_, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{}, mockHandler)
if grpcError, ok := status.FromError(err); grpcError.Code() != codes.InvalidArgument || !ok {
t.Fatalf("Expected error of type %v, got %v", codes.InvalidArgument, grpcError)
}
}
}
func TestStoreAuditorNameInterceptor_Success(t *testing.T) {
cfg := &config.ServiceConfig{
HeaderValueToAuditorName: map[string]string{
"example1.auditor": "example-auditor-1",
},
}
// Mock handler that returns the auditor name from context
mockHandler := func(ctx context.Context, req any) (any, error) {
auditorName, ok := ctx.Value(AuditorNameContextKey).(string)
if !ok {
return nil, status.Error(codes.Internal, "auditor name not found in context")
}
return auditorName, nil
}
ctx := context.WithValue(context.Background(), HeaderValueContextKey, "example1.auditor")
interceptor := storeAuditorNameInterceptor(cfg)
resp, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{}, mockHandler)
assert.NoError(t, err)
auditorName, ok := resp.(string)
assert.True(t, ok, "expected string response")
assert.Equal(t, "example-auditor-1", auditorName)
}

97
cmd/kt-stress/main.go Normal file
View File

@ -0,0 +1,97 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Command kt-stress is a tool for stress testing a key transparency server.
package main
import (
"context"
"crypto/rand"
"flag"
"log"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
"golang.org/x/text/message"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/signalapp/keytransparency/cmd/kt-server/pb"
tpb "github.com/signalapp/keytransparency/tree/transparency/pb"
)
var (
p = message.NewPrinter(message.MatchLanguage("en"))
serverAddr = flag.String("addr", "localhost:8080", "Address of test server.")
threads = flag.Int("threads", 1, "Number of threads to use.")
)
var (
TotalTime int64
TotalUpdates int64
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile | log.LUTC)
flag.Parse()
start := time.Now()
for i := 0; i < *threads; i++ {
go stress()
}
p.Println("Testing started. Press Ctrl^C to stop...")
ch := make(chan os.Signal)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
select {
case <-ch:
case <-time.After(10 * time.Second):
}
dur := time.Since(start)
latency := atomic.LoadInt64(&TotalTime)
updates := atomic.LoadInt64(&TotalUpdates)
p.Println()
p.Println("Report:")
p.Printf(" Duration: %v\n", dur.Round(time.Second))
p.Printf(" Threads: %v\n", *threads)
p.Printf(" Total updates: %d\n", updates)
p.Printf(" Throughput: %.0f op/s\n", float64(updates)/dur.Seconds())
p.Printf(" Latency: %.0f us/op\n", float64(latency)/float64(updates))
}
func random() []byte {
out := make([]byte, 16)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
func stress() {
conn, err := grpc.Dial(*serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
client := pb.NewKeyTransparencyTestServiceClient(conn)
for {
start := time.Now()
req := &tpb.UpdateRequest{SearchKey: random(), Value: random()}
_, err := client.Update(context.Background(), req)
if err != nil {
log.Fatalf("Error executing request: %v", err)
}
atomic.AddInt64(&TotalTime, time.Since(start).Microseconds())
atomic.AddInt64(&TotalUpdates, 1)
}
}

View File

@ -0,0 +1,76 @@
// Copyright 2016 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package commitments implements a cryptographic commitment.
//
// Commitment scheme is as follows:
// T = HMAC(fixedKey, 16 byte nonce || message)
// message is defined as: len(searchKey) || searchKey || len(data) || data
package commitments
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"errors"
)
var (
hashAlgo = sha256.New
// key is publicly known random fixed key for use in the HMAC function.
// This fixed key allows the commitment scheme to be modeled as a random oracle.
fixedKey = []byte{0xd8, 0x21, 0xf8, 0x79, 0x0d, 0x97, 0x70, 0x97, 0x96, 0xb4, 0xd7, 0x90, 0x33, 0x57, 0xc3, 0xf5}
// ErrInvalidCommitment occurs when the commitment doesn't match the profile.
ErrInvalidCommitment = errors.New("invalid commitment")
// ErrInvalidNonce occurs when the nonce is not 16 bytes.
ErrInvalidNonce = errors.New("invalid nonce")
)
// Commit returns an HMAC over the provided search key, data, and nonce using the SHA256 algorithm.
// Returns an error if the length of the provided nonce is not 16.
func Commit(searchKey, data, nonce []byte) ([]byte, error) {
if len(nonce) != 16 {
return nil, ErrInvalidNonce
}
mac := hmac.New(hashAlgo, fixedKey)
mac.Write(nonce)
if len(searchKey) >= 1<<16 {
panic("search key too large")
}
// An error is not possible from any of the following Write() calls:
// 1. binary.Write() delegates to mac.Write()
// 2. mac is a hash.Hash, which is documented as never returning an error from Write()
_ = binary.Write(mac, binary.BigEndian, uint16(len(searchKey)))
mac.Write(searchKey)
if len(data) >= 1<<32 {
panic("data too large")
}
_ = binary.Write(mac, binary.BigEndian, uint32(len(data)))
mac.Write(data)
return mac.Sum(nil), nil
}
func Verify(searchKey, commitment, data, nonce []byte) error {
got, err := Commit(searchKey, data, nonce)
if err != nil {
return err
}
if !hmac.Equal(got, commitment) {
return ErrInvalidCommitment
}
return nil
}

View File

@ -0,0 +1,111 @@
// Copyright 2016 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package commitments
import (
"bytes"
"encoding/hex"
"errors"
"testing"
)
// Use a constant key of zero to obtain consistent test vectors.
var zeroKey = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
func TestCommit(t *testing.T) {
for _, tc := range []struct {
userID, data string
muserID, mdata string
mutate bool
want error
}{
{"foo", "bar", "foo", "bar", false, nil},
{"foo", "bar", "fo", "obar", false, ErrInvalidCommitment},
{"foo", "bar", "foob", "ar", false, ErrInvalidCommitment},
{"foo", "bar", "foo", "bar", true, ErrInvalidCommitment},
} {
data := []byte(tc.data)
c, err := Commit([]byte(tc.userID), data, zeroKey)
if err != nil {
t.Errorf("Unexpected error calculating commitment: %v", err)
}
if tc.mutate {
c[0] ^= 1
}
if got := Verify([]byte(tc.muserID), c, data, zeroKey); !errors.Is(got, tc.want) {
t.Errorf("Verify(%v, %x, %x, %x): %v, want %v", tc.userID, c, data, zeroKey, got, tc.want)
}
}
}
func TestInvalidNonces(t *testing.T) {
nonce1 := []byte{}
nonce2 := []byte{0x00, 0x02}
commit1, err := Commit([]byte{0x00, 0x00}, []byte{0x00, 0x00, 0x00, 0x00}, nonce1)
if err == nil || err != ErrInvalidNonce {
t.Errorf("Expected %v in calculating commitment, got no error instead", ErrInvalidNonce)
}
commit2, err := Commit([]byte{0x00, 0x00}, []byte{0x00, 0x00, 0x00, 0x00}, nonce2)
if err == nil || err != ErrInvalidNonce {
t.Errorf("Expected %v in calculating commitment, got no error instead", ErrInvalidNonce)
}
if commit1 != nil || commit2 != nil {
t.Errorf("Expected no commitments, got: \ncommit1=%x\ncommit2=%x", commit1, commit2)
}
}
func TestVectors(t *testing.T) {
for _, tc := range []struct {
userID, data string
want []byte
}{
{"", "", dh("edc3f59798cd87f2f48ec8836e2b6ef425cde9ab121ffdefc93d769db7cebabf")},
{"foo", "bar", dh("25df431e884358826fe66f96d65702580104240abd63fa741d9ea3f32914bbf5")},
{"foo1", "bar", dh("6c31a163a7660d1467fc1c997bd78b0a70b8921ca76b7eb0c6ca077f1e5e121e")},
{"foo", "bar1", dh("5de6c6c9ed4bf48122f6c851c80e6eacbf885947f02f974cdc794b14c8e975f1")},
} {
userID := []byte(tc.userID)
data := []byte(tc.data)
got, err := Commit(userID, data, zeroKey)
if err != nil {
t.Errorf("Unexpected error calculating commitment: %v", err)
}
if !bytes.Equal(got, tc.want) {
t.Errorf("Commit(%v, %v): %x ,want %x", tc.userID, tc.data, got, tc.want)
}
}
}
func BenchmarkCommit(b *testing.B) {
searchKey := make([]byte, 32)
data := make([]byte, 500)
nonce := make([]byte, 16)
b.ResetTimer()
for i := 0; i < b.N; i++ {
Commit(searchKey, data, nonce)
}
}
func dh(h string) []byte {
result, err := hex.DecodeString(h)
if err != nil {
panic("DecodeString failed")
}
return result
}

View File

@ -0,0 +1,261 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Package ed25519 implements ECVRF-EDWARDS25519-SHA512-TAI from RFC 9381 (https://www.ietf.org/rfc/rfc9381.html).
package ed25519
import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/sha512"
"errors"
"slices"
"filippo.io/edwards25519"
"github.com/signalapp/keytransparency/crypto/vrf"
)
var (
ed25519IdentityPoint = edwards25519.NewIdentityPoint()
hashAlgo = sha512.New
// ErrInvalidPrivateKey occurs when a private key is the wrong size.
ErrInvalidPrivateKey = errors.New("invalid private key")
// ErrInvalidPublicKey occurs when a public key is the wrong size or has low order
ErrInvalidPublicKey = errors.New("invalid public key")
// ErrInvalidVRF occurs when the VRF does not validate.
ErrInvalidVRF = errors.New("invalid VRF proof")
)
// PublicKey holds a public VRF key.
type PublicKey struct {
inner ed25519.PublicKey
}
// PrivateKey holds a private VRF key.
type PrivateKey struct {
inner []byte
}
// GenerateKey generates a fresh keypair for this VRF.
func GenerateKey() (vrf.PrivateKey, vrf.PublicKey) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil
}
return &PrivateKey{inner: priv.Seed()}, &PublicKey{inner: pub}
}
func encodeToCurve(salt, data []byte) (p *edwards25519.Point) {
h := hashAlgo()
for i := 0; i < 100; {
h.Reset()
h.Write([]byte{0x03, 0x01})
h.Write(salt)
h.Write(data)
h.Write([]byte{byte(i), 0x00})
r := h.Sum(nil)
p = interpretHashValueAsPoint(r[:32])
if p != nil {
p.MultByCofactor(p)
// Check that we're not returning the identity point
if ed25519IdentityPoint.Equal(p) != 1 {
return
}
}
i++
}
// This is practically unreachable
panic("No curve point found")
}
// interpretHashValueAsPoint checks if a 32-byte hash can be interpreted as a point on the edwards 25519 curve
// as defined in [Section 5.1.3 of RFC8032](https://www.rfc-editor.org/rfc/rfc8032#section-5.1.3)
// and returns that point if so.
func interpretHashValueAsPoint(hash []byte) *edwards25519.Point {
// Validate that the hash is 32 bytes
if len(hash) != 32 {
return nil
}
// If the input bytes are such that bytes 1 to 30 have value 255, byte 31 has value 255 or 127,
// and byte 0 has value 255 - i for value i in the (0, 2, 3, 4, 8, 9, 12, 13, 14, 15) list, then
// the encoding is invalid.
invalidBytes1To30 := true
for _, b := range hash[1:31] {
if b != 255 {
invalidBytes1To30 = false
break
}
}
invalidByte31 := hash[31] == 255 || hash[31] == 127
set := []uint8{0, 2, 3, 4, 8, 9, 12, 13, 14, 15}
invalidByte0 := slices.Contains(set, 255-hash[0])
if invalidByte0 && invalidBytes1To30 && invalidByte31 {
return nil
}
// the only error is if len(hash) != 32, which we validate above, so it is safe to ignore
p, _ := new(edwards25519.Point).SetBytes(hash)
return p
}
func generateNonce(x, data []byte) *edwards25519.Scalar {
h := hashAlgo()
h.Write(x)
h.Write(data)
kStr := h.Sum(nil)
k, err := new(edwards25519.Scalar).SetUniformBytes(kStr)
if err != nil {
panic(err)
}
return k
}
func generateChallenge(p1, p2, p3, p4, p5 []byte) []byte {
h := hashAlgo()
h.Write([]byte{0x03, 0x02})
h.Write(p1)
h.Write(p2)
h.Write(p3)
h.Write(p4)
h.Write(p5)
h.Write([]byte{0x00})
cStr := h.Sum(nil)
return cStr[:16]
}
func proofToHash(Gamma *edwards25519.Point) [32]byte {
h := hashAlgo()
h.Write([]byte{0x03, 0x03})
h.Write(new(edwards25519.Point).MultByCofactor(Gamma).Bytes())
h.Write([]byte{0x00})
index := [32]byte{}
copy(index[:], h.Sum(nil))
return index
}
// ECVRFProve returns the verifiable random function evaluated at m
func (k PrivateKey) ECVRFProve(m []byte) (index [32]byte, proof []byte) {
h := hashAlgo()
h.Write(k.inner)
hashedSk := h.Sum(nil)
x, err := new(edwards25519.Scalar).SetBytesWithClamping(hashedSk[:32])
if err != nil {
panic(err)
}
Y := k.Public().([]byte)
H := encodeToCurve(Y, m)
hStr := H.Bytes()
Gamma := new(edwards25519.Point).ScalarMult(x, H)
gammaStr := Gamma.Bytes()
nonce := generateNonce(hashedSk[32:], hStr)
kB := new(edwards25519.Point).ScalarBaseMult(nonce)
kH := new(edwards25519.Point).ScalarMult(nonce, H)
cStr := generateChallenge(Y, hStr, gammaStr, kB.Bytes(), kH.Bytes())
c, err := new(edwards25519.Scalar).SetCanonicalBytes(append(cStr, make([]byte, 16)...))
if err != nil {
panic(err)
}
s := new(edwards25519.Scalar).MultiplyAdd(c, x, nonce)
proof = append(append(gammaStr, cStr...), s.Bytes()...)
return proofToHash(Gamma), proof
}
// ECVRFVerify checks that proof is correct for m and outputs index. It only supports key validation.
func (pk PublicKey) ECVRFVerify(m, proof []byte) ([32]byte, error) {
nilIndex := [32]byte{}
if len(proof) != 80 {
return nilIndex, ErrInvalidVRF
}
Gamma, err := new(edwards25519.Point).SetBytes(proof[:32])
if err != nil {
return nilIndex, ErrInvalidVRF
}
cStr := proof[32:48]
cFull := make([]byte, 32)
copy(cFull[:16], cStr)
c, err := new(edwards25519.Scalar).SetCanonicalBytes(cFull)
if err != nil {
return nilIndex, ErrInvalidVRF
}
s, err := new(edwards25519.Scalar).SetCanonicalBytes(proof[48:80])
if err != nil {
return nilIndex, ErrInvalidVRF
}
H := encodeToCurve(pk.inner, m)
U := new(edwards25519.Point).ScalarBaseMult(s)
temp, err := new(edwards25519.Point).SetBytes(pk.inner)
if err != nil {
return nilIndex, ErrInvalidVRF
}
temp.ScalarMult(c, temp)
U.Subtract(U, temp)
V := new(edwards25519.Point).ScalarMult(s, H)
temp.ScalarMult(c, Gamma)
V.Subtract(V, temp)
cPrime := generateChallenge(pk.inner, H.Bytes(), proof[:32], U.Bytes(), V.Bytes())
if !bytes.Equal(cStr, cPrime) {
return nilIndex, ErrInvalidVRF
}
return proofToHash(Gamma), nil
}
func (pk PublicKey) Bytes() []byte { return pk.inner }
// NewVRFSigner creates a signer object from a private key.
func NewVRFSigner(key []byte) (vrf.PrivateKey, error) {
if len(key) != 32 {
return nil, ErrInvalidPrivateKey
}
return &PrivateKey{inner: key}, nil
}
// Public returns the corresponding public key as bytes.
func (k PrivateKey) Public() crypto.PublicKey {
pub := ed25519.NewKeyFromSeed(k.inner).Public()
return []byte(pub.(ed25519.PublicKey))
}
// NewVRFVerifier creates a verifier object from a public key.
func NewVRFVerifier(key ed25519.PublicKey) (vrf.PublicKey, error) {
if len(key) != 32 {
return nil, ErrInvalidPublicKey
}
// Reject a public key with low order
p, err := new(edwards25519.Point).SetBytes(key)
if err != nil {
return nil, ErrInvalidPublicKey
}
p.MultByCofactor(p)
if ed25519IdentityPoint.Equal(p) == 1 {
return nil, ErrInvalidPublicKey
}
return &PublicKey{inner: key}, nil
}

View File

@ -0,0 +1,193 @@
package ed25519
import (
"bytes"
"crypto/rand"
"encoding/hex"
"testing"
)
var (
vectors = []struct {
sk, pk, alpha, h, k, u, v, pi, beta []byte
}{
{
sk: dh("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"),
pk: dh("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"),
alpha: []byte(""),
h: dh("91bbed02a99461df1ad4c6564a5f5d829d0b90cfc7903e7a5797bd658abf3318"),
k: dh("8a49edbd1492a8ee09766befe50a7d563051bf3406cbffc20a88def030730f0f"),
u: dh("aef27c725be964c6a9bf4c45ca8e35df258c1878b838f37d9975523f09034071"),
v: dh("5016572f71466c646c119443455d6cb9b952f07d060ec8286d678615d55f954f"),
pi: dh("8657106690b5526245a92b003bb079ccd1a92130477671f6fc01ad16f26f723f26f8a57ccaed74ee1b190bed1f479d9727d2d0f9b005a6e456a35d4fb0daab1268a1b0db10836d9826a528ca76567805"),
beta: dh("90cf1df3b703cce59e2a35b925d411164068269d7b2d29f3301c03dd757876ff66b71dda49d2de59d03450451af026798e8f81cd2e333de5cdf4f3e140fdd8ae"),
},
{
sk: dh("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"),
pk: dh("3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"),
alpha: dh("72"),
h: dh("5b659fc3d4e9263fd9a4ed1d022d75eaacc20df5e09f9ea937502396598dc551"),
k: dh("d8c3a66921444cb3427d5d989f9b315aa8ca3375e9ec4d52207711a1fdb44107"),
u: dh("1dcb0a4821a2c48bf53548228b7f170962988f6d12f5439f31987ef41f034ab3"),
v: dh("fd03c0bf498c752161bae4719105a074630a2aa5f200ff7b3995f7bfb1513423"),
pi: dh("f3141cd382dc42909d19ec5110469e4feae18300e94f304590abdced48aed5933bf0864a62558b3ed7f2fea45c92a465301b3bbf5e3e54ddf2d935be3b67926da3ef39226bbc355bdc9850112c8f4b02"),
beta: dh("eb4440665d3891d668e7e0fcaf587f1b4bd7fbfe99d0eb2211ccec90496310eb5e33821bc613efb94db5e5b54c70a848a0bef4553a41befc57663b56373a5031"),
},
{
sk: dh("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7"),
pk: dh("fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"),
alpha: dh("af82"),
h: dh("bf4339376f5542811de615e3313d2b36f6f53c0acfebb482159711201192576a"),
k: dh("5ffdbc72135d936014e8ab708585fda379405542b07e3bd2c0bd48437fbac60a"),
u: dh("2bae73e15a64042fcebf062abe7e432b2eca6744f3e8265bc38e009cd577ecd5"),
v: dh("88cba1cb0d4f9b649d9a86026b69de076724a93a65c349c988954f0961c5d506"),
pi: dh("9bc0f79119cc5604bf02d23b4caede71393cedfbb191434dd016d30177ccbf8096bb474e53895c362d8628ee9f9ea3c0e52c7a5c691b6c18c9979866568add7a2d41b00b05081ed0f58ee5e31b3a970e"),
beta: dh("645427e5d00c62a23fb703732fa5d892940935942101e456ecca7bb217c61c452118fec1219202a0edcf038bb6373241578be7217ba85a2687f7a0310b2df19f"),
},
}
)
func TestEncodeToCurve(t *testing.T) {
for _, v := range vectors {
H := encodeToCurve(v.pk, v.alpha)
if got, want := H.Bytes(), v.h; !bytes.Equal(got, want) {
t.Errorf("got = %x, want = %x", got, want)
}
}
}
// func TestGenerateNonce(t *testing.T) {
// for _, v := range vectors {
// if got, want := generateNonce(v.sk, v.h).Bytes(), v.k; !bytes.Equal(got, want) {
// t.Errorf("got = %x, want = %x", got, want)
// }
// }
// }
func TestEvaluate(t *testing.T) {
for _, v := range vectors {
signer, err := NewVRFSigner(v.sk)
if err != nil {
t.Error(err)
}
index, proof := signer.ECVRFProve(v.alpha)
if got, want := index[:], v.beta[:32]; !bytes.Equal(got, want) {
t.Errorf("got = %x, want = %x", got, want)
}
if got, want := proof, v.pi; !bytes.Equal(got, want) {
t.Errorf("got = %x, want = %x", got, want)
}
}
}
func TestProofToHash(t *testing.T) {
for _, v := range vectors {
verifier, err := NewVRFVerifier(v.pk)
if err != nil {
t.Error(err)
}
index, err := verifier.ECVRFVerify(v.alpha, v.pi)
if err != nil {
t.Error(err)
}
if got, want := index[:], v.beta[:32]; !bytes.Equal(got, want) {
t.Errorf("got = %x, want = %x", got, want)
}
}
}
func TestProofToHashFails(t *testing.T) {
for _, v := range vectors {
verifier, err := NewVRFVerifier(v.pk)
if err != nil {
t.Error(err)
}
_, err = verifier.ECVRFVerify([]byte("a"), v.pi)
if got, want := err, ErrInvalidVRF; got != want {
t.Errorf("got = %v, want = %v", got, want)
}
for i := 0; i < len(v.pi); i++ {
pi := make([]byte, len(v.pi))
copy(pi, v.pi)
pi[i] ^= 1
_, err = verifier.ECVRFVerify(v.alpha, pi)
if got, want := err, ErrInvalidVRF; got != want {
t.Errorf("got = %v, want = %v", got, want)
}
}
}
}
var testHashValueAsPointInvalidParameters = []struct {
hash []byte
}{
// Length of hash is not 32
{[]byte{0}},
// Non-canonical encodings
{[]byte{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{253, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{252, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{251, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{247, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{246, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{243, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{242, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{241, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{240, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}},
{[]byte{255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{253, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{252, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{251, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{247, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{246, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{243, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{242, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{241, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
{[]byte{240, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}},
}
func TestInterpretHashValueAsPointInvalid(t *testing.T) {
for _, p := range testHashValueAsPointInvalidParameters {
point := interpretHashValueAsPoint(p.hash)
if point != nil {
t.Fatal("expected no point")
}
}
}
func TestInterpretHashValueAsPointValid(t *testing.T) {
hash := []byte{1, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127}
point := interpretHashValueAsPoint(hash)
if point == nil {
t.Fatal("expected a point")
}
}
func BenchmarkEvaluate(b *testing.B) {
priv := make([]byte, 32)
rand.Read(priv)
signer, err := NewVRFSigner(priv)
if err != nil {
b.Fatal(err)
}
m := make([]byte, 32)
rand.Read(m)
b.ResetTimer()
for i := 0; i < b.N; i++ {
signer.ECVRFProve(m)
}
}
func dh(h string) []byte {
result, err := hex.DecodeString(h)
if err != nil {
panic("DecodeString failed")
}
return result
}

34
crypto/vrf/vrf.go Normal file
View File

@ -0,0 +1,34 @@
// Copyright 2016 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package vrf defines the interface to a verifiable random function.
package vrf
import (
"crypto"
)
// PrivateKey supports evaluating the VRF function.
type PrivateKey interface {
// ECVRFProve returns the output of H(f_k(m)) and its proof.
ECVRFProve(m []byte) (index [32]byte, proof []byte)
// Public returns the corresponding public key.
Public() crypto.PublicKey
}
// PublicKey supports verifying output from the VRF function.
type PublicKey interface {
// ECVRFVerify verifies the NP-proof supplied by Proof and outputs Index.
ECVRFVerify(m, proof []byte) (index [32]byte, err error)
}

122
db/cache_control.go Normal file
View File

@ -0,0 +1,122 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"bytes"
"fmt"
"github.com/hashicorp/go-metrics"
)
func countPrefixCacheControlHit(hit bool) {
lbls := []metrics.Label{{Name: "cache_hit", Value: fmt.Sprint(hit)}}
metrics.IncrCounterWithLabels([]string{"prefix_cache_control"}, 1, lbls)
}
// PrefixCacheControl is used to minimize database requests during update operations
// by fetching and caching the necessary prefix tree data beforehand.
// Blocking the update critical path on network requests to the database can significantly degrade performance.
// This cache is separate from the LRU cache because the LRU cache can end up evicting data needed for the update prematurely.
type PrefixCacheControl struct {
db PrefixStore
tracking bool
data map[uint64][]byte
}
func NewPrefixCacheControl(db PrefixStore) *PrefixCacheControl {
return &PrefixCacheControl{db: db}
}
// StartTracking begins keeping a copy of all data fetched.
func (p *PrefixCacheControl) StartTracking() {
p.tracking = true
p.data = make(map[uint64][]byte)
}
// StopTracking stops keeping copies of data fetched and clears the data stored.
func (p *PrefixCacheControl) StopTracking() {
p.tracking = false
p.data = nil
}
// ExportCache returns the set of database entries cached.
func (p *PrefixCacheControl) ExportCache() map[uint64][]byte {
return p.data
}
// ImportCache adds a set of cached database lookups to the current store's
// cache.
func (p *PrefixCacheControl) ImportCache(data map[uint64][]byte) error {
if p.data == nil {
p.data = make(map[uint64][]byte)
}
for key, val := range data {
if existing, ok := p.data[key]; ok {
if !bytes.Equal(val, existing) {
return fmt.Errorf("different values found for the same prefix tree key: %v", key)
}
} else {
p.data[key] = val
}
}
return nil
}
// BatchGet fetches a batch of keys by first checking the cache and then the underlying datastore.
func (p *PrefixCacheControl) BatchGet(keys []uint64) (map[uint64][]byte, error) {
remaining := make([]uint64, 0)
data := make(map[uint64][]byte)
for _, key := range keys {
if val, ok := p.data[key]; ok {
countPrefixCacheControlHit(true)
data[key] = dup(val)
} else {
countPrefixCacheControlHit(false)
remaining = append(remaining, key)
}
}
if len(remaining) > 0 {
partial, err := p.db.BatchGet(remaining)
if err != nil {
return nil, err
}
for key, val := range partial {
if p.tracking {
p.data[key] = dup(val)
}
data[key] = val
}
}
return data, nil
}
func (p *PrefixCacheControl) GetCached(key uint64) []byte {
if val, ok := p.data[key]; ok {
countPrefixCacheControlHit(true)
return dup(val)
}
countPrefixCacheControlHit(false)
val := p.db.GetCached(key)
if val != nil && p.tracking {
p.data[key] = dup(val)
}
return val
}
// Put stores the given key-value pair in the underlying datastore and caches it if tracking is enabled.
func (p *PrefixCacheControl) Put(key uint64, val []byte) {
if p.tracking {
p.data[key] = dup(val)
} else if _, ok := p.data[key]; ok {
p.data[key] = dup(val)
}
p.db.Put(key, val)
}

168
db/db.go Normal file
View File

@ -0,0 +1,168 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Package db implements database wrappers that match a common interface.
package db
import consumer "github.com/harlow/kinesis-consumer"
// LogStore is the interface a log tree uses to communicate with its database.
type LogStore interface {
BatchGet(keys []uint64) (data map[uint64][]byte, err error)
BatchPut(data map[uint64][]byte)
}
// PrefixStore is the interface a prefix tree uses to communicate with its
// database.
type PrefixStore interface {
BatchGet(keys []uint64) (data map[uint64][]byte, err error)
// GetCached gets a value if and only if it's cached. If the requested value
// is not cached, it returns nil.
GetCached(key uint64) []byte
Put(key uint64, data []byte)
}
// Account contains the account info necessary to verify a phone number search
type Account struct {
DiscoverableByPhoneNumber bool `dynamodbav:"C"`
UnidentifiedAccessKey []byte `dynamodbav:"UAK"`
}
type AccountDB interface {
// GetAccountByAci returns the Account struct corresponding to the ACI, or nil if none exists
GetAccountByAci(aci []byte) (*Account, error)
}
// storedTreeHead represents the key transparency service's signed transparency tree head, as well as
// any auditor-signed tree heads.
type storedTreeHead struct {
TreeHead *TransparencyTreeHead `json:"head"`
AuditorHeads map[string]*AuditorTreeHead `json:"auditor-heads,omitempty"`
}
// Signature represents the key transparency service's signature over the head of a transparency tree.
// The signed data includes the auditor public key, so the service generates one Signature for each auditor.
type Signature struct {
Signature []byte
AuditorPublicKey []byte
}
// TransparencyTreeHead is used by the key transparency service to represent the signed head of a transparency tree.
type TransparencyTreeHead struct {
TreeSize uint64 `json:"n"`
Timestamp int64 `json:"ts"`
Signatures []*Signature `json:"sigs"`
}
// AuditorTransparencyTreeHead is used by a third-party auditor to represent its signed view of the transparency tree.
type AuditorTransparencyTreeHead struct {
TreeSize uint64 `json:"n"`
Timestamp int64 `json:"ts"`
Signature []byte `json:"sig,omitempty"`
}
func (a *AuditorTransparencyTreeHead) Clone() *AuditorTransparencyTreeHead {
if a == nil {
return nil
}
return &AuditorTransparencyTreeHead{
TreeSize: a.TreeSize,
Timestamp: a.Timestamp,
Signature: dup(a.Signature),
}
}
func (h *TransparencyTreeHead) Clone() *TransparencyTreeHead {
if h == nil {
return nil
}
return &TransparencyTreeHead{
TreeSize: h.TreeSize,
Timestamp: h.Timestamp,
Signatures: cloneTransparencyTreeHeadSignatures(h.Signatures),
}
}
func cloneTransparencyTreeHeadSignatures(original []*Signature) []*Signature {
if original == nil {
return nil
}
var clonedSignatures []*Signature
for _, sig := range original {
if sig == nil {
continue
}
newSignature := &Signature{dup(sig.Signature), dup(sig.AuditorPublicKey)}
clonedSignatures = append(clonedSignatures, newSignature)
}
return clonedSignatures
}
// AuditorTreeHead represents the signed head of a transparency tree, from a
// third-party auditor.
type AuditorTreeHead struct {
AuditorTransparencyTreeHead
RootValue []byte `json:"root"`
Consistency [][]byte `json:"consistency"`
}
func (h *AuditorTreeHead) Clone() *AuditorTreeHead {
if h == nil {
return nil
}
var consistency [][]byte
if h.Consistency != nil {
consistency = make([][]byte, len(h.Consistency))
for i, val := range h.Consistency {
consistency[i] = dup(val)
}
}
return &AuditorTreeHead{
AuditorTransparencyTreeHead: *h.AuditorTransparencyTreeHead.Clone(),
RootValue: dup(h.RootValue),
Consistency: consistency,
}
}
func cloneAuditorTreeHeadMap(original map[string]*AuditorTreeHead) map[string]*AuditorTreeHead {
if original == nil {
return nil
}
clonedAuditorTreeHeads := make(map[string]*AuditorTreeHead)
for key, value := range original {
clonedAuditorTreeHeads[key] = value.Clone()
}
return clonedAuditorTreeHeads
}
type StreamStore consumer.Store
// TransparencyStore is the interface a transparency tree uses to communicate
// with its database.
type TransparencyStore interface {
// Clone returns a read-only clone of the current transparency store,
// suitable for distributing to child goroutines.
Clone() TransparencyStore
// GetHead returns the most recent tree head, or the zero value of
// TransparencyTreeHead if there hasn't been a signed head yet.
// It also returns a map of auditor name to the corresponding most recently set auditor tree head.
// The auditor map must not be nil if err is nil.
GetHead() (*TransparencyTreeHead, map[string]*AuditorTreeHead, error)
Get(key uint64) ([]byte, error)
Put(key uint64, data []byte)
LogStore() LogStore
PrefixStore() PrefixStore
StreamStore() StreamStore
Commit(head *TransparencyTreeHead, auditors map[string]*AuditorTreeHead) error
}

118
db/db_test.go Normal file
View File

@ -0,0 +1,118 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"bytes"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func createTestSignature(sig, pubKey []byte) *Signature {
return &Signature{
Signature: sig,
AuditorPublicKey: pubKey,
}
}
func TestCloneTransparencyTreeHeadSignatures(t *testing.T) {
original := []*Signature{
createTestSignature([]byte("sig1"), []byte("key1")),
createTestSignature([]byte("sig2"), []byte("key2")),
}
cloned := cloneTransparencyTreeHeadSignatures(original)
// Check if the signatures are correctly cloned
for i, orig := range original {
// Verify Signature and AuditorPublicKey have same content as original
if !bytes.Equal(cloned[i].Signature, orig.Signature) {
t.Fatalf("cloned Signature doesn't match original: got %x, want %x",
cloned[i].Signature, orig.Signature)
}
if !bytes.Equal(cloned[i].AuditorPublicKey, orig.AuditorPublicKey) {
t.Fatalf("cloned AuditorPublicKey doesn't match original: got %x, want %x",
cloned[i].AuditorPublicKey, orig.AuditorPublicKey)
}
// Verify that the clone is a deep copy
if &cloned[i].Signature[0] == &original[i].Signature[0] {
t.Fatalf("Signature %d was not deep copied", i)
}
if &cloned[i].AuditorPublicKey[0] == &original[i].AuditorPublicKey[0] {
t.Fatalf("AuditorPublicKey %d was not deep copied", i)
}
}
}
func TestCloneAuditorTreeHeadMap(t *testing.T) {
original := map[string]*AuditorTreeHead{
"auditor1": createTestAuditorTreeHead(1, 100, []byte("sig1")),
"auditor2": createTestAuditorTreeHead(2, 200, []byte("sig2")),
}
cloned := cloneAuditorTreeHeadMap(original)
for auditor, origAuditorTreeHead := range original {
assert.True(t, isAuditorTreeHeadDeepCopy(*origAuditorTreeHead, *cloned[auditor]),
fmt.Sprintf("cloned auditor head %+v is not a deep copy of %+v", cloned[auditor], origAuditorTreeHead))
}
}
func createTestAuditorTreeHead(treeSize uint64, timestamp int64, sig []byte) *AuditorTreeHead {
return &AuditorTreeHead{
AuditorTransparencyTreeHead: AuditorTransparencyTreeHead{
TreeSize: treeSize,
Timestamp: timestamp,
Signature: sig,
},
RootValue: []byte("root"),
Consistency: [][]byte{[]byte("cons1"), []byte("cons2")},
}
}
// isDeepCopy checks that the contents of a and b are equal, but that they do not point to the same underlying array
func isDeepCopy(a, b []byte) bool {
if !bytes.Equal(a, b) {
return false
}
if len(a) == 0 || len(b) == 0 {
return true
}
return &a[0] != &b[0]
}
func isAuditorTreeHeadDeepCopy(a, b AuditorTreeHead) bool {
if a.TreeSize != b.TreeSize || a.Timestamp != b.Timestamp {
return false
}
if !isDeepCopy(a.Signature, b.Signature) {
return false
}
if !isDeepCopy(a.RootValue, b.RootValue) {
return false
}
if len(a.Consistency) != len(b.Consistency) {
return false
}
for i := range a.Consistency {
if !isDeepCopy(a.Consistency[i], b.Consistency[i]) {
return false
}
}
return true
}

650
db/dynamodb.go Normal file
View File

@ -0,0 +1,650 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/ratelimit"
"github.com/aws/aws-sdk-go-v2/aws/retry"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
metrics "github.com/hashicorp/go-metrics"
)
const (
maxBatchKeys = 90
maxDynamoBatchSize = 100
keyLabel = "k"
valueLabel = "v"
attrCanonicallyDiscoverable = "C"
attrUnidentifiedAccessKey = "UAK"
attrAci = "U"
)
type dynamoReadReq struct {
keys []string
data map[string][]byte
resp chan error
consistent bool
}
// ddbConn is a wrapper around a base DynamoDB connection that handles batching
// writes between commits transparently.
type ddbConn struct {
conn *dynamodb.Client
table string
ch chan dynamoReadReq
readonly bool
// If readonly is true, batch will by definition be nil (thus: empty).
// Since a writable ddbConn doesn't allow concurrent reads/writes,
// and readonly ddbConn always has a nil map, there is no need to lock this
// against concurrent access.
batch map[string][]byte
}
func newDDBConn(conn *dynamodb.Client, table string, parallel int) *ddbConn {
batches := make(chan []dynamoReadReq)
out := &ddbConn{
conn: conn,
table: table,
ch: make(chan dynamoReadReq, 100),
readonly: false,
batch: make(map[string][]byte),
}
// Start a number of worker goroutines, configured by `parallel`, that will
// take batches of read requests and send them to Dynamo. Build batches in a
// dedicated goroutine, rather than have each worker goroutine build it's
// own batches, to ensure we have the largest batches possible.
//
// Manual batching like this is required in the first place because Dynamo
// doesn't support HTTP/2 -- firing off a bunch of concurrent requests
// results in a large number of distinct connections being made, and the
// overhead of all those connections degrades performance.
go func() {
for {
batches <- out.receiveBatch()
}
}()
for i := 0; i < parallel; i++ {
go func() {
for {
reqs := <-batches
err := out.handleBatch(reqs)
for _, req := range reqs {
req.resp <- err
}
}
}()
}
return out
}
// receiveBatch takes a series of read requests off of the queue, trying to get
// a batch of maxBatchKeys keys.
//
// The max batch size to Dynamo is 100 keys, but once we take a request off of
// the queue we have to handle it. Aims for maxBatchKeys to avoid overcommitting.
func (c *ddbConn) receiveBatch() []dynamoReadReq {
var out []dynamoReadReq
total := 0
req := <-c.ch
out = append(out, req)
total += len(req.keys)
loop:
for total < maxBatchKeys {
select {
case req := <-c.ch:
out = append(out, req)
total += len(req.keys)
default:
break loop
}
}
return out
}
// handleBatch processes a batch of read requests and writes the fetched data into a receiving map on each request.
func (c *ddbConn) handleBatch(reqs []dynamoReadReq) error {
// Build an index from the keys to lookup, to the requests for that key.
keyIndex := make(map[string][]int)
for i, req := range reqs {
for _, key := range req.keys {
keyIndex[key] = append(keyIndex[key], i)
}
}
// Build a slice of keys to batch get
keys := make([]string, 0, len(keyIndex))
for key := range keyIndex {
keys = append(keys, key)
}
// Fetch keys in batch
data := make(map[string][]byte, len(keys))
err := c.batchGet(keys, data)
if err != nil {
return err
}
metrics.IncrCounter([]string{"dynamodb", "strongly_consistent_read"}, 1)
// Move the fetched data into the receiving maps.
for key, val := range data {
for i, j := range keyIndex[key] {
if i == 0 {
reqs[j].data[key] = val
} else {
reqs[j].data[key] = dup(val)
}
}
}
metrics.AddSample([]string{"dynamodb", "batch_size"}, float32(len(keys)))
return nil
}
// batchGet fetches a batch of keys from DynamoDB and writes the data into the provided map.
func (c *ddbConn) batchGet(keys []string, res map[string][]byte) error {
kvs := make([]map[string]types.AttributeValue, len(keys))
for i, key := range keys {
kvs[i] = map[string]types.AttributeValue{
keyLabel: &types.AttributeValueMemberS{Value: key},
}
}
consistent := true
for len(kvs) > 0 {
var now []map[string]types.AttributeValue
if len(kvs) > maxDynamoBatchSize {
now, kvs = kvs[:maxDynamoBatchSize], kvs[maxDynamoBatchSize:]
} else {
now, kvs = kvs, nil
}
out, err := c.conn.BatchGetItem(context.Background(), &dynamodb.BatchGetItemInput{
RequestItems: map[string]types.KeysAndAttributes{c.table: {
Keys: now,
ConsistentRead: &consistent,
}},
})
if err != nil {
return err
}
unprocessed := out.UnprocessedKeys[c.table].Keys
if len(unprocessed) > 0 {
kvs = append(kvs, unprocessed...)
}
consumed := len(now) - len(unprocessed)
metrics.IncrCounterWithLabels(
[]string{"dynamodb", "read_capacity"},
float32(consumed),
[]metrics.Label{{Name: "consistent", Value: fmt.Sprint(consistent)}},
)
for _, data := range out.Responses[c.table] {
key, ok := data[keyLabel].(*types.AttributeValueMemberS)
if !ok {
return fmt.Errorf("malformed database entry")
}
value, ok := data[valueLabel].(*types.AttributeValueMemberB)
if !ok {
return fmt.Errorf("malformed database entry")
}
res[key.Value] = value.Value
}
}
return nil
}
// Get fetches a single key from DynamoDB.
func (c *ddbConn) Get(key string) ([]byte, error) {
keySet := [1]string{key}
out, err := c.BatchGet(keySet[:])
if err != nil {
return nil, err
}
return out[key], nil
}
// BatchGet fetches a batch of keys from DynamoDB.
func (c *ddbConn) BatchGet(keys []string) (map[string][]byte, error) {
data := map[string][]byte{}
var uncachedKeys []string
// Move cached keys directly into the output data map.
for _, key := range keys {
if value, ok := c.batch[key]; ok {
data[key] = dup(value)
} else {
uncachedKeys = append(uncachedKeys, key)
}
}
// Fetch remaining keys from the database.
start := time.Now()
consistent := true
req := dynamoReadReq{
keys: uncachedKeys,
data: data,
resp: make(chan error, 1),
consistent: consistent,
}
c.ch <- req
if err := <-req.resp; err != nil {
return nil, err
}
metrics.MeasureSinceWithLabels([]string{"dynamodb", "get_duration"}, start, []metrics.Label{
{Name: "readonly", Value: fmt.Sprint(c.readonly)},
{Name: "consistent", Value: fmt.Sprint(consistent)},
{Name: "singular", Value: fmt.Sprint(len(keys) == 1)},
})
return data, nil
}
func (c *ddbConn) Put(key string, value []byte) {
if c.readonly {
panic("connection is readonly")
}
c.batch[key] = dup(value)
}
// Commit commits all outstanding writes to DynamoDB.
// The tree head ("root") data write is deferred until all other writes have succeeded.
func (c *ddbConn) Commit() error {
if c.readonly {
panic("connection is readonly")
}
start := time.Now()
iters := 0
defer func() { c.batch = map[string][]byte{} }()
// Build the initial set of write requests to make.
reqs := make([]types.WriteRequest, 0)
for key, value := range c.batch {
if key == "root" {
continue
}
reqs = append(reqs, types.WriteRequest{PutRequest: &types.PutRequest{
Item: map[string]types.AttributeValue{
keyLabel: &types.AttributeValueMemberS{Value: key},
valueLabel: &types.AttributeValueMemberB{Value: value},
},
}})
}
// Submit requests to database in batches, looping until all writes have propagated to the database.
for len(reqs) > 0 {
iters++
var err error
reqs, err = c.batchWriteParallel(reqs)
if err != nil {
return err
}
}
// Write the new root to the database last.
if value, ok := c.batch["root"]; ok {
_, err := c.conn.PutItem(context.Background(), &dynamodb.PutItemInput{
TableName: &c.table,
Item: map[string]types.AttributeValue{
keyLabel: &types.AttributeValueMemberS{Value: "root"},
valueLabel: &types.AttributeValueMemberB{Value: value},
},
})
if err != nil {
return err
}
metrics.IncrCounter([]string{"dynamodb", "write_capacity"}, 1)
}
metrics.MeasureSinceWithLabels(
[]string{"dynamodb", "commit_duration"},
start,
[]metrics.Label{{Name: "iters", Value: fmt.Sprint(iters)}},
)
return nil
}
// batchWriteParallel splits writes across multiple goroutines and returns a list of unfulfilled write requests.
func (c *ddbConn) batchWriteParallel(reqs []types.WriteRequest) ([]types.WriteRequest, error) {
type dynamoWriteRes struct {
unprocessed []types.WriteRequest
err error
}
ch := make(chan dynamoWriteRes)
goroutines := 0
for len(reqs) > 0 {
var now []types.WriteRequest
if len(reqs) > 25 {
now, reqs = reqs[:25], reqs[25:]
} else {
now, reqs = reqs, nil
}
go func() {
unprocessed, err := c.batchWrite(now)
ch <- dynamoWriteRes{unprocessed, err}
}()
goroutines++
}
results := make([]dynamoWriteRes, goroutines)
for i := 0; i < goroutines; i++ {
results[i] = <-ch
}
var unprocessed []types.WriteRequest
for _, res := range results {
if res.err != nil {
return nil, res.err
}
unprocessed = append(unprocessed, res.unprocessed...)
}
return unprocessed, nil
}
// batchWrite makes a single batch write request to DynamoDB and returns any unfulfilled write requests.
func (c *ddbConn) batchWrite(reqs []types.WriteRequest) ([]types.WriteRequest, error) {
out, err := c.conn.BatchWriteItem(context.Background(), &dynamodb.BatchWriteItemInput{
RequestItems: map[string][]types.WriteRequest{c.table: reqs},
})
if err != nil {
return nil, err
}
unprocessed := out.UnprocessedItems[c.table]
metrics.IncrCounter([]string{"dynamodb", "write_capacity"}, float32(len(reqs)-len(unprocessed)))
return unprocessed, nil
}
// Clone returns a read-only copy of the DynamoDB connection.
func (c *ddbConn) Clone() *ddbConn {
if len(c.batch) > 0 {
panic("no outstanding writes are allowed in a cloning ddbConn")
}
return &ddbConn{
conn: c.conn,
table: c.table,
ch: c.ch,
readonly: true,
batch: nil,
}
}
// ddbTransparencyStore implements the TransparencyStore interface over a
// DynamoDB connection.
type ddbTransparencyStore struct {
conn *ddbConn
}
var (
adaptiveRetryer = retry.NewAdaptiveMode(func(opts *retry.AdaptiveModeOptions) {
opts.StandardOptions = append(opts.StandardOptions, func(opts *retry.StandardOptions) {
// Start with a larger token bucket and reduce the cost for non-timeout errors.
// The default is 500 and 5, respectively.
// https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/aws/retry#StandardOptions
opts.RateLimiter = ratelimit.NewTokenRateLimit(1000)
opts.RetryCost = 1
opts.MaxAttempts = 500
opts.MaxBackoff = time.Minute * 30
})
})
)
func NewDynamoDBTransparencyStore(table string, parallel int) (TransparencyStore, error) {
cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRetryer(func() aws.Retryer {
return adaptiveRetryer
}))
if err != nil {
return nil, err
}
return &ddbTransparencyStore{newDDBConn(dynamodb.NewFromConfig(cfg), table, parallel)}, nil
}
// Clone returns a read-only DynamoDB connection for transparency tree data.
func (ddb *ddbTransparencyStore) Clone() TransparencyStore {
return &ddbTransparencyStore{ddb.conn.Clone()}
}
// GetHead fetches and deserializes the latest tree head data from DynamoDB.
func (ddb *ddbTransparencyStore) GetHead() (*TransparencyTreeHead, map[string]*AuditorTreeHead, error) {
latest, err := ddb.conn.Get("root")
if err != nil {
return nil, nil, err
} else if latest == nil {
return &TransparencyTreeHead{}, make(map[string]*AuditorTreeHead), nil
}
return deserializeStoredTreeHead(latest)
}
// Get gets the requested transparency tree data.
func (ddb *ddbTransparencyStore) Get(key uint64) ([]byte, error) {
return ddb.conn.Get("t" + fmt.Sprint(key))
}
// Put adds the specified transparency tree data to the map of outstanding writes.
func (ddb *ddbTransparencyStore) Put(key uint64, data []byte) {
ddb.conn.Put("t"+fmt.Sprint(key), data)
}
// LogStore returns a DynamoDB connection for log tree data.
func (ddb *ddbTransparencyStore) LogStore() LogStore {
return &ddbLogStore{ddb.conn}
}
// PrefixStore returns a DynamoDB connection for prefix tree data.
func (ddb *ddbTransparencyStore) PrefixStore() PrefixStore {
return &ddbPrefixStore{ddb.conn}
}
// StreamStore returns a DynamoDB connection for stream data.
func (ddb *ddbTransparencyStore) StreamStore() StreamStore {
return &ddbStreamStore{ddb.conn.conn, ddb.conn.table}
}
// Commit takes a transparency tree head and auditor tree head as input and commits all outstanding writes
// to DynamoDB.
func (ddb *ddbTransparencyStore) Commit(head *TransparencyTreeHead, auditors map[string]*AuditorTreeHead) error {
raw, err := json.Marshal(&storedTreeHead{head, auditors})
if err != nil {
panic(err)
}
ddb.conn.Put("root", raw)
return ddb.conn.Commit()
}
// ddbLogStore implements the LogStore interface over DynamoDB.
type ddbLogStore struct {
conn *ddbConn
}
// BatchGet fetches the requested log tree data from DynamoDB.
func (ls *ddbLogStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
sKeys := make([]string, len(keys))
for i, key := range keys {
sKeys[i] = "l" + fmt.Sprint(key)
}
data, err := ls.conn.BatchGet(sKeys)
if err != nil {
return nil, err
}
out := make(map[uint64][]byte)
for i, key := range keys {
if val, ok := data[sKeys[i]]; ok {
out[key] = val
}
}
return out, nil
}
// BatchPut adds the specified log tree data to the map of outstanding writes.
func (ls *ddbLogStore) BatchPut(data map[uint64][]byte) {
for key, value := range data {
ls.conn.Put("l"+fmt.Sprint(key), value)
}
}
// ddbPrefixStore implements the PrefixStore interface over DynamoDB.
type ddbPrefixStore struct {
conn *ddbConn
}
// BatchGet fetches the requested prefix tree data from DynamoDB.
func (ps *ddbPrefixStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
sKeys := make([]string, len(keys))
for i, key := range keys {
sKeys[i] = "p" + fmt.Sprint(key)
}
data, err := ps.conn.BatchGet(sKeys)
if err != nil {
return nil, err
}
out := make(map[uint64][]byte)
for i, key := range keys {
if val, ok := data[sKeys[i]]; ok {
out[key] = val
}
}
return out, nil
}
// GetCached fetches the requested prefix tree data from the map of outstanding writes
// and returns nil if the key does not exist.
func (ps *ddbPrefixStore) GetCached(key uint64) []byte {
if value, ok := ps.conn.batch["p"+fmt.Sprint(key)]; ok {
return dup(value)
}
return nil
}
// Put adds the specified prefix tree data to the map of outstanding writes.
func (ps *ddbPrefixStore) Put(key uint64, data []byte) {
ps.conn.Put("p"+fmt.Sprint(key), data)
}
// ddbStreamStore implements the StreamStore interface over DynamoDB.
type ddbStreamStore struct {
conn *dynamodb.Client
table string
}
// key returns a map containing a primary key for the specified shard.
func (ss *ddbStreamStore) key(streamName, shardID string) map[string]types.AttributeValue {
return map[string]types.AttributeValue{
keyLabel: &types.AttributeValueMemberS{
Value: fmt.Sprintf("stream=%v,shardID=%v", streamName, shardID),
},
}
}
// GetCheckpoint returns the last sequence number stored for the specified shard, which allows the stream consumer
// to pick up from where it left off previously in the Kinesis stream.
func (ss *ddbStreamStore) GetCheckpoint(streamName, shardID string) (string, error) {
consistent := true
out, err := ss.conn.GetItem(context.Background(), &dynamodb.GetItemInput{
Key: ss.key(streamName, shardID),
TableName: &ss.table,
ConsistentRead: &consistent,
})
if err != nil {
return "", err
}
metrics.IncrCounterWithLabels(
[]string{"dynamodb", "read_capacity"},
1,
[]metrics.Label{{Name: "consistent", Value: fmt.Sprint(consistent)}},
)
if len(out.Item) == 0 {
return "", nil
}
value, ok := out.Item[valueLabel].(*types.AttributeValueMemberB)
if !ok {
return "", fmt.Errorf("malformed database entry")
}
return string(value.Value), nil
}
// SetCheckpoint stores a sequence number for the specified shard.
func (ss *ddbStreamStore) SetCheckpoint(streamName, shardID string, sequenceNumber string) error {
item := ss.key(streamName, shardID)
item[valueLabel] = &types.AttributeValueMemberB{Value: []byte(sequenceNumber)}
_, err := ss.conn.PutItem(context.Background(), &dynamodb.PutItemInput{
TableName: &ss.table,
Item: item,
})
if err != nil {
return err
}
metrics.IncrCounter([]string{"dynamodb", "write_capacity"}, 1)
return nil
}
// ddbAccount fetches account info from DynamoDB
type ddbAccount struct {
conn *dynamodb.Client
accountDb string
}
func NewAccountDB(accountDb string) (AccountDB, error) {
cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRetryer(func() aws.Retryer {
return adaptiveRetryer
}))
if err != nil {
return nil, err
}
return &ddbAccount{dynamodb.NewFromConfig(cfg), accountDb}, nil
}
func (a *ddbAccount) GetAccountByAci(aci []byte) (*Account, error) {
proj := expression.NamesList(expression.Name(attrCanonicallyDiscoverable), expression.Name(attrUnidentifiedAccessKey))
expr, err := expression.NewBuilder().WithProjection(proj).Build()
if err != nil {
return nil, err
}
result, err := a.conn.GetItem(context.Background(), &dynamodb.GetItemInput{
Key: map[string]types.AttributeValue{
attrAci: &types.AttributeValueMemberB{Value: aci[:]},
},
TableName: aws.String(a.accountDb),
ConsistentRead: aws.Bool(true),
ExpressionAttributeNames: expr.Names(),
ProjectionExpression: expr.Projection(),
})
if err != nil {
return nil, err
}
if len(result.Item) == 0 {
return nil, nil
}
account := Account{}
err = attributevalue.UnmarshalMap(result.Item, &account)
if err != nil {
return nil, err
}
return &account, nil
}

210
db/leveldb.go Normal file
View File

@ -0,0 +1,210 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"encoding/json"
"fmt"
"github.com/syndtr/goleveldb/leveldb"
"github.com/syndtr/goleveldb/leveldb/errors"
)
// ldbConn is a wrapper around a base LevelDB database that handles batching
// writes between commits transparently.
//
// In this service, it is intended to be used for local development.
type ldbConn struct {
conn *leveldb.DB
readonly bool
batch map[string][]byte
}
func newLDBConn(conn *leveldb.DB) *ldbConn {
return &ldbConn{conn, false, make(map[string][]byte)}
}
func (c *ldbConn) Get(key string) ([]byte, error) {
if value, ok := c.batch[key]; ok {
return dup(value), nil
}
return c.conn.Get([]byte(key), nil)
}
func (c *ldbConn) Put(key string, value []byte) {
if c.readonly {
panic("connection is readonly")
}
c.batch[key] = dup(value)
}
func (c *ldbConn) Commit() error {
if c.readonly {
panic("connection is readonly")
}
defer func() {
c.batch = make(map[string][]byte)
}()
b := new(leveldb.Batch)
for key, value := range c.batch {
if key == "root" {
continue
}
b.Put([]byte(key), value)
}
if err := c.conn.Write(b, nil); err != nil {
return err
}
if value, ok := c.batch["root"]; ok {
if err := c.conn.Put([]byte("root"), value, nil); err != nil {
return err
}
}
return nil
}
// ldbTransparencyStore implements the TransparencyStore interface over a
// LevelDB database.
type ldbTransparencyStore struct {
conn *ldbConn
}
func NewLDBTransparencyStore(file string) (TransparencyStore, error) {
conn, err := leveldb.OpenFile(file, nil)
if errors.IsCorrupted(err) {
conn, err = leveldb.RecoverFile(file, nil)
}
if err != nil {
return nil, err
}
return &ldbTransparencyStore{newLDBConn(conn)}, nil
}
func (ldb *ldbTransparencyStore) Clone() TransparencyStore {
return &ldbTransparencyStore{&ldbConn{ldb.conn.conn, true, nil}}
}
func (ldb *ldbTransparencyStore) GetHead() (*TransparencyTreeHead, map[string]*AuditorTreeHead, error) {
latest, err := ldb.conn.Get("root")
if err == leveldb.ErrNotFound {
return &TransparencyTreeHead{}, make(map[string]*AuditorTreeHead), nil
} else if err != nil {
return nil, nil, err
}
return deserializeStoredTreeHead(latest)
}
func (ldb *ldbTransparencyStore) Get(key uint64) ([]byte, error) {
return ldb.conn.Get("t" + fmt.Sprint(key))
}
func (ldb *ldbTransparencyStore) Put(key uint64, data []byte) {
ldb.conn.Put("t"+fmt.Sprint(key), data)
}
func (ldb *ldbTransparencyStore) LogStore() LogStore {
return &ldbLogStore{ldb.conn}
}
func (ldb *ldbTransparencyStore) PrefixStore() PrefixStore {
return &ldbPrefixStore{ldb.conn}
}
func (ldb *ldbTransparencyStore) StreamStore() StreamStore {
return &ldbStreamStore{ldb.conn.conn}
}
func (ldb *ldbTransparencyStore) Commit(head *TransparencyTreeHead, auditors map[string]*AuditorTreeHead) error {
raw, err := json.Marshal(&storedTreeHead{head, auditors})
if err != nil {
panic(err)
}
ldb.conn.Put("root", raw)
return ldb.conn.Commit()
}
// ldbLogStore implements the LogStore interface over LevelDB.
type ldbLogStore struct {
conn *ldbConn
}
func (ls *ldbLogStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
out := make(map[uint64][]byte)
for _, key := range keys {
value, err := ls.conn.Get("l" + fmt.Sprint(key))
if err == leveldb.ErrNotFound {
continue
} else if err != nil {
return nil, err
}
out[key] = value
}
return out, nil
}
func (ls *ldbLogStore) BatchPut(data map[uint64][]byte) {
for key, value := range data {
ls.conn.Put("l"+fmt.Sprint(key), value)
}
}
// ldbPrefixStore implements the PrefixStore interface over LevelDB.
type ldbPrefixStore struct {
conn *ldbConn
}
func (ps *ldbPrefixStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
out := make(map[uint64][]byte)
for _, key := range keys {
value, err := ps.conn.Get("p" + fmt.Sprint(key))
if err != nil {
return nil, err
}
out[key] = value
}
return out, nil
}
func (ps *ldbPrefixStore) GetCached(key uint64) []byte {
if value, ok := ps.conn.batch["p"+fmt.Sprint(key)]; ok {
return dup(value)
}
return nil
}
func (ps *ldbPrefixStore) Put(key uint64, data []byte) {
ps.conn.Put("p"+fmt.Sprint(key), data)
}
// ldbStreamStore implements the StreamStore interface over LevelDB.
type ldbStreamStore struct {
conn *leveldb.DB
}
func (ss *ldbStreamStore) key(streamName, shardID string) []byte {
return []byte(fmt.Sprintf("stream=%v,shardID=%v", streamName, shardID))
}
func (ss *ldbStreamStore) GetCheckpoint(streamName, shardID string) (string, error) {
val, err := ss.conn.Get(ss.key(streamName, shardID), nil)
if err == leveldb.ErrNotFound {
return "", nil
} else if err != nil {
return "", err
}
return string(val), nil
}
func (ss *ldbStreamStore) SetCheckpoint(streamName, shardID, sequenceNumber string) error {
return ss.conn.Put(ss.key(streamName, shardID), []byte(sequenceNumber), nil)
}

283
db/lru.go Normal file
View File

@ -0,0 +1,283 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"sync/atomic"
metrics "github.com/hashicorp/go-metrics"
lru "github.com/hashicorp/golang-lru/v2"
)
func countCacheHit(typ string, hit bool) {
lbls := []metrics.Label{{Name: "type", Value: typ}}
var name []string
if hit {
name = []string{"lru", "cache_hit"}
} else {
name = []string{"lru", "cache_miss"}
}
metrics.IncrCounterWithLabels(name, 1, lbls)
}
const (
TransparencyCache = 1 << iota
LogCache
PrefixCache
HeadCache
)
type headPair struct {
tree *TransparencyTreeHead
auditors map[string]*AuditorTreeHead
}
type cachedTransparencyStore struct {
db TransparencyStore
head *atomic.Value
topCache *lru.Cache[uint64, []byte]
logCache *lru.Cache[uint64, []byte]
prefixCache *lru.Cache[uint64, []byte]
}
type Bitmask uint32
func NewCachedTransparencyStore(db TransparencyStore, cachesToEnable Bitmask, topCacheSize, logCacheSize, prefixCacheSize int) TransparencyStore {
cache := &cachedTransparencyStore{db: db}
var err error
if cachesToEnable&TransparencyCache != 0 {
cache.topCache, err = lru.New[uint64, []byte](topCacheSize)
if err != nil {
panic(err)
}
}
if cachesToEnable&LogCache != 0 {
cache.logCache, err = lru.New[uint64, []byte](logCacheSize)
if err != nil {
panic(err)
}
}
if cachesToEnable&PrefixCache != 0 {
cache.prefixCache, err = lru.New[uint64, []byte](prefixCacheSize)
if err != nil {
panic(err)
}
}
if cachesToEnable&HeadCache != 0 {
cache.head = &atomic.Value{}
}
return cache
}
func (c *cachedTransparencyStore) Clone() TransparencyStore {
return &cachedTransparencyStore{
db: c.db.Clone(),
head: c.head,
topCache: c.topCache,
logCache: c.logCache,
prefixCache: c.prefixCache,
}
}
func (c *cachedTransparencyStore) GetHead() (*TransparencyTreeHead, map[string]*AuditorTreeHead, error) {
if c.head != nil {
if head, ok := c.head.Load().(headPair); ok {
countCacheHit("head", true)
return head.tree.Clone(), cloneAuditorTreeHeadMap(head.auditors), nil
}
countCacheHit("head", false)
}
head, auditors, err := c.db.GetHead()
if err != nil {
return nil, nil, err
}
if c.head != nil {
c.head.Store(headPair{head.Clone(), cloneAuditorTreeHeadMap(auditors)})
}
return head, auditors, nil
}
func (c *cachedTransparencyStore) Get(key uint64) ([]byte, error) {
if c.topCache != nil {
if val, ok := c.topCache.Get(key); ok {
countCacheHit("transparency", true)
return dup(val), nil
}
countCacheHit("transparency", false)
}
val, err := c.db.Get(key)
if err != nil {
return nil, err
}
if c.topCache != nil {
c.topCache.ContainsOrAdd(key, dup(val))
}
return val, nil
}
func (c *cachedTransparencyStore) Put(key uint64, data []byte) {
if c.topCache != nil {
c.topCache.Add(key, dup(data))
}
c.db.Put(key, data)
}
func (c *cachedTransparencyStore) LogStore() LogStore {
return &cachedLogStore{
db: c.db.LogStore(),
cache: c.logCache,
}
}
func (c *cachedTransparencyStore) PrefixStore() PrefixStore {
return &cachedPrefixStore{
db: c.db.PrefixStore(),
cache: c.prefixCache,
}
}
func (c *cachedTransparencyStore) StreamStore() StreamStore { return c.db.StreamStore() }
func (c *cachedTransparencyStore) Commit(head *TransparencyTreeHead, auditors map[string]*AuditorTreeHead) error {
err := c.db.Commit(head, auditors)
if err == nil && c.head != nil {
c.head.Store(headPair{head.Clone(), cloneAuditorTreeHeadMap(auditors)})
}
return err
}
type cachedLogStore struct {
db LogStore
cache *lru.Cache[uint64, []byte]
}
func (c *cachedLogStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
remaining := make([]uint64, 0)
data := make(map[uint64][]byte)
if c.cache != nil {
for _, key := range keys {
if val, ok := c.cache.Get(key); ok {
countCacheHit("log", true)
data[key] = dup(val)
} else {
countCacheHit("log", false)
remaining = append(remaining, key)
}
}
} else {
remaining = keys
}
if len(remaining) > 0 {
partial, err := c.db.BatchGet(remaining)
if err != nil {
return nil, err
}
var readonly bool
switch ls := c.db.(type) {
case *ldbLogStore:
readonly = ls.conn.readonly
case *ddbLogStore:
readonly = ls.conn.readonly
default:
readonly = true
}
for key, val := range partial {
// Only cache fully-formed trees, or trees from a write-enabled connection
// because it does not allow concurrent reads and writes.
cacheable := len(val) == 256 || !readonly
if cacheable && c.cache != nil {
c.cache.ContainsOrAdd(key, dup(val))
}
data[key] = val
}
}
return data, nil
}
func (c *cachedLogStore) BatchPut(data map[uint64][]byte) {
if c.cache != nil {
for key, val := range data {
c.cache.Add(key, dup(val))
}
}
c.db.BatchPut(data)
}
type cachedPrefixStore struct {
db PrefixStore
cache *lru.Cache[uint64, []byte]
}
func (c *cachedPrefixStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
remaining := make([]uint64, 0)
data := make(map[uint64][]byte)
if c.cache != nil {
for _, key := range keys {
if val, ok := c.cache.Get(key); ok {
countCacheHit("prefix", true)
data[key] = dup(val)
} else {
countCacheHit("prefix", false)
remaining = append(remaining, key)
}
}
} else {
remaining = keys
}
if len(remaining) > 0 {
partial, err := c.db.BatchGet(remaining)
if err != nil {
return nil, err
}
for key, val := range partial {
if c.cache != nil {
c.cache.ContainsOrAdd(key, dup(val))
}
data[key] = val
}
}
return data, nil
}
func (c *cachedPrefixStore) GetCached(key uint64) []byte {
if c.cache != nil {
if val, ok := c.cache.Get(key); ok {
countCacheHit("prefix", true)
return dup(val)
}
countCacheHit("prefix", false)
}
return c.db.GetCached(key)
}
func (c *cachedPrefixStore) Put(key uint64, data []byte) {
if c.cache != nil {
c.cache.Add(key, dup(data))
}
c.db.Put(key, data)
}

47
db/lru_test.go Normal file
View File

@ -0,0 +1,47 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"testing"
)
var testNewCachedTransparencyStore = []struct {
cachesToEnable Bitmask
expectPrefixCache bool
expectLogCache bool
expectTopCache bool
expectHeadCache bool
}{
{PrefixCache, true, false, false, false},
{TransparencyCache | PrefixCache | LogCache | HeadCache, true, true, true, true},
{TransparencyCache | PrefixCache | LogCache, true, true, true, false},
}
func TestNewCachedTransparencyStore(t *testing.T) {
mockDb := NewMemoryTransparencyStore()
for _, p := range testNewCachedTransparencyStore {
cachedStore := NewCachedTransparencyStore(mockDb, p.cachesToEnable, 2000, 2000, 20000).(*cachedTransparencyStore)
if p.expectPrefixCache != (cachedStore.prefixCache != nil) {
t.Fatalf("Expect prefix cache: %v, cachedStore.prefixCache != nil is %v", p.expectPrefixCache, cachedStore.prefixCache != nil)
}
if p.expectLogCache != (cachedStore.logCache != nil) {
t.Fatalf("Expect log cache: %v, cachedStore.logCache != nil is %v", p.expectLogCache, cachedStore.logCache != nil)
}
if p.expectTopCache != (cachedStore.topCache != nil) {
t.Fatalf("Expect top cache: %v, cachedStore.topCache != nil is %v", p.expectTopCache, cachedStore.topCache != nil)
}
if p.expectHeadCache != (cachedStore.head != nil) {
t.Fatalf("Expect head cache: %v, cachedStore.head != nil is %v", p.expectHeadCache, cachedStore.head != nil)
}
}
}

106
db/memory.go Normal file
View File

@ -0,0 +1,106 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import "errors"
type memoryTransparencyStore struct {
latest *TransparencyTreeHead
auditors map[string]*AuditorTreeHead
top map[uint64][]byte
log map[uint64][]byte
prefix map[uint64][]byte
}
func NewMemoryTransparencyStore() TransparencyStore {
return &memoryTransparencyStore{
latest: &TransparencyTreeHead{},
top: make(map[uint64][]byte),
log: make(map[uint64][]byte),
prefix: make(map[uint64][]byte),
auditors: make(map[string]*AuditorTreeHead),
}
}
func (m *memoryTransparencyStore) Clone() TransparencyStore { panic("not implemented") }
func (m *memoryTransparencyStore) GetHead() (*TransparencyTreeHead, map[string]*AuditorTreeHead, error) {
return m.latest, m.auditors, nil
}
func (m *memoryTransparencyStore) Get(key uint64) ([]byte, error) {
return m.top[key], nil
}
func (m *memoryTransparencyStore) Put(key uint64, data []byte) {
m.top[key] = data
}
func (m *memoryTransparencyStore) LogStore() LogStore {
return &memoryLogStore{data: m.log}
}
func (m *memoryTransparencyStore) PrefixStore() PrefixStore {
return &memoryPrefixStore{data: m.prefix}
}
func (m *memoryTransparencyStore) StreamStore() StreamStore { panic("not implemented") }
func (m *memoryTransparencyStore) Commit(head *TransparencyTreeHead, auditors map[string]*AuditorTreeHead) error {
m.latest = head
m.auditors = auditors
return nil
}
type memoryLogStore struct {
data map[uint64][]byte
}
func (m *memoryLogStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
out := make(map[uint64][]byte)
for _, key := range keys {
if d, ok := m.data[key]; ok {
out[key] = d
}
}
return out, nil
}
func (m *memoryLogStore) BatchPut(data map[uint64][]byte) {
for key, d := range data {
buf := make([]byte, len(d))
copy(buf, d)
m.data[key] = buf
}
}
type memoryPrefixStore struct {
data map[uint64][]byte
}
func (m *memoryPrefixStore) BatchGet(keys []uint64) (map[uint64][]byte, error) {
out := make(map[uint64][]byte)
for _, key := range keys {
value, ok := m.data[key]
if !ok {
return nil, errors.New("not found")
}
out[key] = value
}
return out, nil
}
func (m *memoryPrefixStore) GetCached(key uint64) []byte { return nil }
func (m *memoryPrefixStore) Put(key uint64, data []byte) {
buf := make([]byte, len(data))
copy(buf, data)
m.data[key] = buf
}

16
db/mock_account_db.go Normal file
View File

@ -0,0 +1,16 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
var (
UnidentifiedAccessKey = []byte("unidentifiedAccessKey")
)
type MockAccountDB struct{}
func (m *MockAccountDB) GetAccountByAci(aci []byte) (*Account, error) {
return &Account{DiscoverableByPhoneNumber: true, UnidentifiedAccessKey: UnidentifiedAccessKey}, nil
}

16
db/shared.go Normal file
View File

@ -0,0 +1,16 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import "encoding/json"
func deserializeStoredTreeHead(raw []byte) (*TransparencyTreeHead, map[string]*AuditorTreeHead, error) {
data := &storedTreeHead{}
if err := json.Unmarshal(raw, data); err != nil {
return nil, nil, err
}
return data.TreeHead, data.AuditorHeads, nil
}

63
db/shared_test.go Normal file
View File

@ -0,0 +1,63 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
import (
"crypto/rand"
"encoding/json"
"testing"
"github.com/kinbiko/jsonassert"
)
func TestSerializeNewStoredTreeHead(t *testing.T) {
// The new stored tree head no longer populates its AuditorHead or TreeHead.Signature fields
newStoredTreeHead := &storedTreeHead{
TreeHead: &TransparencyTreeHead{
TreeSize: 123,
Timestamp: 123456,
//Signature: random(32),
Signatures: []*Signature{
{random(32), random(32)},
},
},
//AuditorHead: &AuditorTreeHead{},
AuditorHeads: map[string]*AuditorTreeHead{
"example-auditor": {
AuditorTransparencyTreeHead: AuditorTransparencyTreeHead{
TreeSize: 234,
Timestamp: 234567,
Signature: random(32),
},
RootValue: random(32),
Consistency: [][]byte{random(32), random(32)},
},
},
}
bytes, err := json.Marshal(newStoredTreeHead)
if err != nil {
t.Fatal("expected no error marshaling new stored tree head")
}
actualJsonString := string(bytes)
expectedJsonString := `{
"head": {"n": 123, "ts": 123456, "sigs": "<<PRESENCE>>"},
"auditor-heads": {
"example-auditor": {"n": 234, "ts": 234567, "sig": "<<PRESENCE>>", "root": "<<PRESENCE>>", "consistency": "<<PRESENCE>>"}
}}`
// go 1.24 requires a constant format string to Printf-like functions
jsonassert.New(t).Assertf(actualJsonString, "%s", expectedJsonString)
}
func random(numBytes int) []byte {
out := make([]byte, numBytes)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}

15
db/util.go Normal file
View File

@ -0,0 +1,15 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package db
func dup(in []byte) []byte {
if in == nil {
return nil
}
out := make([]byte, len(in))
copy(out, in)
return out
}

20
docker-local/README.md Normal file
View File

@ -0,0 +1,20 @@
# docker-local
Docker Compose configuration to support local testing with AWS services.
## Prerequisites
- Docker
## Usage
1. Start the DynamoDB container in the background: `docker compose -f docker-local/docker-compose.yml up -d`
2. Update config.yaml for DynamoDB
- `db.table: kt_local`
- `db.parallel: 2`
3. Run the server
- `AWS_ACCESS_KEY_ID=local AWS_SECRET_ACCESS_KEY=local AWS_ENDPOINT_URL=http://localhost:8000 AWS_REGION=local-kt go run github.com/signalapp/keytransparency/cmd/kt-server -config ./example/config.yaml `
4. Test with kt-client
5. Stop the server
6. Stop the container
- `docker compose -f docker-local/docker-compose.yml down`

View File

@ -0,0 +1,26 @@
name: kt-dynamodb
services:
dynamodb:
image: amazon/dynamodb-local:2.5.2
ports:
- "8000:8000"
dynamodb-init:
image: amazon/aws-cli:latest
environment:
AWS_ACCESS_KEY_ID: local
AWS_SECRET_ACCESS_KEY: local
AWS_ENDPOINT_URL: http://dynamodb:8000
AWS_REGION: local-kt
command:
- dynamodb
- create-table
- --table-name
- kt_local
- --attribute-definitions
- AttributeName=k,AttributeType=S
- --key-schema
- AttributeName=k,KeyType=HASH
- --billing-mode
- PAY_PER_REQUEST
restart: on-failure

8
docker/Dockerfile Normal file
View File

@ -0,0 +1,8 @@
ARG GO_VERSION
FROM --platform=linux/amd64 golang:${GO_VERSION}-alpine AS build
COPY . /src
RUN cd /src/cmd/kt-server && go build
FROM --platform=linux/amd64 alpine:latest AS run
COPY --from=build /src/cmd/kt-server/kt-server /bin/kt-server
ENTRYPOINT ["/bin/kt-server"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
docs/StratumDepth3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

44
docs/database.md Normal file
View File

@ -0,0 +1,44 @@
Database Formats
----------------
This document briefly describes the structure of data that the Key Transparency Server
stores in its database. This server is designed to be run on a key-value database,
which may be eventually consistent.
### Log Tree
The log tree is stored in the database in "chunks", which are 8-node-wide (or
4-node-deep) subtrees. Chunks are addressed by the id of the root node in the
chunk. Only the leaf values of each chunk are stored, which in the context of
the full tree is either a leaf or a cached intermediate hash. These values are
stored concatenated. Intermediate values of a chunk are recomputed from the leaf
nodes as needed.
![Diagram of a Merkle Tree segmented into chunks for storage in the database.](StratumDepth3.png)
Once a chunk is full, it will never be modified again. However the chunks that
represent the "frontier" of the log will be modified to append new hash values
as the tree grows. Because the hashes in a chunk that represent the frontier of
the log are subject to change, they're explicitly not used when computing things
like the root of the log.
### Prefix Tree
The prefix tree is designed to follow a log structure, where each modification
to the prefix tree produces a new entry in the database addressed by a counter.
Log entries contain the key that was modified by this request and pointers to
the log entries on the key's copath, among other data.
![Diagram of a search tree implemented in a persistent way.](PersistentSearchTree.png)
Once an entry is created, it will never be modified again.
### Consistency
All reads to the database are controlled by the "tree size" parameter of the
most recent tree head. The server only accesses entries in the database that it
expects to exist, and that it expects to be done changing, based on the given
tree size. As long as the database prevents a server from observing a tree head
before the server is also able to observe the tree data associated with that
tree head, operations should be able to proceed as expected.

48
example/config.yaml Normal file
View File

@ -0,0 +1,48 @@
kt:
server-addr: localhost:8082
authorized-headers:
ExampleHeader1:
- example value one
- example value two
header-value-to-auditor-name:
example value one: example-auditor-1
example value two: example-auditor-2
kt-query:
server-addr: localhost:8080
# at least one header-value mapping in this map will be required to be present on inbound requests with the configured value
authorized-headers:
ExampleHeader1:
- example value one
- example value two
header-value-to-auditor-name:
example value one: example-auditor-1
example value two: example-auditor-2
kt-test:
server-addr: localhost:8081
metrics-addr: localhost:8083
health-addr: localhost:8084
# Paste in the keys generated via `go run github.com/signalapp/keytransparency/cmd/generate-keys`
api:
signing-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
vrf-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
prefix-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
opening-key: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
# fake:
# count: 1
# interval: 10s
distinguished: 1m
auditors:
example-auditor-1: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
example-auditor-2: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcd
min-search-delay: 1s
min-monitor-delay: 1s
jitter-percent: 10
db:
file: example/db
account-db: mock

File diff suppressed because it is too large Load Diff

2
filter-key-updates/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
target

View File

@ -0,0 +1,9 @@
<extensions xmlns="http://maven.apache.org/EXTENSIONS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/EXTENSIONS/1.0.0 http://maven.apache.org/xsd/core-extensions-1.0.0.xsd">
<extension>
<groupId>fr.brouillard.oss</groupId>
<artifactId>jgitver-maven-plugin</artifactId>
<version>1.9.0</version>
</extension>
</extensions>

View File

@ -0,0 +1,14 @@
<configuration xmlns="http://jgitver.github.io/maven/configuration/1.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://jgitver.github.io/maven/configuration/1.1.0 https://jgitver.github.io/maven/configuration/jgitver-configuration-v1_1_0.xsd">
<useDirty>true</useDirty>
<useDefaultBranchingPolicy>false</useDefaultBranchingPolicy>
<branchPolicies>
<branchPolicy>
<pattern>(.*)</pattern>
<transformations>
<transformation>IGNORE</transformation>
</transformations>
</branchPolicy>
</branchPolicies>
</configuration>

Binary file not shown.

View File

@ -0,0 +1,20 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.4/apache-maven-3.9.4-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar
distributionSha256Sum=e896b60329a71b719d77bb4388b251a50aebcd73c62f69d510c858ce360afe0f
wrapperSha256Sum=e63a53cfb9c4d291ebe3c2b0edacb7622bbc480326beaa5a0456e412f52f066a

308
filter-key-updates/mvnw vendored Executable file
View File

@ -0,0 +1,308 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.2.0
#
# Required ENV vars:
# ------------------
# JAVA_HOME - location of a JDK home dir
#
# Optional ENV vars
# -----------------
# MAVEN_OPTS - parameters passed to the Java VM when running Maven
# e.g. to debug Maven itself, use
# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
# ----------------------------------------------------------------------------
if [ -z "$MAVEN_SKIP_RC" ] ; then
if [ -f /usr/local/etc/mavenrc ] ; then
. /usr/local/etc/mavenrc
fi
if [ -f /etc/mavenrc ] ; then
. /etc/mavenrc
fi
if [ -f "$HOME/.mavenrc" ] ; then
. "$HOME/.mavenrc"
fi
fi
# OS specific support. $var _must_ be set to either true or false.
cygwin=false;
darwin=false;
mingw=false
case "$(uname)" in
CYGWIN*) cygwin=true ;;
MINGW*) mingw=true;;
Darwin*) darwin=true
# Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
# See https://developer.apple.com/library/mac/qa/qa1170/_index.html
if [ -z "$JAVA_HOME" ]; then
if [ -x "/usr/libexec/java_home" ]; then
JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME
else
JAVA_HOME="/Library/Java/Home"; export JAVA_HOME
fi
fi
;;
esac
if [ -z "$JAVA_HOME" ] ; then
if [ -r /etc/gentoo-release ] ; then
JAVA_HOME=$(java-config --jre-home)
fi
fi
# For Cygwin, ensure paths are in UNIX format before anything is touched
if $cygwin ; then
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
[ -n "$CLASSPATH" ] &&
CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
fi
# For Mingw, ensure paths are in UNIX format before anything is touched
if $mingw ; then
[ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] &&
JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)"
fi
if [ -z "$JAVA_HOME" ]; then
javaExecutable="$(which javac)"
if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then
# readlink(1) is not available as standard on Solaris 10.
readLink=$(which readlink)
if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
if $darwin ; then
javaHome="$(dirname "\"$javaExecutable\"")"
javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac"
else
javaExecutable="$(readlink -f "\"$javaExecutable\"")"
fi
javaHome="$(dirname "\"$javaExecutable\"")"
javaHome=$(expr "$javaHome" : '\(.*\)/bin')
JAVA_HOME="$javaHome"
export JAVA_HOME
fi
fi
fi
if [ -z "$JAVACMD" ] ; then
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
else
JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)"
fi
fi
if [ ! -x "$JAVACMD" ] ; then
echo "Error: JAVA_HOME is not defined correctly." >&2
echo " We cannot execute $JAVACMD" >&2
exit 1
fi
if [ -z "$JAVA_HOME" ] ; then
echo "Warning: JAVA_HOME environment variable is not set."
fi
# traverses directory structure from process work directory to filesystem root
# first directory with .mvn subdirectory is considered project base directory
find_maven_basedir() {
if [ -z "$1" ]
then
echo "Path not specified to find_maven_basedir"
return 1
fi
basedir="$1"
wdir="$1"
while [ "$wdir" != '/' ] ; do
if [ -d "$wdir"/.mvn ] ; then
basedir=$wdir
break
fi
# workaround for JBEAP-8937 (on Solaris 10/Sparc)
if [ -d "${wdir}" ]; then
wdir=$(cd "$wdir/.." || exit 1; pwd)
fi
# end of workaround
done
printf '%s' "$(cd "$basedir" || exit 1; pwd)"
}
# concatenates all lines of a file
concat_lines() {
if [ -f "$1" ]; then
# Remove \r in case we run on Windows within Git Bash
# and check out the repository with auto CRLF management
# enabled. Otherwise, we may read lines that are delimited with
# \r\n and produce $'-Xarg\r' rather than -Xarg due to word
# splitting rules.
tr -s '\r\n' ' ' < "$1"
fi
}
log() {
if [ "$MVNW_VERBOSE" = true ]; then
printf '%s\n' "$1"
fi
}
BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
if [ -z "$BASE_DIR" ]; then
exit 1;
fi
MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR
log "$MAVEN_PROJECTBASEDIR"
##########################################################################################
# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
# This allows using the maven wrapper in projects that prohibit checking in binary data.
##########################################################################################
wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
if [ -r "$wrapperJarPath" ]; then
log "Found $wrapperJarPath"
else
log "Couldn't find $wrapperJarPath, downloading it ..."
if [ -n "$MVNW_REPOURL" ]; then
wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
else
wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
fi
while IFS="=" read -r key value; do
# Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
safeValue=$(echo "$value" | tr -d '\r')
case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;;
esac
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
log "Downloading from: $wrapperUrl"
if $cygwin; then
wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
fi
if command -v wget > /dev/null; then
log "Found wget ... using wget"
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
else
wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
fi
elif command -v curl > /dev/null; then
log "Found curl ... using curl"
[ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
else
curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
fi
else
log "Falling back to using Java to download"
javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
# For Cygwin, switch paths to Windows format before running javac
if $cygwin; then
javaSource=$(cygpath --path --windows "$javaSource")
javaClass=$(cygpath --path --windows "$javaClass")
fi
if [ -e "$javaSource" ]; then
if [ ! -e "$javaClass" ]; then
log " - Compiling MavenWrapperDownloader.java ..."
("$JAVA_HOME/bin/javac" "$javaSource")
fi
if [ -e "$javaClass" ]; then
log " - Running MavenWrapperDownloader.java ..."
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
fi
fi
fi
fi
##########################################################################################
# End of extension
##########################################################################################
# If specified, validate the SHA-256 sum of the Maven wrapper jar file
wrapperSha256Sum=""
while IFS="=" read -r key value; do
case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;;
esac
done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
if [ -n "$wrapperSha256Sum" ]; then
wrapperSha256Result=false
if command -v sha256sum > /dev/null; then
if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c - > /dev/null 2>&1; then
wrapperSha256Result=true
fi
elif command -v shasum > /dev/null; then
if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then
wrapperSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available."
echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties."
exit 1
fi
if [ $wrapperSha256Result = false ]; then
echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
exit 1
fi
fi
MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
# For Cygwin, switch paths to Windows format before running java
if $cygwin; then
[ -n "$JAVA_HOME" ] &&
JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
[ -n "$CLASSPATH" ] &&
CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
[ -n "$MAVEN_PROJECTBASEDIR" ] &&
MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
fi
# Provide a "standardized" way to retrieve the CLI args that will
# work with both Windows and non-Windows executions.
MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
export MAVEN_CMD_LINE_ARGS
WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
# shellcheck disable=SC2086 # safe args
exec "$JAVACMD" \
$MAVEN_OPTS \
$MAVEN_DEBUG_OPTS \
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"

205
filter-key-updates/mvnw.cmd vendored Normal file
View File

@ -0,0 +1,205 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.2.0
@REM
@REM Required ENV vars:
@REM JAVA_HOME - location of a JDK home dir
@REM
@REM Optional ENV vars
@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
@REM e.g. to debug Maven itself, use
@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
@REM ----------------------------------------------------------------------------
@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
@echo off
@REM set title of command window
title %0
@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
@REM set %HOME% to equivalent of $HOME
if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
@REM Execute a user defined script before this one
if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
@REM check for pre script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
:skipRcPre
@setlocal
set ERROR_CODE=0
@REM To isolate internal variables from possible post scripts, we use another setlocal
@setlocal
@REM ==== START VALIDATION ====
if not "%JAVA_HOME%" == "" goto OkJHome
echo.
echo Error: JAVA_HOME not found in your environment. >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
:OkJHome
if exist "%JAVA_HOME%\bin\java.exe" goto init
echo.
echo Error: JAVA_HOME is set to an invalid directory. >&2
echo JAVA_HOME = "%JAVA_HOME%" >&2
echo Please set the JAVA_HOME variable in your environment to match the >&2
echo location of your Java installation. >&2
echo.
goto error
@REM ==== END VALIDATION ====
:init
@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
@REM Fallback to current working directory if not found.
set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
set EXEC_DIR=%CD%
set WDIR=%EXEC_DIR%
:findBaseDir
IF EXIST "%WDIR%"\.mvn goto baseDirFound
cd ..
IF "%WDIR%"=="%CD%" goto baseDirNotFound
set WDIR=%CD%
goto findBaseDir
:baseDirFound
set MAVEN_PROJECTBASEDIR=%WDIR%
cd "%EXEC_DIR%"
goto endDetectBaseDir
:baseDirNotFound
set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
cd "%EXEC_DIR%"
:endDetectBaseDir
IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
@setlocal EnableExtensions EnableDelayedExpansion
for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
:endReadAdditionalConfig
SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
)
@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
if exist %WRAPPER_JAR% (
if "%MVNW_VERBOSE%" == "true" (
echo Found %WRAPPER_JAR%
)
) else (
if not "%MVNW_REPOURL%" == "" (
SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar"
)
if "%MVNW_VERBOSE%" == "true" (
echo Couldn't find %WRAPPER_JAR%, downloading it ...
echo Downloading from: %WRAPPER_URL%
)
powershell -Command "&{"^
"$webclient = new-object System.Net.WebClient;"^
"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
"}"^
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
"}"
if "%MVNW_VERBOSE%" == "true" (
echo Finished downloading %WRAPPER_JAR%
)
)
@REM End of extension
@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
SET WRAPPER_SHA_256_SUM=""
FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
)
IF NOT %WRAPPER_SHA_256_SUM%=="" (
powershell -Command "&{"^
"$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
"If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
" Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
" Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
" Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
" exit 1;"^
"}"^
"}"
if ERRORLEVEL 1 goto error
)
@REM Provide a "standardized" way to retrieve the CLI args that will
@REM work with both Windows and non-Windows executions.
set MAVEN_CMD_LINE_ARGS=%*
%MAVEN_JAVA_EXE% ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath %WRAPPER_JAR% ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
%WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
if ERRORLEVEL 1 goto error
goto end
:error
set ERROR_CODE=1
:end
@endlocal & set ERROR_CODE=%ERROR_CODE%
if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
@REM check for post script, once with legacy .bat ending and once with .cmd ending
if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
:skipRcPost
@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
if "%MAVEN_BATCH_PAUSE%"=="on" pause
if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
cmd /C exit /B %ERROR_CODE%

202
filter-key-updates/pom.xml Normal file
View File

@ -0,0 +1,202 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.signal</groupId>
<artifactId>filter-key-updates-lambda</artifactId>
<version>JGITVER</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.13.1</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>2.31.69</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<type>pom</type>
<scope>import</scope>
<version>${junit.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-core</artifactId>
<version>1.3.0</version>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-events</artifactId>
<version>3.16.0</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kinesis</artifactId>
</dependency>
<dependency>
<!-- resolve a dependency resolution conflict by preferring the newer, direct,
versions from software.amazon.awssdk:apache-client. When apache-client updates the httpclient version,
dependency convergence will fail, and we can re-evaluate these exclusions.
-->
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
<exclusions>
<exclusion>
<!--
+-org.signal:filter-key-updates-lambda:jar
+-software.amazon.awssdk:kinesis:jar:2.31.18:compile
+-software.amazon.awssdk:apache-client:jar:2.31.18:runtime
+-org.apache.httpcomponents:httpclient:jar:4.5.13:runtime
+-org.apache.httpcomponents:httpcore:jar:4.4.13:runtime
and
+-org.signal:filter-key-updates-lambda:jar
+-software.amazon.awssdk:kinesis:jar:2.31.18:compile
+-software.amazon.awssdk:apache-client:jar:2.31.18:runtime
+-org.apache.httpcomponents:httpcore:jar:4.4.16:runtime
-->
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore</artifactId>
</exclusion>
<exclusion>
<!--
+-org.signal:filter-key-updates-lambda
+-software.amazon.awssdk:kinesis:2.31.18
+-software.amazon.awssdk:apache-client:2.31.18
+-org.apache.httpcomponents:httpclient:4.5.13
+-commons-codec:commons-codec:1.11
and
+-org.signal:filter-key-updates-lambda
+-software.amazon.awssdk:kinesis:2.31.18
+-software.amazon.awssdk:apache-client:2.31.18
+-commons-codec:commons-codec:1.17.1
-->
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.19.1</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.4.8-jre</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.18.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-tests</artifactId>
<version>1.1.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.bazaarvoice.maven.plugins</groupId>
<artifactId>s3-upload-maven-plugin</artifactId>
<version>2.0.3</version>
<configuration>
<source>${project.build.directory}/${project.build.finalName}.jar</source>
<bucketName>${bucketName}</bucketName>
<destination>${bucketKey}/${project.build.finalName}.jar</destination>
</configuration>
<executions>
<execution>
<id>deploy-to-s3</id>
<phase>deploy</phase>
<goals>
<goal>s3-upload</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<dependencyConvergence/>
<requireMavenVersion>
<version>3.9.4</version>
</requireMavenVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.3</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<version>3.1.4</version>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,111 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.lambda;
import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
record Account(
String number,
byte[] aci,
byte[] aciIdentityKey,
byte[] usernameHash) {
@VisibleForTesting
static final String KEY_ACCOUNT_UUID = "U";
@VisibleForTesting
static final String ATTR_ACCOUNT_E164 = "P";
@VisibleForTesting
static final String ATTR_ACCOUNT_USERNAME_HASH = "N";
@VisibleForTesting
static final String ATTR_ACCOUNT_DATA = "D";
@Override
public boolean equals(final Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Account account = (Account) o;
return Objects.equals(number, account.number) &&
Arrays.equals(aci, account.aci) &&
Arrays.equals(aciIdentityKey, account.aciIdentityKey) &&
Arrays.equals(usernameHash, account.usernameHash);
}
@Override
public String toString() {
return "Account{" +
"number='" + number + '\'' +
", aci=" + Arrays.toString(aci) +
", aciIdentityKey=" + Arrays.toString(aciIdentityKey) +
", usernameHash=" + Arrays.toString(usernameHash) +
'}';
}
public record Pair(Account prev, Account next) {
/** Return a partition key used by Kinesis to group distributed updates.
*
* @return a string that Kinesis uses to group distributed updates. If two Pairs have the same key,
* their updates will go into the same kinesis shard, so their ordering will be maintained. We simply
* make the partition key reliant on the ACI, such that updates to the same account (and thus the same ACI)
* are ordered. Updates to different ACIs may go to different shards in the case where our Kinesis output
* is sharded, and ordering across shards cannot be guaranteed.
*/
public String partitionKey() {
final byte[] aci = prev != null ? prev.aci : next.aci;
return HexFormat.of().formatHex(aci, 0, 4);
}
}
/** Private class used to parse the dynamodb 'D' field containing a base64 encoded JSON with account data in it. */
private record AccountData(String identityKey) { }
private static final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
static Account fromItem(Map<String, AttributeValue> item) {
Preconditions.checkNotNull(item.get(KEY_ACCOUNT_UUID));
Preconditions.checkNotNull(item.get(ATTR_ACCOUNT_E164));
Preconditions.checkNotNull(item.get(ATTR_ACCOUNT_DATA));
final byte[] uuid = new byte[16];
item.get(KEY_ACCOUNT_UUID).getB().get(uuid);
final ByteBuffer data = item.get(ATTR_ACCOUNT_DATA).getB().asReadOnlyBuffer();
final byte[] identityKey;
try {
identityKey = Base64.getDecoder()
.decode(objectMapper.readValue(new ByteBufferInputStream(data), AccountData.class).identityKey);
} catch (IOException e) {
throw new UncheckedIOException("IOException from reading bytes array", e);
}
final String number = item.get(ATTR_ACCOUNT_E164).getS();
final byte[] usernameHash;
final AttributeValue usernameHashAv = item.get(ATTR_ACCOUNT_USERNAME_HASH);
if (usernameHashAv != null) {
usernameHash = new byte[32];
usernameHashAv.getB().asReadOnlyBuffer().get(usernameHash);
} else {
usernameHash = null;
}
return new Account(
number,
uuid,
identityKey,
usernameHash);
}
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.lambda;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
public class ByteBufferInputStream extends InputStream {
public ByteBufferInputStream(ByteBuffer buf) {
this.buf = buf.slice();
}
private final ByteBuffer buf;
@Override
public int read() throws IOException {
if (!buf.hasRemaining())
return -1;
return buf.get() & 0xff;
}
@Override
public int read(byte[] b, int offset, int length) throws IOException {
if (length > buf.remaining()) length = buf.remaining();
buf.get(b, offset, length);
return length;
}
}

View File

@ -0,0 +1,104 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.lambda;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.lambda.runtime.events.DynamodbEvent;
import com.amazonaws.services.lambda.runtime.events.StreamsEventResponse;
import com.amazonaws.services.lambda.runtime.events.models.dynamodb.AttributeValue;
import com.amazonaws.services.lambda.runtime.events.models.dynamodb.StreamRecord;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.kinesis.KinesisClient;
import software.amazon.awssdk.services.kinesis.model.PutRecordRequest;
import java.io.IOException;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Filters DynamoDb record updates for the subset relevant to key transparency, outputting them to Kinesis
*/
public class FilterKTUpdatesHandler implements RequestHandler<DynamodbEvent, Serializable> {
private static final String KINESIS_OUTPUT_STREAM_ENVIRONMENT_VARIABLE = "KINESIS_OUTPUT_STREAM";
private static final String KINESIS_OUTPUT_REGION_ENVIRONMENT_VARIABLE = "KINESIS_OUTPUT_REGION";
@VisibleForTesting
static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final KinesisClient kinesisClient;
private final String kinesisOutputStream;
public FilterKTUpdatesHandler() {
this(KinesisClient.builder()
.region(Region.of(System.getenv(KINESIS_OUTPUT_REGION_ENVIRONMENT_VARIABLE)))
.build(),
System.getenv(KINESIS_OUTPUT_STREAM_ENVIRONMENT_VARIABLE));
}
@VisibleForTesting
FilterKTUpdatesHandler(KinesisClient kinesisClient, String outputStream) {
this.kinesisClient = kinesisClient;
this.kinesisOutputStream = outputStream;
}
// https://docs.aws.amazon.com/lambda/latest/dg/with-ddb-create-package.html
@Override
public StreamsEventResponse handleRequest(final DynamodbEvent dbUpdate, final Context context) {
List<StreamsEventResponse.BatchItemFailure> batchItemFailures = new ArrayList<>();
String curRecordSequenceNumber = "";
for (DynamodbEvent.DynamodbStreamRecord record : dbUpdate.getRecords()) {
StreamRecord dbRecord = record.getDynamodb();
curRecordSequenceNumber = dbRecord.getSequenceNumber();
try {
processRecord(dbRecord);
} catch (Exception e) {
batchItemFailures.add(new StreamsEventResponse.BatchItemFailure(curRecordSequenceNumber));
e.printStackTrace();
}
}
return new StreamsEventResponse(batchItemFailures);
}
// Modeled after https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/kds_gettingstarted.html
@VisibleForTesting
void processRecord(StreamRecord dbRecord) throws IOException {
Account.Pair update = dbUpdateFor(dbRecord);
if (update == null) return;
kinesisClient.putRecord(PutRecordRequest
.builder()
.data(SdkBytes.fromByteArray(OBJECT_MAPPER.writeValueAsBytes(update)))
.partitionKey(update.partitionKey())
.streamName(kinesisOutputStream)
.build());
}
private Account.Pair dbUpdateFor(StreamRecord dbRecord) {
Map<String, AttributeValue> oldImage = dbRecord.getOldImage();
Map<String, AttributeValue> newImage = dbRecord.getNewImage();
if (oldImage == null || oldImage.isEmpty()) {
return new Account.Pair(null, Account.fromItem(newImage));
} else if (newImage == null || newImage.isEmpty()) {
return new Account.Pair(Account.fromItem(oldImage), null);
}
Account oldAccount = Account.fromItem(oldImage);
Account newAccount = Account.fromItem(newImage);
if (oldAccount.equals(newAccount)) {
return null;
} else {
return new Account.Pair(oldAccount, newAccount);
}
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.lambda;
import org.junit.jupiter.api.Test;
import java.nio.ByteBuffer;
import static org.junit.jupiter.api.Assertions.*;
class ByteBufferInputStreamTest {
@Test
void testReadByte() throws Exception {
ByteBuffer b = ByteBuffer.allocate(4);
b.put(new byte[]{1,2,3,4});
ByteBufferInputStream bbis = new ByteBufferInputStream(b.flip().asReadOnlyBuffer());
assertEquals(1, bbis.read());
assertEquals(2, bbis.read());
assertEquals(3, bbis.read());
assertEquals(4, bbis.read());
assertEquals(-1, bbis.read());
}
@Test
void testReadByteArray() throws Exception {
ByteBuffer b = ByteBuffer.allocate(4);
b.put(new byte[]{1,2,3,4});
ByteBufferInputStream bbis = new ByteBufferInputStream(b.flip().asReadOnlyBuffer());
byte output[] = new byte[10];
assertEquals(4, bbis.read(output));
assertArrayEquals(new byte[]{1,2,3,4,0,0,0,0,0,0}, output);
assertEquals(0, bbis.read(output));
}
@Test
void testReadByteArrayOffsetLength() throws Exception {
ByteBuffer b = ByteBuffer.allocate(4);
b.put(new byte[]{1,2,3,4});
ByteBufferInputStream bbis = new ByteBufferInputStream(b.flip().asReadOnlyBuffer());
byte output[] = new byte[10];
assertEquals(2, bbis.read(output, 2, 2));
assertEquals(2, bbis.read(output, 4, 6));
assertArrayEquals(new byte[]{0,0,1,2,3,4,0,0,0,0}, output);
assertEquals(0, bbis.read(output));
}
@Test
void testReadNegativeByte() throws Exception {
ByteBuffer b = ByteBuffer.allocate(4);
b.put(new byte[]{1,-1});
ByteBufferInputStream bbis = new ByteBufferInputStream(b.flip().asReadOnlyBuffer());
assertEquals(1, bbis.read());
assertEquals(255, bbis.read());
assertEquals(-1, bbis.read());
}
@Test
void testReadNegativeByteInArray() throws Exception {
ByteBuffer b = ByteBuffer.allocate(4);
b.put(new byte[]{1,-1});
ByteBufferInputStream bbis = new ByteBufferInputStream(b.flip().asReadOnlyBuffer());
byte output[] = new byte[10];
assertEquals(2, bbis.read(output, 0, 10));
assertArrayEquals(new byte[]{1, -1, 0, 0, 0, 0, 0, 0, 0, 0}, output);
}
}

View File

@ -0,0 +1,104 @@
/*
* Copyright 2022 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.lambda;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.events.DynamodbEvent;
import com.amazonaws.services.lambda.runtime.events.StreamsEventResponse;
import com.amazonaws.services.lambda.runtime.tests.EventLoader;
import java.io.IOException;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.kinesis.KinesisClient;
import software.amazon.awssdk.services.kinesis.model.PutRecordRequest;
// Modeled after https://aws.amazon.com/blogs/opensource/testing-aws-lambda-functions-written-in-java/
class FilterKTUpdatesHandlerTest {
private static byte[] b64(String b) {
return Base64.getDecoder().decode(b);
}
static final byte[] PREV_ACI = b64("IiIiIiIiIiIiIiIiIiIiIg==");
static final byte[] PREV_ACI_KEY = b64("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
static final String PREV_NUM = "+111111111";
static final byte[] NEXT_ACI_KEY = b64("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ");
static final String NEXT_NUM = "+999999999";
static final byte[] PREV_USERHASH = b64("DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD");
static final byte[] NEXT_USERHASH = b64("EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE");
@ParameterizedTest
@MethodSource
void handleRequest(final String filename, final Account.Pair expected) {
final DynamodbEvent event = EventLoader.loadDynamoDbEvent(filename);
KinesisClient mockClient = mock(KinesisClient.class);
FilterKTUpdatesHandler handler = new FilterKTUpdatesHandler(mockClient, "mystream");
Context contextMock = mock(Context.class);
final StreamsEventResponse streamsEventResponse = handler.handleRequest(event, contextMock);
assertTrue(streamsEventResponse.getBatchItemFailures().isEmpty());
ArgumentCaptor<PutRecordRequest> captor = ArgumentCaptor.forClass(PutRecordRequest.class);
verify(mockClient, times(expected == null ? 0 : 1)).putRecord(captor.capture());
if (expected != null) {
List<Account.Pair> accounts = captor.getAllValues().stream().map(c -> mapWithoutException(c.data())).toList();
assertEquals(expected, accounts.get(0));
}
}
private static Stream<Arguments> handleRequest() {
return Stream.of(
Arguments.of(
"testevent_numberchange.json",
new Account.Pair(
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, null),
new Account(NEXT_NUM, PREV_ACI, PREV_ACI_KEY, null))),
Arguments.of(
"testevent_acikeychange.json",
new Account.Pair(
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, null),
new Account(PREV_NUM, PREV_ACI, NEXT_ACI_KEY, null))),
Arguments.of(
"testevent_nochange.json", null),
Arguments.of(
"testevent_registration1.json",
new Account.Pair(
null,
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, null))),
Arguments.of(
"testevent_registration2.json",
new Account.Pair(
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, null),
new Account(PREV_NUM, PREV_ACI, NEXT_ACI_KEY, null))),
Arguments.of(
"testevent_userhashchange.json",
new Account.Pair(
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, PREV_USERHASH),
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, NEXT_USERHASH))),
Arguments.of(
"testevent_userhashadd.json",
new Account.Pair(
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, null),
new Account(PREV_NUM, PREV_ACI, PREV_ACI_KEY, NEXT_USERHASH))));
}
Account.Pair mapWithoutException(SdkBytes in) {
try {
return FilterKTUpdatesHandler.OBJECT_MAPPER.readValue(in.asInputStream(), Account.Pair.class);
} catch (IOException e) {
throw new RuntimeException("mapping", e);
}
}
}

View File

@ -0,0 +1,44 @@
{
"Records": [
{
"awsRegion": "us-east-1",
"eventID": "88888888-85b6-4a4b-a74e-bf4688888888",
"eventName": "MODIFY",
"userIdentity": null,
"recordFormat": "application/json",
"tableName": "Signal_Accounts_Staging",
"dynamodb": {
"ApproximateCreationDateTime": 1630110857000,
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"OldImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"NewImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWloiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"SizeBytes": 7001
},
"eventSource": "aws:dynamodb"
}
]
}

View File

@ -0,0 +1,44 @@
{
"Records": [
{
"awsRegion": "us-east-1",
"eventID": "88888888-85b6-4a4b-a74e-bf4688888888",
"eventName": "MODIFY",
"userIdentity": null,
"recordFormat": "application/json",
"tableName": "Signal_Accounts_Staging",
"dynamodb": {
"ApproximateCreationDateTime": 1630110857000,
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"OldImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"NewImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"SizeBytes": 7001
},
"eventSource": "aws:dynamodb"
}
]
}

View File

@ -0,0 +1,44 @@
{
"Records": [
{
"awsRegion": "us-east-1",
"eventID": "88888888-85b6-4a4b-a74e-bf4688888888",
"eventName": "MODIFY",
"userIdentity": null,
"recordFormat": "application/json",
"tableName": "Signal_Accounts_Staging",
"dynamodb": {
"ApproximateCreationDateTime": 1630110857000,
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"OldImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"NewImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+999999999"
},
"D": {
"B": "eyJudW1iZXIiOiIrOTk5OTk5OTk5IiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"SizeBytes": 7001
},
"eventSource": "aws:dynamodb"
}
]
}

View File

@ -0,0 +1,33 @@
{
"Records": [
{
"eventID": "de07800b8d02f0649ee34aa542ee94d0",
"eventName": "INSERT",
"eventVersion": "1.1",
"eventSource": "aws:dynamodb",
"awsRegion": "us-east-1",
"dynamodb": {
"ApproximateCreationDateTime": "2023-09-20T02:01:56-06:00",
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"NewImage": {
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W3siaWQiOjEsIm5hbWUiOm51bGwsImF1dGhUb2tlbiI6IjIuYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsInNhbHQiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsImdjbUlkIjpudWxsLCJhcG5JZCI6bnVsbCwidm9pcEFwbklkIjpudWxsLCJwdXNoVGltZXN0YW1wIjowLCJ1bmluc3RhbGxlZEZlZWRiYWNrIjowLCJmZXRjaGVzTWVzc2FnZXMiOnRydWUsInJlZ2lzdHJhdGlvbklkIjoxMTExLCJzaWduZWRQcmVLZXkiOm51bGwsImxhc3RTZWVuIjoxMTExMTExMTExMTExLCJjcmVhdGVkIjoxMTExMTExMTExMTExLCJ1c2VyQWdlbnQiOiJTaWduYWwtQW5kcm9pZC82LjI4LjEgc2lnbmFsLWNsaS8wLjEyLjAiLCJjYXBhYmlsaXRpZXMiOnsic3RvcmFnZSI6ZmFsc2UsInRyYW5zZmVyIjpmYWxzZSwicG5pIjpmYWxzZSwicGF5bWVudEFjdGl2YXRpb24iOmZhbHNlfSwicG5pUmVnaXN0cmF0aW9uSWQiOjExMTExLCJwbmlTaWduZWRQcmVLZXkiOm51bGx9XSwiaWRlbnRpdHlLZXkiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsImJhZGdlcyI6W10sInJlZ2lzdHJhdGlvbkxvY2siOm51bGwsInJlZ2lzdHJhdGlvbkxvY2tTYWx0IjpudWxsLCJ2ZXJzaW9uIjowLCJwbmkiOiJjY2NjY2NjYy1jY2NjLWNjY2MtZGRkZC1kZGRkZGRkZGRkZGQiLCJldSI6bnVsbCwicG5pSWRlbnRpdHlLZXkiOm51bGwsImNwdiI6bnVsbCwidWFrIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9"
},
"P": {
"S": "+111111111"
},
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"SequenceNumber": "131421792200000000021909195349",
"SizeBytes": 952,
"StreamViewType": "NEW_AND_OLD_IMAGES"
}
}
]
}

View File

@ -0,0 +1,44 @@
{
"Records": [
{
"eventID": "45f94b43b82bffb83ea4056358205a7c",
"eventName": "MODIFY",
"eventVersion": "1.1",
"eventSource": "aws:dynamodb",
"awsRegion": "us-east-1",
"dynamodb": {
"ApproximateCreationDateTime": "2023-09-20T02:01:56-06:00",
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"NewImage": {
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W3siaWQiOjEsIm5hbWUiOm51bGwsImF1dGhUb2tlbiI6IjIuYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsInNhbHQiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsImdjbUlkIjpudWxsLCJhcG5JZCI6bnVsbCwidm9pcEFwbklkIjpudWxsLCJwdXNoVGltZXN0YW1wIjowLCJ1bmluc3RhbGxlZEZlZWRiYWNrIjowLCJmZXRjaGVzTWVzc2FnZXMiOnRydWUsInJlZ2lzdHJhdGlvbklkIjoxMTExLCJzaWduZWRQcmVLZXkiOnsia2V5SWQiOjEsInB1YmxpY0tleSI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBIiwic2lnbmF0dXJlIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEifSwibGFzdFNlZW4iOjExMTExMTExMTExMTEsImNyZWF0ZWQiOjExMTExMTExMTExMTEsInVzZXJBZ2VudCI6IlNpZ25hbC1BbmRyb2lkLzYuMjguMSBzaWduYWwtY2xpLzAuMTIuMCIsImNhcGFiaWxpdGllcyI6eyJzdG9yYWdlIjpmYWxzZSwidHJhbnNmZXIiOmZhbHNlLCJwbmkiOmZhbHNlLCJwYXltZW50QWN0aXZhdGlvbiI6ZmFsc2V9LCJwbmlSZWdpc3RyYXRpb25JZCI6MTExMTEsInBuaVNpZ25lZFByZUtleSI6eyJrZXlJZCI6MSwicHVibGljS2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJzaWduYXR1cmUiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSJ9fV0sImlkZW50aXR5S2V5IjoiWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWloiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MCwicG5pIjoiY2NjY2NjY2MtY2NjYy1jY2NjLWRkZGQtZGRkZGRkZGRkZGRkIiwiZXUiOm51bGwsInBuaUlkZW50aXR5S2V5IjoiWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVkiLCJjcHYiOm51bGwsInVhayI6IkFBQUFBQUFBQUFBQUFBQUFBQUFBQUE9PSIsInV1YSI6ZmFsc2UsImluQ2RzIjp0cnVlfQo="
},
"P": {
"S": "+111111111"
},
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"OldImage": {
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W3siaWQiOjEsIm5hbWUiOm51bGwsImF1dGhUb2tlbiI6IjIuYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsInNhbHQiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYSIsImdjbUlkIjpudWxsLCJhcG5JZCI6bnVsbCwidm9pcEFwbklkIjpudWxsLCJwdXNoVGltZXN0YW1wIjowLCJ1bmluc3RhbGxlZEZlZWRiYWNrIjowLCJmZXRjaGVzTWVzc2FnZXMiOnRydWUsInJlZ2lzdHJhdGlvbklkIjoxMTExLCJzaWduZWRQcmVLZXkiOm51bGwsImxhc3RTZWVuIjoxMTExMTExMTExMTExLCJjcmVhdGVkIjoxMTExMTExMTExMTExLCJ1c2VyQWdlbnQiOiJTaWduYWwtQW5kcm9pZC82LjI4LjEgc2lnbmFsLWNsaS8wLjEyLjAiLCJjYXBhYmlsaXRpZXMiOnsic3RvcmFnZSI6ZmFsc2UsInRyYW5zZmVyIjpmYWxzZSwicG5pIjpmYWxzZSwicGF5bWVudEFjdGl2YXRpb24iOmZhbHNlfSwicG5pUmVnaXN0cmF0aW9uSWQiOjExMTExLCJwbmlTaWduZWRQcmVLZXkiOm51bGx9XSwiaWRlbnRpdHlLZXkiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsImJhZGdlcyI6W10sInJlZ2lzdHJhdGlvbkxvY2siOm51bGwsInJlZ2lzdHJhdGlvbkxvY2tTYWx0IjpudWxsLCJ2ZXJzaW9uIjowLCJwbmkiOiJjY2NjY2NjYy1jY2NjLWNjY2MtZGRkZC1kZGRkZGRkZGRkZGQiLCJldSI6bnVsbCwicG5pSWRlbnRpdHlLZXkiOm51bGwsImNwdiI6bnVsbCwidWFrIjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQT09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9"
},
"P": {
"S": "+111111111"
},
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"SequenceNumber": "131421792300000000021909195420",
"SizeBytes": 2306,
"StreamViewType": "NEW_AND_OLD_IMAGES"
}
}
]
}

View File

@ -0,0 +1,47 @@
{
"Records": [
{
"awsRegion": "us-east-1",
"eventID": "88888888-85b6-4a4b-a74e-bf4688888888",
"eventName": "MODIFY",
"userIdentity": null,
"recordFormat": "application/json",
"tableName": "Signal_Accounts_Staging",
"dynamodb": {
"ApproximateCreationDateTime": 1630110857000,
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"OldImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjpudWxsLCJyZXNlcnZlZFVzZXJuYW1lSGFzaCI6bnVsbCwiZGV2aWNlcyI6W10sImlkZW50aXR5S2V5IjoiQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUEiLCJiYWRnZXMiOltdLCJyZWdpc3RyYXRpb25Mb2NrIjpudWxsLCJyZWdpc3RyYXRpb25Mb2NrU2FsdCI6bnVsbCwidmVyc2lvbiI6MTMsInBuaSI6ImNjY2NjY2NjLWNjY2MtY2NjYy1kZGRkLWRkZGRkZGRkZGRkZCIsInBuaUlkZW50aXR5S2V5IjoiQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIiLCJjcHYiOiJhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhIiwidWFrIjoiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYj09IiwidXVhIjpmYWxzZSwiaW5DZHMiOnRydWV9Cg=="
}
},
"NewImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"N": {
"B": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE="
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjoiRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRSIsInJlc2VydmVkVXNlcm5hbWVIYXNoIjpudWxsLCJkZXZpY2VzIjpbXSwiaWRlbnRpdHlLZXkiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsImJhZGdlcyI6W10sInJlZ2lzdHJhdGlvbkxvY2siOm51bGwsInJlZ2lzdHJhdGlvbkxvY2tTYWx0IjpudWxsLCJ2ZXJzaW9uIjoxMywicG5pIjoiY2NjY2NjY2MtY2NjYy1jY2NjLWRkZGQtZGRkZGRkZGRkZGRkIiwicG5pSWRlbnRpdHlLZXkiOiJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQiIsImNwdiI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJ1YWsiOiJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiPT0iLCJ1dWEiOmZhbHNlLCJpbkNkcyI6dHJ1ZX0K"
}
},
"SizeBytes": 7001
},
"eventSource": "aws:dynamodb"
}
]
}

View File

@ -0,0 +1,50 @@
{
"Records": [
{
"awsRegion": "us-east-1",
"eventID": "88888888-85b6-4a4b-a74e-bf4688888888",
"eventName": "MODIFY",
"userIdentity": null,
"recordFormat": "application/json",
"tableName": "Signal_Accounts_Staging",
"dynamodb": {
"ApproximateCreationDateTime": 1630110857000,
"Keys": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
}
},
"OldImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"N": {
"B": "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDA="
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjoiRERERERERERERERERERERERERERERERERERERERERERERERERERERERERCIsInJlc2VydmVkVXNlcm5hbWVIYXNoIjpudWxsLCJkZXZpY2VzIjpbXSwiaWRlbnRpdHlLZXkiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsImJhZGdlcyI6W10sInJlZ2lzdHJhdGlvbkxvY2siOm51bGwsInJlZ2lzdHJhdGlvbkxvY2tTYWx0IjpudWxsLCJ2ZXJzaW9uIjoxMywicG5pIjoiY2NjY2NjY2MtY2NjYy1jY2NjLWRkZGQtZGRkZGRkZGRkZGRkIiwicG5pSWRlbnRpdHlLZXkiOiJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQiIsImNwdiI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJ1YWsiOiJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiPT0iLCJ1dWEiOmZhbHNlLCJpbkNkcyI6dHJ1ZX0K"
}
},
"NewImage": {
"U": {
"B": "IiIiIiIiIiIiIiIiIiIiIg=="
},
"P": {
"S": "+111111111"
},
"N": {
"B": "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE="
},
"D": {
"B": "eyJudW1iZXIiOiIrMTExMTExMTExIiwidXNlcm5hbWVIYXNoIjoiRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRSIsInJlc2VydmVkVXNlcm5hbWVIYXNoIjpudWxsLCJkZXZpY2VzIjpbXSwiaWRlbnRpdHlLZXkiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQSIsImJhZGdlcyI6W10sInJlZ2lzdHJhdGlvbkxvY2siOm51bGwsInJlZ2lzdHJhdGlvbkxvY2tTYWx0IjpudWxsLCJ2ZXJzaW9uIjoxMywicG5pIjoiY2NjY2NjY2MtY2NjYy1jY2NjLWRkZGQtZGRkZGRkZGRkZGRkIiwicG5pSWRlbnRpdHlLZXkiOiJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQiIsImNwdiI6ImFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWEiLCJ1YWsiOiJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiPT0iLCJ1dWEiOmZhbHNlLCJpbkNkcyI6dHJ1ZX0K"
}
},
"SizeBytes": 7001
},
"eventSource": "aws:dynamodb"
}
]
}

70
go.mod Normal file
View File

@ -0,0 +1,70 @@
module github.com/signalapp/keytransparency
go 1.24.0
toolchain go1.24.4
require (
filippo.io/edwards25519 v1.1.0
github.com/aws/aws-sdk-go-v2 v1.36.5
github.com/aws/aws-sdk-go-v2/config v1.29.17
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.3
github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.85
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.4
github.com/aws/aws-sdk-go-v2/service/kinesis v1.35.3
github.com/google/uuid v1.6.0
github.com/harlow/kinesis-consumer v0.3.6
github.com/hashicorp/go-metrics v0.5.4
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/kinbiko/jsonassert v1.2.0
github.com/prometheus/client_golang v1.22.0
github.com/rs/zerolog v1.34.0
github.com/stretchr/testify v1.10.0
github.com/syndtr/goleveldb v1.0.0
golang.org/x/sync v0.15.0
golang.org/x/text v0.26.0
google.golang.org/grpc v1.73.0
google.golang.org/protobuf v1.36.6
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/DataDog/datadog-go v4.8.3+incompatible // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/awslabs/kinesis-aggregation/go v0.0.0-20241004223953-c2774b1ab29b // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

484
go.sum Normal file
View File

@ -0,0 +1,484 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/DataDog/datadog-go v4.8.3+incompatible h1:fNGaYSuObuQb5nzeTQqowRAd9bpDIRRV4/gUtIBjh8Q=
github.com/DataDog/datadog-go v4.8.3+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
github.com/apex/log v1.6.0/go.mod h1:x7s+P9VtvFBXge9Vbn+8TrqKmuzmD35TTkeBHul8UtY=
github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=
github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE=
github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
github.com/aws/aws-sdk-go v1.19.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
github.com/aws/aws-sdk-go-v2 v1.8.1/go.mod h1:xEFuWz+3TYdlPRuo+CqATbeDWIWyaT5uAPwPaWtgse0=
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2 v1.11.2/go.mod h1:SQfA+m2ltnu1cA0soUkj4dRSsmITiVQUJvBIZjzfPyQ=
github.com/aws/aws-sdk-go-v2 v1.36.5 h1:0OF9RiEMEdDdZEMqF9MRjevyxAQcf6gY+E7vwBILFj0=
github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
github.com/aws/aws-sdk-go-v2/config v1.6.1/go.mod h1:t/y3UPu0XEDy0cEw6mvygaBQaPzWiYAxfP2SzgtvclA=
github.com/aws/aws-sdk-go-v2/config v1.29.17 h1:jSuiQ5jEe4SAMH6lLRMY9OVC+TqJLP5655pBGjmnjr0=
github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8=
github.com/aws/aws-sdk-go-v2/credentials v1.3.3/go.mod h1:oVieKMT3m9BSfqhOfuQ+E0j/yN84ZAJ7Qv8Sfume/ak=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 h1:ONnH5CM16RTXRkS8Z1qg7/s2eDOhHhaXVd72mmyv4/0=
github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.2.0/go.mod h1:UVFtSYSWCHj2+brBLDHUdlJXmz8LxUpZhA+Ewypc+xQ=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.3 h1:xQYRnbQ+ypDMCLiFlLw5cF7Xd6K+oaL7jco2zwIMqTs=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.3/go.mod h1:X7RC8FFkx0bjNJRBddd3xdoDaDmNLSxICFdIdJ7asqw=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.85 h1:2iCEhB7qQIxjvaSwal54ySXisTrpgVAT3oAKlzo723A=
github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.85/go.mod h1:WIlhfHIvyaXSoQg70L3iXZzKihNu0r4HQg6Abf/y0bQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.1/go.mod h1:+GTydg3uHmVlQdkRoetz6VHKbOMEYof70m19IpMLifc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 h1:KAXP9JSHO1vKGCr5f4O6WmlVKLFFXgWYAGoJosorxzU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.0.4/go.mod h1:W5gGbtNXFpF9/ssYZTaItzG/B+j0bjTnwStiCP2AtWU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 h1:SsytQyTMHMDPspp+spo7XwXTP44aJZZAC7fBV2C5+5s=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 h1:i2vNHQiXUvKhs3quBR6aqlgJaiaexz/aNvdCktW/kAM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.1/go.mod h1:Pv3WenDjI0v2Jl7UaMFIIbPOBbhn33RmmAmGgkXDoqY=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.5.0/go.mod h1:XY5YhCS9SLul3JSQ08XG/nfxXxrkh6RR21XPq/J//NY=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.4 h1:Rv6o9v2AfdEIKoAa7pQpJ5ch9ji2HevFUvGY6ufawlI=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.4/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.4.0/go.mod h1:bYsEP8w5YnbYyrx/Zi5hy4hTwRRQISSJS3RWrsGRijg=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.6 h1:QHaS/SHXfyNycuu4GiWb+AfW5T3bput6X5E3Ai/Q31M=
github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.6/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.3.0/go.mod h1:v8ygadNyATSm6elwJ/4gzJwcFhri9RqS8skgHKiwXPU=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.1.0/go.mod h1:enkU5tq2HoXY+ZMiQprgF3Q83T3PbO77E83yXXzRZWE=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.3/go.mod h1:7gcsONBmFoCcKrAqrm95trrMd2+C/ReYKP7Vfu8yHHA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.6.0/go.mod h1:9O7UG2pELnP0hq35+Gd7XDjOLBkg7tmgRQ0y14ZjoJI=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.35.3 h1:aAi9YBNpYMEX52Z9qy1YP2t3RhDqMcP67Ep/C4q5RiQ=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.35.3/go.mod h1:DH0TzTbBG82HKNpBQlplRNSS4bGz0dsbJvxdK9f6rUY=
github.com/aws/aws-sdk-go-v2/service/sso v1.3.3/go.mod h1:Jgw5O+SK7MZ2Yi9Yvzb4PggAPYaFSliiQuWR0hNjexk=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 h1:AIRJ3lfb2w/1/8wOOSqYb9fUKGwQbtysJ2H1MofRUPg=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 h1:BpOxT3yhLwSJ77qIY3DoHAQjZsc4HEGfMCE4NGy3uFg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E=
github.com/aws/aws-sdk-go-v2/service/sts v1.6.2/go.mod h1:RBhoMJB8yFToaCnbe0jNq5Dcdy0jp6LhHqg55rjClkM=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 h1:NFOJ/NXEGV4Rq//71Hs1jC/NvPs1ezajK+yQmkwnPV0=
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w=
github.com/aws/smithy-go v1.7.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=
github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/awslabs/kinesis-aggregation/go v0.0.0-20210630091500-54e17340d32f/go.mod h1:SghidfnxvX7ribW6nHI7T+IBbc9puZ9kk5Tx/88h8P4=
github.com/awslabs/kinesis-aggregation/go v0.0.0-20241004223953-c2774b1ab29b h1:wB+kTYjUTTEpd7HBzetdxlk92dBLhhN04T5nd4fa7Nk=
github.com/awslabs/kinesis-aggregation/go v0.0.0-20241004223953-c2774b1ab29b/go.mod h1:CQGhQ8Rf1WF5Ke8XuUjcd4PRb+mFTjzKR/pm3EWKaQw=
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-redis/redis/v9 v9.0.0-rc.2/go.mod h1:cgBknjwcBJa2prbnuHH/4k/Mlj4r0pWNV2HBanHujfY=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/harlow/kinesis-consumer v0.3.6 h1:aAg48nNknQllcz3wZZcZVPy8dFAiv6c3pyQP+ngAlUY=
github.com/harlow/kinesis-consumer v0.3.6/go.mod h1:jTE9kH7IVx841D0GgxjykKieSP1yDSckuEg5ceSCjEU=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY=
github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kinbiko/jsonassert v1.2.0 h1:+/JthIVXdIrThrOtSN9ry0mNtWKXMWuvxR0nU7gQ+tI=
github.com/kinbiko/jsonassert v1.2.0/go.mod h1:pCc3uudOt+lVAbkji9O0uw8MSVt4s+1ZJ0y8Ux2F1Og=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU=
github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk=
github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0=
github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo=
github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo=
github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc=
github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM=
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=
github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
github.com/tj/go-buffer v1.0.1/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc=
github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v0.0.0-20200603152657-dc2b0ca8b37e/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

239
tree/log/chunk.go Normal file
View File

@ -0,0 +1,239 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package log
import (
"fmt"
"slices"
"github.com/signalapp/keytransparency/tree/log/math"
"github.com/signalapp/keytransparency/tree/sharedmath"
)
// The log tree implementation is designed to work with a standard key-value
// database. The tree is stored in the database in "chunks", which are
// 8-node-wide (or 4-node-deep) subtrees. Chunks are addressed by the id of the
// root node in the chunk. Only the leaf values of each chunk are stored, which
// in the context of the full tree is either a leaf or a cached intermediate
// hash. These values are stored concatenated.
// nodeData is the primary wrapper struct for representing a single node (leaf
// or intermediate) in the tree.
type nodeData struct {
leaf bool
value []byte
}
// validate checks that value is the expected length
func (nd *nodeData) validate() error {
if len(nd.value) != 32 {
return fmt.Errorf("node value is wrong length: %v", len(nd.value))
}
return nil
}
// marshal returns a slice with the node's value for hashing
func (nd *nodeData) marshal() []byte {
out := make([]byte, 33)
if !nd.leaf {
out[0] = 1
}
copy(out[1:33], nd.value)
return out
}
func (nd *nodeData) isEmpty() bool {
return nd.value == nil
}
// nodeChunk is a helper struct that handles computing/caching the intermediate
// nodes of a chunk.
type nodeChunk struct {
ids []uint64
nodes []*nodeData
}
func newChunk(chunkRootNodeId uint64, data []byte) (*nodeChunk, error) {
// Create a map that shows the node id represented by each element of the
// nodes array. This code is a little bit verbose, but I like that it's easy
// to check it's correct: run with id = 7 and the output is [0, 1, ..., 14].
ids := make([]uint64, 15)
ids[7] = chunkRootNodeId
ids[3] = sharedmath.Left(ids[7])
ids[1] = sharedmath.Left(ids[3])
ids[0] = sharedmath.Left(ids[1])
ids[2] = sharedmath.RightStep(ids[1])
ids[5] = sharedmath.RightStep(ids[3])
ids[4] = sharedmath.Left(ids[5])
ids[6] = sharedmath.RightStep(ids[5])
ids[11] = sharedmath.RightStep(ids[7])
ids[9] = sharedmath.Left(ids[11])
ids[8] = sharedmath.Left(ids[9])
ids[10] = sharedmath.RightStep(ids[9])
ids[13] = sharedmath.RightStep(ids[11])
ids[12] = sharedmath.Left(ids[13])
ids[14] = sharedmath.RightStep(ids[13])
// Parse the serialized data.
leafChunk := sharedmath.Level(chunkRootNodeId) == 3
nodes := make([]*nodeData, 0, 15)
for len(data) > 0 {
if len(data) < 32 {
return nil, fmt.Errorf("unable to parse chunk")
}
if len(nodes) > 0 {
nodes = append(nodes, &nodeData{leaf: false, value: nil})
}
nodes = append(nodes, &nodeData{
leaf: leafChunk,
value: data[:32],
})
data = data[32:]
}
for len(nodes) < 15 {
// if the chunk is not full, initialize remainder with stub data
nodes = append(nodes, &nodeData{
leaf: sharedmath.IsLeaf(ids[len(nodes)]),
value: nil,
})
}
if len(nodes) != 15 {
return nil, fmt.Errorf("unable to parse chunk")
}
return &nodeChunk{ids: ids, nodes: nodes}, nil
}
// findIndex returns the index of the node ID in the chunk's ids slice
func (c *nodeChunk) findIndex(nodeId uint64) uint64 {
// Since c.ids is sorted in increasing order, we can just BinarySearch it.
// This should be a little faster than iterating over IDs.
if n, found := slices.BinarySearch(c.ids, nodeId); found {
return uint64(n)
}
panic("requested hash not available in this chunk")
}
// get returns the data of nodeId with the value populated.
func (c *nodeChunk) get(nodeId, numLeaves uint64, set *chunkSet) *nodeData {
i := c.findIndex(nodeId)
if !sharedmath.IsLeaf(nodeId) && c.nodes[i].isEmpty() {
l, r := sharedmath.Left(nodeId), math.Right(nodeId, numLeaves)
c.nodes[i].value = treeHash(set.get(l), set.get(r))
}
return c.nodes[i]
}
// set updates nodeId to contain the given value and, if necessary, nullifies the values stored by its parent nodes
func (c *nodeChunk) set(nodeId uint64, value []byte) {
nd := &nodeData{
leaf: sharedmath.IsLeaf(nodeId),
value: value,
}
i := c.findIndex(nodeId)
c.nodes[i] = nd
for i != 7 {
i = sharedmath.ParentStep(i)
c.nodes[i].value = nil
}
}
// marshal returns the serialized chunk.
func (c *nodeChunk) marshal() []byte {
out := make([]byte, 0)
for i := 0; i < len(c.nodes); i += 2 { // += 2 because only leaves get serialized
if c.nodes[i].isEmpty() {
// We've reached an empty leaf. Because this is a left-balanced tree, the remaining leaves must also be
// empty, so check that there are no other populated nodes.
for ; i < len(c.nodes); i++ {
if !c.nodes[i].isEmpty() {
panic("chunk has gaps")
}
}
// The invariant is true, and there's nothing left to write, so we can exit now
break
}
out = append(out, c.nodes[i].value...)
}
return out
}
// chunkSet is a helper struct for directing operations to the correct nodeChunk
// in a set.
type chunkSet struct {
numLeaves uint64 // The number of leaves in the log tree when this chunk set was initialized
chunks map[uint64]*nodeChunk
modified map[uint64]struct{} // tracks all chunkIds that have been modified and need to be persisted
}
// newChunkSet creates a new chunkSet from a raw LogStore.BatchGet() response. numLeaves is the number of leaves in
// the *full* tree. Each []byte slice in data is the concatenated data for the nodes in that chunk.
func newChunkSet(numLeaves uint64, data map[uint64][]byte) (*chunkSet, error) {
chunks := make(map[uint64]*nodeChunk, len(data))
for id, raw := range data {
c, err := newChunk(id, raw)
if err != nil {
return nil, err
}
chunks[id] = c
}
return &chunkSet{
numLeaves: numLeaves,
chunks: chunks,
modified: make(map[uint64]struct{}),
}, nil
}
// get returns node x.
func (s *chunkSet) get(nodeId uint64) *nodeData {
c, ok := s.chunks[math.Chunk(nodeId)]
if !ok {
panic("requested hash is not available in this chunk set")
}
return c.get(nodeId, s.numLeaves, s)
}
// add initializes a new empty chunk for node x.
func (s *chunkSet) add(nodeId uint64) {
chunkId := math.Chunk(nodeId)
if _, ok := s.chunks[chunkId]; ok {
panic("cannot add chunk that already exists in set")
}
c, err := newChunk(chunkId, make([]byte, 0))
if err != nil {
panic(err)
}
s.chunks[chunkId] = c
}
// set changes node to the given value.
func (s *chunkSet) set(nodeId uint64, value []byte) {
chunkId := math.Chunk(nodeId)
c, ok := s.chunks[chunkId]
if !ok {
panic("requested hash is not available in this chunk set")
}
c.set(nodeId, value)
s.modified[chunkId] = struct{}{}
}
// marshalChanges returns the set of changes that have been done on this chunkSet
// since its creation, for persisting back in our storage medium.
func (s *chunkSet) marshalChanges() map[uint64][]byte {
out := make(map[uint64][]byte, 0)
for id := range s.modified {
out[id] = s.chunks[id].marshal()
}
return out
}

269
tree/log/log.go Normal file
View File

@ -0,0 +1,269 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Package log implements a log-based Merkle tree where new data is added
// as the right-most leaf.
package log
import (
"crypto/sha256"
"fmt"
"github.com/signalapp/keytransparency/db"
"github.com/signalapp/keytransparency/tree/log/math"
"github.com/signalapp/keytransparency/tree/sharedmath"
)
// treeHash returns the intermediate hash of left and right.
func treeHash(left, right *nodeData) []byte {
if err := left.validate(); err != nil {
panic(err)
} else if err := right.validate(); err != nil {
panic(err)
}
input := append(left.marshal(), right.marshal()...)
output := sha256.Sum256(input)
return output[:]
}
// Tree is an implementation of a log-based Merkle tree where all new data is
// added as the right-most leaf.
type Tree struct {
tx db.LogStore
}
func NewTree(tx db.LogStore) *Tree {
return &Tree{tx: tx}
}
// fetch loads the chunks for the requested nodes from the database. It returns
// an error if not all chunks are found.
func (t *Tree) fetchChunkSets(numLeaves uint64, nodes []uint64) (*chunkSet, error) {
dedup := make(map[uint64]struct{})
for _, id := range nodes {
dedup[math.Chunk(id)] = struct{}{}
}
chunkIds := make([]uint64, 0, len(dedup))
for id := range dedup {
chunkIds = append(chunkIds, id)
}
data, err := t.tx.BatchGet(chunkIds)
if err != nil {
return nil, err
}
for _, id := range chunkIds {
if _, ok := data[id]; !ok {
return nil, fmt.Errorf("chunkId %d not found in database", id)
}
}
// Parse chunk set.
set, err := newChunkSet(numLeaves, data)
if err != nil {
return nil, err
}
return set, nil
}
// fetchSpecific returns the values for the requested nodes, accounting for the
// ragged right-edge of the tree.
func (t *Tree) fetchSpecific(numLeaves uint64, nodes []uint64) ([][]byte, error) {
lookup := make([]uint64, 0)
// Add the nodes that we need to compute the requested hashes.
rightEdge := make(map[uint64][]uint64)
for _, id := range nodes {
if math.IsFullSubtree(id, numLeaves) {
lookup = append(lookup, id)
} else {
subtrees := math.FullSubtrees(id, numLeaves)
rightEdge[id] = subtrees
lookup = append(lookup, subtrees...)
}
}
// Load everything from the database in one roundtrip.
set, err := t.fetchChunkSets(numLeaves, lookup)
if err != nil {
return nil, err
}
// Extract the data we want to return.
out := make([][]byte, len(nodes))
for i, id := range nodes {
if subtrees, ok := rightEdge[id]; ok {
out[i] = t.computeRootFromSet(subtrees, set)
} else {
out[i] = set.get(id).value
}
}
return out, nil
}
// Get returns the value for the given `entry`, along with its proof of inclusion.
func (t *Tree) Get(entry, treeSize uint64) ([]byte, [][]byte, error) {
if treeSize == 0 {
return nil, nil, fmt.Errorf("empty tree")
} else if entry >= treeSize {
return nil, nil, fmt.Errorf("can not get leaf beyond right edge of tree: %d >= %d", entry, treeSize)
}
leaf := 2 * entry
copath := math.Copath(leaf, treeSize)
data, err := t.fetchSpecific(treeSize, append([]uint64{leaf}, copath...))
if err != nil {
return nil, nil, fmt.Errorf("fetching: %w", err)
}
return data[0], data[1:], nil
}
// GetBatchProof returns a batch proof for the given set of log entries.
func (t *Tree) GetBatchProof(entries []uint64, treeSize uint64) ([][]byte, error) {
if treeSize == 0 {
return nil, fmt.Errorf("empty tree")
} else if len(entries) == 0 {
return nil, nil
}
for _, x := range entries {
if x >= treeSize {
return nil, fmt.Errorf("can not get leaf beyond right edge of tree")
}
}
return t.fetchSpecific(treeSize, math.BatchCopath(entries, treeSize))
}
// GetConsistencyProof returns a proof that the current log with n elements is
// an extension of a previous log root with m elements, 0 < m < n.
func (t *Tree) GetConsistencyProof(m, n uint64) ([][]byte, error) {
if m >= n {
return nil, fmt.Errorf("second parameter must be greater than first")
}
return t.fetchSpecific(n, math.ConsistencyProof(m, n))
}
// GetRoot gets the root value of the log with the given number of entries
func (t *Tree) GetRoot(treeSize uint64) ([]byte, error) {
return t.computeRoot(treeSize, nil)
}
// computeRoot computes the root value from a given chunk set.
func (t *Tree) computeRoot(treeSize uint64, set *chunkSet) ([]byte, error) {
subtrees := math.FullSubtrees(math.Root(treeSize), treeSize)
if set == nil {
var err error
set, err = t.fetchChunkSets(treeSize, subtrees)
if err != nil {
return nil, fmt.Errorf("fetching: %w", err)
}
}
return t.computeRootFromSet(subtrees, set), nil
}
// computeRootFromSet computes the root value from a list of subtree node IDs and chunk set
func (t *Tree) computeRootFromSet(subtrees []uint64, set *chunkSet) []byte {
nd := set.get(subtrees[len(subtrees)-1])
for i := len(subtrees) - 2; i >= 0; i-- {
nd = &nodeData{
leaf: false,
value: treeHash(set.get(subtrees[i]), nd),
}
}
return nd.value
}
// Append adds a new element to the end of the log and returns the new root
// value. treeSize is the current size; after this operation is complete, methods to
// this object should be called with treeSize+1.
func (t *Tree) Append(treeSize uint64, value []byte) ([]byte, error) {
return t.BatchAppend(treeSize, [][]byte{value})
}
// BatchAppend adds several new elements to the end of the log and returns the
// new root value. The treeSize parameter is the current size; after this operation is complete,
// methods on this object should be called with treeSize+len(values).
func (t *Tree) BatchAppend(treeSize uint64, values [][]byte) ([]byte, error) {
if len(values) == 0 {
return nil, fmt.Errorf("no values to append provided")
}
for _, value := range values {
if len(value) != 32 {
return nil, fmt.Errorf("value has wrong length: %v", len(value))
}
}
// Calculate the set of nodes that we'll need to update / create.
newTreeSize := treeSize + uint64(len(values))
touched := make([]uint64, 0, len(values)*2) // Node ids that are full subtrees after appending the batch, and may need to have their value stored.
for i := range values {
leaf := 2 * (treeSize + uint64(i))
touched = append(touched, leaf)
for _, id := range math.DirectPath(leaf, newTreeSize) {
if math.IsFullSubtree(id, newTreeSize) {
touched = append(touched, id)
}
}
}
var toFetch []uint64 // These are dedup'ed by fetch.
createChunks := map[uint64]struct{}{}
for _, id := range touched {
chunkId := math.Chunk(id)
if _, ok := createChunks[chunkId]; ok {
continue
}
// a chunk has a height of 4, so go left three times
leftmost := sharedmath.Left(sharedmath.Left(sharedmath.Left(chunkId)))
if id == leftmost {
// Because this is a left-balanced tree, if we touched the leftmost node in a chunk, the chunk is new
createChunks[chunkId] = struct{}{}
} else {
toFetch = append(toFetch, chunkId)
}
}
// Fetch the chunks we'll need to update along with nodes we'll need to know to compute the new root or updated
// intermediates. We only need to fetch the copath for the first new node, 2*treeSize, because it provides the
// values we need for each subsequent update of the intermediate nodes when appending the remaining entries from
// the batch.
for _, id := range math.Copath(2*treeSize, treeSize+1) {
if math.IsFullSubtree(id, treeSize+1) {
toFetch = append(toFetch, id)
}
}
set, err := t.fetchChunkSets(newTreeSize, toFetch)
if err != nil {
return nil, err
}
// Add any new chunks to the set and set the correct hashes everywhere.
for chunkId := range createChunks {
set.add(chunkId)
}
for i, value := range values {
set.set(2*(treeSize+uint64(i)), value)
}
for _, nodeId := range touched {
if sharedmath.IsLeaf(nodeId) {
continue
} else if sharedmath.Level(nodeId)%4 == 0 {
l, r := sharedmath.Left(nodeId), math.Right(nodeId, newTreeSize)
intermediate := treeHash(set.get(l), set.get(r))
set.set(nodeId, intermediate)
}
}
// Push to database.
t.tx.BatchPut(set.marshalChanges())
// Compute the new root from the set we've already got.
return t.computeRoot(newTreeSize, set)
}

255
tree/log/log_test.go Normal file
View File

@ -0,0 +1,255 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package log
import (
"bytes"
"crypto/rand"
mrand "math/rand"
"slices"
"testing"
"github.com/signalapp/keytransparency/db"
)
func assert(ok bool) {
if !ok {
panic("Assertion failed.")
}
}
func random() []byte {
out := make([]byte, 32)
if _, err := rand.Read(out); err != nil {
panic(err)
}
return out
}
func dup(in []byte) []byte {
out := make([]byte, len(in))
copy(out, in)
return out
}
func TestInclusionProof(t *testing.T) {
tree := NewTree(db.NewMemoryTransparencyStore().LogStore())
calc := newSimpleRootCalculator()
var (
nodes [][]byte
roots [][]byte
)
checkTree := func(entry, treeSize uint64) {
value, proof, err := tree.Get(entry, treeSize)
if err != nil {
t.Fatal(err)
}
assert(bytes.Equal(value, nodes[entry]))
if err := VerifyInclusionProof(entry, treeSize, value, proof, roots[treeSize-1]); err != nil {
t.Fatal(err)
}
}
for i := 0; i < 2000; i++ {
leaf := random()
nodes = append(nodes, leaf)
// Append to the tree.
root, err := tree.Append(uint64(i), leaf)
if err != nil {
t.Fatal(err)
}
roots = append(roots, dup(root))
n := i + 1
calc.Add(leaf)
if calculated, err := calc.Root(); err != nil {
t.Fatal(err)
} else {
assert(bytes.Equal(root, calculated))
}
// Do inclusion proofs for a few random entries.
if n < 5 {
continue
}
for j := 0; j < 5; j++ {
x := mrand.Intn(n)
checkTree(uint64(x), uint64(n))
m := mrand.Intn(int(n-1)) + 1
x = mrand.Intn(m)
checkTree(uint64(x), uint64(m))
}
}
}
func TestBatchInclusionProof(t *testing.T) {
tree := NewTree(db.NewMemoryTransparencyStore().LogStore())
var (
leaves [][]byte
root []byte
err error
)
for i := 0; i < 2000; i++ {
leaf := random()
leaves = append(leaves, leaf)
root, err = tree.Append(uint64(i), leaf)
if err != nil {
t.Fatal(err)
}
}
dedup := make(map[uint64]struct{})
for i := 0; i < 10; i++ {
dedup[uint64(mrand.Intn(2000))] = struct{}{}
}
entries := make([]uint64, 0)
for id := range dedup {
entries = append(entries, id)
}
slices.Sort(entries)
values := make([][]byte, 0)
for _, id := range entries {
values = append(values, leaves[id])
}
proof, err := tree.GetBatchProof(entries, 2000)
if err != nil {
t.Fatal(err)
} else if err := VerifyBatchProof(entries, 2000, values, proof, root); err != nil {
t.Fatal(err)
}
}
func TestConsistencyProof(t *testing.T) {
tree := NewTree(db.NewMemoryTransparencyStore().LogStore())
var roots [][]byte
for i := 0; i < 2000; i++ {
leaf := random()
// Append to the tree.
root, err := tree.Append(uint64(i), leaf)
if err != nil {
t.Fatal(err)
}
roots = append(roots, dup(root))
n := i + 1
// Do consistency proofs for a few random revisions.
if n < 5 {
continue
}
for j := 0; j < 5; j++ {
m := mrand.Intn(n-1) + 1
proof, err := tree.GetConsistencyProof(uint64(m), uint64(n))
if err != nil {
t.Fatal(err)
}
err = VerifyConsistencyProof(uint64(m), uint64(n), proof, roots[m-1], roots[n-1])
if err != nil {
t.Fatal(err)
}
if m > 1 {
p := mrand.Intn(m-1) + 1
proof, err := tree.GetConsistencyProof(uint64(p), uint64(m))
if err != nil {
t.Fatal(err)
}
err = VerifyConsistencyProof(uint64(p), uint64(m), proof, roots[p-1], roots[m-1])
if err != nil {
t.Fatal(err)
}
}
}
}
}
func TestBatchAppend(t *testing.T) {
leaves := make([][]byte, 100)
for i := range leaves {
leaves[i] = random()
}
// Add leaves to tree in batches.
tree1 := NewTree(db.NewMemoryTransparencyStore().LogStore())
_, err := tree1.BatchAppend(0, leaves[:50])
if err != nil {
t.Fatal(err)
}
root1, err := tree1.BatchAppend(50, leaves[50:])
if err != nil {
t.Fatal(err)
}
// Add leaves to tree one-by-one.
tree2 := NewTree(db.NewMemoryTransparencyStore().LogStore())
var root2 []byte
for i, leaf := range leaves {
root2, err = tree2.Append(uint64(i), leaf)
if err != nil {
t.Fatal(err)
}
}
// Check that roots are the same.
if !bytes.Equal(root1, root2) {
t.Fatal("log roots were not equal")
}
}
func BenchmarkAppend(b *testing.B) {
tree := NewTree(db.NewMemoryTransparencyStore().LogStore())
for i := 0; i < 100; i++ {
_, err := tree.Append(uint64(i), random())
if err != nil {
b.Fatal(err)
}
}
leaves := make([][]byte, b.N)
for i := range leaves {
leaves[i] = random()
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := tree.Append(100+uint64(i), leaves[i])
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkBatchAppend(b *testing.B) {
const batchSize = 10
tree := NewTree(db.NewMemoryTransparencyStore().LogStore())
for i := 0; i < 100; i++ {
_, err := tree.Append(uint64(i), random())
if err != nil {
b.Fatal(err)
}
}
leaves := make([][]byte, batchSize*b.N)
for i := range leaves {
leaves[i] = random()
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
start := batchSize * i
end := batchSize * (i + 1)
_, err := tree.BatchAppend(100+uint64(start), leaves[start:end])
if err != nil {
b.Fatal(err)
}
}
}

210
tree/log/math/math.go Normal file
View File

@ -0,0 +1,210 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// Package math implements the mathematical operations for a log-based Merkle
// tree.
package math
import (
"slices"
"github.com/signalapp/keytransparency/tree/sharedmath"
)
// NodeWidth returns the number of nodes needed to store a tree with n leaves.
func NodeWidth(numLeaves uint64) uint64 {
if numLeaves == 0 {
return 0
}
return 2*(numLeaves-1) + 1
}
// Root returns the id of the root node of a tree with n leaves.
func Root(numLeaves uint64) uint64 {
w := NodeWidth(numLeaves)
return (1 << sharedmath.Log2(w)) - 1
}
// Right returns the right child of an intermediate node.
func Right(nodeId, numLeaves uint64) uint64 {
r := sharedmath.RightStep(nodeId)
w := NodeWidth(numLeaves)
for r >= w {
r = sharedmath.Left(r)
}
return r
}
// Parent returns the id of the parent node, if there are n leaves in the tree
// total.
func Parent(nodeId, numLeaves uint64) uint64 {
if nodeId == Root(numLeaves) {
panic("root node has no parent")
}
width := NodeWidth(numLeaves)
return sharedmath.Parent(nodeId, width)
}
// Sibling returns the other child of the node's parent.
func Sibling(nodeId, numLeaves uint64) uint64 {
p := Parent(nodeId, numLeaves)
if nodeId < p {
return Right(p, numLeaves)
} else {
return sharedmath.Left(p)
}
}
// DirectPath returns the direct path of a node, ordered from leaf to root.
func DirectPath(nodeId, numLeaves uint64) []uint64 {
rootNodeId := Root(numLeaves)
if nodeId == rootNodeId {
return []uint64{}
}
d := []uint64{}
for nodeId != rootNodeId {
nodeId = Parent(nodeId, numLeaves)
d = append(d, nodeId)
}
return d
}
// Copath returns the copath of a node, ordered from leaf to root.
func Copath(nodeId, numLeaves uint64) []uint64 {
if nodeId > 2*(numLeaves-1) {
panic("nodeId does not exist in the given tree")
}
if nodeId == Root(numLeaves) {
return []uint64{}
}
d := append([]uint64{nodeId}, DirectPath(nodeId, numLeaves)...)
// remove the root
d = d[:len(d)-1]
for i := 0; i < len(d); i++ {
// replace with the sibling
d[i] = Sibling(d[i], numLeaves)
}
return d
}
// IsFullSubtree returns true if nodeId represents a full subtree.
func IsFullSubtree(nodeId, numLeaves uint64) bool {
rightmost := 2 * (numLeaves - 1)
expected := nodeId + (1 << sharedmath.Level(nodeId)) - 1
return expected <= rightmost
}
// FullSubtrees returns the list of full subtree root node IDs, starting from nodeId and traversing down the right side.
func FullSubtrees(nodeId, numLeaves uint64) []uint64 {
out := []uint64{}
for !IsFullSubtree(nodeId, numLeaves) {
out = append(out, sharedmath.Left(nodeId))
nodeId = Right(nodeId, numLeaves)
}
out = append(out, nodeId)
return out
}
// ConsistencyProof returns the list of node ids to return for a consistency
// proof between m and n, based on the algorithm from RFC 6962.
func ConsistencyProof(m, n uint64) []uint64 {
return subProof(m, n, true)
}
// subProof implements the algorithm from RFC 6962. `b` indicates that `m` is the value for which the proof was
// originally requested, since `m` might change during recursive calls to generate subProofs
func subProof(m, n uint64, b bool) []uint64 {
if m == n {
if b {
return []uint64{}
}
// m must be a power of two if it is not its original value, because k (now n) is always a power of 2
return []uint64{Root(m)}
}
k := uint64(1) << sharedmath.Log2(n)
if k == n {
k = k / 2
}
if m <= k {
proof := subProof(m, k, b)
proof = append(proof, Right(Root(n), n))
return proof
}
proof := subProof(m-k, n-k, false)
for i := 0; i < len(proof); i++ {
proof[i] = proof[i] + 2*k
}
proof = append([]uint64{sharedmath.Left(Root(n))}, proof...)
return proof
}
// BatchCopath returns the copath nodes of a batch of leaves. The input leaves are *log entry* IDs, not *node* IDs, sorted in increasing order.
func BatchCopath(leaves []uint64, numLeaves uint64) []uint64 {
// Convert the leaf indices to node indices.
nodes := make([]uint64, len(leaves))
for i, x := range leaves {
nodes[i] = 2 * x
}
slices.Sort(nodes)
// Iteratively combine nodes until there's only one entry in the list (being
// the root), keeping track of the extra nodes we needed to get there.
out := make([]uint64, 0)
root := Root(numLeaves)
for {
if len(nodes) == 1 && nodes[0] == root {
break
}
nextLevel := make([]uint64, 0)
for len(nodes) > 1 {
p := Parent(nodes[0], numLeaves)
if Right(p, numLeaves) == nodes[1] { // Sibling is already here.
nodes = nodes[2:]
} else { // Need to fetch sibling.
out = append(out, Sibling(nodes[0], numLeaves))
nodes = nodes[1:]
}
nextLevel = append(nextLevel, p)
}
if len(nodes) == 1 {
if len(nextLevel) > 0 && sharedmath.Level(Parent(nodes[0], numLeaves)) > sharedmath.Level(nextLevel[0]) {
// the parent of the last, rightmost, node skips one or more levels, so we must hold on to it until
// the current level contains its parent's sibling
nextLevel = append(nextLevel, nodes[0])
} else {
out = append(out, Sibling(nodes[0], numLeaves))
nextLevel = append(nextLevel, Parent(nodes[0], numLeaves))
}
}
nodes = nextLevel
}
slices.Sort(out)
return out
}
// Chunk takes a node id as input and returns the id of the chunk that the node
// would be stored in, in the database.
//
// Chunks store 8 consecutive nodes from the same level of the tree,
// representing a subtree of height 4. The chunk is identified by the root of
// this subtree.
func Chunk(nodeId uint64) uint64 {
c := nodeId
for sharedmath.Level(c)%4 != 3 {
c = sharedmath.ParentStep(c)
}
return c
}

View File

@ -0,0 +1,47 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
package math
import (
"testing"
)
func assert(ok bool) {
if !ok {
panic("Assertion failed.")
}
}
func slicesEq(left, right []uint64) bool {
if len(left) != len(right) {
return false
}
for i := 0; i < len(left); i++ {
if left[i] != right[i] {
return false
}
}
return true
}
func TestMath(t *testing.T) {
assert(Root(5) == 7)
assert(Right(7, 8) == 11)
assert(Parent(1, 4) == 3)
assert(Parent(5, 4) == 3)
assert(Sibling(13, 8) == 9)
assert(Sibling(9, 8) == 13)
assert(slicesEq(DirectPath(4, 8), []uint64{5, 3, 7}))
assert(slicesEq(Copath(4, 8), []uint64{6, 1, 11}))
assert(slicesEq(BatchCopath([]uint64{0, 2, 3, 4}, 8), []uint64{2, 10, 13}))
assert(slicesEq(BatchCopath([]uint64{0, 2, 3}, 8), []uint64{2, 11}))
assert(slicesEq(FullSubtrees(7, 6), []uint64{3, 9}))
}

Some files were not shown because too many files have changed in this diff Show More