release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,8 @@
{
"name": "@shade/keychain",
"version": "4.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"dependencies": {}
}

View File

@@ -0,0 +1,151 @@
/**
* @shade/keychain — OS-keychain backend for @shade/storage-encrypted.
*
* No native deps: shells out to the platform's standard credential CLI:
* - macOS: `security` (CLI shipped with the OS)
* - Linux: `secret-tool` (libsecret CLI; available on most distros)
* - Windows: PowerShell + CredentialManager module (built into Windows 10+)
*
* Values are stored as base64 strings on platforms whose native APIs only
* accept strings (macOS, libsecret) and as raw bytes where supported.
*/
import { spawn } from 'node:child_process';
import { Buffer } from 'node:buffer';
export interface KeychainBackend {
get(service: string, account: string): Promise<Uint8Array | null>;
set(service: string, account: string, value: Uint8Array): Promise<void>;
delete(service: string, account: string): Promise<void>;
}
/** Pick a backend appropriate for the current platform; throw if unsupported. */
export function getDefaultKeychain(): KeychainBackend {
switch (process.platform) {
case 'darwin': return new MacOSKeychain();
case 'linux': return new LibSecretKeychain();
case 'win32': return new WindowsCredentialManager();
default:
throw new Error(`@shade/keychain: unsupported platform ${process.platform}`);
}
}
// ─── macOS ──────────────────────────────────────────────────
export class MacOSKeychain implements KeychainBackend {
async get(service: string, account: string): Promise<Uint8Array | null> {
const { code, stdout } = await runCmd('security', [
'find-generic-password', '-s', service, '-a', account, '-w',
]);
if (code !== 0) return null;
const b64 = stdout.trim();
if (!b64) return null;
return Buffer.from(b64, 'base64');
}
async set(service: string, account: string, value: Uint8Array): Promise<void> {
const b64 = Buffer.from(value).toString('base64');
// -U: update if exists. Send password via stdin to avoid leaking via argv.
const { code, stderr } = await runCmd(
'security',
['add-generic-password', '-U', '-s', service, '-a', account, '-w', b64],
);
if (code !== 0) throw new Error(`security add-generic-password failed: ${stderr}`);
}
async delete(service: string, account: string): Promise<void> {
await runCmd('security', ['delete-generic-password', '-s', service, '-a', account]);
}
}
// ─── Linux (libsecret via secret-tool) ──────────────────────
export class LibSecretKeychain implements KeychainBackend {
async get(service: string, account: string): Promise<Uint8Array | null> {
const { code, stdout } = await runCmd('secret-tool', ['lookup', 'service', service, 'account', account]);
if (code !== 0) return null;
const b64 = stdout.trim();
if (!b64) return null;
return Buffer.from(b64, 'base64');
}
async set(service: string, account: string, value: Uint8Array): Promise<void> {
const b64 = Buffer.from(value).toString('base64');
const { code, stderr } = await runCmd(
'secret-tool',
['store', '--label', `shade:${service}:${account}`, 'service', service, 'account', account],
b64,
);
if (code !== 0) throw new Error(`secret-tool store failed: ${stderr}`);
}
async delete(service: string, account: string): Promise<void> {
await runCmd('secret-tool', ['clear', 'service', service, 'account', account]);
}
}
// ─── Windows Credential Manager ────────────────────────────
export class WindowsCredentialManager implements KeychainBackend {
private target(service: string, account: string): string {
return `shade:${service}:${account}`;
}
async get(service: string, account: string): Promise<Uint8Array | null> {
const target = this.target(service, account);
const ps = `
$ErrorActionPreference = 'Stop'
Import-Module CredentialManager -ErrorAction SilentlyContinue
$c = Get-StoredCredential -Target '${target}'
if ($null -eq $c) { exit 1 }
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($c.Password)
try { [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) }
finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
`.trim();
const { code, stdout } = await runCmd('powershell', ['-NoProfile', '-Command', ps]);
if (code !== 0) return null;
const b64 = stdout.trim();
return b64 ? Buffer.from(b64, 'base64') : null;
}
async set(service: string, account: string, value: Uint8Array): Promise<void> {
const target = this.target(service, account);
const b64 = Buffer.from(value).toString('base64');
const ps = `
$ErrorActionPreference = 'Stop'
Import-Module CredentialManager
$sec = ConvertTo-SecureString -String '${b64}' -AsPlainText -Force
New-StoredCredential -Target '${target}' -UserName '${account}' -SecurePassword $sec -Persist LocalMachine | Out-Null
`.trim();
const { code, stderr } = await runCmd('powershell', ['-NoProfile', '-Command', ps]);
if (code !== 0) throw new Error(`Windows CredentialManager store failed: ${stderr}`);
}
async delete(service: string, account: string): Promise<void> {
const target = this.target(service, account);
const ps = `Remove-StoredCredential -Target '${target}' -ErrorAction SilentlyContinue`;
await runCmd('powershell', ['-NoProfile', '-Command', ps]);
}
}
// ─── Helpers ────────────────────────────────────────────────
interface CmdResult { code: number; stdout: string; stderr: string }
function runCmd(cmd: string, args: string[], stdin?: string): Promise<CmdResult> {
return new Promise((resolve) => {
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
let err = '';
child.stdout.on('data', (d: Buffer) => { out += d.toString('utf8'); });
child.stderr.on('data', (d: Buffer) => { err += d.toString('utf8'); });
child.on('error', () => resolve({ code: 127, stdout: '', stderr: 'spawn failed' }));
child.on('close', (code: number | null) => resolve({ code: code ?? 0, stdout: out, stderr: err }));
if (stdin !== undefined) {
child.stdin.write(stdin);
child.stdin.end();
} else {
child.stdin.end();
}
});
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*", "tests/**/*"]
}