Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
2baa8d2
docs: design — private multisig signing in every wallet app (check, s…
dendisuhubdy Oct 11, 2026
cc1ea4b
docs: msauth signing design — name the randprotocol-msig fixtures, th…
dendisuhubdy Oct 12, 2026
e496e0e
docs: msauth signing design — recipients show the owner-key fingerpri…
dendisuhubdy Oct 12, 2026
f52b8af
docs: msauth signing design — final fixture set (e34117be), change ou…
dendisuhubdy Oct 12, 2026
c2be8b8
docs: msauth signing design — the user's decisions: refuse with no no…
dendisuhubdy Oct 12, 2026
639cae7
docs: msauth signing design — signers enter each payee's address; sig…
dendisuhubdy Oct 12, 2026
395707d
docs: msauth signing implementation plan — 14 tasks (re-pin, core arm…
dendisuhubdy Oct 12, 2026
2ab8c2d
core: vendor fullnode feat/private-multisig e34117b — constraint set …
dendisuhubdy Oct 12, 2026
66a71e5
core: wallet-core depends on the vendored randprotocol-msig (no msaut…
dendisuhubdy Oct 12, 2026
51ea0e0
core: scripts/vendor-fullnode.sh — the re-pin in one step
dendisuhubdy Oct 12, 2026
b4eb166
ui: coreApi's msauth methods, byte vault records, the msig refusals i…
dendisuhubdy Oct 12, 2026
a9dd24f
ui/engine: signer profiles in the vault — policies opened at unlock, …
dendisuhubdy Oct 12, 2026
00c795e
ui: signers contract doc and the fake backend's signers group
dendisuhubdy Oct 12, 2026
4d2d468
ui/engine: an unreadable signer-policies record is reported and never…
dendisuhubdy Oct 12, 2026
63c0731
ui/locales: regenerate en.js (1036 strings)
dendisuhubdy Oct 12, 2026
c717e20
ui/engine: signers check/sign/verifyShare — the CLI's expiry margin a…
dendisuhubdy Oct 12, 2026
8206efd
ui/engine: signers fail closed on a malformed summary, refuse an unma…
dendisuhubdy Oct 12, 2026
41e69d5
ui/engine: signers refuse any output that is not a well-formed {to_ac…
dendisuhubdy Oct 12, 2026
a382aa2
ui/engine: signers count a payee as matched only for a non-empty string
dendisuhubdy Oct 12, 2026
59b6670
platform.saveText: a share as a downloaded file (web, extension) or i…
dendisuhubdy Oct 12, 2026
04c213e
ui: Settings → Multisig signers — add a profile from files, check/rev…
dendisuhubdy Oct 12, 2026
e87e72e
docs: msauth signing design — intended payments (address + amount) un…
dendisuhubdy Oct 12, 2026
eadd8f0
ui: signers fix round — refusal first on a malformed summary, intende…
dendisuhubdy Oct 12, 2026
482a08d
core, apps: chain 21 by default (DEFAULT_CHAIN_ID 21); the CHAIN_BUIL…
dendisuhubdy Oct 12, 2026
4bcd354
Merge feat/msauth-repin — Task 1: core on fullnode e34117be (constrai…
dendisuhubdy Oct 12, 2026
f9a3f8e
core: vendor fullnode main 9b01492 (feat/private-multisig 2771be22 me…
dendisuhubdy Oct 12, 2026
bd88cf3
core: msauth_check, msauth_verify_share, msauth_policy_info over rand…
dendisuhubdy Oct 12, 2026
e73f30c
core: msauth_signer_import and msauth_sign (seed in Zeroizing, throug…
dendisuhubdy Oct 12, 2026
5b93948
msauth signing: amountless exception per row (owner fingerprint), Sig…
dendisuhubdy Oct 12, 2026
24e51dd
msauth signing: a re-check of old payees no longer re-arms a stale Sign
dendisuhubdy Oct 12, 2026
ed8eb94
ui/locales: multisig signing in all 20 languages
dendisuhubdy Oct 12, 2026
1bfa887
e2e: the prover e2e asserts the msauth guest and key on a chain-21 sh…
dendisuhubdy Oct 12, 2026
d26c6e8
ios: msauth core wrappers, MsauthRules, SignerStore (seed behind .use…
dendisuhubdy Oct 12, 2026
1aa3d6e
ios msauth fix round 1: removeAll sweeps every msauth.* item, the see…
dendisuhubdy Oct 12, 2026
ceffdf1
ios msauth fix round 2: removeAll sweeps first and keeps the index wh…
dendisuhubdy Oct 12, 2026
88b1583
ios: Settings → Multisig signers — add from files, check, payment row…
dendisuhubdy Oct 12, 2026
ad1c2a4
ios msauth screens fix round 1: Sign signs the checked text, rows sur…
dendisuhubdy Oct 12, 2026
b4e4277
ios msauth screens fix round 2: payments follow the proposal text, ca…
dendisuhubdy Oct 12, 2026
72ae761
android: msauth core wrappers, SignFlow rules, signer profiles in Key…
dendisuhubdy Oct 12, 2026
5e2fc5d
android msauth fix round 1: the node's head and chain id are read str…
dendisuhubdy Oct 12, 2026
06fc0ff
android: Settings → Multisig signers — add from files (Keystore key, …
dendisuhubdy Oct 12, 2026
5a62f46
android msauth screens fix round 1: removing a signer always asks on …
dendisuhubdy Oct 12, 2026
8ec980a
e2e: private multisig end to end — CLI prepares, the web engine and i…
dendisuhubdy Oct 12, 2026
be89645
core: vendor fullnode a1785c9 — origin/main (private multisig + the v…
dendisuhubdy Oct 12, 2026
7237c25
ui test: a frozen pre-refactor vault record (395707d crypto.js) opens…
dendisuhubdy Oct 12, 2026
b88b872
ui msauth: a payee paid RAND and a token keeps its RAND pinned (dust-…
dendisuhubdy Oct 12, 2026
02c4d98
ui msauth: an unchecked payee is reported before malformed lines (par…
dendisuhubdy Oct 12, 2026
60bcd4c
android msauth: a payee paid RAND and a token keeps its RAND pinned (…
dendisuhubdy Oct 12, 2026
5ed638c
ios msauth: a payee paid RAND and a token keeps its RAND pinned (dust…
dendisuhubdy Oct 12, 2026
b148a1d
core: vendor fullnode v0.8.0 (c0878c3) — the release tag
dendisuhubdy Oct 12, 2026
148e1ca
Merge origin/main (real-node integration suite) into feat/msauth-signing
dendisuhubdy Oct 12, 2026
cded5d4
ci: real-node job on the fullnode v0.8.0 release — this branch's core…
dendisuhubdy Oct 12, 2026
a454cd7
e2e: the real-node suite cuts its Test-profile chain as chain 21, the…
dendisuhubdy Oct 12, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/node-integration.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# The wallet against a real node: the wallet's real transport, reply validators and flows (the wasm
# core and engine under Node, real `fetch`) against a real `rand-node` from the fullnode v0.7.1
# release, on a Test-profile chain the test cuts itself. See scripts/e2e-node.sh for what it covers
# core and engine under Node, real `fetch`) against a real `rand-node` from the fullnode v0.8.0
# release (chain 21 onward: constraint set 9, which this wallet's core proves for), on a Test-profile chain the test cuts itself. See scripts/e2e-node.sh for what it covers
# and web/wallet/test/node.e2e.test.mjs for the cases.
#
# The node binaries are the release's, not built here: that is what makes this a check of the
# wallet against the node that is deployed, and it keeps the job to the wasm core's build plus about
# a minute of tests with `rand-prover`'s one real bundle proof on top (a few minutes on a shared
# runner). To test against another release set FULLNODE_TAG; nothing the suite asserts may depend on
# a node feature newer than v0.7.1.
# a node feature newer than v0.8.0.
name: node-integration

on:
Expand All @@ -26,7 +26,7 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.98.1
FULLNODE_TAG: v0.7.1
FULLNODE_TAG: v0.8.0

jobs:
real-node:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ STARK proof, keys are Poseidon2 hashes, envelopes are ML-KEM-768 + ChaCha20-Poly
primitives exist only in the fullnode's Rust crates, and a wallet that re-implemented them in
Swift, Java or JavaScript would have to be byte-identical to the node or every transfer is
refused. So `core/` vendors the fullnode crates (`core/vendor/fullnode`,
a submodule at fullnode `main` `37c4fdd`, the v0.8.0 candidate, on chain 20: **split authorisation**, where a transaction carries a bundle proof made from the viewing key and a small authorisation proof the wallet always makes itself from the spend key; **BIND-1**, a transaction bound to its chain's genesis; program state and the `Invoke` action (RPL-2); and the chain's bridge fee) and exposes one JSON entry point, `call(method, params)`, that each
a submodule at fullnode's `v0.8.0` tag `c0878c3` (constraint set 9, the `randprotocol-msig` signer checks with the payee check a signer runs, and the verified fold in the verify cache), chain 21 onward: **split authorisation**, where a transaction carries a bundle proof made from the viewing key and a small authorisation proof the wallet always makes itself from the spend key; **BIND-1**, a transaction bound to its chain's genesis; program state and the `Invoke` action (RPL-2); and the chain's bridge fee) and exposes one JSON entry point, `call(method, params)`, that each
client wraps: an XCFramework on iOS, a `.so` on Android, WebAssembly in the browser. Everything
above that line — the RPC client, note store, scan and send flow, key storage and the UI — is
Swift, Java and JavaScript.
Expand Down Expand Up @@ -203,7 +203,7 @@ git checkout v0.7.3 && git submodule update --init # a release, rather than ma
```

or unpack `rand-wallet-<v>-source.tar.gz` from a release, which has the submodule in it already.
The submodule is the full node (`core/vendor/fullnode`, at its `v0.7.0` tag): the wallet's
The submodule is the full node (`core/vendor/fullnode`, at its `v0.8.0` tag): the wallet's
cryptography is the node's own code.

### 3. Test the core (optional, about a minute)
Expand Down
3 changes: 3 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ android {
testOptions {
unitTests.returnDefaultValues = true
}

// The private-multisig vectors (fullnode 2771be22), held to their SHA-256s by ui/test/msauth-fixtures.test.mjs.
sourceSets { test { resources.srcDirs += ['../../ui/test/fixtures/msauth'] } }
}

dependencies {
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@
tools:replace="screenOrientation" />
<activity android:name=".ui.DetailActivity" android:exported="false" />
<activity android:name=".ui.SettingsActivity" android:exported="false" />
<!-- Private multisig signing. A turn of the phone keeps the screen (and the proposal it alone holds):
nothing of a proposal or a key goes into a saved-state Bundle. -->
<activity android:name=".ui.SignersActivity" android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden" />
<activity android:name=".ui.SignProposalActivity" android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden" />

<service
android:name=".wallet.ProvingService"
Expand Down
60 changes: 60 additions & 0 deletions android/app/src/main/java/org/randprotocol/wallet/core/Core.java
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,66 @@ public static JSONObject prepareInvoke(JSONObject params) throws CoreException {
return object("prepare_invoke", params);
}

// ---- private multisig signing (wallet-core msauth.rs: msauth_check, msauth_sign, msauth_verify_share,
// msauth_signer_import, msauth_policy_info). A call's params carry a policy (its msk sees every
// note of the account) and, for msauth_sign, a seed: never log them.

/**
* The proposal checked against the policy and the intended payments ({@code rand1…:AMOUNT}
* each, passed unchanged): fullnode's summary plus {@code recipients_checked}, {@code expected},
* {@code chain_id}, {@code genesis}, {@code binding_domain} and {@code lines}. {@code
* org.randprotocol.wallet.wallet.SignFlow} judges the reply.
*/
public static JSONObject msauthCheck(String proposal, String policy, java.util.List<String> expectedRecipients) throws CoreException {
return object("msauth_check", msauthCheckParams(proposal, policy, expectedRecipients));
}

/**
* The share {@code "i:hex"}. The core re-runs every check, the payee check with {@code
* expectedRecipients} included — pass the very list the check was given. {@code seedHex} is the
* caller's; it wipes its byte[] after (the String cannot be wiped).
*/
public static String msauthSign(String proposal, String policy, String seedHex, java.util.List<String> expectedRecipients) throws CoreException {
Object v = call("msauth_sign", msauthSignParams(proposal, policy, seedHex, expectedRecipients));
if (v instanceof String) return (String) v;
throw new CoreException("msauth_sign returned no share");
}

/** Throws the core's refusal when {@code share} does not verify over this proposal. */
public static void msauthVerifyShare(String proposal, String policy, String share) throws CoreException {
call("msauth_verify_share", msauthVerifyShareParams(proposal, policy, share));
}

/** A keygen file or 64 hex → {@code {seed, public_key, address, index}}; {@code policy} null leaves index null. */
public static JSONObject msauthSignerImport(String input, String policy) throws CoreException {
return object("msauth_signer_import", msauthSignerImportParams(input, policy));
}

/** {@code {fingerprint, threshold, signers, address, policy_hash}}. */
public static JSONObject msauthPolicyInfo(String policy) throws CoreException {
return object("msauth_policy_info", msauthPolicyInfoParams(policy));
}

static JSONObject msauthCheckParams(String proposal, String policy, java.util.List<String> expectedRecipients) {
return p("proposal", proposal, "policy", policy, "expected_recipients", new JSONArray(expectedRecipients));
}

static JSONObject msauthSignParams(String proposal, String policy, String seedHex, java.util.List<String> expectedRecipients) {
return p("proposal", proposal, "policy", policy, "seed", seedHex, "expected_recipients", new JSONArray(expectedRecipients));
}

static JSONObject msauthVerifyShareParams(String proposal, String policy, String share) {
return p("proposal", proposal, "policy", policy, "share", share);
}

static JSONObject msauthSignerImportParams(String input, String policy) {
return policy == null ? p("input", input) : p("input", input, "policy", policy);
}

static JSONObject msauthPolicyInfoParams(String policy) {
return p("policy", policy);
}

public static String formatAmount(String units) {
try {
return String.valueOf(call("format_amount", p("units", units)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,22 @@
import androidx.security.crypto.EncryptedSharedPreferences;
import androidx.security.crypto.MasterKey;

import org.randprotocol.wallet.R;
import org.randprotocol.wallet.util.L10n;

import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.HashSet;
import java.util.Set;

/**
* The spend key at rest: an {@link EncryptedSharedPreferences} whose AES-GCM key lives in the
* Android Keystore. Beside it, and nothing else, a paired prover's record (delegated proving:
* the token AND the key and URL a job is sealed and sent to, and whether the link marked the
* prover as the user's own — the one copy a send decides by) — the viewing key and the address
* are derived from the spend key on demand by the core.
* are derived from the spend key on demand by the core. And the private-multisig signer profiles
* ({@link SignerProfiles}, every name {@code msauth.…}): a policy and a seed sealed by the
* profile's own per-use Keystore key ({@link KeystoreSeedCipher}).
*/
public final class KeyVault {
private static final String FILE = "rand_wallet_vault";
Expand Down Expand Up @@ -56,10 +63,48 @@ public void store(String spendKeyHex) {
prefs.edit().putString(KEY_SPEND, spendKeyHex).commit();
}

/**
* Removes the wallet: first every signer profile and every profile's Keystore key, swept by
* name (also those the index no longer lists); then the spend key and the prover's record.
* When the sweep fails, nothing else is removed and IllegalStateException says so — the
* wallet is kept whole and a retry finds what is left (never a half-wipe).
*/
public void erase() {
try {
SignerProfiles.eraseAll(signerStore(), KeystoreSeedCipher.keystoreAliases());
} catch (Exception e) {
throw new IllegalStateException(L10n.t(R.string.msauth_forget_failed,
"The signer keys on this device could not all be removed, so the wallet was kept. Try again."), e);
}
prefs.edit().remove(KEY_SPEND).remove(KEY_PROVER_PAIRING).remove(KEY_LEGACY_PROVER_TOKEN).commit();
}

/** The signer profiles' storage, in this same encrypted file (spec §3); a write that does not land throws. */
public SignerProfiles.KeyValue signerStore() {
return new SignerProfiles.KeyValue() {
@Override public String get(String k) {
return prefs.getString(k, null);
}

@Override public void put(String k, String v) {
if (!prefs.edit().putString(k, v).commit()) throw new IllegalStateException("the key vault could not be written");
}

@Override public void remove(String k) {
if (!prefs.edit().remove(k).commit()) throw new IllegalStateException("the key vault could not be written");
}

@Override public Set<String> keys() {
return new HashSet<>(prefs.getAll().keySet());
}
};
}

/** The signer profiles, removing a profile's Keystore key with it. */
public SignerProfiles signerProfiles() {
return new SignerProfiles(signerStore(), KeystoreSeedCipher.keystoreAliases());
}

/**
* The paired prover's record — token, key, URL, fingerprint, {@code own} — or null. The token
* travels only inside a job the core sealed to this record's key, which (not {@link Prefs}'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package org.randprotocol.wallet.security;

import android.content.Context;
import android.os.Build;
import android.security.keystore.KeyGenParameterSpec;
import android.security.keystore.KeyProperties;

import androidx.annotation.ChecksSdkIntAtLeast;
import androidx.biometric.BiometricManager;

import org.randprotocol.wallet.R;
import org.randprotocol.wallet.util.L10n;

import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.util.Base64;
import java.util.Collections;
import java.util.List;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

/**
* One Keystore AES-256-GCM key per signer profile (alias {@code msauth.<id>}), usable only right
* after the person authenticates — spec §3: every signature re-authenticates, and so does saving the
* seed. The cipher goes through BiometricPrompt's CryptoObject; nothing here prompts.
*
* <p>API 30+: a strong biometric or the device credential, per use (timeout 0). API 26–29: a
* per-use key with a CryptoObject exists only for biometrics (validity -1; the device credential
* cannot unlock a CryptoObject there), so on those devices a profile needs an enrolled strong
* biometric ({@link #refusal}).
*/
public final class KeystoreSeedCipher {
private KeystoreSeedCipher() {}

private static final String STORE = "AndroidKeyStore";
private static final String TRANSFORM = "AES/GCM/NoPadding";

public static String alias(String id) {
return SignerProfiles.PREFIX + id;
}

/** Whether the device credential can authorise a per-use key on {@code sdk}. */
@ChecksSdkIntAtLeast(parameter = 0)
public static boolean credentialPerUse(int sdk) {
return sdk >= Build.VERSION_CODES.R;
}

/** BiometricPrompt's authenticators for a signer key's CryptoObject on {@code sdk}. */
public static int promptAuthenticators(int sdk) {
return credentialPerUse(sdk)
? BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL
: BiometricManager.Authenticators.BIOMETRIC_STRONG;
}

/**
* Why this device cannot hold a signer key, or null when it can: {@code biometricReady} a strong
* biometric is enrolled and usable, {@code credentialReady} a screen lock is set.
*/
public static String refusal(int sdk, boolean biometricReady, boolean credentialReady) {
boolean ok = credentialPerUse(sdk) ? (biometricReady || credentialReady) : biometricReady;
return ok ? null : L10n.t(R.string.msauth_needs_device_auth,
"Set a screen lock (and on Android 10 or older, a fingerprint) first: a signer key is used only right after you authenticate.");
}

/** {@link #refusal(int, boolean, boolean)} for this device. */
public static String refusal(Context c) {
int sdk = Build.VERSION.SDK_INT;
BiometricManager m = BiometricManager.from(c);
boolean bio = m.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS;
boolean cred = credentialPerUse(sdk) && m.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL) == BiometricManager.BIOMETRIC_SUCCESS;
return refusal(sdk, bio, cred);
}

public static void createKey(String id) throws GeneralSecurityException {
KeyGenParameterSpec.Builder b = new KeyGenParameterSpec.Builder(alias(id), KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // credentialPerUse, inlined for lint
b.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG | KeyProperties.AUTH_DEVICE_CREDENTIAL);
} else {
b.setUserAuthenticationValidityDurationSeconds(-1);
}
KeyGenerator g = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, STORE);
g.init(b.build());
g.generateKey();
}

private static KeyStore keystore() throws GeneralSecurityException {
KeyStore ks = KeyStore.getInstance(STORE);
try {
ks.load(null);
} catch (IOException e) {
throw new GeneralSecurityException(e);
}
return ks;
}

private static SecretKey key(String id) throws GeneralSecurityException {
SecretKey k = (SecretKey) keystore().getKey(alias(id), null);
if (k == null) throw new GeneralSecurityException("no Keystore key for this signer");
return k;
}

/** For saving the seed: authenticate it through a CryptoObject, then {@code doFinal(seed)}; the IV is {@code getIV()}. */
public static Cipher encryptCipher(String id) throws GeneralSecurityException {
Cipher c = Cipher.getInstance(TRANSFORM);
c.init(Cipher.ENCRYPT_MODE, key(id));
return c;
}

/** For reading the seed: authenticate it through a CryptoObject, then {@code doFinal(ct)}. */
public static Cipher decryptCipher(String id, byte[] iv) throws GeneralSecurityException {
Cipher c = Cipher.getInstance(TRANSFORM);
c.init(Cipher.DECRYPT_MODE, key(id), new GCMParameterSpec(128, iv));
return c;
}

/** Best effort, for undoing a failed add; the wallet's sweep ({@link SignerProfiles#eraseAll}) throws instead. */
public static void deleteKey(String id) {
try {
keystore().deleteEntry(alias(id));
} catch (Exception ignored) {
// nothing to delete, or the sweep takes it
}
}

/** The Keystore's aliases, for {@link SignerProfiles}: listing or deleting throws on failure. */
public static SignerProfiles.KeyAliases keystoreAliases() {
return new SignerProfiles.KeyAliases() {
@Override public List<String> list() throws Exception {
return Collections.list(keystore().aliases());
}

@Override public void delete(String alias) throws Exception {
keystore().deleteEntry(alias);
}
};
}

public static String seal(byte[] iv, byte[] ct) {
return Base64.getEncoder().encodeToString(iv) + ":" + Base64.getEncoder().encodeToString(ct);
}

/** {@code {iv, ct}}; IllegalArgumentException for anything but two non-empty base64 halves. */
public static byte[][] unseal(String s) {
int i = s == null ? -1 : s.indexOf(':');
if (i <= 0 || i == s.length() - 1) throw new IllegalArgumentException("a sealed seed is iv:ct");
byte[] iv = Base64.getDecoder().decode(s.substring(0, i));
byte[] ct = Base64.getDecoder().decode(s.substring(i + 1));
if (iv.length == 0 || ct.length == 0) throw new IllegalArgumentException("a sealed seed is iv:ct");
return new byte[][] {iv, ct};
}
}
Loading
Loading