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
28 changes: 28 additions & 0 deletions packages/keystore/src/__tests__/encrypted-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,34 @@ describe('EncryptedFileKeyStore', () => {
});
});

describe('key overwrite (re-init)', () => {
it('generateAndStore twice with same ID uses the new key', async () => {
const store = new EncryptedFileKeyStore(tempDir, PASSPHRASE);
const { publicKey: pk1 } = await store.generateAndStore(DEVICE_ID);
const { publicKey: pk2 } = await store.generateAndStore(DEVICE_ID);

// Keys should differ
expect(pk1).not.toEqual(pk2);

// Sign with the store — must use the NEW key
const msg = new TextEncoder().encode('test overwrite');
const sig = await store.sign(DEVICE_ID, msg);

// Signature must verify against the NEW public key
expect(verifyMessage(sig, msg, pk2)).toBe(true);
// Signature must NOT verify against the OLD public key
expect(verifyMessage(sig, msg, pk1)).toBe(false);
});

it('getPublicKey returns the new key after overwrite', async () => {
const store = new EncryptedFileKeyStore(tempDir, PASSPHRASE);
await store.generateAndStore(DEVICE_ID);
const { publicKey: pk2 } = await store.generateAndStore(DEVICE_ID);

expect(await store.getPublicKey(DEVICE_ID)).toEqual(pk2);
});
});

describe('adversarial: tampered key file', () => {
it('fails to decrypt if ciphertext is modified', async () => {
const store = new EncryptedFileKeyStore(tempDir, PASSPHRASE);
Expand Down
105 changes: 105 additions & 0 deletions packages/keystore/src/__tests__/macos-keychain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, afterEach, setDefaultTimeout } from 'bun:test';
import { verifyMessage } from '@authmesh/core';

setDefaultTimeout(30_000);

// These tests require the macOS Keychain Swift helper and only run on macOS.
// They use a unique tag prefix to avoid colliding with real amesh keys.
const TEST_DEVICE_ID = 'am_test_keychain_ci';

const isMacOS = process.platform === 'darwin';

// Skip all tests on non-macOS platforms
const describeOnMac = isMacOS ? describe : describe.skip;

// Dynamic import to avoid errors on non-macOS
async function getKeychainStore() {
const { MacOSKeychainKeyStore, isMacOSKeychainAvailable } =
await import('../drivers/macos-keychain.js');
const { available } = await isMacOSKeychainAvailable();
return { MacOSKeychainKeyStore, available };
}

describeOnMac('MacOSKeychainKeyStore', () => {
afterEach(async () => {
// Clean up test keys from keychain
try {
const { MacOSKeychainKeyStore } = await getKeychainStore();
const store = new MacOSKeychainKeyStore('/tmp');
await store.delete(TEST_DEVICE_ID);
} catch {
// ignore cleanup errors
}
});

it('generates a 33-byte compressed P-256 public key', async () => {
const { MacOSKeychainKeyStore, available } = await getKeychainStore();
if (!available) return;
const store = new MacOSKeychainKeyStore('/tmp');
const { publicKey } = await store.generateAndStore(TEST_DEVICE_ID);

expect(publicKey).toBeInstanceOf(Uint8Array);
expect(publicKey.length).toBe(33);
expect([0x02, 0x03]).toContain(publicKey[0]);
});

it('sign produces a verifiable 64-byte signature', async () => {
const { MacOSKeychainKeyStore, available } = await getKeychainStore();
if (!available) return;
const store = new MacOSKeychainKeyStore('/tmp');
const { publicKey } = await store.generateAndStore(TEST_DEVICE_ID);

const msg = new TextEncoder().encode('test message');
const sig = await store.sign(TEST_DEVICE_ID, msg);

expect(sig.length).toBe(64);
expect(verifyMessage(sig, msg, publicKey)).toBe(true);
});

it('generateAndStore twice with same ID uses the new key (stale key regression)', async () => {
const { MacOSKeychainKeyStore, available } = await getKeychainStore();
if (!available) return;
const store = new MacOSKeychainKeyStore('/tmp');

// First generate
const { publicKey: pk1 } = await store.generateAndStore(TEST_DEVICE_ID);

// Second generate — same device ID (simulates `amesh init --force`)
const { publicKey: pk2 } = await store.generateAndStore(TEST_DEVICE_ID);

// Keys should differ
expect(pk1).not.toEqual(pk2);

// Sign must use the NEW key
const msg = new TextEncoder().encode('stale key regression test');
const sig = await store.sign(TEST_DEVICE_ID, msg);

// Must verify against the NEW public key
expect(verifyMessage(sig, msg, pk2)).toBe(true);
// Must NOT verify against the OLD public key
expect(verifyMessage(sig, msg, pk1)).toBe(false);
});

it('getPublicKey returns the new key after overwrite', async () => {
const { MacOSKeychainKeyStore, available } = await getKeychainStore();
if (!available) return;
const store = new MacOSKeychainKeyStore('/tmp');

await store.generateAndStore(TEST_DEVICE_ID);
const { publicKey: pk2 } = await store.generateAndStore(TEST_DEVICE_ID);

const retrieved = await store.getPublicKey(TEST_DEVICE_ID);
expect(retrieved).toEqual(pk2);
});

it('delete removes the key', async () => {
const { MacOSKeychainKeyStore, available } = await getKeychainStore();
if (!available) return;
const store = new MacOSKeychainKeyStore('/tmp');

await store.generateAndStore(TEST_DEVICE_ID);
await store.delete(TEST_DEVICE_ID);

await expect(store.sign(TEST_DEVICE_ID, new TextEncoder().encode('test'))).rejects.toThrow();
});
});
12 changes: 10 additions & 2 deletions packages/keystore/swift/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ case "check":

case "generate":
guard let id = cmd.deviceId else { fail("missing_device_id") }
// Delete ALL existing keys with this tag to avoid stale key shadowing.
// SecItemDelete may only remove one item per call, so loop until clean.
let delQuery: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tagFor(id),
]
while SecItemDelete(delQuery as CFDictionary) == errSecSuccess {}
// Try Secure Enclave first, fall back to software keychain
if let key = trySecureEnclaveGenerate(id) {
guard let pub = exportPubKey(key) else { fail("export_failed") }
Expand Down Expand Up @@ -134,10 +141,11 @@ case "get-public-key":

case "delete":
guard let id = cmd.deviceId else { fail("missing_device_id") }
SecItemDelete([
let delQ: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tagFor(id),
] as CFDictionary)
]
while SecItemDelete(delQ as CFDictionary) == errSecSuccess {}
ok()

default:
Expand Down
Loading