Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import { randomBytes } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { deriveKey, seal, unseal, SECRET_CONTEXT } from '../src/main/security/secretBox'
|
|
|
|
const MASTER = randomBytes(32)
|
|
const CTX = SECRET_CONTEXT.credential
|
|
|
|
describe('secretBox', () => {
|
|
it('round-trips a secret', () => {
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
expect(unseal(MASTER, sealed, CTX)).toBe('hunter2')
|
|
})
|
|
|
|
it('round-trips non-ASCII and empty strings intact', () => {
|
|
// Byte-length assumptions are the classic failure here: these strings are
|
|
// longer in bytes than in characters, and a naive length check or a
|
|
// truncating transform mangles them.
|
|
for (const secret of ['密码', 'emoji-🔐-ok', 'ünïcödé', '', 'a'.repeat(4096)]) {
|
|
const sealed = seal(MASTER, secret, CTX)
|
|
expect(unseal(MASTER, sealed, CTX)).toBe(secret)
|
|
}
|
|
})
|
|
|
|
it('does not leave the plaintext recoverable from the ciphertext', () => {
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
expect(sealed.ciphertext.toString('latin1')).not.toContain('hunter2')
|
|
})
|
|
|
|
it('produces different ciphertext each time the same secret is sealed', () => {
|
|
// A fresh nonce and salt per call. Identical ciphertext would leak that two
|
|
// hosts share a password.
|
|
const a = seal(MASTER, 'same-secret', CTX)
|
|
const b = seal(MASTER, 'same-secret', CTX)
|
|
|
|
expect(a.ciphertext.equals(b.ciphertext)).toBe(false)
|
|
expect(a.nonce.equals(b.nonce)).toBe(false)
|
|
expect(a.salt.equals(b.salt)).toBe(false)
|
|
})
|
|
|
|
it('rejects a tampered ciphertext', () => {
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
sealed.ciphertext[0] ^= 0x01
|
|
|
|
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
|
|
})
|
|
|
|
it('rejects a tampered auth tag', () => {
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
const last = sealed.ciphertext.length - 1
|
|
sealed.ciphertext[last] ^= 0x01
|
|
|
|
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
|
|
})
|
|
|
|
it('rejects the wrong master key', () => {
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
expect(() => unseal(randomBytes(32), sealed, CTX)).toThrow()
|
|
})
|
|
|
|
it('rejects the wrong context', () => {
|
|
// Context separation: a credential blob must not decrypt as a private key
|
|
// even when both are sealed under the same master key.
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
expect(() => unseal(MASTER, sealed, SECRET_CONTEXT.privateKey)).toThrow()
|
|
})
|
|
|
|
it('rejects a truncated ciphertext rather than returning a short secret', () => {
|
|
const sealed = seal(MASTER, 'hunter2', CTX)
|
|
sealed.ciphertext = sealed.ciphertext.subarray(0, 4)
|
|
|
|
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
|
|
})
|
|
|
|
it('derives a stable key for identical inputs and a distinct one per salt', () => {
|
|
const salt = randomBytes(16)
|
|
|
|
expect(deriveKey(MASTER, salt, CTX).equals(deriveKey(MASTER, salt, CTX))).toBe(true)
|
|
expect(deriveKey(MASTER, randomBytes(16), CTX).equals(deriveKey(MASTER, salt, CTX))).toBe(
|
|
false,
|
|
)
|
|
})
|
|
})
|