Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions bin/migrate-keys/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@

use anyhow::{Context, Result};
use base64::Engine;
use octo_crypto::{master_key_from_slice, MASTER_KEY_LEN};
use octo_crypto::{master_key_from_slice, reseal_with_account_id, MASTER_KEY_LEN, SCHEME_V2};
use octo_store::Store;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -117,7 +117,7 @@ async fn main() -> Result<()> {

loop {
let batch = store
.list_wallets_needing_reseal(SCHEME_V1 as i16, cfg.batch_size, after_id)
.list_wallets_needing_reseal(SCHEME_V2 as i16, cfg.batch_size, after_id)
.await
.context("list_wallets_needing_reseal")?;

Expand Down Expand Up @@ -150,15 +150,25 @@ async fn main() -> Result<()> {
ciphertext.clone(),
nonce,
salt,
scheme as u8,
u8::try_from(scheme).context("sealed scheme must fit in an unsigned byte")?,
)
.with_context(|| format!("from_parts wallet {}", wallet.id))?;

// Context is the network string bound into the AEAD AAD (e.g. "octo:mainnet").
let context = format!("octo:{}", wallet.network);

// reseal: open under old key → re-seal under new key (Zeroizing throughout).
let new_sealed = reseal(&cfg.old_key, &cfg.new_key, &sealed, context.as_bytes())
let account_id = wallet
.gas_tank_account_g
.as_deref()
.unwrap_or(&wallet.stellar_account_g);

// Reseal into the account-bound scheme (Zeroizing throughout).
let new_sealed = reseal_with_account_id(
&cfg.old_key,
&cfg.new_key,
&sealed,
context.as_bytes(),
account_id,
)
.with_context(|| format!("reseal wallet {}", wallet.id))?;

// Atomically swap the DB record. The idempotency guard (expected_old_scheme)
Expand All @@ -169,7 +179,7 @@ async fn main() -> Result<()> {
&new_sealed.ciphertext,
&new_sealed.nonce,
&new_sealed.salt,
SCHEME_V1 as i16,
SCHEME_V2 as i16,
scheme,
)
.await
Expand Down
5 changes: 3 additions & 2 deletions crates/api/src/routes/sponsor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use octo_crypto::SealedSeed;
use octo_wallet_core::{
compute_inner_tx_hash, inner_sequence_number, sign_fee_bump, FeeBumpRequest,
compute_inner_tx_hash, inner_sequence_number, sign_fee_bump, sign_fee_bump_with_account_id, FeeBumpRequest,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
Expand Down Expand Up @@ -140,7 +140,8 @@ pub async fn sponsor(
let scheme = wallet
.sealed_scheme
.unwrap_or(octo_crypto::SCHEME_V1 as i16);
let sealed = SealedSeed::from_parts_with_scheme(ciphertext.clone(), nonce, salt, scheme as u8)
let scheme_byte = u8::try_from(scheme).map_err(|_| ApiError::Internal)?;
let sealed = SealedSeed::from_parts_with_scheme(ciphertext.clone(), nonce, salt, scheme_byte)
.map_err(|_| ApiError::Internal)?;
let fb = FeeBumpRequest {
inner_xdr: &inner_xdr,
Expand Down
4 changes: 2 additions & 2 deletions crates/api/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,7 @@ impl AppState {
let raw = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.map_err(|_| ApiError::BadRequest("invalid MASTER_KEY (base64)".into()))?;
master_key_from_slice(&raw)
.map_err(|_| ApiError::BadRequest("MASTER_KEY must be 32 bytes".into()))
master_key_from_slice(&raw).map_err(|error| ApiError::BadRequest(error.to_string()))
}

pub fn store(&self) -> &Store {
Expand Down Expand Up @@ -258,6 +257,7 @@ impl AppState {
.into_iter()
.chain(std::iter::once(&*self.inner.master_key))
}
}

pub fn jwt_secret(&self) -> &[u8] {
&self.inner.jwt_secret
Expand Down
8 changes: 6 additions & 2 deletions crates/crypto/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use thiserror::Error;
#[derive(Debug, Error)]
pub enum CryptoError {
/// The master key was not exactly 32 bytes (AES-256 requires a 256-bit key).
#[error("invalid master key length: expected 32 bytes")]
InvalidKeyLength,
#[error("invalid master key length: expected {expected} bytes, got {actual}")]
InvalidMasterKeyLength { expected: usize, actual: usize },

/// A stored nonce was not the expected 12 bytes (corrupt record).
#[error("invalid nonce length: expected 12 bytes")]
Expand All @@ -26,6 +26,10 @@ pub enum CryptoError {
#[error("encryption failed")]
EncryptionFailed,

/// A V2 record was opened without the account identity bound into its AAD.
#[error("account identity is required to open this sealed seed")]
AccountIdentityRequired,

/// The `scheme` tag stored in a [`crate::SealedSeed`] is not a value this version of the
/// code knows how to handle. The record must be migrated (re-sealed under the current scheme)
/// before it can be opened.
Expand Down
118 changes: 115 additions & 3 deletions crates/crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub const SALT_LEN: usize = 32;
/// The current sealing scheme: AES-256-GCM with per-record HKDF-SHA256 subkey derivation and
/// context-bound AAD. All new seals are produced with this scheme tag.
pub const SCHEME_V1: u8 = 1;
/// AES-256-GCM with the network context and owning account id bound into the AAD.
pub const SCHEME_V2: u8 = 2;

/// A sealed secret: the AES-256-GCM ciphertext (including the authentication tag) plus the
/// public, non-secret `nonce` and `salt` needed to open it, and an explicit `scheme` version tag
Expand Down Expand Up @@ -104,6 +106,10 @@ impl SealedSeed {
salt: &[u8],
scheme: u8,
) -> Result<SealedSeed, CryptoError> {
match scheme {
SCHEME_V1 | SCHEME_V2 => {}
_ => return Err(CryptoError::UnknownScheme(scheme)),
}
let nonce: [u8; NONCE_LEN] = nonce
.try_into()
.map_err(|_| CryptoError::InvalidNonceLength)?;
Expand Down Expand Up @@ -176,6 +182,24 @@ pub fn seal(
})
}

/// Seal a secret while binding its owning Stellar account id into the authenticated context.
pub fn seal_with_account_id(
master_key: &[u8; MASTER_KEY_LEN],
plaintext: &[u8],
context: &[u8],
account_id: &str,
) -> Result<SealedSeed, CryptoError> {
if account_id.is_empty() {
return Err(CryptoError::AccountIdentityRequired);
}
let mut bound_context = context.to_vec();
bound_context.push(0);
bound_context.extend_from_slice(account_id.as_bytes());
let mut sealed = seal(master_key, plaintext, &bound_context)?;
sealed.scheme = SCHEME_V2;
Ok(sealed)
}

/// Authenticated-decrypt a [`SealedSeed`] produced by [`seal`].
///
/// Returns the plaintext wrapped in [`Zeroizing`] so it is wiped when dropped. Fails with
Expand All @@ -191,6 +215,7 @@ pub fn open(
// Validate the scheme tag before attempting any cryptographic operation.
match sealed.scheme {
SCHEME_V1 => {} // the only supported scheme
SCHEME_V2 => return Err(CryptoError::AccountIdentityRequired),
_ => return Err(CryptoError::UnknownScheme(sealed.scheme)),
}

Expand All @@ -215,6 +240,27 @@ pub fn open(
Ok(Zeroizing::new(plaintext))
}

/// Open a V2 secret using the expected owning Stellar account id.
pub fn open_with_account_id(
master_key: &[u8; MASTER_KEY_LEN],
sealed: &SealedSeed,
context: &[u8],
account_id: &str,
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
if sealed.scheme != SCHEME_V2 {
return open(master_key, sealed, context);
}
if account_id.is_empty() {
return Err(CryptoError::AccountIdentityRequired);
}
let mut bound_context = context.to_vec();
bound_context.push(0);
bound_context.extend_from_slice(account_id.as_bytes());
let mut v1 = sealed.clone();
v1.scheme = SCHEME_V1;
open(master_key, &v1, &bound_context)
}

/// Rotate the master key protecting an already-sealed secret.
///
/// Opens `sealed` under `old_key`/`context`, then seals the recovered plaintext under `new_key`
Expand Down Expand Up @@ -242,9 +288,24 @@ pub fn reseal(
seal(new_key, plaintext.as_ref(), context)
}

/// Rotate a sealed secret into the account-bound V2 scheme.
pub fn reseal_with_account_id(
old_key: &[u8; MASTER_KEY_LEN],
new_key: &[u8; MASTER_KEY_LEN],
sealed: &SealedSeed,
context: &[u8],
account_id: &str,
) -> Result<SealedSeed, CryptoError> {
let plaintext = open_with_account_id(old_key, sealed, context, account_id)?;
seal_with_account_id(new_key, plaintext.as_ref(), context, account_id)
}

/// Convenience: parse a 32-byte master key from a byte slice (e.g. decoded from a KMS/env value).
pub fn master_key_from_slice(bytes: &[u8]) -> Result<[u8; MASTER_KEY_LEN], CryptoError> {
bytes.try_into().map_err(|_| CryptoError::InvalidKeyLength)
bytes.try_into().map_err(|_| CryptoError::InvalidMasterKeyLength {
expected: MASTER_KEY_LEN,
actual: bytes.len(),
})
}

#[cfg(test)]
Expand Down Expand Up @@ -278,6 +339,45 @@ mod tests {
);
}

#[test]
fn account_bound_v2_rejects_the_wrong_account_id() {
let mk = key();
let sealed = seal_with_account_id(&mk, b"seed", CTX, "GGOOD").unwrap();
assert!(matches!(
open_with_account_id(&mk, &sealed, CTX, "GBAD"),
Err(CryptoError::DecryptionFailed)
));
}

#[test]
fn v1_rows_still_open_with_the_original_context() {
let mk = key();
let sealed = seal(&mk, b"seed", CTX).unwrap();
assert_eq!(open(&mk, &sealed, CTX).unwrap().as_slice(), b"seed");
}

#[test]
fn reseal_migrates_v1_to_account_bound_v2() {
let old_mk = key();
let new_mk = key();
let sealed = seal(&old_mk, b"seed", CTX).unwrap();
let migrated = reseal_with_account_id(
&old_mk,
&new_mk,
&sealed,
CTX,
"GACCOUNT",
)
.unwrap();
assert_eq!(migrated.scheme, SCHEME_V2);
assert_eq!(
open_with_account_id(&new_mk, &migrated, CTX, "GACCOUNT")
.unwrap()
.as_slice(),
b"seed"
);
}

#[test]
fn ciphertext_is_not_plaintext() {
let mk = key();
Expand Down Expand Up @@ -504,14 +604,26 @@ mod tests {
assert!(master_key_from_slice(&[0u8; 32]).is_ok());
assert!(matches!(
master_key_from_slice(&[0u8; 31]),
Err(CryptoError::InvalidKeyLength)
Err(CryptoError::InvalidMasterKeyLength { .. })
));
assert!(matches!(
master_key_from_slice(&[0u8; 33]),
Err(CryptoError::InvalidKeyLength)
Err(CryptoError::InvalidMasterKeyLength { .. })
));
}

#[test]
fn from_parts_with_scheme_rejects_unknown_scheme() {
let error = SealedSeed::from_parts_with_scheme(
vec![0u8; 16],
&[0u8; NONCE_LEN],
&[0u8; SALT_LEN],
255,
)
.unwrap_err();
assert!(matches!(error, CryptoError::UnknownScheme(255)));
}

// ---------------------------------------------------------------------------
// Size-boundary tests
//
Expand Down
Loading
Loading