C# Quellcode für C# Express (.NET 2.0) - 35.5 Kb

C# Quellcode und SoX Binaries - 514 Kb

Worum geht es?

Digitale information ist verlustfrei. Man kann eine Datei von Festlpatte auf Floppy auf CD auf Flash Stick auf ... kopieren und es wird nach wie vor die gleiche Datei sein. Aber wenn man analoge Medien verwendet, wie gutes altes Tape, kann man sich auf das Gegenteil verlassen: Die vom Medium gelesenen Daten werden definitiv anders sein, als das, was du vorher darauf geschrieben hast.

Dennoch gibt es viele Arten, geheime Information in Klang auf einer Audio-Cassette zu verstecken. Eine recht einfache davon möchte ich dir erklären. Alles was du tun musst, bevor du es ausprobieren kannst, ist, einen alten Cassetten-Rekorder zu holen, den Mikrofon-Anschluss mit dem Kopfhörer-Anschluss (oder Line Out) deines Computers, und den Mikrofon-Anschluss (oder Line In) des Computers mit dem Kopfhörer-Anschluss des Rekorders zu verbinden.

Dieser Artikel verwendet Code aus A full-duplex audio player in C# using the waveIn/waveOut APIs, und erfordert Sound Exchange (SoX).

Die Idee

Nachdem wir einen Klang auf eine Audio Cassette gespielt, und wieder in eine Wave-Datei zurück-aufgezeichnet haben, können wir uns nicht darauf verlassen, dass die binären Daten dieselben oder auch nur ähnlich sind. Darum reicht es nicht mehr aus, nur ein paar Bits zu ändern. Wir müssen den Klang so verändern, dass wir es wiedererkennen können, sogar hinter massenweise Rauschen und zufälligen Veränderungen.

Deine erste Idee könnte sein, sehr kurze Piepser alle n Sekunden einzufügen, wobei n ein Byte der geheimen Nachricht ist. Der Empfänger kann die Töne mit einem Band-Pass-Filter isolieren, die Intervalle in einen Stream schreiben, und aus diesem einfach die Nachricht lesen. Aber wer das versucht, dem wird schnell das Band ausgehen:

A = 65
B = 66
C = 67
ABC = 65 + 66 + 67 = 198

Wenn wir eine wiedererkennbare Frequenz in Intervallen, die für unsere Bytes stehen, einfügen würden, bräuchten wir 198 Sekunden allein für eine Kurznachricht wie ABC.

Deine zweite Idee könnte sein, jedes geheime Byte in oberes und unteres Halbbyte zu teilen, so dass das maximale Intervall zwischen zwei Piepsern 15 beträgt, also kein Byte mehr als 30 Sekunden auf der Cassette belegt. Das Zeichen "z" zum Beispiel, das im ersten Versuch 122 Sekunden verbrauchen würde, braucht so nur noch 17 Sekunden:

z = 122 = 1111010
half bytes = 7 (0111) and 10 (1010)

Das ist genau das, was diese Anwendung macht. Sie ermöglicht es, die Träger-Wave nach einer unbenutzten oder relativ leisen Frequenz abzusuchen, und fügt extrem kurze, kaum hörbare Töne in genau dieser Frequenz ein. Man kann das Ergebnis abspielen, und mit dem Cassetten-Rekorder aufzeichnen. Um die so versteckte Nachricht auszulesen, spielt man einfach die Cassette ab, nimmt die Musik mit einem Audio Recorder-Programm auf (z.B. GoldWave etc.), und entfernt die Stille von Anfang und Ende. Danach kann man die Wave-Datei mit dieser Anwendung öffnen, die vorher zum Verstecken verwendete Frequenz eingeben, und zuschauen, während der Band-Pass-Filter die Piepser isoliert und die Nachricht rekonstruiert.

Wie es funktioniert

Verstecken einer Nachricht

Zum Verstecken einer Nachricht gehören fünf Schritte:

  1. Eine Wave-Datei auswählen, und die Nachricht eingeben.
  2. Prüfen, ob die Nachricht in den Sound hinein passt, notfalls kürzen.
  3. Eine Frequenz raten, die nur in geringer Lautstärke oder gar nicht vorkommt.
    Falls "Check sound" eine Warnung ausgibt, die Schwellenlautstärke oder Frequenz erhöhen, bis die Warnung verschwindet.
  4. Den neuen Sound schreiben.
  5. Den neuen Sound abspielen und aufs Band aufzeichnen.

Schritt drei und vier erklären sich nicht unbedingt von selbst. Fangen wir mit Schritt drei an: Eine Frequenz auf Existenz und Lautstärke prüfen. Der Benutzer rät eine Frequenz, und klickt auf "Check sound". Um zu überprüfen, ob die höchste Amplitude einer solchen Frequenz niedriger als der ausgewählte Wert ist (was bedeutet, dass wir die gewählte Kombination verwenden können), müssen wir zuerst die Frequenz mit einem Band-Pass-Filter isolieren. SoX erledigt das für uns. Anschließend vergleichen wir die Samples des Ergebnisses mit der ausgewählten maximalen Amplitude (nennen wir es doch Lautstärke, in diesem Fall ist es fast das gleiche), und zählen die Samples, die zu laut sind.

//filter for frequency

String outFileName = waveUtility.FindFrequency(
       Path.GetTempPath(),
       soxPath, //contains a path like C:\somewhere\sox.exe
       (int)numFrequency.Value);

//Let another utility read the result file

WaveUtility filterUtility = new WaveUtility(outFileName);
WaveSound waveSound = filterUtility.WaveSound;

//filter for volume, check what is left of the sound

long countLoudSamples = 0;
short minVolumne = (short)(numHideVolume.Value - 100);
short[] samples = waveSound.Samples;
for (int n = 0; n < samples.Length; n++) {
    if (Math.Abs(samples[n]) > minVolumne) {
       countLoudSamples++;
    }
}

if (countLoudSamples > 0) {
   MessageBox.Show(String.Format("The Frequency might be" +
     " a bad choice, because there are already {0} " +
     "too loud samples in the sound.", countLoudSamples));
   errorProvider.SetError(numHideFrequency,
     "Frequency not fitting, oder selected volume too low.");
} else {
   errorProvider.SetError(numHideFrequency, String.Empty);
}

Fallses dich interessiert, wie waveUtility.FindFrequency arbeitet: Es kombiniert die Parameter zu einem String, ruft damit SoX auf, und liest die Fehlerausgabe (falls es Ärger gibt).

/// <summary>Let "Sound Exchange" perform a band pass filter on the sound</summary>
/// <param name="tempDirectory">Path of the directory for temporary files</param>
/// <param name="soxPath">Path and Name of sox.exe</param>
/// <param name="frequency">Frequency that may pass the filter</param>
/// <returns>Path of the output file</returns>
public String FindFrequency(String tempDirectory, String soxPath, int frequency)
{
    String inFileName = Path.Combine(tempDirectory, "in.wav");
    String outFileName = Path.Combine(tempDirectory, "out.wav");
    int fileLength = this.WaveSound.SaveToFile(inFileName);

    String soxArguments = String.Format(
              "-t wav \"{0}\" -t .wav -c 1 -s -w \"{1}\" band {2} 10",
              inFileName,
              outFileName,
              frequency);

    RunSox(soxPath, soxArguments);
    return outFileName;
}

/// <summary>Let "Sound Exchange" convert the sound</summary>
/// <param name="soxPath">Path and Name of sox.exe</param>
/// <param name="soxArguments">Arguments for sox.exe</param>
private void RunSox(String soxPath, String soxArguments)
{
    ProcessStartInfo startInfo = new ProcessStartInfo(
                soxPath,
                soxArguments);
    startInfo.RedirectStandardError = true;
    startInfo.UseShellExecute = false;
    Process sox = Process.Start(startInfo);

    StreamReader errorReader = sox.StandardError;
    String errors = errorReader.ReadLine();
    if (errors != null) {
        throw new ApplicationException("sox failed: " + errors);
    }

    sox.WaitForExit(10000);
}

Haben wir erst einmal eine Frequenz und Amplitude gefunden, die im Träger-Klang eindeutig sein wird, so dass sie nicht beim Extrahieren mit unschuldigen Samples verwechselt werden kann, können wir den geheimen Stream verstecken.

/// <summary>Hide a message in the wave</summary>
/// <param name="message">Stream containing the message</param>
/// <param name="frequencyHz">Frequency of the beeps,
///        which will be inserted into the sound</param>
/// <param name="volumne">Maximum sample value of the beeps</param>
public void Hide(Stream message, int frequencyHz, int volumne)
{
    Stream preparedMessage = PrepareMessage(message);
    int messageByte;
    int offset = 0;
    while ((messageByte = preparedMessage.ReadByte()) > -1) {
        offset += messageByte;
        InsertBeep(offset, frequencyHz, volumne);
    }
}

Dieses kurze Code-Stück ruft zwei wichtige Methoden auf, und zwar PrepareMessage und InsertBeep. PrepareMessage nimmt einen Stream mit der geheimen Nachricht an, und spaltet jedes Byte in zwei Halbbytes. Das Intervall zwischen zwei Beeps darf nicht Null Sekunden lang sein, aber viele Halbbytes werden Null sein, darum wird jeder Wert im vorbereiteten Stream Halbbyte + 1 betragen. Später muss die Lesemethode 1 von jedem Intervall abziehen, so dass wir wieder die alten Halbbytes bekommen.

/// <summary>Split the bytes of a message into four-bit-blocks</summary>
/// <param name="message">Stream containing the message</param>
/// <returns>Stream containing the same
///        message with two bytes per original byte</returns>
private Stream PrepareMessage(Stream message)
{
    message.Position = 0;

    MemoryStream preparedMessage = new MemoryStream();
    int messageByte;
    int highHalfByte;
    int lowHalfByte;

    while ((messageByte = message.ReadByte()) > -1) {
        //split into high and low part
        highHalfByte = (messageByte >> 4);
        lowHalfByte = (messageByte - (highHalfByte << 4));

        //intervals of 0 seconds are not possible -> add 1 to all intervals
        preparedMessage.WriteByte((byte)(highHalfByte + 1));
        preparedMessage.WriteByte((byte)(lowHalfByte + 1));
    }

    preparedMessage.Position = 0;
    return preparedMessage;
}

Worauf warten wir noch? Wir haben den Sound, den vorbereitete Nachrichten-Stream, und eine benutzbare Frequenz. Das ist alles, was wir brauchen, um kleine Geräusche an speziellen Sekunden einzufügen, was die Aufgabe von CreateBeep und InsertBeep ist.

/// <summary>Creates sine sound of a specific frequency</summary>
/// <param name="frequencyHz">Frequency in Hertz</param>
/// <param name="volumne">Amplitude</param>
private WaveSound CreateBeep(int frequencyHz, int volumne)
{
    // samples for 1/32 seconds
    short[] samples = new short[waveSound.Format.SamplesPerSec / 32];
    double xValue = 0;
    short yValue;

    double xStep = (2 * Math.PI) / waveSound.Format.SamplesPerSec; // 1 Hz
    xStep = xStep * frequencyHz;

    for (int n = 0; n < samples.Length; n++) {
        xValue += xStep;
        yValue = (short)(Math.Sin(xValue) * volumne);
        samples[n] = yValue;
    }

    WaveSound beep = new WaveSound(waveSound.Format, samples);
    return beep;
}

/// <summary>Replaces a part of the sound with a beep</summary>
/// <param name="insertAtSecond">Where to put the beep</param>
/// <param name="frequencyHz">Frequency of the beep in Hertz</param>
/// <param name="volumne">Maximum sample value of the beep</param>
public void InsertBeep(float insertAtSecond, int frequencyHz, int volumne)
{
    short[] beepWave = CreateBeep(frequencyHz, volumne).Samples;
    int insertAtSample = (int)(waveSound.Format.SamplesPerSec * insertAtSecond);
    int longWaveIndex = insertAtSample;
    for (int index = 0; index < beepWave.Length; index++) {
        waveSound[longWaveIndex] = beepWave[index];
        longWaveIndex++;
    }
}

Wenn Hide die while-Schleife verlässt, wurde der gesamte Nachrichten-Stream in den Sound gepiept. Vorausgesetzt, man hat eine passende Frequenz und nicht zu hohe Amplitude eingestellt, ist die Veränderung kaum hörbar (andernfalls kann sie dem menschlichen Ohr zumindest wie gewöhnliche Störungen vorkommen). Mann kann das Ergebnis in eine .wav-Datei speichern, oder abspielen und ungespeichert aufnehmen.

Auslesen einer Nachricht

Vor dem Lesen einer versteckten Nachricht muss der Benutzer den Sound filtern. Falls der Cassetten-Spieler sehr schlimme Störungen in ausgerechnet unserer Frequenz hinzugefügt hat, so dass falsche Piepser erkannt werden, können diese Fehler aussortiert werden.

  1. Frequenz der erwarteten Töne eingeben, und den Klang Band-Pass filtern. Der zweite Filter-Button - threshold volume - ist nicht wirklich nötig. Man kann damit Sampels aus der Grafik entfernen, die ohnehin als Stille behandelt würden.
  2. Die Töne finden. Ein Piepser ist eine Gruppe von Samples, die größer sind als die gewählte Schwellenlautstärke. In der Grafik werden Anfang und Ende jedes erkannten Piepsers mit roten Linien markiert. Die CheckBoxes ermöglichen es, einzelne Piepser aus der Auswertugn auszuschließen, wenn man sicher ist, dass sie nicht zur Nachricht gehören.
  3. Die Nachricht lesen. Der letzte Schritt listet die Abstände zwischen den ausgewählten Geräsuchen auf, und rekonstruiert daraus die versteckte Nachricht.

"Filter sound" wendet den gleichen Band-Pass-Filter an, den wir schon kennen. Diesmal zählen wir aber keine hohen Samples, sondern zeigen die gefilterte Wave an.

String outFileName = waveUtility.FindFrequency(
        Path.GetTempPath(),
        soxPath,
        (int)numExtractFrequency.Value);

waveControl.OpenFromFile(outFileName);

Jeder Ton ist 1/32 Sekunde lang. Das ist nicht ivel für menschliche Ohren, aber es sind eine Menge Samples. Die Sample-Gruppen werden durch Stille getrennt (dank Band-Pass-Filter und Schwellenlautstärke), daher können wir ein Scan-Fenster über die Samples schieben, und ein Stück Stille so definieren: Alle Samples im aktuellen Zeitfenster liegen unterhalb der Schwelle. Samples über der Schwelle werden als Teil desselben Tons behandelt, wenn keine Stille zwischen ihnen liegt.

/// <summary>Find anything but silence in the sound</summary>
/// <remarks>Raises the BeepFound event everytime a sound
///      is detected between two blocks of silence</remarks>
/// <param name="tolerance">
/// Sample values greater than [tolerance] are sound,
/// sample values less than [tolerance] are silence
/// </param>
public void FindAnything(short tolerance) {
    //size of scan window in samples
    int scanWindowSize = waveSound.Format.SamplesPerSec / beepLength;
    //size of scan window in seconds
    float scanWindowSizeSeconds = (float)scanWindowSize /
                (float)waveSound.Format.SamplesPerSec;

    int startIndex = -1;
    int endIndex = -1;
    int countSilentSamples = 0;
    for (int n = 0; n < waveSound.Count; n++) {
        if (Math.Abs(WaveSound[n]) > tolerance) { //found a sound
            countSilentSamples = 0;
            if(startIndex < 0){
                startIndex = n;
            }
        } else if (startIndex > -1) { //searched and found silence
            countSilentSamples++;
            if (countSilentSamples == scanWindowSize) {
                endIndex = n - scanWindowSize;

                //tell the caller to mark a found beep in the wave
                NotifyOnBeep(startIndex, endIndex, scanWindowSizeSeconds);

                //scan next time window
                countSilentSamples = 0;
                startIndex = -1;
             }
        }
   }

   if (startIndex > -1) { //wave ends with a beep
       NotifyOnBeep(startIndex, waveSound.Count-1, scanWindowSizeSeconds);
   }
}

Wenn der Benutzer schließlich auf "Read message" klickt, wurden alle Informationen bereits extrahiert, wir müssen nur noch die Halbbytes zusammensetzen.

private void btnExtract_Click(object sender, EventArgs e)
{
    //list the beginning seconds of the selected beeps
    Collection<Beep> selectedItems = waveControl.SelectedItems;
    Collection<float> startSeconds = new Collection<float>();
    foreach (Beep beep in selectedItems) {
        startSeconds.Add(beep.StartSecond);
    }

    //read the hidden message from the seconds
    Stream messageStream = waveUtility.Extract(startSeconds);
    StreamReader messageReader = new StreamReader(messageStream, Encoding.Default);
    String message = messageReader.ReadToEnd();
    messageReader.Close();

    txtExtractMessage.Text = message;
}

Klingt nach einem Beispiel-Klang

Falls du gerade keine Wave-Dateien zur Hand hast, um mit der Anwendung zu spielen, fühl dich frei, diese Beispiel-Aufnahmen zu verwenden: demoWaves.zip.

  1. 101seconds.wav - 101 Sekunden langer Original-Sound.
  2. result_f2500_v2000.wav - Der Sound inklusive "! C# RULES !", versteckt mit 2500Hz und einer Amplitude von 2000.
  3. record_f2500_v2000.wav - Gespielt von dem Casseten-Spieler auf dem Foto, aufgezeichnet von meinem Notebook, via Kopfhörer/Mikrofon-Anschlüsse. Versuch es ruhig, "! C# RULES !" kann immer noch problmlos extrahiert werden.