android: V4.9 + V4.10 Kotlin ports + KeystoreStorage adapter
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user