Key transparency auditor
Some checks failed
Service CI / Build and test Java (push) Has been cancelled
Some checks failed
Service CI / Build and test Java (push) Has been cancelled
This commit is contained in:
commit
572d8402c9
1396
.editorconfig
Normal file
1396
.editorconfig
Normal file
File diff suppressed because it is too large
Load Diff
40
.github/workflows/push.yml
vendored
Normal file
40
.github/workflows/push.yml
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
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@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0
|
||||
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 JDK 21
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
cache: 'maven'
|
||||
|
||||
- name: Build and push container image
|
||||
run: |
|
||||
./mvnw -e -B deploy \
|
||||
-Dpackaging=docker \
|
||||
-Djib.to.image="${{ vars.ECR_REGISTRY }}/${{ vars.ECR_REPO }}:${GITHUB_REF_NAME}"
|
||||
25
.github/workflows/test.yml
vendored
Normal file
25
.github/workflows/test.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
name: Service CI
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test-service:
|
||||
name: Build and test Java
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout main project
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
cache: 'maven'
|
||||
|
||||
- name: Build and test with Maven
|
||||
run: ./mvnw -e -B verify
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
.gradle
|
||||
build/
|
||||
target/
|
||||
out/
|
||||
.micronaut/
|
||||
.idea
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.project
|
||||
.settings
|
||||
.classpath
|
||||
.factorypath
|
||||
124
.mvn/wrapper/MavenWrapperDownloader.java
vendored
Normal file
124
.mvn/wrapper/MavenWrapperDownloader.java
vendored
Normal file
@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2007-present the original author or authors.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.Authenticator;
|
||||
import java.net.PasswordAuthentication;
|
||||
import java.net.URL;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.ReadableByteChannel;
|
||||
import java.util.Properties;
|
||||
|
||||
public class MavenWrapperDownloader {
|
||||
|
||||
private static final String WRAPPER_VERSION = "0.5.6";
|
||||
/**
|
||||
* Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
|
||||
*/
|
||||
private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
|
||||
+ WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
|
||||
|
||||
/**
|
||||
* Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
|
||||
* use instead of the default one.
|
||||
*/
|
||||
private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
|
||||
".mvn/wrapper/maven-wrapper.properties";
|
||||
|
||||
/**
|
||||
* Path where the maven-wrapper.jar will be saved to.
|
||||
*/
|
||||
private static final String MAVEN_WRAPPER_JAR_PATH =
|
||||
".mvn/wrapper/maven-wrapper.jar";
|
||||
|
||||
/**
|
||||
* Name of the property which should be used to override the default download url for the wrapper.
|
||||
*/
|
||||
private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
|
||||
|
||||
public static void main(String args[]) {
|
||||
System.out.println("- Downloader started");
|
||||
File baseDirectory = new File(args[0]);
|
||||
System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
|
||||
|
||||
// If the maven-wrapper.properties exists, read it and check if it contains a custom
|
||||
// wrapperUrl parameter.
|
||||
File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
|
||||
String url = DEFAULT_DOWNLOAD_URL;
|
||||
if(mavenWrapperPropertyFile.exists()) {
|
||||
FileInputStream mavenWrapperPropertyFileInputStream = null;
|
||||
try {
|
||||
mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
|
||||
Properties mavenWrapperProperties = new Properties();
|
||||
mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
|
||||
url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
|
||||
} catch (IOException e) {
|
||||
System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
|
||||
} finally {
|
||||
try {
|
||||
if(mavenWrapperPropertyFileInputStream != null) {
|
||||
mavenWrapperPropertyFileInputStream.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Ignore ...
|
||||
}
|
||||
}
|
||||
}
|
||||
System.out.println("- Downloading from: " + url);
|
||||
|
||||
File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
|
||||
if(!outputFile.getParentFile().exists()) {
|
||||
if(!outputFile.getParentFile().mkdirs()) {
|
||||
System.out.println(
|
||||
"- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
|
||||
}
|
||||
}
|
||||
System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
|
||||
try {
|
||||
downloadFileFromURL(url, outputFile);
|
||||
System.out.println("Done");
|
||||
System.exit(0);
|
||||
} catch (Throwable e) {
|
||||
System.out.println("- Error downloading");
|
||||
e.printStackTrace();
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void downloadFileFromURL(String urlString, File destination) throws Exception {
|
||||
if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) {
|
||||
String username = System.getenv("MVNW_USERNAME");
|
||||
char[] password = System.getenv("MVNW_PASSWORD").toCharArray();
|
||||
Authenticator.setDefault(new Authenticator() {
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
return new PasswordAuthentication(username, password);
|
||||
}
|
||||
});
|
||||
}
|
||||
URL website = new URL(urlString);
|
||||
ReadableByteChannel rbc;
|
||||
rbc = Channels.newChannel(website.openStream());
|
||||
FileOutputStream fos = new FileOutputStream(destination);
|
||||
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
|
||||
fos.close();
|
||||
rbc.close();
|
||||
}
|
||||
|
||||
}
|
||||
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
Normal file
BIN
.mvn/wrapper/maven-wrapper.jar
vendored
Normal file
Binary file not shown.
20
.mvn/wrapper/maven-wrapper.properties
vendored
Normal file
20
.mvn/wrapper/maven-wrapper.properties
vendored
Normal 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.9/apache-maven-3.9.9-bin.zip
|
||||
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar
|
||||
distributionSha256Sum=4ec3f26fb1a692473aea0235c300bd20f0f9fe741947c82c1234cefd76ac3a3c
|
||||
wrapperSha256Sum=3d8f20ce6103913be8b52aef6d994e0c54705fb527324ceb9b835b338739c7a8
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
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 keysManager, 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
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
69
README.md
Normal file
69
README.md
Normal file
@ -0,0 +1,69 @@
|
||||
key-transparency-auditor
|
||||
========================
|
||||
|
||||
A reference implementation of a third-party auditor for Signal's key transparency service, based on the [key transparency](https://bren2010.github.io/draft-key-transparency/draft-mcmillion-key-transparency.html) specification.
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
This service is written in Java using the [Micronaut](https://docs.micronaut.io/4.4.10/guide/) framework. To build and unit test, run
|
||||
|
||||
```shell
|
||||
./mvnw clean test
|
||||
```
|
||||
in the root directory.
|
||||
|
||||
The main class is the [`Auditor`](./src/main/java/org/signal/keytransparency/audit/Auditor.java), which runs a scheduled job that requests a
|
||||
stream of updates from the key transparency service. It maintains a condensed view of the key transparency service's [prefix tree](https://bren2010.github.io/draft-key-transparency/draft-mcmillion-key-transparency.html#name-prefix-tree)
|
||||
and [log tree](https://bren2010.github.io/draft-key-transparency/draft-mcmillion-key-transparency.html#name-log-tree),
|
||||
storing just enough information to verify and accept each update sequentially. If the auditor has processed a certain number of updates or a certain amount of time has elapsed, the auditor sends back a
|
||||
[signed tree head](https://bren2010.github.io/draft-key-transparency/draft-mcmillion-key-transparency.html#name-tree-head-signature)
|
||||
to the key transparency service, indicating that its view of the prefix and log trees up to the given update matches.
|
||||
If the remote call succeeds, the auditor writes its state data to an [`AuditorStateRepository`](./src/main/java/org/signal/keytransparency/audit/storage/AuditorStateRepository.java),
|
||||
which it may use to resume from its most recent position in the key transparency log if the auditor is restarted.
|
||||
|
||||
If the auditor encounters an inconsistency in verifying an update, it throws an `InvalidProofException` and stops
|
||||
sending signed tree heads back to the key transparency service.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
The service needs `Auditor`, `KeyTransparencyServiceClient`, and `AuditorStateRepository` beans to run.
|
||||
The table below describes the [configuration](https://docs.micronaut.io/latest/guide/#configurationProperties) [properties](https://docs.micronaut.io/latest/guide/#valueAnnotation) necessary to instantiate those beans.
|
||||
|
||||
|
||||
| Property | Required? | Description |
|
||||
|-------------------------------------------------------|--------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `auditor.private-key` | yes | A PKCS#8-formatted Ed25519 private key encoded in standard base64 and used to sign the tree head sent back to the key transparency service. Can be generated via `openssl genpkey -algorithm ed25519` and discarding the PEM header and footer. |
|
||||
| `auditor.public-key` | yes | A X509-formatted Ed25519 public key encoded in standard base64 that is the counterpart to `auditor.private-key`. |
|
||||
| `auditor.key-transparency-service-signing-public-key` | yes | A PKCS#8-formatted Ed25519 public key encoded in standard base64 and used by clients to verify the key transparency service's signature over the tree head. |
|
||||
| `auditor.key-transparency-service-vrf-public-key` | yes | A PKCS#8-formatted Ed25519 public key encoded in standard base64 and used by clients to verify that the input to a [Verifiable Random Function](https://www.rfc-editor.org/rfc/rfc9381.html) (requested search key) matches the output (commitment index used to traverse the prefix tree). |
|
||||
| `auditor.batch-size` | yes | The maximum number of updates that the key transparency service should return in a single response. This value should be less than or equal to 1000. |
|
||||
| `auditor.interval` | no | The time interval at which the auditor job should run to process key transparency updates. Defaults to 1 minute. |
|
||||
| `auditor.signature.interval` | no | The interval at which the auditor should send a signed tree head to the key transparency service, in duration. Defaults to 1 hour. |
|
||||
| `auditor.signature.page-size` | no | The interval at which the auditor should send a signed tree head to the key transparency service, in number of updates. Defaults to 1,000,000. |
|
||||
| `grpc.channels.key-transparency.address` | yes | The address of the key transparency service. |
|
||||
| `storage.dynamodb.region` | Exactly one `storage.<type>` must be specified | The AWS region of the DynamoDB table used to store auditor state. |
|
||||
| `storage.dynamodb.table-name` | Exactly one `storage.<type>` must be specified | The name of the DynamoDB table used to store auditor state. |
|
||||
| `storage.file.name` | Exactly one `storage.<type>` must be specified | The name of the file used to store auditor state. |
|
||||
|
||||
Contributing bug reports
|
||||
------------------------
|
||||
|
||||
We use [GitHub][github issues] for bug tracking. Security issues should be sent to <a href="mailto:security@signal.org">security@signal.org</a>.
|
||||
|
||||
Help
|
||||
----
|
||||
|
||||
We cannot provide direct technical support. Get help running this software in your own environment in our [unofficial community forum][community forum].
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
Copyright 2025 Signal Messenger, LLC
|
||||
|
||||
Licensed under the AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
|
||||
[github issues]: https://github.com/signalapp/key-transparency-auditor/issues
|
||||
[community forum]: https://community.signalusers.org
|
||||
6
micronaut-cli.yml
Normal file
6
micronaut-cli.yml
Normal file
@ -0,0 +1,6 @@
|
||||
applicationType: default
|
||||
defaultPackage: org.signal.auditor
|
||||
testFramework: junit
|
||||
sourceLanguage: java
|
||||
buildTool: maven
|
||||
features: [app-name, http-client-test, java, java-application, junit, logback, maven, maven-enforcer-plugin, micronaut-aot, micronaut-http-validation, netty-server, properties, readme, serialization-jackson, shade, static-resources]
|
||||
287
mvnw
vendored
Executable file
287
mvnw
vendored
Executable file
@ -0,0 +1,287 @@
|
||||
#!/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.1.1
|
||||
#
|
||||
# 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" ] &&
|
||||
JAVA_HOME="`(cd "$JAVA_HOME"; 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; \\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/.."; pwd`
|
||||
fi
|
||||
# end of workaround
|
||||
done
|
||||
printf '%s' "$(cd "$basedir"; pwd)"
|
||||
}
|
||||
|
||||
# concatenates all lines of a file
|
||||
concat_lines() {
|
||||
if [ -f "$1" ]; then
|
||||
echo "$(tr -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
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo $MAVEN_PROJECTBASEDIR
|
||||
fi
|
||||
|
||||
##########################################################################################
|
||||
# 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.
|
||||
##########################################################################################
|
||||
if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found .mvn/wrapper/maven-wrapper.jar"
|
||||
fi
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..."
|
||||
fi
|
||||
if [ -n "$MVNW_REPOURL" ]; then
|
||||
wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar"
|
||||
else
|
||||
wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar"
|
||||
fi
|
||||
while IFS="=" read key value; do
|
||||
case "$key" in (wrapperUrl) wrapperUrl="$value"; break ;;
|
||||
esac
|
||||
done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties"
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Downloading from: $wrapperUrl"
|
||||
fi
|
||||
wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar"
|
||||
if $cygwin; then
|
||||
wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"`
|
||||
fi
|
||||
|
||||
if command -v wget > /dev/null; then
|
||||
QUIET="--quiet"
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found wget ... using wget"
|
||||
QUIET=""
|
||||
fi
|
||||
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
|
||||
wget $QUIET "$wrapperUrl" -O "$wrapperJarPath"
|
||||
else
|
||||
wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath"
|
||||
fi
|
||||
[ $? -eq 0 ] || rm -f "$wrapperJarPath"
|
||||
elif command -v curl > /dev/null; then
|
||||
QUIET="--silent"
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Found curl ... using curl"
|
||||
QUIET=""
|
||||
fi
|
||||
if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
|
||||
curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L
|
||||
else
|
||||
curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L
|
||||
fi
|
||||
[ $? -eq 0 ] || rm -f "$wrapperJarPath"
|
||||
else
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo "Falling back to using Java to download"
|
||||
fi
|
||||
javaSource="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java"
|
||||
javaClass="$BASE_DIR/.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
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Compiling MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
# Compiling the Java class
|
||||
("$JAVA_HOME/bin/javac" "$javaSource")
|
||||
fi
|
||||
if [ -e "$javaClass" ]; then
|
||||
# Running the downloader
|
||||
if [ "$MVNW_VERBOSE" = true ]; then
|
||||
echo " - Running MavenWrapperDownloader.java ..."
|
||||
fi
|
||||
("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
##########################################################################################
|
||||
# End of extension
|
||||
##########################################################################################
|
||||
|
||||
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
|
||||
|
||||
exec "$JAVACMD" \
|
||||
$MAVEN_OPTS \
|
||||
$MAVEN_DEBUG_OPTS \
|
||||
-classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
|
||||
"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
|
||||
${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
|
||||
187
mvnw.bat
Normal file
187
mvnw.bat
Normal file
@ -0,0 +1,187 @@
|
||||
@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 https://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.1.1
|
||||
@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.1.1/maven-wrapper-3.1.1.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.1.1/maven-wrapper-3.1.1.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 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%
|
||||
287
pom.xml
Normal file
287
pom.xml
Normal file
@ -0,0 +1,287 @@
|
||||
<?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>key-transparency-auditor</artifactId>
|
||||
<version>0.1</version>
|
||||
<packaging>${packaging}</packaging>
|
||||
|
||||
<parent>
|
||||
<groupId>io.micronaut.platform</groupId>
|
||||
<artifactId>micronaut-parent</artifactId>
|
||||
<version>4.8.0</version>
|
||||
</parent>
|
||||
<properties>
|
||||
<packaging>jar</packaging>
|
||||
<jdk.version>21</jdk.version>
|
||||
<release.version>21</release.version>
|
||||
<micronaut.runtime>netty</micronaut.runtime>
|
||||
<exec.mainClass>org.signal.keytransparency.audit.Application</exec.mainClass>
|
||||
<errorprone.annotation.version>2.30.0</errorprone.annotation.version>
|
||||
</properties>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<!-- Resolve a dependency resolution conflict by preferring the newer, direct,
|
||||
version 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:key-transparency-auditor:jar:0.1
|
||||
+-software.amazon.awssdk:dynamodb:jar:2.31.6:compile
|
||||
+-software.amazon.awssdk:apache-client:jar:2.31.6:runtime
|
||||
+-org.apache.httpcomponents:httpclient:jar:4.5.13:runtime
|
||||
+-org.apache.httpcomponents:httpcore:jar:4.4.13:runtime
|
||||
and
|
||||
+-org.signal:key-transparency-auditor:jar:0.1
|
||||
+-software.amazon.awssdk:dynamodb:jar:2.31.6:compile
|
||||
+-software.amazon.awssdk:apache-client:jar:2.31.6:runtime
|
||||
+-org.apache.httpcomponents:httpcore:jar:4.4.16:runtime
|
||||
-->
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpcore</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<!--
|
||||
+-org.signal:key-transparency-auditor:jar:0.1
|
||||
+-software.amazon.awssdk:dynamodb:jar:2.31.6:compile
|
||||
+-software.amazon.awssdk:apache-client:jar:2.31.6:runtime
|
||||
+-org.apache.httpcomponents:httpclient:jar:4.5.13:runtime
|
||||
+-commons-codec:commons-codec:jar:1.11:runtime
|
||||
and
|
||||
+-org.signal:key-transparency-auditor:jar:0.1
|
||||
+-software.amazon.awssdk:dynamodb:jar:2.31.6:compile
|
||||
+-software.amazon.awssdk:apache-client:jar:2.31.6:runtime
|
||||
+-commons-codec:commons-codec:jar:1.17.1:runtime
|
||||
-->
|
||||
<groupId>commons-codec</groupId>
|
||||
<artifactId>commons-codec</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
<artifactId>snakeyaml</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut.reactor</groupId>
|
||||
<artifactId>micronaut-reactor</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut.serde</groupId>
|
||||
<artifactId>micronaut-serde-jackson</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut.grpc</groupId>
|
||||
<artifactId>micronaut-grpc-client-runtime</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut.test</groupId>
|
||||
<artifactId>micronaut-test-junit5</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>dynamodb</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.amazonaws</groupId>
|
||||
<artifactId>DynamoDBLocal</artifactId>
|
||||
<version>2.6.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut.validation</groupId>
|
||||
<artifactId>micronaut-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>javax.annotation</groupId>
|
||||
<artifactId>javax.annotation-api</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- Resolve a dependency resolution conflict from grpc-protobuf 1.69.1.-->
|
||||
<groupId>com.google.errorprone</groupId>
|
||||
<artifactId>error_prone_annotations</artifactId>
|
||||
<version>${errorprone.annotation.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-protobuf</artifactId>
|
||||
<!-- grpc-protobuf's grpc-api and guava dependencies pull in conflicting versions. -->
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.google.errorprone</groupId>
|
||||
<artifactId>error_prone_annotations</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.grpc</groupId>
|
||||
<artifactId>grpc-stub</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut</groupId>
|
||||
<artifactId>micronaut-http-server-netty</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut.micrometer</groupId>
|
||||
<artifactId>micronaut-micrometer-registry-statsd</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micronaut</groupId>
|
||||
<artifactId>micronaut-management</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-core</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.logstash.logback</groupId>
|
||||
<artifactId>logstash-logback-encoder</artifactId>
|
||||
<version>8.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>sts</artifactId>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<extensions>
|
||||
<extension>
|
||||
<groupId>kr.motd.maven</groupId>
|
||||
<artifactId>os-maven-plugin</artifactId>
|
||||
<version>1.7.0</version>
|
||||
</extension>
|
||||
</extensions>
|
||||
<plugins>
|
||||
<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.9</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.xolstice.maven.plugins</groupId>
|
||||
<artifactId>protobuf-maven-plugin</artifactId>
|
||||
<version>0.6.1</version>
|
||||
|
||||
<configuration>
|
||||
<checkStaleness>false</checkStaleness>
|
||||
<clearOutputDirectory>false</clearOutputDirectory>
|
||||
<outputDirectory>${project.build.directory}/generated-sources/java</outputDirectory>
|
||||
<protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
|
||||
<pluginId>grpc-java</pluginId>
|
||||
<pluginArtifact>
|
||||
io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
|
||||
</pluginArtifact>
|
||||
</configuration>
|
||||
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>compile</goal>
|
||||
<goal>compile-custom</goal>
|
||||
<goal>test-compile</goal>
|
||||
<goal>test-compile-custom</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>io.micronaut.maven</groupId>
|
||||
<artifactId>micronaut-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Uncomment to enable incremental compilation -->
|
||||
<!-- <useIncrementalCompilation>false</useIncrementalCompilation> -->
|
||||
|
||||
<annotationProcessorPaths combine.children="append">
|
||||
<path>
|
||||
<groupId>io.micronaut</groupId>
|
||||
<artifactId>micronaut-http-validation</artifactId>
|
||||
<version>${micronaut.core.version}</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>io.micronaut.serde</groupId>
|
||||
<artifactId>micronaut-serde-processor</artifactId>
|
||||
<version>${micronaut.serialization.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>io.micronaut</groupId>
|
||||
<artifactId>micronaut-inject</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
<compilerArgs>
|
||||
<arg>-Amicronaut.processing.group=hello.world</arg>
|
||||
<arg>-Amicronaut.processing.module=hello-world</arg>
|
||||
</compilerArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import io.micronaut.runtime.Micronaut;
|
||||
|
||||
public class Application {
|
||||
|
||||
public static void main(String[] args) {
|
||||
Micronaut.run(Application.class, args);
|
||||
}
|
||||
}
|
||||
359
src/main/java/org/signal/keytransparency/audit/Auditor.java
Normal file
359
src/main/java/org/signal/keytransparency/audit/Auditor.java
Normal file
@ -0,0 +1,359 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Timer;
|
||||
import io.micronaut.context.annotation.Value;
|
||||
import io.micronaut.scheduling.annotation.Scheduled;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.interfaces.EdECPrivateKey;
|
||||
import java.security.interfaces.EdECPublicKey;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import org.signal.keytransparency.audit.client.KeyTransparencyServiceClient;
|
||||
import org.signal.keytransparency.audit.metrics.MetricsUtil;
|
||||
import org.signal.keytransparency.audit.storage.AuditorState;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateAndSignature;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Provides third-party auditing for the key transparency service by fetching and processing batches of updates and
|
||||
* periodically sending back signed tree heads. If an update contains an inconsistency with the auditor's prefix tree
|
||||
* root hash or the auditor's view of the log tree, the auditor will stop sending back signed tree heads to the key
|
||||
* transparency service.
|
||||
*/
|
||||
@Singleton
|
||||
public class Auditor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(Auditor.class);
|
||||
private static final int TREE_HEAD_BYTE_LENGTH = 153;
|
||||
private static final byte[] CIPHER_SUITE_IDENTIFIER = {0x00, 0x00};
|
||||
private static final byte THIRD_PARTY_AUDITING_MODE = 0x03;
|
||||
private static final int ED25519_KEY_LENGTH = 32;
|
||||
private final Counter updatesProcessedCounter;
|
||||
private final Counter storeAuditorStateCounter;
|
||||
private final Counter getAuditorStateCounter;
|
||||
private final Timer auditTimer;
|
||||
private final DistributionSummary updatesPerBatchDistributionSummary;
|
||||
private final AuditorConfiguration configuration;
|
||||
private final AuditorStateRepository auditorStorage;
|
||||
private final KeyTransparencyServiceClient keyTransparencyServiceClient;
|
||||
private final int sendSignaturePageSize;
|
||||
private final Duration sendSignatureInterval;
|
||||
private final ReentrantLock treeUpdateLock;
|
||||
private final Clock clock;
|
||||
private CondensedPrefixTree condensedPrefixTree;
|
||||
private CondensedLogTree condensedLogTree;
|
||||
private long totalUpdatesProcessed;
|
||||
private Instant lastTreeHeadSent;
|
||||
private long updatesSinceLastTreeHeadSent;
|
||||
|
||||
public Auditor(final AuditorConfiguration configuration,
|
||||
final AuditorStateRepository auditorStorage,
|
||||
final KeyTransparencyServiceClient keyTransparencyServiceClient,
|
||||
// the auditor must be no more than 1e7 entries behind
|
||||
@Value("${auditor.signature.page-size:1000000}") int sendSignaturePageSize,
|
||||
// the auditor must be no more than 7 days behind
|
||||
@Value("${auditor.signature.interval:PT1H}") Duration sendSignatureInterval,
|
||||
final MeterRegistry meterRegistry,
|
||||
final Clock clock) throws NoSuchAlgorithmException, InvalidKeyException {
|
||||
this.configuration = configuration;
|
||||
this.auditorStorage = auditorStorage;
|
||||
this.keyTransparencyServiceClient = keyTransparencyServiceClient;
|
||||
this.sendSignaturePageSize = sendSignaturePageSize;
|
||||
this.sendSignatureInterval = sendSignatureInterval;
|
||||
this.updatesProcessedCounter = meterRegistry.counter(MetricsUtil.name(Auditor.class, "updatesProcessed"));
|
||||
this.getAuditorStateCounter = meterRegistry.counter(MetricsUtil.name(Auditor.class, "getAuditorState"));
|
||||
this.storeAuditorStateCounter = meterRegistry.counter(MetricsUtil.name(Auditor.class, "storeAuditorState"));
|
||||
this.auditTimer = meterRegistry.timer(MetricsUtil.name(Auditor.class, "batchTimer"));
|
||||
this.updatesPerBatchDistributionSummary = DistributionSummary
|
||||
.builder(MetricsUtil.name(Auditor.class, "updatesPerBatch"))
|
||||
.distributionStatisticExpiry(Duration.ofHours(2))
|
||||
.register(meterRegistry);
|
||||
this.treeUpdateLock = new ReentrantLock();
|
||||
this.clock = clock;
|
||||
this.lastTreeHeadSent = clock.instant();
|
||||
// Check if the Ed25519 algorithm is supported and if the keys are valid
|
||||
final Signature testSignature = Signature.getInstance("Ed25519");
|
||||
testSignature.initSign(configuration.privateKey());
|
||||
testSignature.initVerify(configuration.publicKey());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
@VisibleForTesting
|
||||
void loadStoredState() throws IOException, InvalidAuditorSignatureException {
|
||||
|
||||
treeUpdateLock.lock();
|
||||
|
||||
try {
|
||||
|
||||
getAuditorStateCounter.increment();
|
||||
final Optional<byte[]> storedState = auditorStorage.getAuditorStateAndSignature();
|
||||
|
||||
if (storedState.isPresent()) {
|
||||
final AuditorStateAndSignature auditorStateAndSignature = AuditorStateAndSignature.parseFrom(storedState.get());
|
||||
|
||||
verifySignature(auditorStateAndSignature.getSerializedAuditorState().toByteArray(),
|
||||
auditorStateAndSignature.getSignature().toByteArray(),
|
||||
configuration.publicKey());
|
||||
|
||||
final AuditorState auditorState = AuditorState.parseFrom(auditorStateAndSignature.getSerializedAuditorState());
|
||||
|
||||
final Collection<LogTreeNode> logTreeNodes = auditorState.getLogTreeNodesList().stream()
|
||||
.map(Auditor::fromLogTreeNodeProtobuf).toList();
|
||||
condensedLogTree = new CondensedLogTree(logTreeNodes, auditorState.getTotalUpdatesProcessed());
|
||||
condensedPrefixTree = new CondensedPrefixTree(auditorState.getCurrentPrefixTreeRootHash().toByteArray());
|
||||
totalUpdatesProcessed = auditorState.getTotalUpdatesProcessed();
|
||||
} else {
|
||||
condensedLogTree = new CondensedLogTree();
|
||||
condensedPrefixTree = new CondensedPrefixTree();
|
||||
totalUpdatesProcessed = 0;
|
||||
}
|
||||
} finally {
|
||||
treeUpdateLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return condensedLogTree != null && condensedPrefixTree != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Fetches and processes batches of updates from the key transparency service.
|
||||
* For each update in a batch, it does a few things:</p>
|
||||
* <ol>
|
||||
* <li>If the auditor has a current prefix tree root hash, check that it matches the one used by the update
|
||||
* as a starting point</li>
|
||||
* <li>Calculate a new prefix tree root hash</li>
|
||||
* <li>Calculate a new log tree leaf hash using the new prefix tree root hash and the commitment in the update</li>
|
||||
* <li>Calculate the new log tree root hash using the new log tree leaf hash and previously stored log tree hashes</li>
|
||||
* <li>Updates its current view of the prefix tree root hash</li>
|
||||
* </ol>
|
||||
* <p>Periodically, the auditor persists {@link AuditorState} and returns a signature
|
||||
* over the log tree head to the key transparency service,
|
||||
* indicating that its view of the state of the world up to the given update matches.</p>
|
||||
*/
|
||||
@Scheduled(fixedDelay = "${auditor.interval:1m}")
|
||||
void auditKeyTransparencyService() {
|
||||
|
||||
if (!treeUpdateLock.tryLock()) {
|
||||
// This should only happen at startup, if loadStoredState() hasn't completed.
|
||||
logger.warn("Lock unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
final Timer.Sample sample = Timer.start();
|
||||
|
||||
try {
|
||||
|
||||
final long numUpdatesProcessedInThisRun =
|
||||
keyTransparencyServiceClient.getUpdates(totalUpdatesProcessed, configuration.batchSize())
|
||||
.doOnNext(update -> {
|
||||
|
||||
try {
|
||||
condensedPrefixTree.applyUpdate(update, totalUpdatesProcessed);
|
||||
} catch (final InvalidProofException e) {
|
||||
logger.error("Encountered invalid proof", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
condensedLogTree.addLeafNode(update.commitment(), condensedPrefixTree.getRootHash().orElseThrow(),
|
||||
totalUpdatesProcessed);
|
||||
updatesProcessedCounter.increment();
|
||||
totalUpdatesProcessed += 1;
|
||||
updatesSinceLastTreeHeadSent += 1;
|
||||
|
||||
setTreeHeadAndStoreAuditorStateIfNecessary();
|
||||
})
|
||||
.count()
|
||||
.blockOptional()
|
||||
.orElse(0L);
|
||||
|
||||
updatesPerBatchDistributionSummary.record(numUpdatesProcessedInThisRun);
|
||||
|
||||
// In case there are no updates to process,
|
||||
// we still want to send an auditor tree head and persist state after a certain time interval.
|
||||
setTreeHeadAndStoreAuditorStateIfNecessary();
|
||||
} finally {
|
||||
treeUpdateLock.unlock();
|
||||
sample.stop(auditTimer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void setTreeHeadAndStoreAuditorStateIfNecessary() {
|
||||
if (clock.instant().isBefore(lastTreeHeadSent.plus(sendSignatureInterval))
|
||||
&& updatesSinceLastTreeHeadSent < sendSignaturePageSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
treeUpdateLock.lock();
|
||||
|
||||
try {
|
||||
|
||||
// Set tree head in key transparency service
|
||||
final long timestampInMilliseconds = clock.instant().toEpochMilli();
|
||||
|
||||
keyTransparencyServiceClient.setTreeHead(totalUpdatesProcessed, timestampInMilliseconds,
|
||||
generateTreeHeadSignature(
|
||||
configuration.keyTransparencyServiceSigningPublicKey(),
|
||||
configuration.keyTransparencyServiceVrfPublicKey(),
|
||||
configuration.publicKey(),
|
||||
totalUpdatesProcessed,
|
||||
timestampInMilliseconds,
|
||||
condensedLogTree.getRootHash(),
|
||||
configuration.privateKey()
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
// Store auditor state - only persist to storage if the remote call succeeds.
|
||||
// This prevents storing corrupted state, and allows corruption to be resolved by restarting the auditor.
|
||||
final ByteString serializedAuditorState = AuditorState.newBuilder()
|
||||
.setTotalUpdatesProcessed(totalUpdatesProcessed)
|
||||
.setCurrentPrefixTreeRootHash(ByteString.copyFrom(condensedPrefixTree.getRootHash().orElseThrow()))
|
||||
.addAllLogTreeNodes(condensedLogTree.getNodes().stream().map(Auditor::toLogTreeNodeProtobuf).toList())
|
||||
.build()
|
||||
.toByteString();
|
||||
|
||||
final byte[] signature = generateSignature(serializedAuditorState.toByteArray(), configuration.privateKey());
|
||||
final AuditorStateAndSignature auditorStateAndSignature = AuditorStateAndSignature.newBuilder()
|
||||
.setSerializedAuditorState(serializedAuditorState)
|
||||
.setSignature(ByteString.copyFrom(signature))
|
||||
.build();
|
||||
auditorStorage.storeAuditorStateAndSignature(auditorStateAndSignature.toByteArray());
|
||||
storeAuditorStateCounter.increment();
|
||||
|
||||
} catch (final IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
|
||||
lastTreeHeadSent = clock.instant();
|
||||
updatesSinceLastTreeHeadSent = 0;
|
||||
} finally {
|
||||
treeUpdateLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static byte[] generateTreeHeadSignature(
|
||||
final EdECPublicKey keyTransparencyServicePublicSigningKey,
|
||||
final EdECPublicKey keyTransparencyServicePublicVrfKey,
|
||||
final EdECPublicKey auditorPublicKey,
|
||||
final long treeSize,
|
||||
final long timestamp,
|
||||
final byte[] logTreeRootHash,
|
||||
final EdECPrivateKey auditorPrivateKey) {
|
||||
final ByteBuffer buffer = ByteBuffer.allocate(TREE_HEAD_BYTE_LENGTH);
|
||||
buffer.put(CIPHER_SUITE_IDENTIFIER);
|
||||
buffer.put(THIRD_PARTY_AUDITING_MODE);
|
||||
|
||||
final byte[] keyTransparencyServicePublicSigningKeyBytes = getRawPublicKeyBytes(
|
||||
keyTransparencyServicePublicSigningKey);
|
||||
buffer.putShort((short) keyTransparencyServicePublicSigningKeyBytes.length);
|
||||
buffer.put(keyTransparencyServicePublicSigningKeyBytes);
|
||||
|
||||
final byte[] keyTransparencyServicePublicVrfKeyBytes = getRawPublicKeyBytes(keyTransparencyServicePublicVrfKey);
|
||||
buffer.putShort((short) keyTransparencyServicePublicVrfKeyBytes.length);
|
||||
buffer.put(keyTransparencyServicePublicVrfKeyBytes);
|
||||
|
||||
final byte[] auditorPublicKeyBytes = getRawPublicKeyBytes(auditorPublicKey);
|
||||
buffer.putShort((short) auditorPublicKeyBytes.length);
|
||||
buffer.put(auditorPublicKeyBytes);
|
||||
|
||||
buffer.putLong(treeSize);
|
||||
buffer.putLong(timestamp);
|
||||
buffer.put(logTreeRootHash);
|
||||
|
||||
return generateSignature(buffer.array(), auditorPrivateKey);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
byte[] getLogTreeRootHash() {
|
||||
return condensedLogTree.getRootHash();
|
||||
}
|
||||
|
||||
private static byte[] getRawPublicKeyBytes(final EdECPublicKey edECPublicKey) {
|
||||
final byte[] x509Bytes = edECPublicKey.getEncoded();
|
||||
|
||||
// The last 32 bytes of an X.509 encoding of an Ed25519 public key are the raw bytes of the key itself
|
||||
return Arrays.copyOfRange(x509Bytes, x509Bytes.length - ED25519_KEY_LENGTH, x509Bytes.length);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static byte[] generateSignature(final byte[] dataToSign, final EdECPrivateKey auditorPrivateKey) {
|
||||
try {
|
||||
final Signature signature = Signature.getInstance("Ed25519");
|
||||
signature.initSign(auditorPrivateKey);
|
||||
signature.update(dataToSign);
|
||||
return signature.sign();
|
||||
} catch (final NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
|
||||
// We checked for Ed25519 support and key validity at construction time
|
||||
// and initialized the signature object for signing
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static void verifySignature(final byte[] dataToSign, final byte[] expectedSignature,
|
||||
final EdECPublicKey auditorPublicKey) throws InvalidAuditorSignatureException {
|
||||
try {
|
||||
final Signature signature = Signature.getInstance("Ed25519");
|
||||
signature.initVerify(auditorPublicKey);
|
||||
signature.update(dataToSign);
|
||||
if (!signature.verify(expectedSignature)) {
|
||||
logger.error("Invalid auditor signature");
|
||||
throw new InvalidAuditorSignatureException("Signature did not match");
|
||||
}
|
||||
} catch (final NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
|
||||
// We checked for Ed25519 support and key validity at construction time
|
||||
// and initialized the signature object for signing
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static org.signal.keytransparency.audit.storage.LogTreeNode toLogTreeNodeProtobuf(
|
||||
final LogTreeNode logTreeNode) {
|
||||
return org.signal.keytransparency.audit.storage.LogTreeNode.newBuilder()
|
||||
.setId(logTreeNode.id())
|
||||
.setHash(ByteString.copyFrom(logTreeNode.hash()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LogTreeNode fromLogTreeNodeProtobuf(
|
||||
final org.signal.keytransparency.audit.storage.LogTreeNode protobuf) {
|
||||
return new LogTreeNode(protobuf.getId(), protobuf.getHash().toByteArray());
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
AuditorConfiguration getConfiguration() {
|
||||
return configuration;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import io.micronaut.context.annotation.ConfigurationProperties;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
import java.security.interfaces.EdECPrivateKey;
|
||||
import java.security.interfaces.EdECPublicKey;
|
||||
|
||||
/**
|
||||
* Configuration parameters for an {@link Auditor}.
|
||||
*
|
||||
* @param privateKey An Ed25519 private key used by the auditor to sign the tree head sent
|
||||
* back to the key transparency service
|
||||
* @param publicKey The public counterpart to {@param privateKey}.
|
||||
* @param keyTransparencyServiceSigningPublicKey An Ed25519 public key used to verify the key transparency service's
|
||||
* signature over the tree head.
|
||||
* @param keyTransparencyServiceVrfPublicKey An Ed25519 public key used to verify the input-output pair of a
|
||||
* <a href="https://www.rfc-editor.org/rfc/rfc9381.html">Verifiable Random
|
||||
* Function</a>.
|
||||
* @param batchSize The maximum number of updates that the key transparency service should
|
||||
* return in a single response.
|
||||
*/
|
||||
@ConfigurationProperties("auditor")
|
||||
record AuditorConfiguration(
|
||||
@NotNull
|
||||
EdECPrivateKey privateKey,
|
||||
@NotNull
|
||||
EdECPublicKey publicKey,
|
||||
@NotNull
|
||||
EdECPublicKey keyTransparencyServiceSigningPublicKey,
|
||||
@NotNull
|
||||
EdECPublicKey keyTransparencyServiceVrfPublicKey,
|
||||
@Positive
|
||||
int batchSize) {
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
/**
|
||||
* Provided by the key transparency service to represent its starting prefix tree state before applying the given
|
||||
* update. The auditor verifies this proof against its own stored prefix tree root hash before accepting the update.
|
||||
*/
|
||||
public interface AuditorProof {
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* Provides the data necessary for the auditor to verify and accept the given update.
|
||||
*
|
||||
* @param isRealUpdate whether the given update is real or fake.
|
||||
* @param commitmentIndex the <a href="https://www.rfc-editor.org/rfc/rfc9381.html">Verifiable Random Function</a>
|
||||
* output of the search key that was updated. This value is used to search the prefix tree and to
|
||||
* calculate the prefix tree leaf hash. This is a randomly generated value if the update is
|
||||
* fake.
|
||||
* @param standInHashSeed a pseudo-random value that is hashed with a prefix tree's level index to generate a stand-in
|
||||
* value for that level.
|
||||
* @param commitment a cryptographic hash of the update used to calculate the log tree leaf hash. This is a
|
||||
* randomly generated value if the update is fake.
|
||||
* @param proof additional information to help the auditor efficiently verify that the update uses the
|
||||
* auditor's current prefix tree root hash as a starting point.
|
||||
*/
|
||||
public record AuditorUpdate(boolean isRealUpdate,
|
||||
@Size(min = 32, max = 32)
|
||||
byte[] commitmentIndex,
|
||||
@Size(min = 16, max = 16)
|
||||
byte[] standInHashSeed,
|
||||
@Size(min = 32, max = 32)
|
||||
byte[] commitment,
|
||||
AuditorProof proof) {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AuditorUpdate{" +
|
||||
"isRealUpdate=" + isRealUpdate +
|
||||
", commitmentIndex=" + HexFormat.of().formatHex(commitmentIndex) +
|
||||
", standInHashSeed=" + HexFormat.of().formatHex(standInHashSeed) +
|
||||
", commitment=" + HexFormat.of().formatHex(commitment) +
|
||||
", proof=" + proof +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import io.micronaut.context.annotation.Factory;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.time.Clock;
|
||||
|
||||
@Factory
|
||||
public class ClockFactory {
|
||||
|
||||
@Singleton
|
||||
public Clock getClock() {
|
||||
return Clock.systemUTC();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,359 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.signal.keytransparency.audit.util.Sha256MessageDigest;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* A condensed form of the key transparency service's left balanced, binary Merkle log tree that only stores enough
|
||||
* nodes to reconstruct the root hash.
|
||||
* <p>
|
||||
* Nodes are stored in ascending order by node ID, where each node is either a leaf or the root of a full subtree.
|
||||
*/
|
||||
class CondensedLogTree {
|
||||
|
||||
private final ArrayDeque<LogTreeNode> nodes;
|
||||
@VisibleForTesting
|
||||
static byte LEAF_NODE_DOMAIN_INDICATOR = 0x00;
|
||||
@VisibleForTesting
|
||||
static byte INTERMEDIATE_NODE_DOMAIN_INDICATOR = 0x01;
|
||||
|
||||
CondensedLogTree() {
|
||||
this.nodes = new ArrayDeque<>();
|
||||
}
|
||||
|
||||
CondensedLogTree(final Collection<LogTreeNode> logTreeNodes, final long numLogEntries) {
|
||||
this.nodes = new ArrayDeque<>();
|
||||
// make sure the nodes are in ascending order by node ID
|
||||
logTreeNodes.stream()
|
||||
.sorted(Comparator.comparingLong(LogTreeNode::id))
|
||||
.forEach(nodes::addLast);
|
||||
|
||||
// verify that the given nodes are what we expect for a log tree of the given size
|
||||
verifyConsistentState(nodes, getMaxLeafNodeId(numLogEntries));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param numLogEntries the number of log entries in the tree
|
||||
* @return the maximum leaf node ID in the log tree given the number of log entries
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static long getMaxLeafNodeId(final long numLogEntries) {
|
||||
if (numLogEntries <= 0) {
|
||||
throw new IllegalArgumentException("Number of log entries must be greater than 0");
|
||||
}
|
||||
|
||||
return (numLogEntries - 1) * 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the IDs and ordering of the given nodes are consistent with what is expected for a log tree of the
|
||||
* given size.
|
||||
*
|
||||
* @param actualNodes the stored nodes in the log tree
|
||||
* @param maxLeafNodeId the maximum leaf node ID in the log tree
|
||||
* @throws IllegalArgumentException if the stored node IDs do not match the expected set of node IDs
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static void verifyConsistentState(final Collection<LogTreeNode> actualNodes, final long maxLeafNodeId) {
|
||||
final List<Long> expectedNodes = getFullSubtreeRootNodeIds(maxLeafNodeId);
|
||||
if (!expectedNodes.equals(actualNodes.stream().map(LogTreeNode::id).toList())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Stored node IDs do not match the expected node IDs for a tree of the given size");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a node ID and the max leaf node ID, return whether the given node ID is the root of a full subtree in the log tree.
|
||||
* A subtree is considered full if the number of leaves it has is a power of two.
|
||||
*
|
||||
* @param nodeId the node ID for which to determine if it serves as the root of a full subtree
|
||||
* @param maxLeafNodeId the maximum leaf node ID in the log tree
|
||||
* @return whether the given node is the root of a full subtree
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static boolean isFullSubtree(final long nodeId, final long maxLeafNodeId) {
|
||||
if (nodeId < 0 || maxLeafNodeId < 0) {
|
||||
throw new IllegalArgumentException("Node IDs must be non-negative");
|
||||
} else if (nodeId > maxLeafNodeId) {
|
||||
throw new IllegalArgumentException("The given node does not exist in the tree");
|
||||
}
|
||||
|
||||
// calculate the expected max leaf node ID in a full subtree where nodeId is the root
|
||||
final long expectedMaxLeafNodeId = nodeId + (1L << getLevel(nodeId)) - 1;
|
||||
|
||||
return expectedMaxLeafNodeId <= maxLeafNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of full subtree root node IDs starting from the root node of the log tree and traversing down
|
||||
* the right side. Because the log tree is left-balanced, if a right child node exists, the left subtree must be
|
||||
* full.
|
||||
*
|
||||
* @param maxLeafNodeId the maximum leaf node ID in the log tree
|
||||
* @return a list of node IDs that are the root nodes of full subtrees in the log tree
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static List<Long> getFullSubtreeRootNodeIds(final long maxLeafNodeId) {
|
||||
if (maxLeafNodeId < 0) {
|
||||
throw new IllegalArgumentException("Node IDs must be non-negative");
|
||||
}
|
||||
|
||||
long rootNodeId = getRoot(maxLeafNodeId);
|
||||
final List<Long> subtreeRootNodeIds = new ArrayList<>();
|
||||
while (!isFullSubtree(rootNodeId, maxLeafNodeId)) {
|
||||
subtreeRootNodeIds.add(getLeftChild(rootNodeId));
|
||||
rootNodeId = getRightChild(rootNodeId, maxLeafNodeId);
|
||||
}
|
||||
subtreeRootNodeIds.add(rootNodeId);
|
||||
|
||||
return subtreeRootNodeIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a leaf node to the condensed log tree.
|
||||
* As each leaf node is added, the condensed log tree calculates and stores the root node of the largest full subtree
|
||||
* created by the addition of the leaf node, discarding any intermediate nodes that were previously stored.
|
||||
*
|
||||
* @param commitment a cryptographic hash of the update
|
||||
* @param prefixTreeRootHash the root hash of the prefix tree after the given update
|
||||
* @param numLogEntries the total number of updates processed so far by the auditor
|
||||
*/
|
||||
void addLeafNode(final byte[] commitment, final byte[] prefixTreeRootHash, final long numLogEntries) {
|
||||
final long maxLeafNodeId = numLogEntries * 2; // the maximum leaf node ID when including the current node
|
||||
byte[] currentHash = calculateLeafHash(prefixTreeRootHash, commitment);
|
||||
long currentNodeId = maxLeafNodeId;
|
||||
int currentLevel = 0;
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
|
||||
// If the current node has the same level as the most recently added node in the list,
|
||||
// the two are part of a full subtree and can therefore be discarded in favor of the parent node.
|
||||
while (!nodes.isEmpty() && getLevel(nodes.peekLast().id()) == currentLevel) {
|
||||
|
||||
// Pop the node from the stack
|
||||
final LogTreeNode node = nodes.removeLast();
|
||||
|
||||
// Get the hash of the parent node
|
||||
final byte domainIndicator = currentLevel == 0 ? LEAF_NODE_DOMAIN_INDICATOR : INTERMEDIATE_NODE_DOMAIN_INDICATOR;
|
||||
messageDigest.update(domainIndicator);
|
||||
messageDigest.update(node.hash());
|
||||
messageDigest.update(domainIndicator);
|
||||
messageDigest.update(currentHash);
|
||||
currentHash = messageDigest.digest();
|
||||
|
||||
// Calculate the node ID and level of the parent node
|
||||
currentNodeId = getParent(node.id(), maxLeafNodeId);
|
||||
currentLevel += 1;
|
||||
}
|
||||
|
||||
// Add the node to the stack
|
||||
nodes.addLast(new LogTreeNode(currentNodeId, currentHash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the commitment and prefix tree root hash associated with an update, calculates the leaf hash of the log
|
||||
* tree.
|
||||
*
|
||||
* @param commitment a cryptographic hash of the update
|
||||
* @param prefixTreeRootHash the root hash of the prefix tree after the update represented by the commitment
|
||||
* @return the log tree leaf hash corresponding to the given update
|
||||
*/
|
||||
private static byte[] calculateLeafHash(final byte[] prefixTreeRootHash, final byte[] commitment) {
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
messageDigest.update(prefixTreeRootHash);
|
||||
messageDigest.update(commitment);
|
||||
return messageDigest.digest();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node ID of the parent of the given node in a log tree of a given size.
|
||||
*
|
||||
* @param nodeId the ID of the node for which to find a parent
|
||||
* @param maxLeafNodeId the ID of the right-most leaf node in the tree
|
||||
* @return the ID of the parent of the given node
|
||||
* @throws IllegalArgumentException if the given node is the root of the tree, is not present in a log tree with the
|
||||
* given maximum leaf node ID, or is negative
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static long getParent(final long nodeId, final long maxLeafNodeId) {
|
||||
if (nodeId < 0 || maxLeafNodeId < 0) {
|
||||
throw new IllegalArgumentException("Node IDs must be non-negative");
|
||||
} else if (nodeId > maxLeafNodeId) {
|
||||
throw new IllegalArgumentException("The given node does not exist in the tree");
|
||||
}
|
||||
|
||||
final long rootNodeId = getRoot(maxLeafNodeId);
|
||||
if (nodeId == rootNodeId) {
|
||||
throw new IllegalArgumentException("Root nodes do not have parent nodes");
|
||||
}
|
||||
|
||||
long parentNodeId = rootNodeId;
|
||||
|
||||
// Descend from the root until our next step is the target node
|
||||
while (true) {
|
||||
final long childNodeId =
|
||||
nodeId < parentNodeId ? getLeftChild(parentNodeId) : getRightChild(parentNodeId, maxLeafNodeId);
|
||||
|
||||
if (childNodeId != nodeId) {
|
||||
parentNodeId = childNodeId;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return parentNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs the root hash of the log tree.
|
||||
* <p>
|
||||
* If only one node is stored, then it contains the root hash. Otherwise, hash together the last two nodes to produce
|
||||
* their parent hash. This intermediate parent hash is then hashed with the next node in the stack, and so on, until
|
||||
* the root of the log tree is reached.
|
||||
*
|
||||
* @return the root hash of the log tree
|
||||
* @throws IllegalArgumentException if there are no entries in the log tree
|
||||
*/
|
||||
byte[] getRootHash() {
|
||||
if (nodes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Cannot return root hash of an empty log tree");
|
||||
} else if (nodes.size() == 1) {
|
||||
return nodes.peek().hash();
|
||||
} else {
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
|
||||
// we stored the nodes in ascending order by node ID
|
||||
// but hash them together in reverse order to get the root hash of the log tree
|
||||
final LogTreeNode mostRecentlyAddedNode = nodes.getLast();
|
||||
byte[] rootHash = mostRecentlyAddedNode.hash();
|
||||
// only the most recently added node has the potential to be a leaf
|
||||
boolean isLeafNode = isLeafNode(mostRecentlyAddedNode.id());
|
||||
|
||||
final Iterator<LogTreeNode> reverseIterator = nodes.descendingIterator();
|
||||
// skip the most recent node
|
||||
reverseIterator.next();
|
||||
while (reverseIterator.hasNext()) {
|
||||
final LogTreeNode node = reverseIterator.next();
|
||||
messageDigest.update(INTERMEDIATE_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(node.hash());
|
||||
messageDigest.update(isLeafNode ? LEAF_NODE_DOMAIN_INDICATOR : INTERMEDIATE_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(rootHash);
|
||||
|
||||
rootHash = messageDigest.digest();
|
||||
isLeafNode = false;
|
||||
}
|
||||
return rootHash;
|
||||
}
|
||||
}
|
||||
|
||||
List<LogTreeNode> getNodes() {
|
||||
return nodes.stream().toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node ID of the left child of an intermediate node within a log tree. Callers are responsible for
|
||||
* checking that the {@code nodeId} does not exceed the tree's maximum node ID.
|
||||
*
|
||||
* @param nodeId the node ID of the intermediate node for which to find the left child node
|
||||
* @return the node ID of the left child of the given intermediate node
|
||||
* @throws IllegalArgumentException if the given node ID belongs to a leaf node or is negative
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static long getLeftChild(final long nodeId) {
|
||||
if (nodeId < 0) {
|
||||
throw new IllegalArgumentException("Node ID must be non-negative");
|
||||
} else if (isLeafNode(nodeId)) {
|
||||
throw new IllegalArgumentException("Leaf nodes do not have children");
|
||||
}
|
||||
|
||||
return nodeId - (1L << (getLevel(nodeId) - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node ID of the right child of an intermediate node within a log tree of a given size.
|
||||
*
|
||||
* @param nodeId the node ID of the intermediate node for which to find the right child node
|
||||
* @return the node ID of the right child of the given intermediate node
|
||||
* @throws IllegalArgumentException if the given node ID belongs to a leaf node or is not present in the log tree or
|
||||
* is negative
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static long getRightChild(final long nodeId, final long maxLeafNodeId) {
|
||||
if (nodeId < 0 || maxLeafNodeId < 0) {
|
||||
throw new IllegalArgumentException("Node IDs must be non-negative");
|
||||
} else if (isLeafNode(nodeId)) {
|
||||
throw new IllegalArgumentException("Leaf nodes do not have children");
|
||||
} else if (nodeId > maxLeafNodeId) {
|
||||
throw new IllegalArgumentException("Tree does not contain given intermediate node");
|
||||
}
|
||||
|
||||
// Start at where we think the right child WOULD be if this were a full subtree, then walk left until we find a
|
||||
// child node that ACTUALLY IS in a tree of the given size
|
||||
long rightNodeId = nodeId + (1L << (getLevel(nodeId) - 1));
|
||||
|
||||
while (rightNodeId > maxLeafNodeId) {
|
||||
rightNodeId = getLeftChild(rightNodeId);
|
||||
}
|
||||
|
||||
return rightNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node ID of the root node in a log tree of a given size.
|
||||
*
|
||||
* @param maxLeafNodeId the ID of the right-most leaf node in the tree
|
||||
* @return the node ID of the root node in a log tree of a given size
|
||||
* @throws IllegalArgumentException if {@param maxLeafNodeId} is negative
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static long getRoot(final long maxLeafNodeId) {
|
||||
if (maxLeafNodeId < 0) {
|
||||
throw new IllegalArgumentException("Max leaf node ID must be non-negative");
|
||||
}
|
||||
|
||||
return maxLeafNodeId == 0 ? 0 : Long.highestOneBit(maxLeafNodeId) - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the level of a given node ID within a log tree. Leaf nodes are located at the bottom level (0), and the
|
||||
* level of a node's parent is equal to the level of the node plus 1. Callers are responsible for checking
|
||||
* that the {@code nodeId} does not exceed the tree's maximum node ID.
|
||||
*
|
||||
* @param nodeId the ID of the node for which to find a level within the log tree
|
||||
* @return the node's level within the log tree
|
||||
* @throws IllegalArgumentException if {@param nodeId} is negative
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static int getLevel(final long nodeId) {
|
||||
if (nodeId < 0) {
|
||||
throw new IllegalArgumentException("Node IDs must be non-negative");
|
||||
}
|
||||
|
||||
return isLeafNode(nodeId) ? 0 : Long.numberOfTrailingZeros(~nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given node ID maps to a leaf node in a log tree. Callers are responsible for checking
|
||||
* that the {@code nodeId} does not exceed the tree's maximum node ID.
|
||||
*
|
||||
* @param nodeId the ID of the node to inspect
|
||||
* @return {@code true} if the given node ID belongs to a leaf node or {@code false} if the given node ID belongs to
|
||||
* an intermediate node
|
||||
* @throws IllegalArgumentException if the given node ID is negative
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static boolean isLeafNode(final long nodeId) {
|
||||
if (nodeId < 0) {
|
||||
throw new IllegalArgumentException("Node IDs must be non-negative");
|
||||
}
|
||||
|
||||
return nodeId % 2 == 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
import org.signal.keytransparency.audit.util.Sha256MessageDigest;
|
||||
|
||||
/**
|
||||
* A prefix tree is a 256-level binary Merkle tree where the leaves store data used by the key transparency service for
|
||||
* efficient lookup of entries within the log tree. The prefix tree is traversed with a commitment index, a 256-bit
|
||||
* value deterministically computed from the original search key via a <a
|
||||
* href="https://datatracker.ietf.org/doc/html/rfc9381">Verifiable Random Function</a>. Unlike the condensed log tree,
|
||||
* prefix trees treat the root — not leaves — as level 0, and the leaves as level 256.
|
||||
* <p>
|
||||
* The only data the auditor stores concerning prefix trees is the most recent prefix tree root hash. For each update,
|
||||
* the auditor first verifies that the update uses the same prefix tree root hash as a starting point before calculating
|
||||
* the new prefix tree root hash.
|
||||
*/
|
||||
@Singleton
|
||||
class CondensedPrefixTree {
|
||||
|
||||
private Optional<byte[]> rootHash;
|
||||
private final static byte LEAF_NODE_DOMAIN_INDICATOR = 0x00;
|
||||
private final static byte INTERMEDIATE_NODE_DOMAIN_INDICATOR = 0x01;
|
||||
private final static byte STAND_IN_NODE_DOMAIN_INDICATOR = 0x02;
|
||||
private final static int ROOT_LEVEL = 0;
|
||||
private final static int LEAF_LEVEL = 256;
|
||||
|
||||
CondensedPrefixTree() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
CondensedPrefixTree(@Nullable final byte[] rootHash) {
|
||||
this.rootHash = Optional.ofNullable(rootHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the given update to the condensed prefix tree by first verifying that the auditor has the same starting
|
||||
* prefix tree root hash as the key transparency service, then calculating the new prefix tree root hash and updating
|
||||
* the auditor's view of it.
|
||||
*
|
||||
* @param update the update to apply to the condensed prefix tree
|
||||
* @param numLogEntries the total number of updates processed so far by the auditor
|
||||
* @throws InvalidProofException if the provided {@link AuditorUpdate#proof()} is inconsistent with the auditor's view
|
||||
* of the prefix tree
|
||||
*/
|
||||
void applyUpdate(final AuditorUpdate update, final long numLogEntries) throws InvalidProofException {
|
||||
verifyStartingRootHash(update, numLogEntries);
|
||||
|
||||
rootHash = update.isRealUpdate()
|
||||
? Optional.of(calculateNewRootHashForRealUpdate(update, numLogEntries))
|
||||
: Optional.of(calculateNewRootHashForFakeUpdate(update));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the starting prefix tree root hash between the auditor and the key transparency service.
|
||||
*
|
||||
* @param update the update for which to verify its starting prefix tree root hash
|
||||
* @param numLogEntries the total number of updates processed so far by the auditor
|
||||
* @throws InvalidProofException if the provided {@link AuditorUpdate#proof()} is inconsistent with the auditor's view
|
||||
* of the prefix tree
|
||||
*/
|
||||
@VisibleForTesting
|
||||
void verifyStartingRootHash(final AuditorUpdate update, final long numLogEntries) throws InvalidProofException {
|
||||
if (update.proof() instanceof NewTreeProof) {
|
||||
if (numLogEntries != 0 || rootHash.isPresent()) {
|
||||
throw new InvalidProofException("Auditor must have zero log entries and no root hash for a new tree proof");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (rootHash.isEmpty()) {
|
||||
if (numLogEntries == 0) {
|
||||
throw new InvalidProofException("First proof type must be newTree");
|
||||
}
|
||||
// This should never happen, unless #applyUpdate doesn't save the rootHash for NewTreeProof
|
||||
throw new InvalidProofException("No root hash present for proof");
|
||||
}
|
||||
|
||||
final byte[] rootHashFromProof = switch (update.proof()) {
|
||||
case DifferentKeyProof differentKey -> {
|
||||
// Use the old seed to calculate a stand-in hash at the end of the copath
|
||||
final byte[] startingHash = calculateStandInHash(differentKey.oldStandInHashSeed(),
|
||||
differentKey.copath().size());
|
||||
|
||||
// And then hash our way up to the root starting from the level of the last copath value.
|
||||
// The old seed is *only* used to calculate the startingHash in this proof.
|
||||
yield calculateRootHash(startingHash, differentKey.oldStandInHashSeed(), update.commitmentIndex(),
|
||||
differentKey.copath(), differentKey.copath().size());
|
||||
}
|
||||
case SameKeyProof sameKey -> {
|
||||
final byte[] startingHash = calculateLeafHash(update.commitmentIndex(), sameKey.counter(),
|
||||
sameKey.firstLogTreePosition());
|
||||
yield calculateRootHash(startingHash, update.standInHashSeed(), update.commitmentIndex(), sameKey.copath(),
|
||||
LEAF_LEVEL);
|
||||
}
|
||||
default -> throw new AssertionError("Unexpected proof type");
|
||||
};
|
||||
|
||||
if (!Arrays.equals(rootHash.get(), rootHashFromProof)) {
|
||||
final String expectedRootHash = HexFormat.of().formatHex(rootHash.get());
|
||||
final String actualRootHash = HexFormat.of().formatHex(rootHashFromProof);
|
||||
throw new InvalidProofException(
|
||||
String.format("The auditor's starting prefix tree root hash for update %d does not match the one provided by the key transparency service."
|
||||
+ "Expected %s, got %s. \nAuditor update: %s", numLogEntries, expectedRootHash, actualRootHash, update));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the new prefix tree root hash for the given real update. This involves calculating a new leaf hash and
|
||||
* combining that with the provided copath and/or stand-in hashes (calculated from the provided seed) to hash our way
|
||||
* back up to the root hash.
|
||||
*
|
||||
* @param update the update for which to calculate the new prefix tree root hash
|
||||
* @param numLogEntries the total number of updates processed so far by the auditor
|
||||
* @return the new prefix tree root hash for the given real update
|
||||
*/
|
||||
@VisibleForTesting
|
||||
byte[] calculateNewRootHashForRealUpdate(final AuditorUpdate update, final long numLogEntries) {
|
||||
final byte[] leafHash = switch (update.proof()) {
|
||||
case NewTreeProof ignored -> calculateLeafHash(update.commitmentIndex(), 0, numLogEntries);
|
||||
case DifferentKeyProof ignored -> calculateLeafHash(update.commitmentIndex(), 0, numLogEntries);
|
||||
case SameKeyProof sameKey ->
|
||||
calculateLeafHash(update.commitmentIndex(), sameKey.counter() + 1, sameKey.firstLogTreePosition());
|
||||
default -> throw new AssertionError("Unexpected proof type");
|
||||
};
|
||||
|
||||
final List<byte[]> copath = switch (update.proof()) {
|
||||
case NewTreeProof ignored -> Collections.emptyList();
|
||||
case DifferentKeyProof differentKey -> differentKey.copath();
|
||||
case SameKeyProof sameKey -> sameKey.copath();
|
||||
default -> throw new AssertionError("Unexpected proof type");
|
||||
};
|
||||
|
||||
return calculateRootHash(leafHash, update.standInHashSeed(), update.commitmentIndex(), copath, LEAF_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the new prefix tree root hash for the given fake update. This involves calculating a new stand-in hash and
|
||||
* combining that with the provided copath and/or stand-in hashes (calculated from the provided seed) to hash our way
|
||||
* back up to the root hash.
|
||||
*
|
||||
* @param update the update for which to calculate the new prefix tree root hash
|
||||
* @return the new prefix tree root hash for the given fake update
|
||||
*/
|
||||
@VisibleForTesting
|
||||
byte[] calculateNewRootHashForFakeUpdate(final AuditorUpdate update) throws InvalidProofException {
|
||||
final byte[] standInHash;
|
||||
final List<byte[]> copath;
|
||||
|
||||
switch (update.proof()) {
|
||||
case NewTreeProof ignored -> throw new InvalidProofException("NewTree proof cannot be given for a fake update");
|
||||
case DifferentKeyProof differentKeyProof -> {
|
||||
standInHash = calculateStandInHash(update.standInHashSeed(), differentKeyProof.copath().size());
|
||||
copath = differentKeyProof.copath();
|
||||
}
|
||||
case SameKeyProof ignored -> throw new InvalidProofException("sameKey proof cannot be given for a fake update");
|
||||
default -> throw new AssertionError("Unexpected proof type");
|
||||
}
|
||||
|
||||
return calculateRootHash(standInHash, update.standInHashSeed(), update.commitmentIndex(), copath, copath.size());
|
||||
}
|
||||
|
||||
Optional<byte[]> getRootHash() {
|
||||
return rootHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the root hash of the prefix tree after the update represented by the starting hash. The "dense" part of
|
||||
* the prefix tree refers to intermediate nodes in the direct path of a populated leaf node. The hashes of those
|
||||
* intermediate nodes are provided by the key transparency service when they are in the copath of another node.
|
||||
*
|
||||
* @param startingHash the hash to start with. Will be a leaf hash for a real update and a stand-in hash for a fake
|
||||
* update.
|
||||
* @param seed a pseudo-random value that is combined with a prefix tree level to calculate stand-in
|
||||
* sibling hashes in the sparse part of the prefix tree
|
||||
* @param commitmentIndex a 256-bit value used to traverse the prefix tree
|
||||
* @param copath the sibling hashes in the dense part of the prefix tree. Ordered from root to leaf.
|
||||
* @param startingLevel the level to start from in traversing up to the root of the prefix tree
|
||||
* @return the root hash of the prefix tree after the update represented by the starting hash
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static byte[] calculateRootHash(final byte[] startingHash,
|
||||
final byte[] seed,
|
||||
final byte[] commitmentIndex,
|
||||
final List<byte[]> copath,
|
||||
final int startingLevel) {
|
||||
validateByteArrayLength(startingHash, 32, "Starting hash must be 32 bytes");
|
||||
validateByteArrayLength(seed, 16, "Seed must be 16 bytes");
|
||||
validateByteArrayLength(commitmentIndex, 32, "Commitment index must be 32 bytes");
|
||||
|
||||
for (final byte[] hash : copath) {
|
||||
validateByteArrayLength(hash, 32, "Intermediate hash must be 32 bytes");
|
||||
}
|
||||
|
||||
if (copath.size() > 256 || startingLevel <= ROOT_LEVEL || startingLevel > LEAF_LEVEL) {
|
||||
throw new IllegalArgumentException("Invalid copath size or starting level");
|
||||
}
|
||||
|
||||
byte[] hash = startingHash;
|
||||
for (int level = startingLevel; level > ROOT_LEVEL; level--) {
|
||||
// if we are in the dense part of the prefix tree, use the corresponding copath value;
|
||||
// otherwise calculate a stand-in hash
|
||||
final byte[] siblingHash = level <= copath.size() ? copath.get(level - 1) : calculateStandInHash(seed, level);
|
||||
|
||||
hash = isBitSet(commitmentIndex, level)
|
||||
? calculateParentHash(siblingHash, hash)
|
||||
: calculateParentHash(hash, siblingHash);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the bit corresponding to the given level in the commitment index is 1.
|
||||
*
|
||||
* @param commitmentIndex a big-endian representation of 256 bits used to traverse the prefix tree
|
||||
* @param level the level of interest
|
||||
* @return whether the bit corresponding to {@param level} in {@param commitmentIndex} is 1
|
||||
*/
|
||||
@VisibleForTesting
|
||||
static boolean isBitSet(final byte[] commitmentIndex, final int level) {
|
||||
// bitIndex is the index of the bit at the given level
|
||||
// e.g. at level 1, we want the bit at index 0
|
||||
final int bitIndex = level - 1;
|
||||
|
||||
// get the byte that contains the bit of interest
|
||||
final byte nthByte = commitmentIndex[bitIndex / 8];
|
||||
|
||||
// and how many times to right shift the bit
|
||||
final int rightShiftNumTimes = 7 - bitIndex % 8;
|
||||
|
||||
return ((nthByte >> rightShiftNumTimes) & 1) == 1;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static byte[] calculateLeafHash(final byte[] commitmentIndex, final int updateCount, final long logTreePosition) {
|
||||
validateByteArrayLength(commitmentIndex, 32, "Commitment index must be 32 bytes");
|
||||
|
||||
if (updateCount < 0 || logTreePosition < 0) {
|
||||
throw new IllegalArgumentException("Update count and log tree position cannot be less than 0");
|
||||
}
|
||||
|
||||
// big-endian is the default byte order for ByteBuffer, no matter the underlying platform's native byte order
|
||||
final ByteBuffer countAndPositionBuffer = ByteBuffer.allocate(12);
|
||||
countAndPositionBuffer.putInt(updateCount);
|
||||
countAndPositionBuffer.putLong(logTreePosition);
|
||||
countAndPositionBuffer.flip();
|
||||
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
messageDigest.update(LEAF_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(commitmentIndex);
|
||||
messageDigest.update(countAndPositionBuffer);
|
||||
return messageDigest.digest();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static byte[] calculateParentHash(final byte[] left, final byte[] right) {
|
||||
validateByteArrayLength(left, 32, "Left hash must be 32 bytes");
|
||||
validateByteArrayLength(right, 32, "Right hash must be 32 bytes");
|
||||
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
messageDigest.update(INTERMEDIATE_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(left);
|
||||
messageDigest.update(right);
|
||||
return messageDigest.digest();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static byte[] calculateStandInHash(final byte[] seed, final int level) {
|
||||
validateByteArrayLength(seed, 16, "Seed must be 16 bytes");
|
||||
|
||||
if (level <= ROOT_LEVEL || level > LEAF_LEVEL) {
|
||||
throw new IllegalArgumentException("Level must be greater than 0 and less than or equal to 256");
|
||||
}
|
||||
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
messageDigest.update(STAND_IN_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(seed);
|
||||
|
||||
// We subtract one from the level index because we need it to fit within a byte
|
||||
// and will never calculate a stand-in hash for level 0 (the root node).
|
||||
messageDigest.update((byte) (level - 1));
|
||||
return messageDigest.digest();
|
||||
}
|
||||
|
||||
private static void validateByteArrayLength(final byte[] bytes, final int expectedLength, final String errorMessage) {
|
||||
if (bytes.length != expectedLength) {
|
||||
throw new IllegalArgumentException(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Returned when there has been at least one real update so far, and the given update does not affect an existing leaf
|
||||
* in the prefix tree. This means that the search for AuditorUpdate.index in the prefix tree ended in a stand-in hash
|
||||
* value. Can be applied to real or fake updates.
|
||||
*
|
||||
* @param oldStandInHashSeed used to calculate the stand-in hash value where the search ended.
|
||||
* @param copath the list of sibling hashes up to and including the sibling of the stand-in hash value. This
|
||||
* list is returned in top to bottom order.
|
||||
*/
|
||||
public record DifferentKeyProof(byte[] oldStandInHashSeed,
|
||||
List<byte[]> copath) implements AuditorProof {
|
||||
@Override
|
||||
public String toString() {
|
||||
return "DifferentKeyProof{" +
|
||||
"oldStandInHashSeed=" + HexFormat.of().formatHex(oldStandInHashSeed) +
|
||||
", copath=" + copath.stream().map(bytes -> HexFormat.of().formatHex(bytes)).toList() +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
/**
|
||||
* Indicates that a calculated signature did not match the one presented
|
||||
*
|
||||
* @see java.security.Signature#verify(byte[])
|
||||
*/
|
||||
public class InvalidAuditorSignatureException extends Exception {
|
||||
|
||||
public InvalidAuditorSignatureException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
/**
|
||||
* Indicates that the key transparency service provided an invalid proof of its starting prefix tree.
|
||||
*/
|
||||
public class InvalidProofException extends Exception {
|
||||
|
||||
InvalidProofException(final String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
/**
|
||||
* A leaf or intermediate node in the log tree.
|
||||
*
|
||||
* @param id the node ID
|
||||
* @param hash the hash stored by the node. See {@link CondensedLogTree#calculateLeafHash} and
|
||||
* {@link CondensedLogTree#addLeafNode} for leaf and intermediate hash calculations, respectively.
|
||||
*/
|
||||
public record LogTreeNode(long id, byte[] hash) {
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
/**
|
||||
* Returned for the very first update in the key transparency service. Can only be applied to a real update.
|
||||
*/
|
||||
public record NewTreeProof() implements AuditorProof {
|
||||
@Override
|
||||
public String toString() {
|
||||
return "NewTreeProof";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Returned if there has been at least one real update so far, and the given update affects an existing leaf in the
|
||||
* prefix tree. This means that to verify the previous prefix tree root hash, the auditor must start all the way from
|
||||
* the prefix tree leaf hash and hash its way up to the root hash. Can only be applied to real updates.
|
||||
*
|
||||
* @param counter the number of times that the value associated with the search key has been updated.
|
||||
* @param firstLogTreePosition the position of the first instance of the search key in the log tree.
|
||||
* @param copath the sibling hashes of nodes in the direct path to the given leaf. This list only contains
|
||||
* hashes that are in the direct path of another leaf (the "explored part" of the prefix
|
||||
* tree). Use {@link AuditorUpdate#standInHashSeed} to calculate stand-in hashes in the
|
||||
* "unexplored" part of the prefix tree.
|
||||
*/
|
||||
public record SameKeyProof(int counter,
|
||||
long firstLogTreePosition,
|
||||
List<byte[]> copath
|
||||
) implements AuditorProof {
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SameKeyProof{" +
|
||||
"counter=" + counter +
|
||||
", firstLogTreePosition=" + firstLogTreePosition +
|
||||
", copath=" + copath.stream().map(bytes -> HexFormat.of().formatHex(bytes)).toList() +
|
||||
"}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.client;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.grpc.StatusRuntimeException;
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.DistributionSummary;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import jakarta.inject.Singleton;
|
||||
import org.signal.keytransparency.audit.*;
|
||||
import org.signal.keytransparency.audit.AuditorProof;
|
||||
import org.signal.keytransparency.audit.AuditorUpdate;
|
||||
import org.signal.keytransparency.audit.metrics.MetricsUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.function.Tuple2;
|
||||
import reactor.util.function.Tuples;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* A client that talks to the key transparency service to request updates or provide a signed tree head.
|
||||
*/
|
||||
@Singleton
|
||||
public class KeyTransparencyServiceClient {
|
||||
|
||||
private final KeyTransparencyServiceGrpc.KeyTransparencyServiceBlockingStub stub;
|
||||
private final Counter sendSignedTreeHeadCounter;
|
||||
private final DistributionSummary getUpdatesDistributionSummary;
|
||||
private static final Logger logger = LoggerFactory.getLogger(KeyTransparencyServiceClient.class);
|
||||
|
||||
public KeyTransparencyServiceClient(final KeyTransparencyServiceGrpc.KeyTransparencyServiceBlockingStub stub,
|
||||
final MeterRegistry meterRegistry) {
|
||||
this.stub = stub;
|
||||
this.sendSignedTreeHeadCounter = meterRegistry.counter(
|
||||
MetricsUtil.name(KeyTransparencyServiceClient.class, "sendSignedTreeHead"));
|
||||
this.getUpdatesDistributionSummary = DistributionSummary
|
||||
.builder(MetricsUtil.name(KeyTransparencyServiceClient.class, "getUpdates"))
|
||||
.distributionStatisticExpiry(Duration.ofHours(2))
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a stream of updates from the key transparency service.
|
||||
*
|
||||
* @param start the index of the next update to audit in the key transparency log
|
||||
* @param batchSize the maximum number of updates to return
|
||||
* @return a stream of all updates from the key transparency service starting from the given {@code start} and
|
||||
* terminating at the last update known to the key transparency service
|
||||
*/
|
||||
public Flux<AuditorUpdate> getUpdates(final long start, final int batchSize) {
|
||||
return Flux.from(fetchPage(start, batchSize))
|
||||
.expand(auditResponseAndOffset -> {
|
||||
final AuditResponse auditResponse = auditResponseAndOffset.getT1();
|
||||
final long offset = auditResponseAndOffset.getT2();
|
||||
|
||||
if (auditResponse.getMore()) {
|
||||
return fetchPage(offset, batchSize);
|
||||
} else {
|
||||
return Mono.empty();
|
||||
}
|
||||
})
|
||||
.flatMapIterable(auditResponseAndOffset -> auditResponseAndOffset.getT1().getUpdatesList())
|
||||
.map(KeyTransparencyServiceClient::fromAuditorUpdateProtobuf);
|
||||
}
|
||||
|
||||
private Mono<Tuple2<AuditResponse, Long>> fetchPage(final long start, final int batchSize) {
|
||||
return Mono.fromCallable(() -> stub.audit(AuditRequest.newBuilder()
|
||||
.setStart(start)
|
||||
.setLimit(batchSize)
|
||||
.build()))
|
||||
.doOnNext(auditResponse -> getUpdatesDistributionSummary.record(auditResponse.getUpdatesCount()))
|
||||
.map(auditResponse -> Tuples.of(auditResponse, start + auditResponse.getUpdatesCount()));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
public static AuditorUpdate fromAuditorUpdateProtobuf(
|
||||
final org.signal.keytransparency.audit.client.AuditorUpdate protobuf) {
|
||||
AuditorProof proof = switch (protobuf.getProof().getProofCase()) {
|
||||
case NEW_TREE -> new NewTreeProof();
|
||||
case DIFFERENT_KEY -> new DifferentKeyProof(
|
||||
protobuf.getProof().getDifferentKey().getOldSeed().toByteArray(),
|
||||
protobuf.getProof().getDifferentKey().getCopathList().stream().map(ByteString::toByteArray).toList());
|
||||
case SAME_KEY -> new SameKeyProof(
|
||||
protobuf.getProof().getSameKey().getCounter(),
|
||||
protobuf.getProof().getSameKey().getPosition(),
|
||||
protobuf.getProof().getSameKey().getCopathList().stream().map(ByteString::toByteArray).toList());
|
||||
case PROOF_NOT_SET -> throw new IllegalArgumentException("Unexpected proof type");
|
||||
};
|
||||
return new AuditorUpdate(protobuf.getReal(),
|
||||
protobuf.getIndex().toByteArray(),
|
||||
protobuf.getSeed().toByteArray(),
|
||||
protobuf.getCommitment().toByteArray(),
|
||||
proof);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a signed, audited tree head to the key transparency service.
|
||||
*
|
||||
* @param treeSize the number of updates in the auditor's view of the log tree
|
||||
* @param timestamp the time the signature was generated in milliseconds since the Unix epoch
|
||||
* @param signature a signature computed over the auditor's view of the log tree's current state and long-term log
|
||||
* configuration
|
||||
*/
|
||||
public void setTreeHead(final long treeSize, final long timestamp, final byte[] signature) {
|
||||
final TreeHead treeHead = TreeHead.newBuilder()
|
||||
.setTreeSize(treeSize)
|
||||
.setTimestamp(timestamp)
|
||||
.setSignature(ByteString.copyFrom(signature))
|
||||
.build();
|
||||
try {
|
||||
stub.setAuditorHead(treeHead);
|
||||
sendSignedTreeHeadCounter.increment();
|
||||
} catch (final StatusRuntimeException e) {
|
||||
logger.error("Encountered error sending signed tree head to the key transparency service", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.client;
|
||||
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.micronaut.context.annotation.Factory;
|
||||
import io.micronaut.grpc.annotation.GrpcChannel;
|
||||
import jakarta.inject.Singleton;
|
||||
|
||||
@Factory
|
||||
class KeyTransparencyServiceStubFactory {
|
||||
|
||||
@Singleton
|
||||
KeyTransparencyServiceGrpc.KeyTransparencyServiceBlockingStub keyTransparencyServiceClient(
|
||||
@GrpcChannel("key-transparency") ManagedChannel managedChannel) {
|
||||
return KeyTransparencyServiceGrpc.newBlockingStub(managedChannel);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.health;
|
||||
|
||||
import io.micronaut.core.async.publisher.Publishers;
|
||||
import io.micronaut.health.HealthStatus;
|
||||
import io.micronaut.management.health.indicator.HealthIndicator;
|
||||
import io.micronaut.management.health.indicator.HealthResult;
|
||||
import io.micronaut.management.health.indicator.annotation.Liveness;
|
||||
import jakarta.inject.Singleton;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.signal.keytransparency.audit.Auditor;
|
||||
|
||||
@Singleton
|
||||
@Liveness
|
||||
public class LivenessIndicator implements HealthIndicator {
|
||||
|
||||
private final Auditor auditor;
|
||||
|
||||
public LivenessIndicator(final Auditor auditor) {
|
||||
this.auditor = auditor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Publisher<HealthResult> getResult() {
|
||||
return Publishers.just(HealthResult.builder("AuditorHealthy",
|
||||
auditor.isHealthy() ? HealthStatus.UP : HealthStatus.DOWN).build());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.health;
|
||||
|
||||
import io.micronaut.core.async.publisher.Publishers;
|
||||
import io.micronaut.health.HealthStatus;
|
||||
import io.micronaut.management.health.indicator.HealthIndicator;
|
||||
import io.micronaut.management.health.indicator.HealthResult;
|
||||
import io.micronaut.management.health.indicator.annotation.Readiness;
|
||||
import jakarta.inject.Singleton;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.signal.keytransparency.audit.Auditor;
|
||||
|
||||
@Singleton
|
||||
@Readiness
|
||||
public class ReadinessIndicator implements HealthIndicator {
|
||||
|
||||
private final Auditor auditor;
|
||||
|
||||
ReadinessIndicator(final Auditor auditor) {
|
||||
this.auditor = auditor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Publisher<HealthResult> getResult() {
|
||||
return Publishers.just(HealthResult.builder(
|
||||
"AuditorReady",
|
||||
auditor.isReady() ? HealthStatus.UP : HealthStatus.DOWN)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.metrics;
|
||||
|
||||
public class MetricsUtil {
|
||||
|
||||
private static final String METRIC_NAME_PREFIX = "key_transparency_auditor";
|
||||
|
||||
/**
|
||||
* Returns a qualified name for a metric contained within the given class.
|
||||
*
|
||||
* @param clazz the class that contains the metric
|
||||
* @param metricName the name of the metrics
|
||||
* @return a qualified name for the given metric
|
||||
*/
|
||||
public static String name(final Class<?> clazz, final String metricName) {
|
||||
return METRIC_NAME_PREFIX + "." + clazz.getSimpleName() + "." + metricName;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
/**
|
||||
* Stores auditor state data and a signature over it. The auditor may use this data to resume from its most recent
|
||||
* position in the key transparency log if it is restarted.
|
||||
*/
|
||||
public interface AuditorStateRepository {
|
||||
|
||||
/**
|
||||
* @return the most recently stored serialized auditor state and signature
|
||||
*/
|
||||
Optional<byte[]> getAuditorStateAndSignature() throws IOException;
|
||||
|
||||
/**
|
||||
* Store the serialized auditor state and a signature over it.
|
||||
*
|
||||
* @param serializedAuditorStateAndSignature the serialized auditor state and signature to persist
|
||||
*/
|
||||
void storeAuditorStateAndSignature(byte[] serializedAuditorStateAndSignature) throws IOException;
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.dynamodb;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import jakarta.inject.Singleton;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import software.amazon.awssdk.awscore.exception.AwsServiceException;
|
||||
import software.amazon.awssdk.core.SdkBytes;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
|
||||
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* An auditor state repository that uses DynamoDB as its backing store.
|
||||
*/
|
||||
@Singleton
|
||||
public class DynamoDbAuditorStateRepository implements AuditorStateRepository {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DynamoDbAuditorStateRepository.class);
|
||||
// we're only storing one item, so set the key to a constant value
|
||||
@VisibleForTesting
|
||||
static final AttributeValue KEY_ATTRIBUTE_VALUE = AttributeValue.builder().s("AuditorState").build();
|
||||
@VisibleForTesting
|
||||
static final String KEY = "K";
|
||||
// auditor state and signature data; bytes
|
||||
@VisibleForTesting
|
||||
static final String ATTR_AUDITOR_STATE_AND_SIGNATURE = "A";
|
||||
@VisibleForTesting
|
||||
final DynamoDbClient dynamoDbClient;
|
||||
private final DynamoDbConfiguration dynamoDbConfiguration;
|
||||
|
||||
public DynamoDbAuditorStateRepository(final DynamoDbClient dynamoDbClient,
|
||||
final DynamoDbConfiguration dynamoDbConfiguration) {
|
||||
this.dynamoDbClient = dynamoDbClient;
|
||||
this.dynamoDbConfiguration = dynamoDbConfiguration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> getAuditorStateAndSignature() throws IOException {
|
||||
try {
|
||||
final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder()
|
||||
.tableName(dynamoDbConfiguration.tableName())
|
||||
.key(Map.of(KEY, KEY_ATTRIBUTE_VALUE))
|
||||
.build());
|
||||
if (!response.hasItem()) {
|
||||
logger.info("Auditor state and signature data not found");
|
||||
return Optional.empty();
|
||||
} else {
|
||||
return Optional.of(response.item().get(ATTR_AUDITOR_STATE_AND_SIGNATURE).b().asByteArray());
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
logger.error("Unexpected error getting auditor state data", e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeAuditorStateAndSignature(final byte[] serializedAuditorStateAndSignature) throws IOException {
|
||||
final PutItemRequest.Builder builder = PutItemRequest.builder()
|
||||
.tableName(dynamoDbConfiguration.tableName())
|
||||
.item(Map.of(
|
||||
KEY, KEY_ATTRIBUTE_VALUE,
|
||||
ATTR_AUDITOR_STATE_AND_SIGNATURE,
|
||||
AttributeValue.builder().b(SdkBytes.fromByteArray(serializedAuditorStateAndSignature)).build()
|
||||
));
|
||||
try {
|
||||
dynamoDbClient.putItem(builder.build());
|
||||
} catch (final AwsServiceException e) {
|
||||
logger.error("Unexpected error writing auditor state data", e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.dynamodb;
|
||||
|
||||
import io.micronaut.context.annotation.Bean;
|
||||
import io.micronaut.context.annotation.Factory;
|
||||
import jakarta.inject.Singleton;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
|
||||
@Factory
|
||||
class DynamoDbClientFactory {
|
||||
|
||||
@Bean(preDestroy = "close")
|
||||
@Singleton
|
||||
DynamoDbClient dynamoDbClient(final DynamoDbConfiguration config) {
|
||||
return DynamoDbClient.builder()
|
||||
.region(Region.of(config.region()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.dynamodb;
|
||||
|
||||
import io.micronaut.context.annotation.ConfigurationProperties;
|
||||
import io.micronaut.context.annotation.Context;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
@Context
|
||||
@ConfigurationProperties("storage.dynamodb")
|
||||
record DynamoDbConfiguration(@NotBlank String tableName, @NotBlank String region) {
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@Configuration
|
||||
@Requires(property = "storage.dynamodb.table-name")
|
||||
@Requires(property = "storage.dynamodb.region")
|
||||
package org.signal.keytransparency.audit.storage.dynamodb;
|
||||
|
||||
import io.micronaut.context.annotation.Configuration;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.file;
|
||||
|
||||
import io.micronaut.context.annotation.Property;
|
||||
import io.micronaut.context.annotation.Requires;
|
||||
import jakarta.inject.Singleton;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* An auditor state repository that uses a file as its backing store.
|
||||
*/
|
||||
@Singleton
|
||||
@Requires(property = "storage.file.name")
|
||||
public class FileAuditorStateRepository implements AuditorStateRepository {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(FileAuditorStateRepository.class);
|
||||
private final String fileName;
|
||||
|
||||
public FileAuditorStateRepository(@Property(name = "storage.file.name") String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<byte[]> getAuditorStateAndSignature() throws IOException {
|
||||
try (final FileInputStream fileInputStream = new FileInputStream(fileName)) {
|
||||
return Optional.of(fileInputStream.readAllBytes());
|
||||
} catch (final FileNotFoundException e) {
|
||||
logger.info("Auditor state data not found");
|
||||
return Optional.empty();
|
||||
} catch (final IOException e) {
|
||||
logger.error("Unexpected error reading auditor state data", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeAuditorStateAndSignature(byte[] serializedAuditorStateAndSignature) throws IOException {
|
||||
try {
|
||||
// recursively create parent directories if they don't already exist
|
||||
final Path path = Paths.get(fileName).getParent();
|
||||
if (path != null) {
|
||||
Files.createDirectories(path);
|
||||
}
|
||||
try (final FileOutputStream fileOutputStream = new FileOutputStream(fileName)) {
|
||||
fileOutputStream.write(serializedAuditorStateAndSignature);
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
logger.error("Unexpected error writing auditor state data", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.util;
|
||||
|
||||
import io.micronaut.context.annotation.Prototype;
|
||||
import io.micronaut.core.convert.ConversionContext;
|
||||
import io.micronaut.core.convert.TypeConverter;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.EdECPrivateKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
@Prototype
|
||||
class EdECPrivateKeyDeserializer implements TypeConverter<String, EdECPrivateKey> {
|
||||
|
||||
@Override
|
||||
public Optional<EdECPrivateKey> convert(final String base64, final Class<EdECPrivateKey> targetType,
|
||||
final ConversionContext context) {
|
||||
try {
|
||||
final KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
|
||||
final byte[] privateKeyBytes = Base64.getDecoder().decode(base64);
|
||||
return Optional.of((EdECPrivateKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(privateKeyBytes)));
|
||||
} catch (final InvalidKeySpecException | NoSuchAlgorithmException | IllegalArgumentException e) {
|
||||
context.reject(base64, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.util;
|
||||
|
||||
import io.micronaut.context.annotation.Prototype;
|
||||
import io.micronaut.core.convert.ConversionContext;
|
||||
import io.micronaut.core.convert.TypeConverter;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.EdECPublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
@Prototype
|
||||
class EdECPublicKeyDeserializer implements TypeConverter<String, EdECPublicKey> {
|
||||
|
||||
@Override
|
||||
public Optional<EdECPublicKey> convert(final String base64, final Class<EdECPublicKey> targetType,
|
||||
final ConversionContext context) {
|
||||
try {
|
||||
final KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
|
||||
final byte[] publicKeyBytes = Base64.getDecoder().decode(base64);
|
||||
return Optional.of((EdECPublicKey) keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes)));
|
||||
} catch (final InvalidKeySpecException | NoSuchAlgorithmException | IllegalArgumentException e) {
|
||||
context.reject(base64, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.util;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
public class Sha256MessageDigest {
|
||||
|
||||
/**
|
||||
* Infallibly returns a new {@code MessageDigest} instance that uses the SHA-256 algorithm. While getting a new
|
||||
* {@code MessageDigest} can fail in general, every implementation of the Java platform is required to support
|
||||
* SHA-256.
|
||||
*
|
||||
* @return a new {@code MessageDigest} instance that uses the SHA-256 algorithm
|
||||
*/
|
||||
public static MessageDigest getMessageDigest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (final NoSuchAlgorithmException e) {
|
||||
throw new AssertionError("Every implementation of the Java platform is required to support SHA-256", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/main/proto/key_transparency.proto
Normal file
155
src/main/proto/key_transparency.proto
Normal file
@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "org.signal.keytransparency.audit.client";
|
||||
|
||||
package kt;
|
||||
|
||||
import "google/protobuf/empty.proto";
|
||||
|
||||
/**
|
||||
* 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 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(TreeHead) returns (google.protobuf.Empty) {}
|
||||
}
|
||||
|
||||
|
||||
message AuditRequest {
|
||||
/**
|
||||
* The index of the next update to process.
|
||||
*/
|
||||
uint64 start = 1;
|
||||
/**
|
||||
* The maximum number of updates to return for auditing, starting from the given index.
|
||||
* The key transparency service will reject requests where the limit is set greater than 1000.
|
||||
*/
|
||||
uint64 limit = 2;
|
||||
}
|
||||
|
||||
message AuditResponse {
|
||||
/**
|
||||
* A list of updates for the auditor to audit.
|
||||
*/
|
||||
repeated AuditorUpdate updates = 1;
|
||||
/**
|
||||
* Whether there are additional updates for the auditor to audit.
|
||||
*/
|
||||
bool more = 2;
|
||||
}
|
||||
|
||||
message AuditorUpdate {
|
||||
/**
|
||||
* Whether the update was real or fake.
|
||||
*/
|
||||
bool real = 1;
|
||||
/**
|
||||
* The VRF output of the search key that was updated. This value is used to search the prefix tree
|
||||
* and to calculate the prefix tree leaf hash.
|
||||
* This is a randomly generated value if the update is fake.
|
||||
*/
|
||||
bytes index = 2;
|
||||
/**
|
||||
* A pseudo-random value that is hashed with a level n to generate a stand-in value for that level in the prefix tree.
|
||||
*/
|
||||
bytes seed = 3;
|
||||
/**
|
||||
* A cryptographic hash of the update; it is used to calculate the log tree leaf hash.
|
||||
* This is a randomly generated value if the update is fake.
|
||||
*/
|
||||
bytes commitment = 4;
|
||||
/**
|
||||
* Additional information to help the auditor efficiently verify that the update
|
||||
* uses the auditor's current prefix tree root hash as a starting point.
|
||||
*/
|
||||
AuditorProof proof = 5;
|
||||
}
|
||||
|
||||
message AuditorProof {
|
||||
/**
|
||||
* Returned for the very first update in the key transparency service.
|
||||
* Can only be applied to a real update.
|
||||
*/
|
||||
message NewTree {}
|
||||
|
||||
/**
|
||||
* Returned if there has been at least one real update so far,
|
||||
* and the given update does not affect an existing leaf in the prefix tree.
|
||||
* This means that the search for AuditorUpdate.index in the prefix tree ended in a stand-in hash value.
|
||||
* Can be applied to real or fake updates.
|
||||
*/
|
||||
message DifferentKey {
|
||||
/**
|
||||
* The list of sibling hashes up to and including the sibling of the stand-in hash value.
|
||||
* This list is returned in root-to-leaf order.
|
||||
*/
|
||||
repeated bytes copath = 1;
|
||||
/**
|
||||
* Used to calculate the stand-in hash value where the search ended.
|
||||
*/
|
||||
bytes old_seed = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned if there has been at least one real update so far,
|
||||
* and the given update affects an existing leaf in the prefix tree.
|
||||
* This means that to verify the previous prefix tree root hash, the auditor must begin all the way from
|
||||
* the prefix tree leaf hash and hash their way up to the root hash.
|
||||
* Can only be applied to real updates.
|
||||
*/
|
||||
message SameKey {
|
||||
/**
|
||||
* Hashes of the siblings of nodes in the direct path to the given leaf.
|
||||
* This list only contains hashes that are in the direct path of another leaf (the "explored part" of the prefix tree).
|
||||
* Use AuditorUpdate.seed to calculate stand-in hashes in the "unexplored" part of the prefix tree.
|
||||
*/
|
||||
repeated bytes copath = 1;
|
||||
/**
|
||||
* The number of times that the value associated with the search key has been updated.
|
||||
* Used to calculate the previous prefix tree leaf hash.
|
||||
*/
|
||||
uint32 counter = 2;
|
||||
/**
|
||||
* The position of the first instance of the search key in the log tree.
|
||||
* Used to calculate the previous prefix tree leaf hash.
|
||||
*/
|
||||
uint64 position = 3;
|
||||
}
|
||||
|
||||
oneof proof {
|
||||
NewTree new_tree = 1;
|
||||
DifferentKey different_key = 3;
|
||||
SameKey same_key = 4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
message TreeHead {
|
||||
/**
|
||||
* The number of updates in the audited log tree.
|
||||
*/
|
||||
uint64 tree_size = 1;
|
||||
/**
|
||||
* When the signature was created.
|
||||
*/
|
||||
int64 timestamp = 2;
|
||||
/**
|
||||
* A signature computed over the auditor's view of the log tree's current state and
|
||||
* long-term log configuration.
|
||||
*/
|
||||
bytes signature = 3;
|
||||
}
|
||||
50
src/main/proto/storage.proto
Normal file
50
src/main/proto/storage.proto
Normal file
@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
|
||||
package org.signal.keytransparency.audit.storage;
|
||||
|
||||
message AuditorState {
|
||||
/**
|
||||
* The number of updates the auditor has processed so far.
|
||||
*/
|
||||
uint64 total_updates_processed = 1;
|
||||
/**
|
||||
* The hash of the current prefix tree root.
|
||||
*/
|
||||
bytes current_prefix_tree_root_hash = 2;
|
||||
/**
|
||||
* An ordered list of log tree nodes sufficient to reconstruct the log tree root hash
|
||||
* after the given number of updates.
|
||||
*/
|
||||
repeated LogTreeNode log_tree_nodes = 3;
|
||||
}
|
||||
|
||||
message LogTreeNode {
|
||||
/**
|
||||
* The node ID.
|
||||
*/
|
||||
uint64 id = 1;
|
||||
/**
|
||||
* The hash stored by the node
|
||||
*/
|
||||
bytes hash = 2;
|
||||
}
|
||||
|
||||
message AuditorStateAndSignature {
|
||||
/**
|
||||
* State data sufficient for the auditor to pick up where it left off in processing key transparency updates; this is
|
||||
* an `AuditorState` entity in its serialized form.
|
||||
*/
|
||||
bytes serialized_auditor_state = 1;
|
||||
|
||||
/**
|
||||
* A signature over the auditor state data
|
||||
*/
|
||||
bytes signature = 2;
|
||||
}
|
||||
3
src/main/resources/application.yml
Normal file
3
src/main/resources/application.yml
Normal file
@ -0,0 +1,3 @@
|
||||
micronaut:
|
||||
application:
|
||||
name: key-transparency-auditor
|
||||
14
src/main/resources/logback.xml
Normal file
14
src/main/resources/logback.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<configuration>
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<!-- encoders are assigned the type
|
||||
ch.qos.logback.classic.encoder.PatternLayoutEncoder by default -->
|
||||
<encoder>
|
||||
<pattern>%cyan(%d{HH:mm:ss.SSS}) %gray([%thread]) %highlight(%-5level) %magenta(%logger{36}) - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
||||
@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import io.micronaut.runtime.EmbeddedApplication;
|
||||
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
@MicronautTest
|
||||
class ApplicationTest {
|
||||
|
||||
@Inject
|
||||
EmbeddedApplication<?> application;
|
||||
|
||||
@Test
|
||||
void testItWorks() {
|
||||
Assertions.assertTrue(application.isRunning());
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import io.micronaut.context.annotation.Property;
|
||||
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@Property(name = "auditor.public-key", value = AuditorConfigurationTest.BASE_64_AUDITOR_PUBLIC_KEY)
|
||||
@Property(name = "auditor.private-key", value = AuditorConfigurationTest.BASE_64_AUDITOR_PRIVATE_KEY)
|
||||
@Property(name = "auditor.key-transparency-service-signing-public-key", value = AuditorConfigurationTest.BASE_64_SIGNING_PUBLIC_KEY)
|
||||
@Property(name = "auditor.key-transparency-service-vrf-public-key", value = AuditorConfigurationTest.BASE_64_VRF_PUBLIC_KEY)
|
||||
@Property(name = "auditor.batch-size", value = "1")
|
||||
@MicronautTest
|
||||
public class AuditorConfigurationTest {
|
||||
|
||||
public static final String BASE_64_AUDITOR_PUBLIC_KEY = "MCowBQYDK2VwAyEAK7NtsIg6wI2G/TYXD0qvsQsnX+GGPoDZdeWRAtIuTOw=";
|
||||
public static final String BASE_64_AUDITOR_PRIVATE_KEY = "MC4CAQAwBQYDK2VwBCIEILoryblmKqRCHWG9V9l2cw4KuFsbO071mTrmFKq1avxc";
|
||||
public static final String BASE_64_SIGNING_PUBLIC_KEY = "MCowBQYDK2VwAyEAJTNezQ4UXzOvW5f0ghoxk537fHeZvLBDU4pbaC1Emr8=";
|
||||
public static final String BASE_64_VRF_PUBLIC_KEY = "MCowBQYDK2VwAyEAket+Qf23umGTOM3zJpTaZrAZXAYqGjEpoweHpCNBr5M=";
|
||||
|
||||
@Inject
|
||||
AuditorConfiguration auditorConfiguration;
|
||||
|
||||
@Test
|
||||
void testAuditorConfiguration() {
|
||||
assertEquals(BASE_64_AUDITOR_PUBLIC_KEY,
|
||||
Base64.getEncoder().encodeToString(auditorConfiguration.publicKey().getEncoded()));
|
||||
assertEquals(BASE_64_AUDITOR_PRIVATE_KEY,
|
||||
Base64.getEncoder().encodeToString(auditorConfiguration.privateKey().getEncoded()));
|
||||
assertEquals(BASE_64_SIGNING_PUBLIC_KEY,
|
||||
Base64.getEncoder().encodeToString(auditorConfiguration.keyTransparencyServiceSigningPublicKey().getEncoded()));
|
||||
assertEquals(BASE_64_VRF_PUBLIC_KEY,
|
||||
Base64.getEncoder().encodeToString(auditorConfiguration.keyTransparencyServiceVrfPublicKey().getEncoded()));
|
||||
assertEquals(1, auditorConfiguration.batchSize());
|
||||
}
|
||||
}
|
||||
291
src/test/java/org/signal/keytransparency/audit/AuditorTest.java
Normal file
291
src/test/java/org/signal/keytransparency/audit/AuditorTest.java
Normal file
@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.anyInt;
|
||||
import static org.mockito.Mockito.anyLong;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.EdECPrivateKey;
|
||||
import java.security.interfaces.EdECPublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Named;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.signal.keytransparency.audit.client.AuditResponse;
|
||||
import org.signal.keytransparency.audit.client.KeyTransparencyServiceClient;
|
||||
import org.signal.keytransparency.audit.storage.AuditorState;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateRepository;
|
||||
import org.signal.keytransparency.audit.storage.LogTreeNode;
|
||||
import org.signal.keytransparency.audit.util.TestClock;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
public class AuditorTest {
|
||||
private AuditorStateRepository auditorStateRepository;
|
||||
private KeyTransparencyServiceClient keyTransparencyServiceClient;
|
||||
private AuditorConfiguration auditorConfiguration;
|
||||
private Auditor auditor;
|
||||
private TestClock clock;
|
||||
|
||||
private static TestVectors testVectors;
|
||||
|
||||
@BeforeAll
|
||||
static void init() throws IOException {
|
||||
try (final InputStream testVectorInputStream = AuditorTest.class.getResourceAsStream("katie_test_vectors.pb")) {
|
||||
if (testVectorInputStream == null) {
|
||||
throw new IOException("Could not load test vectors");
|
||||
}
|
||||
testVectors = TestVectors.parseFrom(testVectorInputStream);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchAlgorithmException, InvalidKeyException, IOException, InvalidAuditorSignatureException {
|
||||
final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
|
||||
final KeyPair auditorKeyPair = keyPairGenerator.generateKeyPair();
|
||||
final KeyPair keyTransparencyServiceSigningKeyPair = keyPairGenerator.generateKeyPair();
|
||||
final KeyPair keyTransparencyServiceVrfKeyPair = keyPairGenerator.generateKeyPair();
|
||||
|
||||
auditorStateRepository = mock(AuditorStateRepository.class);
|
||||
auditorConfiguration = new AuditorConfiguration(
|
||||
(EdECPrivateKey) auditorKeyPair.getPrivate(),
|
||||
(EdECPublicKey) auditorKeyPair.getPublic(),
|
||||
(EdECPublicKey) keyTransparencyServiceSigningKeyPair.getPublic(),
|
||||
(EdECPublicKey) keyTransparencyServiceVrfKeyPair.getPublic(),
|
||||
1
|
||||
);
|
||||
|
||||
when(auditorStateRepository.getAuditorStateAndSignature()).thenReturn(Optional.empty());
|
||||
clock = TestClock.pinned(Instant.EPOCH);
|
||||
keyTransparencyServiceClient = mock(KeyTransparencyServiceClient.class);
|
||||
auditor = new Auditor(auditorConfiguration, auditorStateRepository, keyTransparencyServiceClient, 100,
|
||||
Duration.ofMinutes(1), new SimpleMeterRegistry(), clock);
|
||||
auditor.loadStoredState();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testFailures(final AuditResponse auditResponse) throws IOException {
|
||||
when(auditorStateRepository.getAuditorStateAndSignature()).thenReturn(Optional.empty());
|
||||
when(keyTransparencyServiceClient.getUpdates(anyLong(), anyInt()))
|
||||
.thenReturn(Flux.fromIterable(auditResponse.getUpdatesList())
|
||||
.map(KeyTransparencyServiceClient::fromAuditorUpdateProtobuf));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> auditor.auditKeyTransparencyService());
|
||||
}
|
||||
|
||||
private static Stream<Arguments> testFailures() {
|
||||
return testVectors.getShouldFailList().stream()
|
||||
.map(testVector -> Arguments.of(Named.named(
|
||||
testVector.getDescription(),
|
||||
AuditResponse.newBuilder()
|
||||
.addAllUpdates(testVector.getUpdatesList())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccess() throws IOException {
|
||||
when(auditorStateRepository.getAuditorStateAndSignature()).thenReturn(Optional.empty());
|
||||
doNothing().when(auditorStateRepository).storeAuditorStateAndSignature(any());
|
||||
TestVectors.ShouldSucceedTestVector succeedTestVector = testVectors.getShouldSucceed();
|
||||
|
||||
for (TestVectors.ShouldSucceedTestVector.UpdateAndHash updateAndHash : succeedTestVector.getUpdatesList()) {
|
||||
when(keyTransparencyServiceClient.getUpdates(anyLong(), anyInt()))
|
||||
.thenReturn(Flux.just(KeyTransparencyServiceClient.fromAuditorUpdateProtobuf(updateAndHash.getUpdate())));
|
||||
|
||||
assertDoesNotThrow(() -> auditor.auditKeyTransparencyService());
|
||||
assertArrayEquals(updateAndHash.getLogRoot().toByteArray(), auditor.getLogTreeRootHash());
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testSendTreeHeadAndPersistStateAfterNumUpdates(final int numUpdates, final int expectedNumCalls)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException, IOException, InvalidAuditorSignatureException {
|
||||
when(auditorStateRepository.getAuditorStateAndSignature()).thenReturn(Optional.empty());
|
||||
doNothing().when(auditorStateRepository).storeAuditorStateAndSignature(any());
|
||||
|
||||
auditor = new Auditor(auditorConfiguration, auditorStateRepository, keyTransparencyServiceClient, 3,
|
||||
Duration.ofMinutes(5), new SimpleMeterRegistry(), clock);
|
||||
auditor.loadStoredState();
|
||||
|
||||
TestVectors.ShouldSucceedTestVector succeedTestVector = testVectors.getShouldSucceed();
|
||||
|
||||
final List<AuditorUpdate> updates = succeedTestVector.getUpdatesList()
|
||||
.subList(0, numUpdates)
|
||||
.stream()
|
||||
.map(TestVectors.ShouldSucceedTestVector.UpdateAndHash::getUpdate)
|
||||
.map(KeyTransparencyServiceClient::fromAuditorUpdateProtobuf)
|
||||
.toList();
|
||||
|
||||
when(keyTransparencyServiceClient.getUpdates(anyLong(), anyInt()))
|
||||
.thenReturn(Flux.fromIterable(updates));
|
||||
assertDoesNotThrow(() -> auditor.auditKeyTransparencyService());
|
||||
|
||||
verify(keyTransparencyServiceClient, times(expectedNumCalls)).setTreeHead(anyLong(), anyLong(), any());
|
||||
verify(auditorStateRepository, times(expectedNumCalls)).storeAuditorStateAndSignature(any());
|
||||
}
|
||||
|
||||
private static Stream<Arguments> testSendTreeHeadAndPersistStateAfterNumUpdates() {
|
||||
return Stream.of(
|
||||
Arguments.of(1, 0),
|
||||
Arguments.of(3, 1),
|
||||
Arguments.of(10, 3)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void testSendTreeHeadAndPersistStateAfterInterval(final Duration timePassed, final int expectedNumCalls)
|
||||
throws NoSuchAlgorithmException, InvalidKeyException, IOException, InvalidAuditorSignatureException {
|
||||
when(auditorStateRepository.getAuditorStateAndSignature()).thenReturn(Optional.empty());
|
||||
doNothing().when(auditorStateRepository).storeAuditorStateAndSignature(any());
|
||||
|
||||
auditor = new Auditor(auditorConfiguration, auditorStateRepository, keyTransparencyServiceClient, 50,
|
||||
Duration.ofMinutes(5), new SimpleMeterRegistry(), clock);
|
||||
auditor.loadStoredState();
|
||||
|
||||
clock.pin(Instant.EPOCH.plus(timePassed));
|
||||
|
||||
TestVectors.ShouldSucceedTestVector succeedTestVector = testVectors.getShouldSucceed();
|
||||
|
||||
// There are 10 updates in the success test vector
|
||||
final List<AuditorUpdate> updates = succeedTestVector.getUpdatesList()
|
||||
.stream()
|
||||
.map(TestVectors.ShouldSucceedTestVector.UpdateAndHash::getUpdate)
|
||||
.map(KeyTransparencyServiceClient::fromAuditorUpdateProtobuf)
|
||||
.toList();
|
||||
|
||||
when(keyTransparencyServiceClient.getUpdates(anyLong(), anyInt()))
|
||||
.thenReturn(Flux.fromIterable(updates));
|
||||
assertDoesNotThrow(() -> auditor.auditKeyTransparencyService());
|
||||
|
||||
verify(keyTransparencyServiceClient, times(expectedNumCalls)).setTreeHead(anyLong(), anyLong(), any());
|
||||
verify(auditorStateRepository, times(expectedNumCalls)).storeAuditorStateAndSignature(any());
|
||||
}
|
||||
|
||||
private static Stream<Arguments> testSendTreeHeadAndPersistStateAfterInterval() {
|
||||
return Stream.of(
|
||||
Arguments.of(Duration.ofSeconds(30), 0),
|
||||
Arguments.of(Duration.ofMinutes(5), 1),
|
||||
Arguments.of(Duration.ofMinutes(10), 1)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendTreeHeadAndPersistStateAfterIntervalWithNoUpdates()
|
||||
throws NoSuchAlgorithmException, InvalidKeyException, IOException, InvalidAuditorSignatureException {
|
||||
when(auditorStateRepository.getAuditorStateAndSignature()).thenReturn(Optional.empty());
|
||||
doNothing().when(auditorStateRepository).storeAuditorStateAndSignature(any());
|
||||
|
||||
auditor = new Auditor(auditorConfiguration, auditorStateRepository, keyTransparencyServiceClient, 50,
|
||||
Duration.ofMinutes(5), new SimpleMeterRegistry(), clock);
|
||||
auditor.loadStoredState();
|
||||
|
||||
TestVectors.ShouldSucceedTestVector succeedTestVector = testVectors.getShouldSucceed();
|
||||
|
||||
final AuditorUpdate update = KeyTransparencyServiceClient.fromAuditorUpdateProtobuf(
|
||||
succeedTestVector.getUpdatesList()
|
||||
.getFirst()
|
||||
.getUpdate()
|
||||
);
|
||||
|
||||
when(keyTransparencyServiceClient.getUpdates(anyLong(), anyInt()))
|
||||
.thenReturn(Flux.just(update));
|
||||
assertDoesNotThrow(() -> auditor.auditKeyTransparencyService());
|
||||
|
||||
verify(keyTransparencyServiceClient, times(0)).setTreeHead(anyLong(), anyLong(), any());
|
||||
verify(auditorStateRepository, times(0)).storeAuditorStateAndSignature(any());
|
||||
|
||||
clock.pin(Instant.EPOCH.plus(Duration.ofMinutes(5).plusMillis(1)));
|
||||
|
||||
when(keyTransparencyServiceClient.getUpdates(anyLong(), anyInt()))
|
||||
.thenReturn(Flux.empty());
|
||||
assertDoesNotThrow(() -> auditor.auditKeyTransparencyService());
|
||||
|
||||
// Send tree head and persist state after a certain time interval even when there have been no updates
|
||||
verify(keyTransparencyServiceClient, times(1)).setTreeHead(anyLong(), anyLong(), any());
|
||||
verify(auditorStateRepository, times(1)).storeAuditorStateAndSignature(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSignature() throws InvalidKeySpecException, NoSuchAlgorithmException {
|
||||
final TestVectors.SignatureTestVector signatureTestVector = testVectors.getSignature();
|
||||
|
||||
final KeyFactory kf = KeyFactory.getInstance("Ed25519");
|
||||
final EdECPrivateKey auditorPrivateKey = (EdECPrivateKey) kf.generatePrivate(
|
||||
new PKCS8EncodedKeySpec(signatureTestVector.getAuditorPrivateKey().toByteArray()));
|
||||
final EdECPublicKey auditorPublicKey = (EdECPublicKey) kf.generatePublic(
|
||||
new X509EncodedKeySpec(signatureTestVector.getAuditorPublicKey().toByteArray()));
|
||||
final EdECPublicKey signingPublicKey = (EdECPublicKey) kf.generatePublic(
|
||||
new X509EncodedKeySpec(signatureTestVector.getSignaturePublicKey().toByteArray()));
|
||||
final EdECPublicKey vrfPublicKey = (EdECPublicKey) kf.generatePublic(
|
||||
new X509EncodedKeySpec(signatureTestVector.getVrfPublicKey().toByteArray()));
|
||||
|
||||
assertArrayEquals(signatureTestVector.getSignature().toByteArray(),
|
||||
Auditor.generateTreeHeadSignature(signingPublicKey,
|
||||
vrfPublicKey,
|
||||
auditorPublicKey,
|
||||
signatureTestVector.getTreeSize(),
|
||||
signatureTestVector.getTimestamp(),
|
||||
signatureTestVector.getRoot().toByteArray(),
|
||||
auditorPrivateKey
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testInvalidAuditorSignature() {
|
||||
final byte[] serializedAuditorState = AuditorState.newBuilder()
|
||||
.setTotalUpdatesProcessed(1)
|
||||
.setCurrentPrefixTreeRootHash(ByteString.copyFrom(new byte[32]))
|
||||
.addAllLogTreeNodes(List.of(LogTreeNode.newBuilder()
|
||||
.setId(0)
|
||||
.setHash(ByteString.copyFrom(new byte[32]))
|
||||
.build()
|
||||
))
|
||||
.build()
|
||||
.toByteArray();
|
||||
|
||||
final byte[] modifiedSerializedAuditorState = new byte[serializedAuditorState.length];
|
||||
System.arraycopy(serializedAuditorState, 0, modifiedSerializedAuditorState, 0, serializedAuditorState.length);
|
||||
modifiedSerializedAuditorState[0] ^= (byte) 0xFF;
|
||||
|
||||
// Create an invalid signature by signing modified data
|
||||
final byte[] invalidSignature = Auditor.generateSignature(modifiedSerializedAuditorState,
|
||||
auditor.getConfiguration().privateKey());
|
||||
|
||||
assertThrows(InvalidAuditorSignatureException.class, () -> Auditor.verifySignature(serializedAuditorState,
|
||||
invalidSignature, auditor.getConfiguration().publicKey()));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,335 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.signal.keytransparency.audit.util.Util;
|
||||
import org.signal.keytransparency.audit.util.Sha256MessageDigest;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class CondensedLogTreeTest {
|
||||
|
||||
private CondensedLogTree condensedLogTree;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
condensedLogTree = new CondensedLogTree();
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructNewCondensedLogTree() {
|
||||
final List<org.signal.keytransparency.audit.LogTreeNode> nodes = new ArrayList<>(List.of(
|
||||
new org.signal.keytransparency.audit.LogTreeNode(9, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(12, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(3, new byte[32])
|
||||
));
|
||||
final CondensedLogTree condensedLogTree1 = new CondensedLogTree(nodes, 7);
|
||||
nodes.sort(Comparator.comparingLong(org.signal.keytransparency.audit.LogTreeNode::id));
|
||||
|
||||
assertEquals(nodes, condensedLogTree1.getNodes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRootHashEmptyLogTree() {
|
||||
assertThrows(IllegalArgumentException.class, () -> condensedLogTree.getRootHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addLeafNodeAndGetRootHash() {
|
||||
// add first log entry
|
||||
final byte[] firstCommitment = Util.generateRandomBytes(32);
|
||||
final byte[] firstPrefixTreeRootHash = Util.generateRandomBytes(32);
|
||||
condensedLogTree.addLeafNode(firstCommitment, firstPrefixTreeRootHash, 0);
|
||||
|
||||
final MessageDigest messageDigest = Sha256MessageDigest.getMessageDigest();
|
||||
messageDigest.update(firstPrefixTreeRootHash);
|
||||
messageDigest.update(firstCommitment);
|
||||
final byte[] expectedFirstLeafHash = messageDigest.digest();
|
||||
|
||||
// check stored node
|
||||
assertEquals(1, condensedLogTree.getNodes().size());
|
||||
assertEquals(0L, condensedLogTree.getNodes().getFirst().id());
|
||||
assertArrayEquals(expectedFirstLeafHash, condensedLogTree.getNodes().getFirst().hash());
|
||||
|
||||
// check root hash
|
||||
assertArrayEquals(expectedFirstLeafHash, condensedLogTree.getRootHash());
|
||||
|
||||
// add second log entry
|
||||
final byte[] secondCommitment = Util.generateRandomBytes(32);
|
||||
final byte[] secondPrefixTreeRootHash = Util.generateRandomBytes(32);
|
||||
condensedLogTree.addLeafNode(secondCommitment, secondPrefixTreeRootHash, 1);
|
||||
|
||||
messageDigest.update(secondPrefixTreeRootHash);
|
||||
messageDigest.update(secondCommitment);
|
||||
final byte[] expectedSecondLeafHash = messageDigest.digest();
|
||||
|
||||
messageDigest.update(CondensedLogTree.LEAF_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(expectedFirstLeafHash);
|
||||
messageDigest.update(CondensedLogTree.LEAF_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(expectedSecondLeafHash);
|
||||
final byte[] expectedSecondLogTreeRootHash = messageDigest.digest();
|
||||
|
||||
// check stored node
|
||||
assertEquals(1, condensedLogTree.getNodes().size());
|
||||
assertEquals(1L, condensedLogTree.getNodes().getFirst().id());
|
||||
assertArrayEquals(expectedSecondLogTreeRootHash, condensedLogTree.getNodes().getFirst().hash());
|
||||
|
||||
// check root hash
|
||||
assertArrayEquals(expectedSecondLogTreeRootHash, condensedLogTree.getRootHash());
|
||||
|
||||
// add third log entry
|
||||
final byte[] thirdCommitment = Util.generateRandomBytes(32);
|
||||
final byte[] thirdPrefixTreeRootHash = Util.generateRandomBytes(32);
|
||||
condensedLogTree.addLeafNode(thirdCommitment, thirdPrefixTreeRootHash, 2);
|
||||
|
||||
messageDigest.update(thirdPrefixTreeRootHash);
|
||||
messageDigest.update(thirdCommitment);
|
||||
final byte[] expectedThirdLeafHash = messageDigest.digest();
|
||||
|
||||
messageDigest.update(CondensedLogTree.INTERMEDIATE_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(expectedSecondLogTreeRootHash);
|
||||
messageDigest.update(CondensedLogTree.LEAF_NODE_DOMAIN_INDICATOR);
|
||||
messageDigest.update(expectedThirdLeafHash);
|
||||
final byte[] expectedThirdLogTreeRootHash = messageDigest.digest();
|
||||
|
||||
final List<org.signal.keytransparency.audit.LogTreeNode> listNodes = condensedLogTree.getNodes().stream().toList();
|
||||
|
||||
// check stored nodes
|
||||
assertEquals(2, condensedLogTree.getNodes().size());
|
||||
assertEquals(List.of(1L, 4L),
|
||||
condensedLogTree.getNodes().stream().map(org.signal.keytransparency.audit.LogTreeNode::id).toList());
|
||||
assertArrayEquals(expectedSecondLogTreeRootHash, listNodes.get(0).hash());
|
||||
assertArrayEquals(expectedThirdLeafHash, listNodes.get(1).hash());
|
||||
|
||||
// check root hash
|
||||
assertArrayEquals(expectedThirdLogTreeRootHash, condensedLogTree.getRootHash());
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, 0, true",
|
||||
"3, 4, false",
|
||||
"3, 12, true",
|
||||
"9, 12, true"
|
||||
})
|
||||
void isFullSubtree(final long nodeId, final long maxLeafNodeId, final boolean expectedIsFullSubtree) {
|
||||
assertEquals(expectedIsFullSubtree, CondensedLogTree.isFullSubtree(nodeId, maxLeafNodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"-1, 0", // nodeId is negative
|
||||
"0, -1", // maxLeafNodeId is negative
|
||||
"5, 4", // nodeId does not exist in tree
|
||||
})
|
||||
void isFullSubtreeIllegalNodeId(final long nodeId, final long maxLeafNodeId) {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.isFullSubtree(nodeId, maxLeafNodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void getFullSubtreeRootNodeIds(final long maxLeafNodeId, final List<Long> expectedRootNodeIds) {
|
||||
assertIterableEquals(expectedRootNodeIds, CondensedLogTree.getFullSubtreeRootNodeIds(maxLeafNodeId));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> getFullSubtreeRootNodeIds() {
|
||||
return Stream.of(
|
||||
Arguments.of(0, List.of(0L)),
|
||||
Arguments.of(2, List.of(1L)),
|
||||
Arguments.of(4, List.of(1L, 4L)),
|
||||
Arguments.of(6, List.of(3L)),
|
||||
Arguments.of(8, List.of(3L, 8L)),
|
||||
Arguments.of(12, List.of(3L, 9L, 12L))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFullSubtreeRootNodeIdsIllegalNodeId() {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getFullSubtreeRootNodeIds(-1));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"1, 0",
|
||||
"2, 2",
|
||||
"3, 4",
|
||||
"6, 10"
|
||||
})
|
||||
void getMaxLeafNodeId(final long numLogEntries, final long expectedMaxLeafNodeId) {
|
||||
assertEquals(expectedMaxLeafNodeId, CondensedLogTree.getMaxLeafNodeId(numLogEntries));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getMaxLeafNodeIdIllegalNodeId() {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getMaxLeafNodeId(0));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void verifyConsistentState(final List<org.signal.keytransparency.audit.LogTreeNode> actualNodes,
|
||||
final long maxLeafNodeId,
|
||||
final boolean consistentState) {
|
||||
if (consistentState) {
|
||||
assertDoesNotThrow(() -> CondensedLogTree.verifyConsistentState(new ArrayDeque<>(actualNodes), maxLeafNodeId));
|
||||
} else {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> CondensedLogTree.verifyConsistentState(new ArrayDeque<>(actualNodes), maxLeafNodeId));
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream<Arguments> verifyConsistentState() {
|
||||
return Stream.of(
|
||||
Arguments.of(List.of(new org.signal.keytransparency.audit.LogTreeNode(0, new byte[32])), 0, true),
|
||||
Arguments.of(List.of(new org.signal.keytransparency.audit.LogTreeNode(1, new byte[32])), 2, true),
|
||||
Arguments.of(List.of(
|
||||
new LogTreeNode(1, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(4, new byte[32])), 4, true),
|
||||
Arguments.of(List.of(
|
||||
new org.signal.keytransparency.audit.LogTreeNode(3, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(9, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(12, new byte[32])), 12, true),
|
||||
// wrong nodes
|
||||
Arguments.of(List.of(
|
||||
new org.signal.keytransparency.audit.LogTreeNode(3, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(9, new byte[32])), 12, false),
|
||||
// inconsistent ordering
|
||||
Arguments.of(List.of(
|
||||
new org.signal.keytransparency.audit.LogTreeNode(3, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(12, new byte[32]),
|
||||
new org.signal.keytransparency.audit.LogTreeNode(9, new byte[32])), 12, false)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"1, 0",
|
||||
"3, 1",
|
||||
"7, 3",
|
||||
"9, 8"
|
||||
})
|
||||
void getLeftChild(final long nodeId, final long expectedLeftChildNodeId) {
|
||||
assertEquals(expectedLeftChildNodeId, CondensedLogTree.getLeftChild(nodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"-1", // nodeId is negative
|
||||
"4", // nodeId is leaf node
|
||||
})
|
||||
void getLeftChildIllegalNodeId(final long nodeId) {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getLeftChild(nodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"1, 2, 2",
|
||||
"3, 4, 4",
|
||||
"3, 6, 5",
|
||||
"7, 8, 8",
|
||||
"7, 10, 9"
|
||||
})
|
||||
void getRightChild(final long nodeId, final long maxLeafNodeId, final long expectedRightChildNodeId) {
|
||||
assertEquals(expectedRightChildNodeId, CondensedLogTree.getRightChild(nodeId, maxLeafNodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"-1, 2", // nodeId is negative
|
||||
"2, -1", // maxLeafNodeId is negative
|
||||
"4, 4", // nodeId is leaf node
|
||||
"5, 4", // nodeId does not exist in tree
|
||||
})
|
||||
void getRightChildIllegalNodeId(final long nodeId, final long maxLeafNodeId) {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getRightChild(nodeId, maxLeafNodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, 2, 1",
|
||||
"2, 4, 1",
|
||||
"3, 10, 7",
|
||||
"7, 16, 15"
|
||||
})
|
||||
void getParentNodeId(final long nodeId, final long maxLeafNodeId, final long expectedParentNodeId) {
|
||||
assertEquals(expectedParentNodeId, CondensedLogTree.getParent(nodeId, maxLeafNodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"-1, 2", // nodeId is negative
|
||||
"2, -1", // maxLeafNodeId is negative
|
||||
"3, 4", // nodeId is root node
|
||||
"11, 10", // nodeId does not exist in tree
|
||||
})
|
||||
void getParentNodeIdIllegalNodeId(final long nodeId, final long maxLeafNodeId) {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getParent(nodeId, maxLeafNodeId));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, 0",
|
||||
"2, 1",
|
||||
"4, 3",
|
||||
"6, 3",
|
||||
"10, 7"
|
||||
})
|
||||
void getRootNodeId(final long maxLeafNodeId, final long expectedRootNodeId) {
|
||||
assertEquals(expectedRootNodeId, CondensedLogTree.getRoot(maxLeafNodeId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRootNodeIdIllegalNodeId() {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getRoot(-1));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, 0",
|
||||
"2, 0",
|
||||
"1, 1",
|
||||
"5, 1",
|
||||
"3, 2",
|
||||
"11, 2",
|
||||
"7, 3"
|
||||
})
|
||||
void getLevel(final long nodeId, final int expectedLevel) {
|
||||
assertEquals(expectedLevel, CondensedLogTree.getLevel(nodeId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getLevelIllegalNodeId() {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.getLevel(-1));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"0, true",
|
||||
"16, true",
|
||||
"1, false",
|
||||
"15, false"
|
||||
})
|
||||
void isLeafNode(final long nodeId, final boolean expectLeafNode) {
|
||||
assertEquals(expectLeafNode, CondensedLogTree.isLeafNode(nodeId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isLeafNodeIllegalNodeId() {
|
||||
assertThrows(IllegalArgumentException.class, () -> CondensedLogTree.isLeafNode(-1));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.signal.keytransparency.audit.util.Util.generateRandomBytes;
|
||||
|
||||
public class CondensedPrefixTreeTest {
|
||||
|
||||
private CondensedPrefixTree condensedPrefixTree;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
condensedPrefixTree = new CondensedPrefixTree();
|
||||
}
|
||||
|
||||
private record FakeProof() implements AuditorProof {
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyStartingRootHashNotNewTreeInvalidProof() throws InvalidProofException {
|
||||
final NewTreeProof newTreeProof = new NewTreeProof();
|
||||
final AuditorUpdate firstUpdate = generateAuditorUpdateWithProof(newTreeProof);
|
||||
final AuditorUpdate secondUpdate = generateAuditorUpdateWithProof(newTreeProof);
|
||||
condensedPrefixTree.applyUpdate(firstUpdate, 0);
|
||||
assertThrows(InvalidProofException.class, () -> condensedPrefixTree.verifyStartingRootHash(secondUpdate, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyStartingRootHashForNewTreeInvalidProof() {
|
||||
final DifferentKeyProof differentKeyProof = new DifferentKeyProof(generateRandomBytes(16),
|
||||
List.of(generateRandomBytes(32)));
|
||||
final AuditorUpdate firstUpdate = generateAuditorUpdateWithProof(differentKeyProof);
|
||||
assertThrows(InvalidProofException.class, () -> condensedPrefixTree.applyUpdate(firstUpdate, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyStartingRootHashSameKeyInvalidProof() throws InvalidProofException {
|
||||
final NewTreeProof newTreeProof = new NewTreeProof();
|
||||
final DifferentKeyProof differentKeyProof = new DifferentKeyProof(generateRandomBytes(16),
|
||||
List.of(generateRandomBytes(32)));
|
||||
final AuditorUpdate firstUpdate = generateAuditorUpdateWithProof(newTreeProof);
|
||||
|
||||
final AuditorUpdate secondUpdate = generateAuditorUpdateWithProof(differentKeyProof);
|
||||
condensedPrefixTree.applyUpdate(firstUpdate, 0);
|
||||
assertThrows(InvalidProofException.class, () -> condensedPrefixTree.verifyStartingRootHash(secondUpdate, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyStartingRootHashUnexpectedProofType() throws InvalidProofException {
|
||||
final NewTreeProof newTreeProof = new NewTreeProof();
|
||||
final AuditorUpdate firstUpdate = generateAuditorUpdateWithProof(newTreeProof);
|
||||
condensedPrefixTree.applyUpdate(firstUpdate, 0);
|
||||
|
||||
final FakeProof fakeProof = new FakeProof();
|
||||
final AuditorUpdate update = generateAuditorUpdateWithProof(fakeProof);
|
||||
|
||||
assertThrows(AssertionError.class, () -> condensedPrefixTree.verifyStartingRootHash(update, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyStartingRootHashEmptyRootHash() {
|
||||
final FakeProof fakeProof = new FakeProof();
|
||||
final AuditorUpdate update = generateAuditorUpdateWithProof(fakeProof);
|
||||
|
||||
assertThrows(InvalidProofException.class, () -> condensedPrefixTree.verifyStartingRootHash(update, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculateNewRootHashForRealUpdateUnexpectedProofType() {
|
||||
assertThrows(AssertionError.class, () ->
|
||||
condensedPrefixTree.calculateNewRootHashForRealUpdate(generateAuditorUpdateWithProof(new FakeProof()), 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculateNewRootHashForFakeUpdateUnexpectedProofType() {
|
||||
assertThrows(AssertionError.class, () ->
|
||||
condensedPrefixTree.calculateNewRootHashForFakeUpdate(generateAuditorUpdateWithProof(new FakeProof())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void calculateNewRootHashForFakeUpdateIllegalArgument() {
|
||||
assertThrows(InvalidProofException.class, () ->
|
||||
condensedPrefixTree.calculateNewRootHashForFakeUpdate(generateAuditorUpdateWithProof(new NewTreeProof())));
|
||||
assertThrows(InvalidProofException.class, () ->
|
||||
condensedPrefixTree.calculateNewRootHashForFakeUpdate(generateAuditorUpdateWithProof(new SameKeyProof(
|
||||
0,
|
||||
1,
|
||||
List.of(generateRandomBytes(32))
|
||||
))));
|
||||
}
|
||||
|
||||
private AuditorUpdate generateAuditorUpdateWithProof(final AuditorProof proof) {
|
||||
return new AuditorUpdate(
|
||||
true,
|
||||
generateRandomBytes(32),
|
||||
generateRandomBytes(16),
|
||||
generateRandomBytes(32),
|
||||
proof);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void calculateRootHashInvalidInput(final byte[] startingHash,
|
||||
final byte[] seed,
|
||||
final byte[] commitmentIndex,
|
||||
final List<byte[]> copath,
|
||||
final int startingLevel) {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
CondensedPrefixTree.calculateRootHash(startingHash, seed, commitmentIndex, copath, startingLevel));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> calculateRootHashInvalidInput() {
|
||||
final byte[] validStartingHash = generateRandomBytes(32);
|
||||
final byte[] validSeed = generateRandomBytes(16);
|
||||
final byte[] validCommitmentIndex = generateRandomBytes(32);
|
||||
final List<byte[]> validCopath = List.of(generateRandomBytes(32));
|
||||
final int validStartingLevel = 25;
|
||||
|
||||
final List<byte[]> tooLongCopathList = new java.util.ArrayList<>(Collections.emptyList());
|
||||
IntStream.range(0, 257).forEach(unused -> tooLongCopathList.add(generateRandomBytes(32)));
|
||||
|
||||
return Stream.of(
|
||||
// Invalid startingHash
|
||||
Arguments.of(generateRandomBytes(31), validSeed, validCommitmentIndex, validCopath, validStartingLevel),
|
||||
// Invalid seed
|
||||
Arguments.of(validStartingHash, generateRandomBytes(15), validCommitmentIndex, validCopath, validStartingLevel),
|
||||
// Invalid commitmentIndex
|
||||
Arguments.of(validStartingHash, validSeed, generateRandomBytes(31), validCopath, validStartingLevel),
|
||||
// Invalid copath hash size
|
||||
Arguments.of(validStartingHash, validSeed, validCommitmentIndex, List.of(generateRandomBytes(31)),
|
||||
validStartingLevel),
|
||||
// Invalid copath length
|
||||
Arguments.of(validStartingHash, validSeed, validCommitmentIndex, tooLongCopathList, validStartingLevel),
|
||||
// Starting level too small
|
||||
Arguments.of(validStartingHash, validSeed, validCommitmentIndex, List.of(generateRandomBytes(31)), 0),
|
||||
// Starting level too large
|
||||
Arguments.of(validStartingHash, validSeed, validCommitmentIndex, List.of(generateRandomBytes(31)), 257)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void calculateLeafHashInvalidInput(final byte[] commitmentIndex,
|
||||
final int updateCount,
|
||||
final long logTreePosition) {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
CondensedPrefixTree.calculateLeafHash(commitmentIndex, updateCount, logTreePosition));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> calculateLeafHashInvalidInput() {
|
||||
final byte[] validCommitmentIndex = generateRandomBytes(32);
|
||||
return Stream.of(
|
||||
// Invalid commitment index
|
||||
Arguments.of(generateRandomBytes(31), 0, 0),
|
||||
// Invalid update count
|
||||
Arguments.of(validCommitmentIndex, -1, 0),
|
||||
// Invalid log tree position
|
||||
Arguments.of(validCommitmentIndex, 0, -1)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void calculateParentHashInvalidInput(final byte[] leftHash,
|
||||
final byte[] rightHash) {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
CondensedPrefixTree.calculateParentHash(leftHash, rightHash));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> calculateParentHashInvalidInput() {
|
||||
final byte[] validHash = generateRandomBytes(32);
|
||||
return Stream.of(
|
||||
Arguments.of(generateRandomBytes(31), validHash),
|
||||
Arguments.of(validHash, generateRandomBytes(31))
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void calculateStandInHashInvalidInput(final byte[] seed, final int level) {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
CondensedPrefixTree.calculateStandInHash(seed, level));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> calculateStandInHashInvalidInput() {
|
||||
final byte[] validSeed = generateRandomBytes(16);
|
||||
return Stream.of(
|
||||
Arguments.of(validSeed, 0),
|
||||
Arguments.of(validSeed, 257),
|
||||
Arguments.of(generateRandomBytes(15), 1)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
void isBitSet(final byte[] commitmentIndex, final int level, final boolean expected) {
|
||||
assertEquals(expected, CondensedPrefixTree.isBitSet(commitmentIndex, level));
|
||||
}
|
||||
|
||||
private static Stream<Arguments> isBitSet() {
|
||||
return Stream.of(
|
||||
Arguments.of(new byte[]{1, 1, 1}, 3, false),
|
||||
Arguments.of(new byte[]{1, 1, 1}, 17, false),
|
||||
Arguments.of(new byte[]{1, 1, 1}, 8, true),
|
||||
Arguments.of(new byte[]{1, 1, 1}, 16, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.client;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.keytransparency.audit.util.Util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class KeyTransparencyServiceClientTest {
|
||||
|
||||
private KeyTransparencyServiceGrpc.KeyTransparencyServiceBlockingStub stub;
|
||||
private KeyTransparencyServiceClient client;
|
||||
|
||||
private static final int EXISTING_UPDATE_COUNT = 13;
|
||||
private static final int BATCH_SIZE = 77;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
stub = mock(KeyTransparencyServiceGrpc.KeyTransparencyServiceBlockingStub.class);
|
||||
client = new KeyTransparencyServiceClient(stub, new SimpleMeterRegistry());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUpdates() {
|
||||
final AuditResponse firstPage = AuditResponse.newBuilder()
|
||||
.addAllUpdates(generateRandomUpdates(3))
|
||||
.setMore(true)
|
||||
.build();
|
||||
|
||||
final AuditResponse secondPage = AuditResponse.newBuilder()
|
||||
.addAllUpdates(generateRandomUpdates(5))
|
||||
.setMore(true)
|
||||
.build();
|
||||
|
||||
final AuditResponse thirdPage = AuditResponse.newBuilder()
|
||||
.addAllUpdates(generateRandomUpdates(7))
|
||||
.setMore(false)
|
||||
.build();
|
||||
|
||||
when(stub.audit(any()))
|
||||
.thenReturn(firstPage)
|
||||
.thenReturn(secondPage)
|
||||
.thenReturn(thirdPage);
|
||||
|
||||
final List<org.signal.keytransparency.audit.AuditorUpdate> expectedUpdates =
|
||||
Stream.concat(Stream.concat(firstPage.getUpdatesList().stream(), secondPage.getUpdatesList().stream()),
|
||||
thirdPage.getUpdatesList().stream())
|
||||
.map(KeyTransparencyServiceClient::fromAuditorUpdateProtobuf)
|
||||
.toList();
|
||||
|
||||
final List<org.signal.keytransparency.audit.AuditorUpdate> retrievedUpdates =
|
||||
client.getUpdates(EXISTING_UPDATE_COUNT, BATCH_SIZE).collectList().block();
|
||||
|
||||
assertNotNull(retrievedUpdates);
|
||||
assertEquals(expectedUpdates.size(), retrievedUpdates.size());
|
||||
|
||||
for (int i = 0; i < expectedUpdates.size(); i++) {
|
||||
assertTrue(updatesEqual(expectedUpdates.get(i), retrievedUpdates.get(i)));
|
||||
}
|
||||
|
||||
verify(stub).audit(AuditRequest.newBuilder()
|
||||
.setStart(EXISTING_UPDATE_COUNT)
|
||||
.setLimit(BATCH_SIZE)
|
||||
.build());
|
||||
|
||||
verify(stub).audit(AuditRequest.newBuilder()
|
||||
.setStart(EXISTING_UPDATE_COUNT + firstPage.getUpdatesCount())
|
||||
.setLimit(BATCH_SIZE)
|
||||
.build());
|
||||
|
||||
verify(stub).audit(AuditRequest.newBuilder()
|
||||
.setStart(EXISTING_UPDATE_COUNT + firstPage.getUpdatesCount() + secondPage.getUpdatesCount())
|
||||
.setLimit(BATCH_SIZE)
|
||||
.build());
|
||||
}
|
||||
|
||||
private static boolean updatesEqual(final org.signal.keytransparency.audit.AuditorUpdate a,
|
||||
final org.signal.keytransparency.audit.AuditorUpdate b) {
|
||||
|
||||
// We're always using "new tree" proofs that don't have any internal data,
|
||||
// which does not reflect the actual log entries of the key transparency service.
|
||||
// This is fine because we're only testing fetching pages of updates here;
|
||||
// proof comparison is tested separately.
|
||||
return a.isRealUpdate() == b.isRealUpdate() &&
|
||||
Arrays.equals(a.commitmentIndex(), b.commitmentIndex()) &&
|
||||
Arrays.equals(a.standInHashSeed(), b.standInHashSeed()) &&
|
||||
Arrays.equals(a.commitment(), b.commitment()) &&
|
||||
a.proof().equals(b.proof());
|
||||
}
|
||||
|
||||
private List<AuditorUpdate> generateRandomUpdates(final int updateCount) {
|
||||
final List<AuditorUpdate> updates = new ArrayList<>(updateCount);
|
||||
|
||||
for (int i = 0; i < updateCount; i++) {
|
||||
updates.add(AuditorUpdate.newBuilder()
|
||||
.setReal(ThreadLocalRandom.current().nextBoolean())
|
||||
.setIndex(ByteString.copyFrom(Util.generateRandomBytes(32)))
|
||||
.setSeed(ByteString.copyFrom(Util.generateRandomBytes(32)))
|
||||
.setCommitment(ByteString.copyFrom(Util.generateRandomBytes(32)))
|
||||
.setProof(AuditorProof.newBuilder()
|
||||
.setNewTree(AuditorProof.NewTree.newBuilder().build())
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.dynamodb;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import io.micronaut.context.annotation.Property;
|
||||
import io.micronaut.context.annotation.Replaces;
|
||||
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.signal.keytransparency.audit.storage.AuditorState;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateAndSignature;
|
||||
import org.signal.keytransparency.audit.storage.LogTreeNode;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.signal.keytransparency.audit.util.Util.generateRandomBytes;
|
||||
|
||||
@MicronautTest
|
||||
@Property(name = "storage.dynamodb.table-name", value = "AuditorStateRepositoryTest")
|
||||
@Property(name = "storage.dynamodb.region", value = "us-east-1")
|
||||
public class DynamoDbAuditorStateRepositoryTest {
|
||||
|
||||
private static final String AUDITOR_STATE_TABLE_NAME = "AuditorStateRepositoryTest";
|
||||
@RegisterExtension
|
||||
private static final DynamoDbExtension dynamoDbExtension = DynamoDbExtension.builder()
|
||||
.tableName(AUDITOR_STATE_TABLE_NAME)
|
||||
.hashKey(DynamoDbAuditorStateRepository.KEY)
|
||||
.attributeDefinition(AttributeDefinition.builder()
|
||||
.attributeName(DynamoDbAuditorStateRepository.KEY)
|
||||
.attributeType(ScalarAttributeType.S)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
@Replaces(DynamoDbClient.class)
|
||||
@Singleton
|
||||
DynamoDbClient dynamoDbClient() throws Exception {
|
||||
return dynamoDbExtension.createClient();
|
||||
}
|
||||
|
||||
@Inject
|
||||
DynamoDbAuditorStateRepository dynamoDbAuditorStateRepository;
|
||||
|
||||
@Test
|
||||
void testStoreAndRetrieveAuditorState() throws IOException {
|
||||
final Optional<byte[]> auditorStateAndSignatureBytes = dynamoDbAuditorStateRepository.getAuditorStateAndSignature();
|
||||
assertTrue(auditorStateAndSignatureBytes.isEmpty());
|
||||
|
||||
final byte[] signature = generateRandomBytes(64);
|
||||
final byte[] prefixTreeRootHash = generateRandomBytes(32);
|
||||
final ByteString serializedAuditorState = AuditorState.newBuilder()
|
||||
.setTotalUpdatesProcessed(1)
|
||||
.setCurrentPrefixTreeRootHash(ByteString.copyFrom(prefixTreeRootHash))
|
||||
.addAllLogTreeNodes(List.of(LogTreeNode.newBuilder()
|
||||
.setHash(ByteString.copyFrom(generateRandomBytes(32)))
|
||||
.setId(0)
|
||||
.build()))
|
||||
.build()
|
||||
.toByteString();
|
||||
|
||||
final AuditorStateAndSignature auditorStateAndSignature = AuditorStateAndSignature.newBuilder()
|
||||
.setSignature(ByteString.copyFrom(signature))
|
||||
.setSerializedAuditorState(serializedAuditorState)
|
||||
.build();
|
||||
|
||||
dynamoDbAuditorStateRepository.storeAuditorStateAndSignature(auditorStateAndSignature.toByteArray());
|
||||
|
||||
final AuditorStateAndSignature retrievedAuditorStateAndSignature = AuditorStateAndSignature.parseFrom(
|
||||
dynamoDbAuditorStateRepository.getAuditorStateAndSignature().get());
|
||||
assertEquals(serializedAuditorState, retrievedAuditorStateAndSignature.getSerializedAuditorState());
|
||||
assertArrayEquals(signature, retrievedAuditorStateAndSignature.getSignature().toByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.dynamodb;
|
||||
|
||||
import com.amazonaws.services.dynamodbv2.local.embedded.DynamoDBEmbedded;
|
||||
import com.amazonaws.services.dynamodbv2.local.shared.access.AmazonDynamoDBLocal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.extension.AfterAllCallback;
|
||||
import org.junit.jupiter.api.extension.AfterEachCallback;
|
||||
import org.junit.jupiter.api.extension.BeforeAllCallback;
|
||||
import org.junit.jupiter.api.extension.BeforeEachCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
|
||||
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
|
||||
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest;
|
||||
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
|
||||
import software.amazon.awssdk.services.dynamodb.model.KeyType;
|
||||
import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput;
|
||||
|
||||
public class DynamoDbExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, AfterAllCallback {
|
||||
|
||||
private static DynamoDbClient client;
|
||||
private final List<AttributeDefinition> attributeDefinitions;
|
||||
private final String tableName;
|
||||
private final String hashKeyName;
|
||||
|
||||
private final long readCapacityUnits;
|
||||
private final long writeCapacityUnits;
|
||||
|
||||
private AmazonDynamoDBLocal embedded;
|
||||
|
||||
private DynamoDbExtension(final String tableName,
|
||||
final String hashKey,
|
||||
final List<AttributeDefinition> attributeDefinitions,
|
||||
final long readCapacityUnits,
|
||||
final long writeCapacityUnits) {
|
||||
this.tableName = tableName;
|
||||
this.hashKeyName = hashKey;
|
||||
this.attributeDefinitions = attributeDefinitions;
|
||||
this.readCapacityUnits = readCapacityUnits;
|
||||
this.writeCapacityUnits = writeCapacityUnits;
|
||||
}
|
||||
|
||||
public static DynamoDbExtensionBuilder builder() {
|
||||
return new DynamoDbExtensionBuilder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeAll(final ExtensionContext context) {
|
||||
embedded = DynamoDBEmbedded.create(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeEach(final ExtensionContext context) {
|
||||
createTable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterEach(final ExtensionContext context) {
|
||||
deleteTable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterAll(final ExtensionContext context) {
|
||||
|
||||
try {
|
||||
embedded.shutdown();
|
||||
} catch (final Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public DynamoDbClient createClient() {
|
||||
if (client == null) {
|
||||
client = embedded.dynamoDbClient();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private void createTable() {
|
||||
final CreateTableRequest createTableRequest = CreateTableRequest.builder()
|
||||
.tableName(tableName)
|
||||
.keySchema(KeySchemaElement.builder().attributeName(hashKeyName).keyType(KeyType.HASH).build())
|
||||
.attributeDefinitions(attributeDefinitions)
|
||||
.provisionedThroughput(ProvisionedThroughput.builder()
|
||||
.readCapacityUnits(readCapacityUnits)
|
||||
.writeCapacityUnits(writeCapacityUnits)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
client.createTable(createTableRequest);
|
||||
}
|
||||
|
||||
private void deleteTable() {
|
||||
client.deleteTable(DeleteTableRequest.builder()
|
||||
.tableName(tableName).build());
|
||||
}
|
||||
|
||||
public static class DynamoDbExtensionBuilder {
|
||||
|
||||
private String tableName;
|
||||
private String hashKey;
|
||||
private final List<AttributeDefinition> attributeDefinitions = new ArrayList<>();
|
||||
|
||||
public DynamoDbExtensionBuilder tableName(String databaseName) {
|
||||
this.tableName = databaseName;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DynamoDbExtensionBuilder hashKey(String hashKey) {
|
||||
this.hashKey = hashKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
public DynamoDbExtensionBuilder attributeDefinition(AttributeDefinition attributeDefinition) {
|
||||
attributeDefinitions.add(attributeDefinition);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DynamoDbExtension build() {
|
||||
final long readCapacityUnits = 5L;
|
||||
final long writeCapacityUnits = 5L;
|
||||
return new DynamoDbExtension(tableName, hashKey, attributeDefinitions, readCapacityUnits,
|
||||
writeCapacityUnits);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.storage.file;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.signal.keytransparency.audit.storage.AuditorState;
|
||||
import org.signal.keytransparency.audit.storage.AuditorStateAndSignature;
|
||||
import org.signal.keytransparency.audit.storage.LogTreeNode;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.signal.keytransparency.audit.util.Util.generateRandomBytes;
|
||||
|
||||
public class FileAuditorStateRepositoryTest {
|
||||
|
||||
private File testFile;
|
||||
private FileAuditorStateRepository fileAuditorStateRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
testFile = File.createTempFile("test", null);
|
||||
testFile.deleteOnExit();
|
||||
fileAuditorStateRepository = new FileAuditorStateRepository(testFile.getCanonicalPath());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
testFile.delete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStoreAndGetAuditorStateAndSignature() throws IOException {
|
||||
final ByteString serializedAuditorState = AuditorState.newBuilder()
|
||||
.setTotalUpdatesProcessed(1)
|
||||
.setCurrentPrefixTreeRootHash(ByteString.copyFrom(generateRandomBytes(32)))
|
||||
.addAllLogTreeNodes(List.of(
|
||||
LogTreeNode.newBuilder()
|
||||
.setHash(ByteString.copyFrom(generateRandomBytes(32)))
|
||||
.setId(0)
|
||||
.build()
|
||||
))
|
||||
.build()
|
||||
.toByteString();
|
||||
|
||||
final byte[] signature = generateRandomBytes(64);
|
||||
fileAuditorStateRepository.storeAuditorStateAndSignature(AuditorStateAndSignature.newBuilder()
|
||||
.setSerializedAuditorState(serializedAuditorState)
|
||||
.setSignature(ByteString.copyFrom(signature))
|
||||
.build()
|
||||
.toByteArray());
|
||||
|
||||
final AuditorStateAndSignature auditorStateAndSignature = AuditorStateAndSignature.parseFrom(
|
||||
fileAuditorStateRepository.getAuditorStateAndSignature().get());
|
||||
|
||||
assertEquals(serializedAuditorState, auditorStateAndSignature.getSerializedAuditorState());
|
||||
assertArrayEquals(signature, auditorStateAndSignature.getSignature().toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFileNotFound() throws IOException {
|
||||
testFile.delete();
|
||||
Optional<byte[]> auditorStateAndSignatureBytes = fileAuditorStateRepository.getAuditorStateAndSignature();
|
||||
assertTrue(auditorStateAndSignatureBytes.isEmpty());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.util;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Clock class specialized for testing.
|
||||
* <p>
|
||||
* This clock can be pinned to a particular instant or can provide the "normal" time.
|
||||
* <p>
|
||||
* Unlike normal clocks it can be dynamically pinned and unpinned to help with testing.
|
||||
* It should not be used in production.
|
||||
*/
|
||||
public class TestClock extends Clock {
|
||||
|
||||
private volatile Optional<Instant> pinnedInstant;
|
||||
private final ZoneId zoneId;
|
||||
|
||||
private TestClock(Optional<Instant> maybePinned, ZoneId id) {
|
||||
this.pinnedInstant = maybePinned;
|
||||
this.zoneId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a test clock pinned to a particular instant.
|
||||
* <p>
|
||||
* The clock can later be pinned to a different instant or unpinned if desired.
|
||||
* <p>
|
||||
* Unlike the fixed constructor no time zone is required (it defaults to UTC).
|
||||
*
|
||||
* @param instant the instant to pin the clock to.
|
||||
* @return test clock pinned to the given instant.
|
||||
*/
|
||||
public static TestClock pinned(Instant instant) {
|
||||
return new TestClock(Optional.of(instant), ZoneId.of("UTC"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin this test clock to the given instance.
|
||||
* <p>
|
||||
* This modifies the existing clock in-place.
|
||||
*
|
||||
* @param instant the instant to pin the clock to.
|
||||
*/
|
||||
public void pin(Instant instant) {
|
||||
this.pinnedInstant = Optional.of(instant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpin this test clock so it will return the "real" time.
|
||||
* <p>
|
||||
* This modifies the existing clock in-place.
|
||||
*/
|
||||
public void unpin() {
|
||||
this.pinnedInstant = Optional.empty();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public TestClock withZone(ZoneId id) {
|
||||
return new TestClock(pinnedInstant, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return zoneId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return pinnedInstant.orElseGet(Instant::now);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.keytransparency.audit.util;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
public class Util {
|
||||
|
||||
public static byte[] generateRandomBytes(final int length) {
|
||||
final byte[] bytes = new byte[length];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
48
src/test/proto/test_vector.proto
Normal file
48
src/test/proto/test_vector.proto
Normal file
@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "org.signal.keytransparency.audit";
|
||||
|
||||
package org.signal.keytransparency.audit;
|
||||
|
||||
import "key_transparency.proto";
|
||||
|
||||
message TestVectors {
|
||||
message ShouldFailTestVector {
|
||||
string description = 1;
|
||||
repeated kt.AuditorUpdate updates = 2;
|
||||
}
|
||||
|
||||
message ShouldSucceedTestVector {
|
||||
message UpdateAndHash {
|
||||
kt.AuditorUpdate update = 1;
|
||||
bytes log_root = 2;
|
||||
}
|
||||
repeated UpdateAndHash updates = 1;
|
||||
}
|
||||
|
||||
message SignatureTestVector {
|
||||
bytes auditor_private_key = 8;
|
||||
|
||||
uint32 deployment_mode = 1;
|
||||
bytes signature_public_key = 2;
|
||||
bytes auditor_public_key = 9;
|
||||
bytes vrf_public_key = 3;
|
||||
|
||||
uint64 tree_size = 4;
|
||||
int64 timestamp = 5;
|
||||
bytes root = 6;
|
||||
|
||||
bytes signature = 7;
|
||||
}
|
||||
|
||||
repeated ShouldFailTestVector should_fail = 1;
|
||||
ShouldSucceedTestVector should_succeed = 2;
|
||||
SignatureTestVector signature = 3;
|
||||
}
|
||||
|
||||
3
src/test/resources/application-test.yml
Normal file
3
src/test/resources/application-test.yml
Normal file
@ -0,0 +1,3 @@
|
||||
micronaut:
|
||||
metrics:
|
||||
enabled: false
|
||||
BIN
src/test/resources/org/signal/keytransparency/audit/katie_test_vectors.pb
Executable file
BIN
src/test/resources/org/signal/keytransparency/audit/katie_test_vectors.pb
Executable file
Binary file not shown.
Loading…
Reference in New Issue
Block a user