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
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:
8
packages/shade-keychain/package.json
Normal file
8
packages/shade-keychain/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@shade/keychain",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
151
packages/shade-keychain/src/index.ts
Normal file
151
packages/shade-keychain/src/index.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
4
packages/shade-keychain/tsconfig.json
Normal file
4
packages/shade-keychain/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*", "tests/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user