android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
Some checks failed
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Test / test (push) Has been cancelled

Pure-JVM additions to shade-android (no Android SDK needed):
- V4.9 blob primitives: BlobKdf (HKDF deriveBlobSlotId/Key/SigningSeed),
  BlobAead (nonce||ct||tag with shade-profile-aad-v1:<slot> AAD),
  BlobClient (java.net.http with hand-written canonical JSON signing
  matching TS signPayload output), Profile high-level namespace.
- V4.10 approval helpers: CanonicalProfileBlob schema with denormalized
  trustedApproverFingerprints, build/sign/verify proxy approvals via
  length-prefixed u16 BE UTF-8 canonical signing payload.
- Password KDFs: scrypt + argon2id via Bouncy Castle, NFKC-normalized.
- SessionStateJson at-rest serializer for persistence layer.

Cross-platform vectors (test-vectors/blob.json, approval.json) gate
byte-identical output between TS and Kotlin, including a TS-signed
Ed25519 signature the Kotlin port verifies and reproduces (Ed25519 is
deterministic).

New shade-android-keystore sibling Gradle module (Android-specific):
- KeystoreMasterKey: hardware-backed AES-256-GCM with BIOMETRIC_STRONG
  gating, StrongBox-backed when available, invalidated on enrollment.
- BiometricUnlock: coroutine wrapper around BiometricPrompt with
  tagged cancellation/failure exceptions.
- KeystoreStorage: StorageProvider over biometric-gated AES-encrypted
  SharedPreferences with AAD-bound row keys.

All 25 SDK packages typecheck clean; 104 SDK tests + 24 new Kotlin
tests + 11 cross-platform vector tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 17:38:15 +02:00
parent 1bd7037a6d
commit 188c3db56a
26 changed files with 3181 additions and 1 deletions

View File

@@ -26,6 +26,18 @@ import {
buildChunkAad,
aesGcmEncryptWithNonce,
} from '../packages/shade-streams/src/index.js';
import {
deriveBlobSlotId,
deriveBlobKey,
deriveBlobSigningSeed,
} from '../packages/shade-storage-encrypted/src/crypto.js';
import { ed25519PublicKeyFromSeed } from '../packages/shade-crypto-web/src/index.js';
import {
canonicalApprovalSigningBytes,
signProxyApproval,
buildApprovalRequest,
type ApprovalRequestFrame,
} from '../packages/shade-sdk/src/index.js';
const VECTOR_FILE_VERSION = 2;
@@ -653,6 +665,162 @@ async function generateStorageEncryptionSubset(): Promise<Vector[]> {
];
}
async function generateBlobVectors(): Promise<Vector[]> {
// Three (master, app) cases. The first two share a master with
// different app namespaces, exercising the namespace separation;
// the third uses a different master entirely.
const cases: Array<{ masterKey: Uint8Array; app: string }> = [
{ masterKey: new Uint8Array(32).map((_, i) => i + 1), app: 'prism-profile-v1' },
{ masterKey: new Uint8Array(32).map((_, i) => i + 1), app: 'test-namespace' },
{ masterKey: new Uint8Array(32).fill(0xff), app: 'prism-profile-v1' },
];
const kdf = cases.map((c) => {
const slotId = deriveBlobSlotId(c.masterKey, c.app);
const blobKey = deriveBlobKey(c.masterKey, c.app);
const signingSeed = deriveBlobSigningSeed(c.masterKey, c.app);
const ownerPubkey = ed25519PublicKeyFromSeed(signingSeed);
return {
description: `V4.9 blob KDF (master + app="${c.app}")`,
masterKey: hex(c.masterKey),
app: c.app,
slotId: hex(slotId),
blobKey: hex(blobKey),
signingSeed: hex(signingSeed),
ownerPubkey: hex(ownerPubkey),
};
});
// Three deterministic AEAD round-trips: pinned key, pinned nonce,
// pinned plaintext. The wire form is `nonce || ct||tag`.
const aeadCases = [
{
key: new Uint8Array(32).fill(0xab),
nonce: new Uint8Array(12).fill(0x01),
slotIdHex: '00'.repeat(32),
plaintext: new TextEncoder().encode('hello shade-blob-v1'),
},
{
key: new Uint8Array(32).map((_, i) => i),
nonce: new Uint8Array(12).map((_, i) => 0xa0 + i),
slotIdHex: 'ff'.repeat(32),
plaintext: new TextEncoder().encode(
'{"version":1,"hosts":[],"clients":[],"trustedApproverFingerprints":[],"updatedAt":1}',
),
},
];
const aead = await Promise.all(
aeadCases.map(async (c) => {
const aad = new TextEncoder().encode(`shade-profile-aad-v1:${c.slotIdHex}`);
const ctTag = await aesGcmEncryptDeterministic(c.key, c.nonce, c.plaintext, aad);
const wire = new Uint8Array(c.nonce.length + ctTag.length);
wire.set(c.nonce, 0);
wire.set(ctTag, c.nonce.length);
return {
description: 'V4.9 blob AEAD: nonce || AES-256-GCM(key, plaintext, aad="shade-profile-aad-v1:<slotIdHex>")',
key: hex(c.key),
nonce: hex(c.nonce),
slotIdHex: c.slotIdHex,
plaintext: hex(c.plaintext),
wire: hex(wire),
};
}),
);
return [...kdf, ...aead];
}
async function generateApprovalVectors(): Promise<Vector[]> {
// Pinned signing-payload bytes for canonical approval. Length-
// prefixed UTF-8 with u16 BE lengths — Kotlin/Swift implementations
// produce byte-identical input by spec.
const cases = [
{
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'approve' as const,
},
{
domain: 'shade-link-approve-v1',
requestId: 'aabbccddeeff00112233445566778899',
hostFingerprint: '11111 22222 33333 44444',
requestingDeviceFingerprint: '55555 66666 77777 88888',
decision: 'reject' as const,
},
{
domain: 'prism-link-approve-v1',
requestId: '00000000000000000000000000000000',
hostFingerprint: 'a',
requestingDeviceFingerprint: 'b',
decision: 'approve' as const,
},
];
const payloads = cases.map((c) => ({
description: 'V4.10 approval signing payload (length-prefixed u16 BE UTF-8)',
domain: c.domain,
requestId: c.requestId,
hostFingerprint: c.hostFingerprint,
requestingDeviceFingerprint: c.requestingDeviceFingerprint,
decision: c.decision,
signingPayload: hex(canonicalApprovalSigningBytes(c)),
}));
// Pinned end-to-end sign + verify: deterministic seed → pubkey →
// sign(payload) → verify against the pubkey. Lets the Kotlin port
// assert the exact 64-byte signature without re-running RNG.
const seed = new Uint8Array(32).map((_, i) => 0x10 + i);
const pubkey = ed25519PublicKeyFromSeed(seed);
const fakeReq: ApprovalRequestFrame = {
kind: 'approvalNeeded',
requestId: 'cafebabe1234567890abcdef00112233',
hostAddress: 'device:host',
hostFingerprint: 'host-fp',
requestingDevice: { fingerprint: 'req-fp', receivedAt: 1 },
expiresAt: 9_999_999_999_999,
domain: 'shade-link-approve-v1',
};
const signed = await signProxyApproval({
request: fakeReq,
decision: 'approve',
approverFingerprint: 'approver-fp',
approverSigningKey: seed,
crypto,
});
const e2e = {
description: 'V4.10 approval Ed25519 sign/verify (deterministic seed)',
seed: hex(seed),
publicKey: hex(pubkey),
request: {
requestId: fakeReq.requestId,
hostFingerprint: fakeReq.hostFingerprint,
requestingDeviceFingerprint: fakeReq.requestingDevice.fingerprint,
decision: 'approve',
domain: fakeReq.domain,
},
signingPayload: hex(
canonicalApprovalSigningBytes({
domain: fakeReq.domain,
requestId: fakeReq.requestId,
hostFingerprint: fakeReq.hostFingerprint,
requestingDeviceFingerprint: fakeReq.requestingDevice.fingerprint,
decision: 'approve',
}),
),
signature: signed.signature,
};
// Sanity self-check at generation time so a silently broken sign
// path can't ship vectors that "verify" themselves.
void buildApprovalRequest; // imported but unused — keeps the symbol live
return [...payloads, e2e];
}
async function main() {
console.log('Generating cross-platform test vectors…');
@@ -667,6 +835,8 @@ async function main() {
['backup.json', { vectors: await generateBackupVectors() }],
['group.json', { vectors: await generateGroupVectors() }],
['storage-hkdf.json', { vectors: await generateStorageEncryptionSubset() }],
['blob.json', { vectors: await generateBlobVectors() }],
['approval.json', { vectors: await generateApprovalVectors() }],
];
for (const [name, data] of files) {