server-web: implement secret storage provider (#191538)

Works quite similarly to vscode.dev. The client has a key stored in
secret storage. The server has a key stored server-side, and issues
an http-only cookie to the client. The client can ask the server to
combine its key and the http-only cookie key to a key component, which
it combines with its local key to encrypt and decrypt data.

This logic kicks in if the web server bits see a `vscode-secret-key-path`
cookie set when it loads.
This commit is contained in:
Connor Peet 2023-08-28 17:48:09 -07:00 committed by GitHub
parent 5cd507ba17
commit 8ef6961789
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 366 additions and 79 deletions

View file

@ -10,6 +10,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use const_format::concatcp;
use hyper::service::{make_service_fn, service_fn};
use hyper::{Body, Request, Response, Server};
use tokio::io::{AsyncBufReadExt, BufReader};
@ -23,6 +24,7 @@ use crate::constants::VSCODE_CLI_QUALITY;
use crate::download_cache::DownloadCache;
use crate::log;
use crate::options::Quality;
use crate::state::{LauncherPaths, PersistedState};
use crate::update_service::{
unzip_downloaded_release, Platform, Release, TargetKind, UpdateService,
};
@ -48,6 +50,22 @@ const SERVER_ACTIVE_TIMEOUT_SECS: u64 = SERVER_IDLE_TIMEOUT_SECS * 24 * 30 * 12;
/// How long to cache the "latest" version we get from the update service.
const RELEASE_CACHE_SECS: u64 = 60 * 60;
/// Number of bytes for the secret keys. See workbench.ts for their usage.
const SECRET_KEY_BYTES: usize = 32;
/// Path to mint the key combining server and client parts.
const SECRET_KEY_MINT_PATH: &str = "/_vscode-cli/mint-key";
/// Cookie set to the `SECRET_KEY_MINT_PATH`
const PATH_COOKIE_NAME: &str = "vscode-secret-key-path";
/// Cookie set to the `SECRET_KEY_MINT_PATH`
const PATH_COOKIE_VALUE: &str = concatcp!(
PATH_COOKIE_NAME,
"=",
SECRET_KEY_MINT_PATH,
"; SameSite=Strict; Path=/"
);
/// HTTP-only cookie where the client's secret half is stored.
const SECRET_KEY_COOKIE_NAME: &str = "vscode-cli-secret-half";
/// Implements the vscode "server of servers". Clients who go to the URI get
/// served the latest version of the VS Code server whenever they load the
/// page. The VS Code server prefixes all assets and connections it loads with
@ -69,10 +87,14 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result<i3
}
let cm = ConnectionManager::new(&ctx, platform, args.clone());
let key = get_server_key_half(&ctx.paths);
let make_svc = move || {
let cm = cm.clone();
let log = cm.log.clone();
let service = service_fn(move |req| handle(cm.clone(), log.clone(), req));
let ctx = HandleContext {
cm: cm.clone(),
log: cm.log.clone(),
server_secret_key: key.clone(),
};
let service = service_fn(move |req| handle(ctx.clone(), req));
async move { Ok::<_, Infallible>(service) }
};
@ -106,35 +128,82 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result<i3
Ok(0)
}
/// Handler function for an inbound request
async fn handle(
#[derive(Clone)]
struct HandleContext {
cm: Arc<ConnectionManager>,
log: log::Logger,
req: Request<Body>,
) -> Result<Response<Body>, Infallible> {
let release = if let Some((r, _)) = get_release_from_path(req.uri().path(), cm.platform) {
server_secret_key: SecretKeyPart,
}
/// Handler function for an inbound request
async fn handle(ctx: HandleContext, req: Request<Body>) -> Result<Response<Body>, Infallible> {
let client_key_half = get_client_key_half(&req);
let mut res = match req.uri().path() {
SECRET_KEY_MINT_PATH => handle_secret_mint(ctx, req),
_ => handle_proxied(ctx, req).await,
};
append_secret_headers(&mut res, &client_key_half);
Ok(res)
}
async fn handle_proxied(ctx: HandleContext, req: Request<Body>) -> Response<Body> {
let release = if let Some((r, _)) = get_release_from_path(req.uri().path(), ctx.cm.platform) {
r
} else {
match cm.get_latest_release().await {
match ctx.cm.get_latest_release().await {
Ok(r) => r,
Err(e) => {
error!(log, "error getting latest version: {}", e);
return Ok(response::code_err(e));
error!(ctx.log, "error getting latest version: {}", e);
return response::code_err(e);
}
}
};
Ok(match cm.get_connection(release).await {
match ctx.cm.get_connection(release).await {
Ok(rw) => {
if req.headers().contains_key(hyper::header::UPGRADE) {
forward_ws_req_to_server(cm.log.clone(), rw, req).await
forward_ws_req_to_server(ctx.log.clone(), rw, req).await
} else {
forward_http_req_to_server(rw, req).await
}
}
Err(CodeError::ServerNotYetDownloaded) => response::wait_for_download(),
Err(e) => response::code_err(e),
})
}
}
fn handle_secret_mint(ctx: HandleContext, req: Request<Body>) -> Response<Body> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(ctx.server_secret_key.0.as_ref());
hasher.update(get_client_key_half(&req).0.as_ref());
let hash = hasher.finalize();
let hash = hash[..SECRET_KEY_BYTES].to_vec();
response::secret_key(hash)
}
/// Appends headers to response to maintain the secret storage of the workbench:
/// sets the `PATH_COOKIE_VALUE` so workbench.ts knows about the 'mint' endpoint,
/// and maintains the http-only cookie the client will use for cookies.
fn append_secret_headers(res: &mut Response<Body>, client_key_half: &SecretKeyPart) {
let headers = res.headers_mut();
headers.append(
hyper::header::SET_COOKIE,
PATH_COOKIE_VALUE.parse().unwrap(),
);
headers.append(
hyper::header::SET_COOKIE,
format!(
"{}={}; SameSite=Strict; HttpOnly; Max-Age=2592000; Path=/",
SECRET_KEY_COOKIE_NAME,
client_key_half.encode()
)
.parse()
.unwrap(),
);
}
/// Gets the release info from the VS Code path prefix, which is in the
@ -258,6 +327,77 @@ fn is_commit_hash(s: &str) -> bool {
s.len() == COMMIT_HASH_LEN && s.chars().all(|c| c.is_ascii_hexdigit())
}
/// Gets a cookie from the request by name.
fn extract_cookie(req: &Request<Body>, name: &str) -> Option<String> {
for h in req.headers().get_all(hyper::header::COOKIE) {
if let Ok(str) = h.to_str() {
for pair in str.split("; ") {
let i = match pair.find('=') {
Some(i) => i,
None => continue,
};
if &pair[..i] == name {
return Some(pair[i + 1..].to_string());
}
}
}
}
None
}
#[derive(Clone)]
struct SecretKeyPart(Box<[u8; SECRET_KEY_BYTES]>);
impl SecretKeyPart {
pub fn new() -> Self {
let key: [u8; SECRET_KEY_BYTES] = rand::random();
Self(Box::new(key))
}
pub fn decode(s: &str) -> Result<Self, base64::DecodeSliceError> {
use base64::{engine::general_purpose, Engine as _};
let mut key: [u8; SECRET_KEY_BYTES] = [0; SECRET_KEY_BYTES];
let v = general_purpose::URL_SAFE.decode(s)?;
if v.len() != SECRET_KEY_BYTES {
return Err(base64::DecodeSliceError::OutputSliceTooSmall);
}
key.copy_from_slice(&v);
Ok(Self(Box::new(key)))
}
pub fn encode(&self) -> String {
use base64::{engine::general_purpose, Engine as _};
general_purpose::URL_SAFE.encode(self.0.as_ref())
}
}
/// Gets the server's half of the secret key.
fn get_server_key_half(paths: &LauncherPaths) -> SecretKeyPart {
let ps = PersistedState::new(paths.root().join("serve-web-key-half"));
let value: String = ps.load();
if let Ok(sk) = SecretKeyPart::decode(&value) {
return sk;
}
let key = SecretKeyPart::new();
let _ = ps.save(key.encode());
key
}
/// Gets the client's half of the secret key.
fn get_client_key_half(req: &Request<Body>) -> SecretKeyPart {
if let Some(c) = extract_cookie(req, SECRET_KEY_COOKIE_NAME) {
if let Ok(sk) = SecretKeyPart::decode(&c) {
return sk;
}
}
SecretKeyPart::new()
}
/// Module holding original responses the CLI's server makes.
mod response {
use const_format::concatcp;
@ -287,6 +427,14 @@ mod response {
.body(Body::from(concatcp!("The latest version of the ", QUALITYLESS_SERVER_NAME, " is downloading, please wait a moment...<script>setTimeout(()=>location.reload(),1500)</script>", )))
.unwrap()
}
pub fn secret_key(hash: Vec<u8>) -> Response<Body> {
Response::builder()
.status(200)
.header("Content-Type", "application/octet-stream") // todo: get latest
.body(Body::from(hash))
.unwrap()
}
}
/// Handle returned when getting a stream to the server, used to refcount
@ -515,6 +663,7 @@ impl ConnectionManager {
let executable = path
.join("bin")
.join(args.release.quality.server_entrypoint());
let socket_path = get_socket_name();
#[cfg(not(windows))]

File diff suppressed because one or more lines are too long

View file

@ -4,99 +4,221 @@
*--------------------------------------------------------------------------------------------*/
import { isStandalone } from 'vs/base/browser/browser';
import { parse } from 'vs/base/common/marshalling';
import { VSBuffer, decodeBase64, encodeBase64 } from 'vs/base/common/buffer';
import { Emitter } from 'vs/base/common/event';
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
import { parse } from 'vs/base/common/marshalling';
import { Schemas } from 'vs/base/common/network';
import { posix } from 'vs/base/common/path';
import { isEqual } from 'vs/base/common/resources';
import { ltrim } from 'vs/base/common/strings';
import { URI, UriComponents } from 'vs/base/common/uri';
import product from 'vs/platform/product/common/product';
import { ISecretStorageProvider } from 'vs/platform/secrets/common/secrets';
import { isFolderToOpen, isWorkspaceToOpen } from 'vs/platform/window/common/window';
import { create } from 'vs/workbench/workbench.web.main';
import { posix } from 'vs/base/common/path';
import { ltrim } from 'vs/base/common/strings';
import type { IURLCallbackProvider } from 'vs/workbench/services/url/browser/urlService';
import type { IWorkbenchConstructionOptions } from 'vs/workbench/browser/web.api';
import type { IWorkspace, IWorkspaceProvider } from 'vs/workbench/services/host/browser/browserHostService';
import { ISecretStorageProvider } from 'vs/platform/secrets/common/secrets';
import { AuthenticationSessionInfo } from 'vs/workbench/services/authentication/browser/authenticationService';
import type { IURLCallbackProvider } from 'vs/workbench/services/url/browser/urlService';
import { create } from 'vs/workbench/workbench.web.main';
class LocalStorageSecretStorageProvider implements ISecretStorageProvider {
private static readonly STORAGE_KEY = 'secrets.provider';
interface ISecretStorageCrypto {
seal(data: string): Promise<string>;
unseal(data: string): Promise<string>;
}
private _secrets: Record<string, string> | undefined;
class TransparentCrypto implements ISecretStorageCrypto {
async seal(data: string): Promise<string> {
return data;
}
async unseal(data: string): Promise<string> {
return data;
}
}
const enum AESConstants {
ALGORITHM = 'AES-GCM',
KEY_LENGTH = 256,
IV_LENGTH = 12,
}
class ServerKeyedAESCrypto implements ISecretStorageCrypto {
private _serverKey: Uint8Array | undefined;
/** Gets whether the algorithm is supported; requires a secure context */
public static supported() {
return !!crypto.subtle;
}
constructor(private readonly authEndpoint: string) { }
async seal(data: string): Promise<string> {
// Get a new key and IV on every change, to avoid the risk of reusing the same key and IV pair with AES-GCM
// (see also: https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams#properties)
const iv = window.crypto.getRandomValues(new Uint8Array(AESConstants.IV_LENGTH));
// crypto.getRandomValues isn't a good-enough PRNG to generate crypto keys, so we need to use crypto.subtle.generateKey and export the key instead
const clientKeyObj = await window.crypto.subtle.generateKey(
{ name: AESConstants.ALGORITHM as const, length: AESConstants.KEY_LENGTH as const },
true,
['encrypt', 'decrypt']
);
const clientKey = new Uint8Array(await window.crypto.subtle.exportKey('raw', clientKeyObj));
const key = await this.getKey(clientKey);
const dataUint8Array = new TextEncoder().encode(data);
const cipherText: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: AESConstants.ALGORITHM as const, iv },
key,
dataUint8Array
);
// Base64 encode the result and store the ciphertext, the key, and the IV in localStorage
// Note that the clientKey and IV don't need to be secret
const result = new Uint8Array([...clientKey, ...iv, ...new Uint8Array(cipherText)]);
return encodeBase64(VSBuffer.wrap(result));
}
async unseal(data: string): Promise<string> {
// encrypted should contain, in order: the key (32-byte), the IV for AES-GCM (12-byte) and the ciphertext (which has the GCM auth tag at the end)
// Minimum length must be 44 (key+IV length) + 16 bytes (1 block encrypted with AES - regardless of key size)
const dataUint8Array = decodeBase64(data);
if (dataUint8Array.byteLength < 60) {
throw Error('Invalid length for the value for credentials.crypto');
}
const keyLength = AESConstants.KEY_LENGTH / 8;
const clientKey = dataUint8Array.slice(0, keyLength);
const iv = dataUint8Array.slice(keyLength, keyLength + AESConstants.IV_LENGTH);
const cipherText = dataUint8Array.slice(keyLength + AESConstants.IV_LENGTH);
// Do the decryption and parse the result as JSON
const key = await this.getKey(clientKey.buffer);
const decrypted = await window.crypto.subtle.decrypt(
{ name: AESConstants.ALGORITHM as const, iv: iv.buffer },
key,
cipherText.buffer
);
return new TextDecoder().decode(new Uint8Array(decrypted));
}
/**
* Given a clientKey, returns the CryptoKey object that is used to encrypt/decrypt the data.
* The actual key is (clientKey XOR serverKey)
*/
private async getKey(clientKey: Uint8Array): Promise<CryptoKey> {
if (!clientKey || clientKey.byteLength !== AESConstants.KEY_LENGTH / 8) {
throw Error('Invalid length for clientKey');
}
const serverKey = await this.getServerKeyPart();
const keyData = new Uint8Array(AESConstants.KEY_LENGTH / 8);
for (let i = 0; i < keyData.byteLength; i++) {
keyData[i] = clientKey[i]! ^ serverKey[i]!;
}
return window.crypto.subtle.importKey(
'raw',
keyData,
{
name: AESConstants.ALGORITHM as const,
length: AESConstants.KEY_LENGTH as const,
},
true,
['encrypt', 'decrypt']
);
}
private async getServerKeyPart(): Promise<Uint8Array> {
if (this._serverKey) {
return this._serverKey;
}
let attempt = 0;
let lastError: unknown | undefined;
while (attempt <= 3) {
try {
const res = await fetch(this.authEndpoint, { credentials: 'include', method: 'POST' });
if (!res.ok) {
throw new Error(res.statusText);
}
const serverKey = new Uint8Array(await await res.arrayBuffer());
if (serverKey.byteLength !== AESConstants.KEY_LENGTH / 8) {
throw Error(`The key retrieved by the server is not ${AESConstants.KEY_LENGTH} bit long.`);
}
this._serverKey = serverKey;
return this._serverKey;
} catch (e) {
lastError = e;
attempt++;
// exponential backoff
await new Promise(resolve => setTimeout(resolve, attempt * attempt * 100));
}
}
throw lastError;
}
}
export class LocalStorageSecretStorageProvider implements ISecretStorageProvider {
private readonly _storageKey = 'secrets.provider';
private _secretsPromise: Promise<Record<string, string>> = this.load();
type: 'in-memory' | 'persisted' | 'unknown' = 'persisted';
constructor() {
let authSessionInfo: (AuthenticationSessionInfo & { scopes: string[][] }) | undefined;
const authSessionElement = document.getElementById('vscode-workbench-auth-session');
const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined;
if (authSessionElementAttribute) {
constructor(
private readonly crypto: ISecretStorageCrypto,
) { }
private async load(): Promise<Record<string, string>> {
// Get the secrets from localStorage
const encrypted = window.localStorage.getItem(this._storageKey);
if (encrypted) {
try {
authSessionInfo = JSON.parse(authSessionElementAttribute);
} catch (error) { /* Invalid session is passed. Ignore. */ }
}
if (authSessionInfo) {
// Settings Sync Entry
this.set(`${product.urlProtocol}.loginAccount`, JSON.stringify(authSessionInfo));
// Auth extension Entry
if (authSessionInfo.providerId !== 'github') {
console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`);
return;
return JSON.parse(await this.crypto.unseal(encrypted));
} catch (err) {
// TODO: send telemetry
console.error('Failed to decrypt secrets from localStorage', err);
window.localStorage.removeItem(this._storageKey);
}
const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' });
this.set(authAccount, JSON.stringify(authSessionInfo.scopes.map(scopes => ({
id: authSessionInfo!.id,
scopes,
accessToken: authSessionInfo!.accessToken
}))));
}
return {};
}
get(key: string): Promise<string | undefined> {
return Promise.resolve(this.secrets[key]);
async get(key: string): Promise<string | undefined> {
const secrets = await this._secretsPromise;
return secrets[key];
}
set(key: string, value: string): Promise<void> {
this.secrets[key] = value;
async set(key: string, value: string): Promise<void> {
const secrets = await this._secretsPromise;
secrets[key] = value;
this._secretsPromise = Promise.resolve(secrets);
this.save();
return Promise.resolve();
}
async delete(key: string): Promise<void> {
delete this.secrets[key];
const secrets = await this._secretsPromise;
delete secrets[key];
this._secretsPromise = Promise.resolve(secrets);
this.save();
return Promise.resolve();
}
private get secrets(): Record<string, string> {
if (!this._secrets) {
try {
const serializedCredentials = window.localStorage.getItem(LocalStorageSecretStorageProvider.STORAGE_KEY);
if (serializedCredentials) {
this._secrets = JSON.parse(serializedCredentials);
}
} catch (error) {
// ignore
}
if (!(this._secrets instanceof Object)) {
this._secrets = {};
}
private async save(): Promise<void> {
try {
const encrypted = await this.crypto.seal(JSON.stringify(await this._secretsPromise));
window.localStorage.setItem(this._storageKey, encrypted);
} catch (err) {
console.error(err);
}
return this._secrets;
}
private save(): void {
window.localStorage.setItem(LocalStorageSecretStorageProvider.STORAGE_KEY, JSON.stringify(this.secrets));
}
}
class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider {
private static REQUEST_ID = 0;
@ -390,6 +512,17 @@ class WorkspaceProvider implements IWorkspaceProvider {
}
}
function readCookie(name: string): string | undefined {
const cookies = document.cookie.split('; ');
for (const cookie of cookies) {
if (cookie.startsWith(name + '=')) {
return cookie.substring(name.length + 1);
}
}
return undefined;
}
(function () {
// Find config by checking for DOM
@ -399,6 +532,9 @@ class WorkspaceProvider implements IWorkspaceProvider {
throw new Error('Missing web configuration element');
}
const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute);
const secretStorageKeyPath = readCookie('vscode-secret-key-path');
const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported()
? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto();
// Create workbench
create(document.body, {
@ -407,6 +543,8 @@ class WorkspaceProvider implements IWorkspaceProvider {
settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined,
workspaceProvider: WorkspaceProvider.create(config),
urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute),
secretStorageProvider: config.remoteAuthority ? undefined /* with a remote, we don't use a local secret storage provider */ : new LocalStorageSecretStorageProvider()
secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath
? undefined /* with a remote without embedder-preferred storage, store on the remote */
: new LocalStorageSecretStorageProvider(secretStorageCrypto),
});
})();