/* * Copyright (C) 2012-2016 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.facebook.nifty.ssl; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; import com.google.common.io.Files; import org.apache.tomcat.jni.SessionTicketKey; import java.io.File; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import static com.facebook.nifty.ssl.CryptoUtil.decodeHex; import static com.facebook.nifty.ssl.CryptoUtil.hkdf; import static com.google.common.base.Preconditions.checkArgument; import static java.util.Objects.requireNonNull; /** * To make distribution of ticket keys and rotating ticket keys easier, tickets can be distributed * as a seed file in the following format * <pre> * { * "current": ["5afc6fb03ba4b15", "9547a3ab68b440ef7"], * "new": ["5afc6fb03ba4b15", "9547a3ab68b440ef7"], * "old": ["5afc6fb03ba4b15", "9547a3ab68b440ef7"] * } * </pre> * * The real ticket keys are generated from the seeds. The seeds can be arbitrary length hex encoded values. * The current seeds are used to generate new tickets, and tickets encrypted with the old and new keys are * accepted to allow for some leeway in rotation of tickets. * * The algorithm used to compute the session ticket encryption keys is the following: * * <pre> * aesKey = hkdf(seed, "aes") - truncated to AES bytes. * hmacKey = hkdf(seed, "hmac") - truncated to HMAC bytes * name = hkdf(seed, "name") - truncated to name bytes * </pre> */ public class TicketSeedFileParser { private static final byte[] NAME_BYTES = new byte[]{'n', 'a', 'm', 'e'}; private static final byte[] AES_BYTES = new byte[]{'a', 'e', 's'}; private static final byte[] HMAC_BYTES = new byte[]{'h', 'm', 'a', 'c'}; private static final List<String> EMPTY_STRING_LIST = ImmutableList.of(); /* * Randomly generated salt to use for the purpose of ticket seeds. The salt in the HKDF is meant to be public. * We do not want to use a random salt so that every machine that gets the same seed will compute the same * ticket keys. */ private static final byte[] DEFAULT_TICKET_SALT = decodeHex("b78973d13c2d0eb24cf94cd692239867"); private final byte[] salt; private final ObjectMapper objectMapper; /** * Creates a ticket seed file parser with the default salt value. */ public TicketSeedFileParser() { this(DEFAULT_TICKET_SALT); } /** * Creates a ticket seed file parser with the given hex-encoded salt value. * * @param hexSalt the hex-encoded salt value to use. Must not be null. */ public TicketSeedFileParser(String hexSalt) { this(decodeHex(hexSalt)); } /** * Creates a ticket seed file parser with the given binary salt value. All servers in a given logical tier * should use the same salt value (otherwise tickets will not be interchangeable between servers), but * different tiers of machines should use different, unique-per-tier salt values. * The salts do not need to be kept secret. * * @param salt the binary salt value to use. If null, use DEFAULT_TICKET_SALT. */ public TicketSeedFileParser(byte[] salt) { if (salt == null) { salt = DEFAULT_TICKET_SALT; } // Copy the salt array to make sure the one we have is not modified later this.salt = new byte[salt.length]; System.arraycopy(salt, 0, this.salt, 0, salt.length); objectMapper = new ObjectMapper(); } /** * Returns a list of tickets parsed from the ticket file. The keys are returned in a format suitable for use * with netty. The first keys are the current keys, following that are the new keys and old keys. * * @param file the ticket seed file. * @return a list of ticket keys. Current keys are first, then new keys, then old keys. * @throws IOException if reading the file or parsing the JSON fails. * @throws IllegalArgumentException if the JSON file does not contain any current seeds. */ public List<SessionTicketKey> parse(File file) throws IOException { return parseBytes(Files.toByteArray(file)); } /** * Returns a list of tickets parsed from the given JSON bytes. The keys are returned in a format suitable for * use with netty. The first keys are the current keys, following that are the new keys and old keys. * * @param json the JSON bytes containing ticket seed data. * @return a list of ticket keys. Current keys are first, then new keys, then old keys. * @throws IOException if parsing the JSON fails. * @throws IllegalArgumentException if the JSON does not contain any current seeds. */ public List<SessionTicketKey> parseBytes(byte[] json) throws IOException { List<String> allSeeds = TicketSeeds.parseFromJSONBytes(json, objectMapper).getAllSeeds(); return allSeeds.stream().map(this::deriveKeyFromSeed).collect(Collectors.toList()); } /** * Helper class used to represent the contents of a parsed ticket seed file. */ private static class TicketSeeds { final List<String> currentSeeds; final List<String> newSeeds; final List<String> oldSeeds; TicketSeeds(List<String> currentSeeds, List<String> newSeeds, List<String> oldSeeds) { checkArgument(currentSeeds != null && currentSeeds.size() > 0, "current seeds must not be empty"); this.currentSeeds = ImmutableList.copyOf(currentSeeds); this.newSeeds = ImmutableList.copyOf(newSeeds); this.oldSeeds = ImmutableList.copyOf(oldSeeds); } /** * Returns a list of all ticket seeds. The order is: currentSeeds, newSeeds, oldSeeds. * * @return a list of all ticket seeds. */ List<String> getAllSeeds() { return new ImmutableList.Builder<String>() .addAll(currentSeeds) .addAll(newSeeds) .addAll(oldSeeds) .build(); } /** * Parses the contents of the given bytes as JSON to construct a TicketSeeds object. See comment at the * top of this file for example ticket seed file format. * * @param bytes the contents of a JSON file containing the ticket seeds data. * @return a TicketSeeds object. * @throws IOException if reading the file or parsing the contents as JSON fails. * @throws IllegalArgumentException if the file does not contain any current seeds. */ static TicketSeeds parseFromJSONBytes(byte[] bytes, ObjectMapper objectMapper) throws IOException { Map<String, List<String>> map = objectMapper.readValue( bytes, new TypeReference<Map<String, List<String>>>(){}); return new TicketSeeds( map.getOrDefault("current", EMPTY_STRING_LIST), map.getOrDefault("new", EMPTY_STRING_LIST), map.getOrDefault("old", EMPTY_STRING_LIST)); } } /** * Derives a {@link SessionTicketKey} from the given ticket seed using the {@link CryptoUtil#hkdf} function. * * @param seed the ticket seed. * @return the ticket key. */ private SessionTicketKey deriveKeyFromSeed(String seed) { byte[] seedBin = decodeHex(seed); byte[] keyName = hkdf(seedBin, salt, NAME_BYTES, SessionTicketKey.NAME_SIZE); byte[] aesKey = hkdf(seedBin, salt, AES_BYTES, SessionTicketKey.AES_KEY_SIZE); byte[] hmacKey = hkdf(seedBin, salt, HMAC_BYTES, SessionTicketKey.HMAC_KEY_SIZE); return new SessionTicketKey(keyName, hmacKey, aesKey); } }